среда, 11 сентября 2013 г.

Иерархический список

В этой статье расскажу как можно сделать иерархический список на андроиде. Постараемся разделить сам принцип работы списка от данных, чтобы можно было с минимальными усилиями использовать наш код повторно в других приложениях.
full


1. Создадим новый проект (у меня это Hierarchy). И прежде чем приступить к программированию немного рассмотрим теорию.

2. Если посмотреть на скриншот выше, то можно заметить, что иерархический список это по сути обычный список ListView, только у некоторых его элементов есть отступы слева. Про списки я уже писала тут, поэтому здесь не буду подробно рассказывать про сами списки, только напомню основные шаги:
1. Добавляем на разметку экрана элемент ListView
2. Пишем адаптер, то есть класс, который наследует BaseAdapter. С помощью это класса в список будут добавляться элементы. В адаптере при наследовании от BaseAdapter, нам необходимо переопределить 4 метода
int getCount() - возвращает кол-во элементов в списке
Object getItem(int position) — возвращает элемент списка в позиции position
long getItemId(int position) — возвращает ID элемента в позиции position
View getView(int position, View convertView, ViewGroup parent) — возвращает разметку элемента в позиции position

Нас особенно интересуют первый и последний метод. Говоря простыми словами, при построении списка система андроид поступает так:
1. Вызывает у адаптера метод getCount(), чтобы узнать сколько элементов надо отобразить.
2. В цикле для каждого элемента вызывает метод getView () передовая ему порядковый номер в этом списке position.

Если мы хотим построить список на основе простого массива, то все просто. Метод getCount() возвращает длину это массива, а метод getView() возвращает разметки элементов (например TextView с текстом значения элемента массива).

Стоит иметь в виду, что если после отображения списка мы каким-то образом изменим массив (например добавим новый элемент), то самостоятельно список на экране не изменится. Для этого необходимо в адаптере вызвать метод notifyDataSetChanged(). Тогда весь процесс пойдет заново, то есть вызывается метод getCount(), а затем getView() для каждой строчки.

Первое просто - добавим в xml разметку нашей активити элемент ListView.
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <ListView
    android:id="@+id/listView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

</RelativeLayout>

3. Со списком на основе массива, все понятно, но с иерархическим списком у нас возникают проблемы:
1. Как определить количество отображаемых элементов, ведь оно будет меняться в зависимости от того открыты ли подпункты?
2. Как определить какой элемент у нас будет стоять на позиции position, элемент верхнего уровня или уже подпункт?

Возьмем простой иерархический список (на картинке выше) и попробуем ответить на эти вопросы.
1. Когда мы первый раз отрисовываем список, то кол-во элементов у нас равно кол-ву элементов верхнего уровня (то есть 3). Если мы открываем Item2, то кол-во элементов равно кол-ву элементов верхнего уровня плюс кол-во потомков у Item2 (то есть 3 + 2 = 5). Если открыть еще Item1, то будет соответственно 3 + 3 + 2 = 8. Таким образом, можно сказать, что getCount() должен возвращать кол-во элементов верхнего уровня плюс кол-во потомков во всех открытых элементах. Понятно, что это кол-во будет меняться каждый раз когда открывается/закрывается какой-то элемент и должно быть пересчитано.

2. Когда система узнает, вызвав getCount(), кол-во элементов, она начинает для каждого из них вызывать метод getView() передавая в него текущую позицию. Тут начинается самое интересное. Каждый раз нам надо проходить по всей иерархии и считать элементы, пока не дойдем до нужной позиции. Для такой иерархии это будет выглядеть так:

position = 0: Начинаем просматривать элементы верхнего уровня. Первый элемент Item-1 и будет для нас на позиции 0. Отрисовываем его.

position = 1: Начинаем просматривать элементы верхнего уровня. Первый элемент Item-1 это позиция 0. Проверяем открыт ли элемент. Нет, он не открыт, значит переходим к следующему элементу верхнего уровня. Это элемент Item-1, он будет на позиции 1.

