Секционный список в Android

в 11:27, , рубрики: android, Песочница, Разработка под android, списки, метки: ,

Списки, разделённые на секции, встречаются довольно часто как в системных приложениях, так и в сторонних. Но, как ни странно, платформа не предлагает способов для их реализации «из коробки», в отличие от iOS.
image

Мне известны два подхода к реализации заголовков списков:

  • В вёрстке каждого элемента списка прописывается заголовок, видимость которого будет определяться условием «является ли данный элемент первым в секции?»
  • Отдельно верстается заголовок для списка, а в методе Adapter#getView идёт выбор: создавать элемент типа «заголовок», либо элемент типа «обычная строка»

Первый способ проще с точки зрения написания кода: не надо задумываться о выборе типа ячейки списка. Но влечёт за собой более «тяжёлый», с точки зрениния производительности, интерфейс. Второй — чуть более громоздкий в реализации, но немного быстрее и, что самое главное, позволяет использовать системные вёрстки.
В данной статье используется второй подход.

Базовый класс адаптера

Базовый адаптер берёт на себя ответственность за определение типа строки списка, взамен просит своих наследников уметь создавать заголовки и строки с данными:

    protected abstract int getSectionsCount( );
    protected abstract int getRowsCountInSection( int section );
    protected abstract View getHeaderView( int section, View convertView, 
        ViewGroup parent, LayoutInflater inflater );
    protected abstract View getItemView( int section, int row, View convertView, 
        ViewGroup parent, LayoutInflater inflater );

Далее, при изменении данных в списке (при вызове notifyDataSetChanged), мы считаем общее количество строк в списке, кешируем количество элементов в каждой секции, количество разных типов строк списка и связь этих типов с секциями:

    private void setupAdapter( ) {
        int sectionsCount = getSectionsCount();
        _rowsCountsInSections = new int [sectionsCount];
        _viewTypes = new int [sectionsCount];

        _totalRowsCount = sectionsCount;
        Set<Integer> viewTypesSet = new HashSet<Integer>();
        for (int i = 0; i < sectionsCount; i++) {
            _rowsCountsInSections[i] = getRowsCountInSection(i);
            _totalRowsCount += _rowsCountsInSections[i];
            _viewTypes[i] = getViewTypeForSection(i);
            viewTypesSet.add(_viewTypes[i]);
        }

        viewTypesSet.add(VIEW_TYPE_HEADER);
        _viewTypesCount = viewTypesSet.size();
    }

На основе собранной информации можно без особых проблем выбрать, что нам нужно создавать в getView:

    @Override
    final public View getView( int position, View convertView, ViewGroup parent ) {
        int section = getItemSection(position);

        if (isItemHeader(position))
            return getHeaderView(section, convertView, parent, _inflater);

        int rowInSection = getItemRowInSection(position);
        return getItemView(section, rowInSection, convertView, parent, _inflater);
    }

Определяем, является ли строка заголовком или элементом секции, так:

    private boolean isItemHeader( int position ) {
        int sum = 0;
        for (int i = 0; i < _rowsCountsInSections.length && sum <= position; i++) {
            if (sum == position)
                return true;

            sum += _rowsCountsInSections[i] + 1;
        }

        return false;
    }

    private int getItemSection( int position ) {
        int sum = 0;
        int section = 0;
        while (sum <= position && section < _rowsCountsInSections.length)
            sum += _rowsCountsInSections[section++] + 1;

        return section - 1;
    }

    private int getItemRowInSection( int position ) {
        int section = getItemSection(position);
        int sum = 0;
        for (int i = 0; i < section; i++)
            sum += _rowsCountsInSections[i] + 1;

        return position - sum - 1;
    }

В принципе, методы получения номера секции и индекса внутри секции можно объединить в один, разделение оставлено для наглядности.
Базовый класс целиком:

abstract public class BaseSectionedListAdapter extends BaseAdapter {
    private static int VIEW_TYPE_HEADER = 0;
    protected static int VIEW_TYPE_DATA = 1;

    final private LayoutInflater _inflater;
    private int _totalRowsCount = -1;
    private int [] _rowsCountsInSections;
    private int [] _viewTypes;
    private int _viewTypesCount;

    public BaseSectionedListAdapter( Context context ) {
        _inflater = LayoutInflater.from(context);
    }

    @Override
    final public int getCount( ) {
        if (_totalRowsCount == -1)
            setupAdapter();

        return _totalRowsCount;
    }

    @Override
    final public Object getItem( int position ) {
        return null;
    }

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

    @Override
    final public View getView( int position, View convertView, ViewGroup parent ) {
        int section = getItemSection(position);

        if (isItemHeader(position))
            return getHeaderView(section, convertView, parent, _inflater);

        int rowInSection = getItemRowInSection(position);
        return getItemView(section, rowInSection, convertView, parent, _inflater);
    }

