Недавно у меня возникла идея собрать все базовые наиболее часто используемые фичи для ListView в Android и для удобства объединить их в один проект. Как обычно, я отправился в интернет и нашел там замечательную статью и ее переводы на хабре (перевод 1, перевод 2). Не все, на мой взгляд, было нужным и полезным в этой статье, поэтому я включил в конечный проект только то, что мне показалось значимым. Надеюсь, в будущем это пригодится кому-то еще.
К слову, я Xamarin разработчик, поэтому проект (и сэмплы, соответственно) будут написаны на C# для Xamarin.Android.
Итак, приступим:
Наполнение данными (TODO 1)
Как известно, ListView в Android — это элемент, который предоставляет данные в виде списка, где каждый элемент представлен своей View. Для управления отображением ячеек используются Adapters. Для того чтобы наполнить ListView данными в самом скромном виде можно использовать ArrayAdapter. Это делается в две строчки:
var animals = GetAnimals ();
var adapter = new ArrayAdapter<Animal> (this, Android.Resource.Layout.SimpleListItem1, animals);
Как вы видите, результат соответствующий — быстро, дешево и не особо красиво, но к этому мы еще вернемся.
Обработка выбора ячейки (TODO 2)
Пользователю часто нужно как-то общаться с отображаемым списком — выбирать элементы, просматривать детальную информацию и т.д. Для обработки выбора ячейки в Xamarin.Android нужно всего лишь подписаться на событие ItemClick у ListView. Для удаления выбранного элемента используется следующий код:
bool inDeletion = false;
list.ItemClick += (sender, e) => {
if (!inDeletion) {
inDeletion = true;
e.View.Animate ()
.SetDuration (500)
.Alpha (0.0f)
.WithEndAction (new Runnable (() => {
#region TODO5
//TODO 05
var item = adapter.GetRawItem (e.Position);
#endregion
//var item = list.Adapter.GetItem(e.Position);
adapter.Remove (item);
adapter.NotifyDataSetChanged ();
e.View.Alpha = 1;
#region TODO9
//TODO 09
ShowSnackBar (1);
#endregion
inDeletion = false;
}));
}
};
Обработка пустого списка (TODO 3)
Есть вероятность, что пользователь увидит список без элементов. Для того, чтобы он не потерялся и не закрыл приложение, лучше всего использовать EmptyView. Его следует объявить в layout Activity и одной строчкой дать понять ListView, что эту View он должен показать, когда у него нет элементов:
list.EmptyView = FindViewById<TextView> (Resource.Id.empty);
Изменение внешнего вида ячейки (TODO 4,5)
Чтобы слегка улучшить внешний вид нашего списка, можно применить перегрузку конструктора ArrayAdapter и передать в него layout, который будет использоваться для отображения элемента списка. Также нужно передать Id текстового поля, где будет показываться строковый аналог объекта:
var adapter = new ArrayAdapter<Animal> (this, Resource.Layout.row_custom, Resource.Id.row_custom_text, animals);
Теперь наш список выглядит чуть получше:
Для дальнейшей модернизации внешнего вида мы создадим собственный адаптер, в котором переопределим метод GetView и уже сами будем управлять внешним видом ячеек. Реализация адаптера самая базовая, поэтому постить сюда весь код излишне. Если хотите увидеть изменения, расскоментируйте код в регионе TODO5. Также некоторый старый код потребует модификации — метод GetItem может возвращать только объект типа Java.Lang.Object. Так как оборачивать каждый элемент списка в Java.Lang.Object — это излишняя роскошь, создадим свой метод GetRawItem, который будет возвращать нужный тип. Теперь что касается модификации, чтобы проект скомпилировался, при удалении ячейки нужно заменить вызов GetItem на GetRawItem.
Вот такое мы видим в результате. На конкурс дизайна не годится, но для тестового проекта то, что нужно.
Добавление Contextual Action Bar (CAB) (TODO 6)
Следующим нашим шагом будет добавление CAB функционала к нашему ListView. Для справки, описание и детальное руководство по внедрению можно найти здесь. Для того чтобы добавить CAB, во-первых нужно меню которое, будет появляться при пользовательском long tap. Файл меню:
<?xml version="1.0" encoding="UTF-8" ?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/ActionModeDeleteItem"
android:title="Delete"
android:showAsAction="always"/>
</menu>
Во-вторых, необходимо реализовать в Activity интерфейс ActionMode.ICallback следующим образом:
public bool OnActionItemClicked (ActionMode mode, IMenuItem item)
{
switch(item.ItemId){
case Resource.Id.ActionModeDeleteItem:
SparseBooleanArray selected = _adapter.SelectedIds;
List<Animal> itemList = new List<Animal> ();
var keys = new List<int> ();
for (int i = (selected.Size () - 1); i >= 0; i--) {
//checkisvaluecheckedby user
if (selected.ValueAt (i)) {
keys.Add (selected.KeyAt (i));
var selectedItem = _adapter.GetRawItem (selected.KeyAt (i));
itemList.Add (selectedItem);
}
}
_adapter.Remove (itemList);
_mode.Finish();
return true;
default:
return false;
}
}
public bool OnCreateActionMode (ActionMode mode, IMenu menu)
{
mode.MenuInflater.Inflate (Resource.Menu.menu, menu);
inActionMode = true;
return true;
}
public void OnDestroyActionMode (ActionMode mode)
{
_adapter.RemoveSelection ();
_adapter.NotifyDataSetChanged ();
_mode = null;
inActionMode = false;
}
public bool OnPrepareActionMode (ActionMode mode, IMenu menu)
{
return false;
}
В-третьих, в методе обработки пользовательского клика нужно учесть, что пользователь может находится в ActionMode:
if(inActionMode){
adapter.ToggleSelection(e.Position);
_mode.Title=(adapter.SelectedCount.ToString()+" selected");
return;
}
В-четвертых, в адаптер следует добавить методы, которые будут отвечать за выбор элемента:
protected virtual void SelectView(int position, bool value){
if(value)
_selectedItemsIds.Put(position,value);
else
_selectedItemsIds.Delete(position);
NotifyDataSetChanged();
}
public void ToggleSelection(int position){
SelectView(position, !_selectedItemsIds.Get(position));
}
public int SelectedCount {
get{
return _selectedItemsIds.Size();
}
}
public void RemoveSelection(){
_selectedItemsIds=new SparseBooleanArray();
NotifyDataSetChanged();
}
public SparseBooleanArray SelectedIds{
get {
return _selectedItemsIds;
}
}
После чего получаем требуемый результат:
Производительность в ListView (TODO 7)
Чтобы ListView показывал свои элементы быстрее, реализуем ViewHolder паттерн. Для этого создадим класс ViewHolder, который наследует базовый Java.Lang.Object (это нужно для того, чтобы присвоить ViewHolder свойству view.Tag). Знающие люди говорят, что ListView с ViewHolder работает на 15% быстрее. У меня на симуляторе быстродействие от 1-3ms подскочило до 0-1ms для одной View. Разумеется, это зависит от того, как часто вызывается FindViewByID. Теперь, собственно, посмотрим, как изменился наш GetView метод:
public override View GetView(int position, View convertView, ViewGroup parent)
{
var item = this [position];
var view = convertView;
if (view == null) {
view = LayoutInflater.From (parent.Context).Inflate (Resource.Layout.row_custom_adapter, parent, false);
var viewHolder = new ViewHolder ();
viewHolder.Text = view.FindViewById<TextView> (Resource.Id.row_custom_name);
viewHolder.Weight = view.FindViewById<TextView> (Resource.Id.row_custom_weight);
viewHolder.Icon = view.FindViewById<ImageView> (Resource.Id.row_custom_icon);
view.Tag = viewHolder;
}
var holder = (ViewHolder)view.Tag;
holder.Text.Text = item.Name;
holder.Weight.Text = item.Weight.ToString ("F");
if (_selectedItemsIds.Size () > 0 && _selectedItemsIds.Get (position)) {
view.SetBackgroundColor (Android.Graphics.Color.CadetBlue);
} else {
view.Background = GetBackground (item.Color);
}
holder.Icon.SetImageResource (GetImage (item.Name));
return view;
}
Добавление секций (TODO 8)
Для того, чтобы наши звери смотрелись более консистентно, следует разбить их на группы по цветам. Для этого создадим новый адаптер SectionAnimalAdapter, который будет наследовать AnimalAdapter. Главное отличие этого адаптера в том, что он хранит объекты как словарь Dictionary<string, List> и при каждом удалении элемента вызывает метод BuildSections, чтобы пересоздать Dictionary. Также добавится новый Viewtype — header и layout для него. Код можно посмотреть в файле.
Теперь наш список будет выглядеть следующим образом:
Отмена последнего действия (TODO 9)
Очень часто у пользователей появляется желание отменить последнее действие, особенно когда это касается удаления. Реализуем этот функционал с помощью Snackbar. После удаления будем показывать пользователю Snackbar с предложением отменить последнее действие. Конечно же, это потребует небольших манипуляций в адаптере — следует создать дополнительный список, где будет храниться последнее состояние, к которому сможет откатиться пользователь, и метод Rollback:
public virtual void RollBack ()
{
_animals = _rollbackAnimals;
_rollbackAnimals = null;
}
Раскрывающие списки (TODO 10)
При необходимости можно реализовать раскрывающие списки. Чтобы список был раскрывающимся, он должен иметь тип ExpandableListView, а его адаптеру необходимо наследовать тип BaseExpandableListAdapter. Основное его отличие от обычного адаптера — это два метода для группы и для child элемента:
public override View GetGroupView (int position, bool isExpandable, View convertView, ViewGroup parent)
{
GroupViewHolder holder = null;
var view = convertView;
if (view != null)
holder = view.Tag as GroupViewHolder;
if (holder == null) {
view = LayoutInflater.From (parent.Context).Inflate (Resource.Layout.row_expandable_header, null);
holder = new GroupViewHolder ();
holder.Text = view as CheckedTextView;
}
var sect = _sections.Keys.ToList () [position];
var name = _sections [sect].First ().Name;
holder.Text.Text = name;
holder.Text.Checked = isExpandable;
return view;
}
public override View GetChildView (int groupPosition, int childPosition, bool isLastChild, View convertView, ViewGroup parent)
{
var view = convertView;
var sect = _sections.Keys.ToList () [groupPosition];
var item = _sections [sect] [childPosition];
if (view == null) {
view = LayoutInflater.From (parent.Context).Inflate (Resource.Layout.row_custom_adapter, parent, false);
var viewHolder = new ChildViewHolder ();
viewHolder.Text = view.FindViewById<TextView> (Resource.Id.row_custom_name);
viewHolder.Weight = view.FindViewById<TextView> (Resource.Id.row_custom_weight);
viewHolder.Icon = view.FindViewById<ImageView> (Resource.Id.row_custom_icon);
view.Tag = viewHolder;
}
var holder = (ChildViewHolder)view.Tag;
holder.Text.Text = item.Name;
holder.Weight.Text = item.Weight.ToString ("F");
holder.Icon.SetImageResource (GetImage (item.Name));
return view;
}
Drag’n’Drop списки (TODO 11)
Чтобы реализовать список, который будет поддерживать drag’n’drop, нужно создать новый класс, который будет наследовать класс ListView. Исходный пример взят из этого видео. В двух словах, при long-tap пользователя на ячейку создается ее snapshot, который перемещается в след за пальцем пользователя. Когда палец достигает соседней ячейки, объекты в адаптере меняются местами и перерисовуются. Код DynamicListView здесь.
Для того, чтобы запустить приложение с drag’n’drop списком, следует сделать стартовой SecondaryActivity. Расскоментировав строку MainLauncher = true.
Это все, что я хотел рассказать о ListView для Android. Но, возможно, проекту еще не хватает каких то важных фич? Буду рад услышать в комментариях, какие фичи чаще всего используют ваши ListView :)
Автор: igr777