position = 2: Начинаем просматривать элементы верхнего уровня. Первый элемент Item-1 это позиция 0. Этот элемент закрыт значит переходим к следующему элементу верхнего уровня. Элемент Item-1 это позиция 1. Этот элемент открыт, значит дальше искать будем по его потомкам. Первый его потомок это Item 2.1 это позиция 2.

и так далее. Я не зря в каждом пункте повторяла текст из предыдущего, потому что для каждой позиции в методе getView() нам придется заново проходить по всей иерархии и считать элементы. Это весьма затратное дело и к тому же довольно бессмысленное. Гораздо разумнее было бы пройтись один раз по иерархии и присвоить каждому элементу свой порядковый номер. Когда мы открываем или закрываем элемент (а также если мы добавляем или удаляем элемент), мы заново пробегаемся по иерархии и присваиваем номера.

Каким образом нам присвоить номера элементам иерархии? Самое банально запихнуть их в массив в нужном порядке. Теперь задача полностью свелась к изначальной «Как отобразить массив в список ListView». А уж это мы умеем :)

4. Итак, стало понятно как отрисовать иерархию в список ListView. Каждый раз при изменении иерархии, мы пробегаемся по ней и заносим видимые элементы в массив в нужном нам порядке. А затем уже работаем именно с этим массивом. Но что такое иерархия? В каком виде она будет? Как я говорила в самом начале, хочется сделать это решение универсальным, что бы его можно было использовать в любых приложениях. Когда мы говорим об универсальности в java, сразу вспоминаются интерфейсы. Ведь по большому счету нам совершенно не важно чем являются элементы иерархии: пункт меню, файл или отдел в иерархии подразделений. Для нас главное чтобы от него мы могли получить 3 вещи:
- Название
- Картинка
- Массив потомков
Как видите, состояния объекта (то есть открыт он или закрыт) тут нет, так как за это должен отвечать не сам объект, а адаптер.

Создаем новый файл Item.java и определяем в нем интерфейс
public interface Item {
  public String getTitle();
  public int getIconResource();
  public ArrayList<Item> getChilds();
}

5. Теперь нам достаточно унаследовать этот интерфейс и определить все методы. Например создадим файл ListItem.java. И напишем класс для иерархии как на картинке, который наследует этот интерфейс
public class ListItem implements Item {

  private String title;
  private ArrayList<Item> childs;

  public ListItem (String title) { // 1
    this.title = title;
    childs = new ArrayList<Item>();
  }
 
  @Override
  public String getTitle() { // 2
    return title;
  }

  @Override
  public ArrayList<Item> getChilds() { // 3
    return childs;
  }
 
  @Override
  public int getIconResource() { // 4
    if (childs.size() > 0)
      return R.drawable.folder;
    return R.drawable.file;
  }

  public void addChild (Item item) { // 5
    childs.add(item);
  }
}
Это класс в котором содержится строка с названием и массив потомков (таких же наследников Item как и текущий класс).
1) В конструктор передаем название элемента и инициализируем массив потомков (он по умолчанию пустой)
2) Переопределенный метод возвращающий название
3) Переопределенный метод возвращающий массив потомков
4) Метод возвращающий картинку. Если есть потомки, то будет иконка в виде папки, если нет, то в виде файла.
5) Метод для добавления потомка.

6. Перейдем к классу нашей активности HierarchyActivity и создадим иерархию из элементов ListItem.
public class HierarchyActivity extends Activity {