    @Override
    public int getItemViewType( int position ) {
        if (isItemHeader(position))
            return VIEW_TYPE_HEADER;

        int section = getItemSection(position);
        return _viewTypes[section];
    }

    @Override
    public int getViewTypeCount( ) {
        return _viewTypesCount;
    }

    @Override
    public boolean isEnabled( int position ) {
        return !isItemHeader(position);
    }

    @Override
    final public boolean areAllItemsEnabled( ) {
        return false;
    }

    @Override
    final public void notifyDataSetChanged( ) {
        super.notifyDataSetChanged();
        setupAdapter();
    }

    @Override
    public boolean hasStableIds( ) {
        return true;
    }

    private boolean isItemHeader( int position ) {
        int sum = 0;
        for (int i = 0; i < _rowsCountsInSections.length && sum <= position; i++) {
            if (sum == position)
                return true;

            sum += _rowsCountsInSections[i] + 1;
        }

        return false;
    }

    private int getItemSection( int position ) {
        int sum = 0;
        int section = 0;
        while (sum <= position && section < _rowsCountsInSections.length)
            sum += _rowsCountsInSections[section++] + 1;

        return section - 1;
    }

    private int getItemRowInSection( int position ) {
        int section = getItemSection(position);
        int sum = 0;
        for (int i = 0; i < section; i++)
            sum += _rowsCountsInSections[i] + 1;

        return position - sum - 1;
    }

    private void setupAdapter( ) {
        int sectionsCount = getSectionsCount();
        _rowsCountsInSections = new int [sectionsCount];
        _viewTypes = new int [sectionsCount];

        _totalRowsCount = sectionsCount;
        Set<Integer> viewTypesSet = new HashSet<Integer>();
        for (int i = 0; i < sectionsCount; i++) {
            _rowsCountsInSections[i] = getRowsCountInSection(i);
            _totalRowsCount += _rowsCountsInSections[i];
            _viewTypes[i] = getViewTypeForSection(i);
            viewTypesSet.add(_viewTypes[i]);
        }

        viewTypesSet.add(VIEW_TYPE_HEADER);
        _viewTypesCount = viewTypesSet.size();
    }

    protected int getViewTypeForSection( int section ) {
        return VIEW_TYPE_DATA;
    }

    protected abstract int getSectionsCount( );
    protected abstract int getRowsCountInSection( int section );
    protected abstract View getHeaderView( int section, View convertView,
        ViewGroup parent, LayoutInflater inflater );
    protected abstract View getItemView( int section, int row, View convertView,
        ViewGroup parent, LayoutInflater inflater );
}
Пример использования

Простой пример: на вход подаём список «секций», рисуем все данные секций одним стилем.
Секция представляет название заголовка и набор данных:

final public class SimpleSection {
    final private String _title;
    final private List<?> _data;

    public SimpleSection( String title, List<?> data ) {
        _title = title;
        _data = data;
    }

    public String getTitle( ) {
        return _title;
    }

    public List<?> getData( ) {
        return _data;
    }
}

Для заголовка используется простейшая вёрстка, состаящая из одного TextView:

<TextView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@android:id/text1"
    android:layout_width="match_parent"
    android:layout_height="30dp"
    android:textAppearance="?android:attr/textAppearanceSmallInverse"
    android:gravity="center_vertical"
    android:paddingLeft="6dip"
    android:background="@android:color/black"
/>

Простой адаптер целиком:

final public class SimpleSectionedListAdapter extends BaseSectionedListAdapter {
    final private List<SimpleSection> _sections;

    public SimpleSectionedListAdapter( Context context, List<SimpleSection> sections ) {
        super(context);
        _sections = sections;
    }

    @Override
    protected int getSectionsCount( ) {
        return _sections.size();
    }

    @Override
    protected int getRowsCountInSection( int section ) {
        return _sections.get(section).getData().size();
    }

    @Override
    protected View getHeaderView( int section, View convertView, ViewGroup parent, LayoutInflater inflater ) {
        if (convertView == null)
            convertView = inflater.inflate(R.layout.list_header, parent, false);

        TextView text = (TextView)convertView;
        text.setText(_sections.get(section).getTitle());

        return convertView;
    }

    protected Object getItemInSection( int section, int row ) {
        return _sections.get(section).getData().get(row);
    }

    @Override
    protected View getItemView( int section, int row, View convertView, ViewGroup parent, LayoutInflater inflater ) {
        Object item = getItemInSection(section, row);
        if (convertView == null)
            convertView = inflater.inflate(android.R.layout.simple_list_item_1, parent, false);

        TextView text = (TextView)convertView;
        text.setText(item.toString());

        return convertView;
    }
}
Итого

Если натравить на приведённый выше адаптер некоторый набор данных, то можно будет получить что-то похожее на (если поиграться с getViewTypeForSection, то легко получаем вторую картинку):

image image

Приветствуются любые комментарии, пожелания, а также ругательства относительно стиля кодирования.

Автор: HighFlyer


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js