  ArrayList<Item> items; // 1
  ListAdapter adapter; // 2
 
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_hierarchy);

    items = generateSomeHierarchy(); // 3

    adapter = new ListAdapter(this, items); // 4

    ListView mList = (ListView) this.findViewById(R.id.listView); // 5
    mList.setAdapter(adapter); // 6
    mList.setOnItemClickListener(new OnItemClickListener() { //7
      @Override
      public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        adapter.clickOnItem(position); //8
      }
    });
  }

  private ArrayList<Item> generateSomeHierarchy() { // 9
    items = new ArrayList<Item>();

    ListItem li1 = new ListItem("Item 1");
    ListItem li2 = new ListItem("Item 2");
    ListItem li3 = new ListItem("Item 3");

    items.add(li1);
    items.add(li2);
    items.add(li3);

    ListItem li11 = new ListItem("Item 1.1");
    ListItem li12 = new ListItem("Item 1.2");
    ListItem li13 = new ListItem("Item 1.3");

    li1.addChild(li11);
    li1.addChild(li12);
    li1.addChild(li13);
  
    ListItem li21 = new ListItem("Item 2.1");
    ListItem li22 = new ListItem("Item 2.2");

    li2.addChild(li21);
    li2.addChild(li22);
  
    ListItem li211 = new ListItem("Item 2.1.1");
    ListItem li212 = new ListItem("Item 2.1.2");
  
    li21.addChild(li211);
    li21.addChild(li212);

    return items;
  }
}
1) Элементы верхнего уровня мы будем хранить в массиве items.
2) ListAdapter это адаптер для нашего списка, его мы создадим позже
3) Создаем иерархию, подробнее в пункте 9
4) Создаем адаптер и передаем ему первым параметром контекст активити, а вторым массив элементов верхнего уровня
5) Находим ListView в файле разметки
6) Задаем адаптер для списка
7) Добавляем обработчик события нажатия на элементе списка
8) По нажатию мы будем вызывать метод clickOnItem() у адаптера, в нем адаптер должен заново прочитать иерархию и обновить список
9) Метод создания иерархии. В нем все просто. Инициализируем массив элементов верхнего уровня items. Создаем три первых элемента и кладем их в этот массив. Дальше создаем элементы второго уровня и с помощью метода addChild() делаем их потомками нужного элемента.

7. Теперь перейдем к самому вкусному - классу адаптера. Но вначале добавим разметку для строки списка. Создаем в /res/layout файл row.xml. Несмотря на иконку в каждый строке на разметке только TextView, к нему и будет добавляться картинка.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="horizontal" >

  <TextView
    android:id="@+id/title"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textSize="20sp"
    android:drawablePadding="10dp"
    android:layout_marginBottom="15dp"/>

</LinearLayout>
8. Теперь создаем файл ListAdapter.java, а в нем класса наследник от BaseAdapter
public class ListAdapter extends BaseAdapter {
 
  private LayoutInflater mLayoutInflater; // 1
  private ArrayList<Item> hierarchyArray; // 2
 
  private ArrayList<Item> originalItems; // 3
  private LinkedList<Item> openItems; // 4

  public ListAdapter (Context ctx, ArrayList<Item> items) {  
    mLayoutInflater = LayoutInflater.from(ctx);
    originalItems = items; 
  
    hierarchyArray = new ArrayList<Item>();
    openItems = new LinkedList<Item>(); 
  
    generateHierarchy(); // 5
  }  
     
  @Override
  public int getCount() {
    return hierarchyArray.size();
  }

  @Override
  public Object getItem(int position) {
    return hierarchyArray.get(position);
  }

  @Override
  public long getItemId(int position) {
    return 0;
  }

  @Override
  public View getView(int position, View convertView, ViewGroup parent) {
    if (convertView == null)
      convertView = mLayoutInflater.inflate(R.layout.row, null);             
    
    TextView title = (TextView)convertView.findViewById(R.id.title);

    Item item = hierarchyArray.get(position);
  
    title.setText(item.getTitle());
    title.setCompoundDrawablesWithIntrinsicBounds(item.getIconResource(), 0, 0, 0); // 6
    return convertView;  
  }
}
1) Что такое LayoutInflater и зачем он нужен я рассказывала в статье про списки. Там же можно прочитать про то, что происходит в методе getView()
2) Это массив, который будет содержать видимые элементы иерархии и который мы собственно и будем отрисовывать.
3) Массив элементов верхнего уровня иерархии. Этот список передается конструктору адаптера, он нам нужен, чтобы обновлять массив hierarchyArray при открытии/закрытии какого-то элемента.
4) Нам нужно как-то сохранять какие элементы мы открыли. Самый простой способ это запоминать их в массиве. В данном случае будем использовать не ArrayList, а LinkedList. LinkedList это тоже массив и работа с ним абстолютно такая же как и с ArrayList. Разница есть в их внутреннем устройстве. Если коротко: ArrayList — реализован на основе простого массива, а значит обеспечивает быстрое добавление в конец списка и поиск элемента по индексу. А LinkedList — реализован на основе связанного списка и позволяет быстро удалять и добавлять элементы причем не только в конец, но и в середину. Это как раз для нас и важно, потому что элементы будут открываться (мы добавляем элемент в массив) и закрываться (элемент удаляется из массива). В целом в таком маленьком приложении, разницу между ArrayList и LinkedList почувствовать будет невозможно, но все-таки правильнее будет использование LinkedList. Если интересно более подробнее почитать про различие этих массивов, это легко гуглиться. А пока вернемся к нашему адаптеру.
5) Метод, который формирует массив из иерархии, о нем ниже
6) void setCompoundDrawablesWithIntrinsicBounds (int left, int top, int right, int bottom) — устанавливает изображение в TextView. Параметры это идентификаторы ресурса изображения для соотвественно левого, верхнего, правого или нижнего края.

9. Добавим метод generateHierarchy().
private void generateHierarchy() {
  hierarchyArray.clear(); // 1
  generateList(originalItems); // 2
}

private void generateList(ArrayList<Item> items) { // 3
  for (Item i : items) {
    hierarchyArray.add(i);
    if (openItems.contains(i))
      generateList(i.getChilds());
  }
}
1) Очищаем список
2) Вызываем рекурсивную функцию, передавая ей список элементов верхнего уровня
3) Функция добавляет каждый элемент переданного массива в hierarchyArray. Если данный элемент открыт (то есть он есть в списке openItems) то она вызывает саму себя, передовая в качестве параметра массив нижестоящих элементов.

10. В классе HierarchyActivity мы написали, что при нажатии на элемент списка будет вызваться метод clickOnItem(position). Напишем его
public void clickOnItem (int position) {
  
  Item i = hierarchyArray.get(position);
  if (!openItems.remove(i))
    openItems.add(i);

  generateHierarchy();
  notifyDataSetChanged();
}
Здесь весь принцип работы построен на том факте, что метод remove() у ArrayList возвращает true, если такой элемент в массиве был и соответственно был удален или false — если такого элемента не было. Когда мы тыкаем по элементу, нам его нужно закрыть, если он был открыт и наоборот открыть если он был закрыт. Чтобы не делать лишних проверок, мы пытаемся его удалить и если remove() вернул false (то есть в списке открытых элемента не было), то мы добавляем его туда. После этого перестраиваем массив из иерархии и вызываем notifyDataSetChanged() чтобы перерисовать список.

11. Запустим. Список рисуется, подпункты открываются и закрываются, но выглядит не очень красиво, потому что подпункты располагаются на том же уровне, что и вышестоящие элементы. Нам нужно добавить отступ слева, причем чем ниже в иерархии элемент, тем больше должен быть отступ. Добавить отступ мы можем в методе getView(), когда отрисовываем каждый элемент, но как узнать на сколько сдвигать? Ведь в массиве hierarchyArray все элементы лежат последовательно и никакой информации о том, на каком уровне иерархии располагается этот элемент нет. Значит при формировании этого массива, надо не просто складывать элементы, но и сохранять информацию об уровне вложенности.
Создадим простой класс «Пара», атрибутами которого будет элемент Item и значение уровня. Сделаем этот класс вложенным, чтобы не плодить лишние файлы
public class ListAdapter extends BaseAdapter {
 
  private class Pair {
    Item item;
    int level;
  
    Pair (Item item, int level) {
      this.item = item;
      this.level = level;
    }
  }

  private LayoutInflater mLayoutInflater;
  private ArrayList<Pair> hierarchyArray; // 1
 
  private ArrayList<Item> originalItems;
  private LinkedList<Item> openItems;
  ...
}
Класс не выполняем никаких действий и является просто хранилищем для двух значений.
1) Также изменим массив hierarchyArray, теперь он содержит не просто Item, а экземпляры класса Pair, то есть по сути все тот же Item плюс level.

Соответственно нужно изменить все места где используем массив hierarchyArray. Если вы пишите в eclipse, то после предыдущего изменения он подсветит красным эти места, так как изменился тип массива, исправим их

В конструкторе
hierarchyArray = new ArrayList<Pair>();
Метод getItem() (это, кстати, единственное место где eclipse (да и компилятор при сборке) не выявит ошибки, поэтому его легко пропустить)
public Object getItem(int position) {
  return hierarchyArray.get(position).item;
}
Метод clickOnItem()
public void clickOnItem (int position) {
  Item i = hierarchyArray.get(position).item;
  ... 
}
Метод getView()
public View getView(int position, View convertView, ViewGroup parent) {
  if (convertView == null)  
    convertView = mLayoutInflater.inflate(R.layout.row, null);             
  TextView title = (TextView)convertView.findViewById(R.id.title);
  
  Pair pair = hierarchyArray.get(position);
  
  title.setText(pair.item.getTitle());
  title.setCompoundDrawablesWithIntrinsicBounds(pair.item.getIconResource(), 0, 0, 0);
  title.setPadding(pair.level * 15, 0, 0, 0); // 1
  return convertView;  
}
1) Метод setPadding(int left, int top, int right, int bottom) — добавляем отступ к элементу в пикселях с соответственно с левого, верхнего, правого или нижнего края. Отступ у нас зависит от уровня level и кратен 15 пикселям.

Метод generateHierarchy()
private void generateHierarchy() {
  hierarchyArray.clear();
  generateList(originalItems, 0); // 1
}

private void generateList(ArrayList<Item> items, int level) {
  
  for (Item i : items) {
    hierarchyArray.add(new Pair(i, level));
    if (openItems.contains(i))
      generateList(i.getChilds(), level + 1); // 2
  }
}
Добавляем к рекурсивному методу generateList() еще один параметр int level.
1) При первом вызове метода generateList(), когда мы передаем массив элементов верхнего уровня, значение уровня = 0.
2) С каждой новой итерацией, когда мы добавляем нижестоящие элементы,значение уровня увеличивается.

12. Запустим еще раз. Вот теперь список похож именно на иерархический список. Последнее изменение не обязательное, но все-таки. Если в этом примере открыть Item2, в нем открыть элемент Item2.1, а затем Item2 закрыть. То при следующем открытии Item2, элемент Item2.1 будет уже открытым. Что не удивительно, ведь если посмотреть на код, то при закрытии элемента, мы удаляем его из массива openItems, но не удаляем его потомков. Исправим это
public void clickOnItem (int position) {
  
  Item i = hierarchyArray.get(position).item;
  if (!closeItem(i)) // 1
    openItems.add(i); 

  generateHierarchy();
  notifyDataSetChanged();
}
 
private boolean closeItem (Item i) {
  if (openItems.remove(i)) { // 2
  for (Item c : i.getChilds()) // 3
    closeItem(c);
  return true;
  }
  return false;
}
1) Теперь вместо простого удаления элемента из массива будем вызывать рекурсивный метод closeItem()
2) В этом методе мы точно также как раньше пытаемся удалить элемент из массива и если метод remove() вернул true (то есть элемент был открыт и мы его успешно закрыли), то
3) Вызываем этот метод рекурсивно для всех его нижестоящих элементов.

13. Запускаем. Теперь закрывая элемент мы также закрываем все нижестоящие. Все иерархический список готов.

14. Последним пунктом я хотела бы показать, как наш список можно использовать повторно для совершено другой иерархии. Сделаем иерархический список для файловой системы sd-карты устройства. Мы не будем вносить какие-то исправления в ListAdapter. Все что нам нужно для создания простенького файлового менеджера, это унаследовать интерфейс Item. Напишем класс FileItem для файла или папки файловой системы.
public class FileItem implements Item {
 
  File file; // 1
  ArrayList<Item> childs; // 2
 
  public FileItem (File f) {
    file = f; // 3
  }
 
  @Override
  public String getTitle() { // 4
    return file.getName();
  }

  @Override
  public int getIconResource() { // 5
    if (file.isDirectory()) {
      if (getCountChilds() > 0)
        return R.drawable.folder;
      return R.drawable.empty_folder;
    }
  return R.drawable.file;
  }

  @Override
  public ArrayList<Item> getChilds() { // 6
    if (childs != null)
      return childs;
 
    childs = new ArrayList<Item>();
 
    File[] files = file.listFiles();

    if (files != null) {
      for (File f : files) 
        childs.add(new FileItem(f));
    }
     
    return childs;
  }

  private int getCountChilds() { // 7
    if (childs != null)
      return childs.size();
  
    File[] files = file.listFiles();
    if (files == null)
      return 0;
    return files.length;
  }
}
1) File — это «абстрактное» представление файла, которое содержит путь к этому файлу (абсолютный по отношению к файловой системе или локальный относительно текущего каталога). Несмотря на название экземпляр класса File может указывать как на файл так и на папку. Этот класс включает набор функций для получения / настройки прав доступа к файлам, тип файла и время последнего изменения. Нам собственно это всего пока не понадобиться, главное что мы сможем получить имя файла и, если это папка, то вложенные файлы.
2) Это как раз массив вложенных файлов. Обратите внимание, что массив не File, а уже Item, который нужен для адаптера.
3) В конструктор передаем ссылку на File, но не заполняем массив вложенных файлов, их мы будем добавлять только если пользователь откроет эту папку.
4) Переопределяем метод getTitle(), который возвращает название в иерархическом списке.
5) Переопредеяем метод getResourse(), который возвращает иконку. Тут немного усложним и будем возвращать картинку «файл», «пустая папка» или «не пустая папка»
6) переопределяем метод getChild(). Если массив childs не пустой, то есть мы уже когда открывали этот пункт, то не будем создавать его заново, а просто возвращаем. Иначе получаем с помощью метод listFiles() массив вложенных файлов и, создавая экземпляра класса FileItem, добавляем их в массив childs()
7) Дополнительный метод, который возвращает кол-во вложенных файлов.

15. Теперь в HierarchyActivity вместо метода
items = generateSomeHierarchy();
инициализируем массив элементов верхнего уровня items с помощью метода
items = readSDCard();
И собственно сам метод
private ArrayList<Item> readSDCard() {
  FileItem sdcard = new FileItem (Environment.getExternalStorageDirectory());
  return sdcard.getChilds();
}
Он получился очень коротким и лакончиным. Статичный метод getExternalStorageDirectory () класса Environment возвращает объект File для корневой директории sd карточки. Элементами верхнего уровня нашего списка, будут является вложенные в эту директорию файлы и папки. Мы создаем экземпляр класса FileItem и с помощью метода getItem() получаем его потомков, их и отображаем.

16. Запускаем. И видим файлы на sd карточке


Исходники: Hierarchy.zip

Комментариев нет:

Отправить комментарий