В этой статье мы хотим поделиться опытом создания кастомного ViewGroup в Android, который мы разработали в рамках одного из проектов Программы «Единая фронтальная система». Перед нами стояла задача создать красивую галерею банковских карт. При этом обычный список, который предоставляет RecyclerView и LinearLayoutManager, не подходил. Была задумка показать нестандартную механику скролла карт, чтобы при переходе карты не уходили полностью за пределы экрана, а собирались в стопку. О том, как мы это сделали, читайте под катом.




В предыстории скажем, что наш первый вариант был тривиальным – использовать готовое решение. Например в Android уже давно есть похожий контрол StackView. Код приводить не будем, он достаточно простой, ищем в Activity StackView, сетим в него адаптер, который отдает View наших карт. Смотрим, что получается. Карты расположены по диагонали, плюс анимация какая-то странная. Совсем не то, что хотелось бы. В кастомизации этого класса разбираться долго, так что попробуем сами.

Механика списка


Методом проб и ошибок мы пришли к механике, где карты отображаются в похожем на список виде. При этом карты, которые не видны в обычном списке, когда уходят за его пределы, у нас складываются в стопку. Здесь важно ограничить использование памяти, точнее, держать в памяти не все дочерние View, а минимальное, желательно, постоянное количество.



Для простоты опишем механику стопки сверху при скролле вверх. Стопка снизу будет работать примерно так же — только карты будут подсовываться под нее, а не наезжать. Красная линия на рисунке показывает, где проходит граница начала стопки.

Для последующей работы введем обозначения:

  • foldHeight — высота области для стопки;
  • maxCardCountInFold — максимальное возможное количество карт в стопке, в нашем примере оно равно трем;
  • cardFoldHeight = foldHeight / maxCardCountInFold — высота карты в стопке.

Состояния списка


  1. На экране целые карты. Одна за другой. Все как в обычном списке.
  2. Начинаем скроллить вверх. Синяя карта начинает наезжать на зеленую карту. Останавливается в положении, когда видимая часть зеленой карты становится равной cardFoldHeight. Сейчас есть одна карта в стопке.
  3. Продолжаем скроллить вверх. Первые две карты не двигаются. Розовая карта надвигается на синюю. Останавливается в положении, когда видимая часть синей карты, лежащей под ней, становится равна cardFoldHeight. Теперь в стопке две карты.
  4. Скроллим дальше. Только теперь видимая часть розовой карты становится равна cardFoldHeight. В этом состоянии в стопке три карты — максимально допустимое количество карт. Чтобы добавить в стопку новую карту, нужно вывести из нее первую добавленную карту. Строго говоря, стопка работает по принципу FIFO: первый вошел — первый пошел.

В какой момент начинать двигать всю стопку, чтобы выкинуть за борт первую карту?  Рассмотрим возможные варианты:

а) начинаем двигать стопку в момент, когда бирюзовая карта соприкоснулась с желтой картой;


б) начинаем двигать стопку в момент, когда бирюзовая карта уже наехала на желтую, а желтая имеет размер cardFoldHeight, точка A.


В обоих случаях стопка двигается при перемещении желтой карты от точки A до точки B. Когда бирюзовая карта попадает в положение B, синяя карта становится полностью не видна. В этом состоянии наш StackView освобождает память, занятую синей картой.

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



Внутреннее устройство StackView


Опишем кратко, из каких основных компонентов состоит наш кастомный ViewGroup, и как они взаимодействуют.

Вспомогательные классы



//  хранит в себе индексы карт, которые сейчас видны 
class Range {
    private int mFrom;
    private int mTo;
}

//класс который вычисляет видимый диапазон карт в зависимости от текущего 
//смещения карт currentScroll.
public class RangeCalculator {
    public Range getVisibleRange(int currentScroll);
}

// рассчитывает параметры стопки в зависимости от текущего смещения 
// карт — currentScroll.
class Fold {
   public int minTop();
   public int maxTop(); 
   public void update(int currentScroll, int fullCardHeight);
}

Теперь сам StackView


StackView является наследником ViewGroup. В нашем StackView в методе dispatchTouchEvent(MotionEvent event) с помощью наследника GestureDetector.SimpleOnGestureListener мы определяем, когда пользователь скроллит список и смещение currentScroll. От параметра currentScroll будет зависеть позиция карт в списке.

Основные методы класса StackView, которые определяют размеры и позиции дочерних View, это onMeasure() и onLayout(). Ниже приведен псевдокод этих методов.

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    measureChildren(widthMeasureSpec, heightMeasureSpec);    
        mFold.update(mCurrentScroll, mFullCardHeight);
        final Range newRange = mRangeCalculator.getVisibleRange(mCurrentScroll);
        if (getChildCount() == 0) {
            addCards(newRange);
        } else {
            removeCards(newRange);
            addNewCards(newRange);
        }
        mVisibleCardsRange.set(newRange);
    }
} 

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    final int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        final View child = getChildAt(i);
        final int childLeft = getPaddingLeft();
        final int childRight = childLeft + child.getMeasuredWidth();
        final int childHeight = child.getMeasuredHeight();
        final int childTop = getChildTop(childCount, i, childHeight);
        final int childBottom = childTop + childHeight;
        child.layout(childLeft, childTop, childRight, childBottom);
    }
}
 

// вычисление childTop текущей карты
private int getChildTop(final int childCount, final int childIndex, int childHeight) {
    int childTop = -mCurrentScroll + (childIndex + mVisibleCardsRange.from()) *    childHeight + getPaddingTop();    
    int minTopForCurrentChild = (int) (childIndex * mFold.getCardSizeInFold()) -  mFold.minTop();
    minTopForCurrentChild = Math.max(0, minTopForCurrentChild);
    int maxTopForCurrentChild = (int) (getMeasuredHeight() - (childCount - childIndex) * mFold.getCardSizeInFold()) + mFold.maxTop();
    maxTopForCurrentChild = Math.min(maxTopForCurrentChild, getMeasuredHeight());    
    if (childTop < minTopForCurrentChild) childTop = minTopForCurrentChild;
    if (childTop > maxTopForCurrentChild) childTop = maxTopForCurrentChild;
    return childTop;
}

Что получилось и какие мы сделали выводы


Мы начинали создание этого кастомного ViewGroup компонента не с нуля, взяли за основу реализацию похожего списка. Но пришлось потратить время на изучение чужого кода и допиливание его до нужного нам состояния. В процессе мы сделали несколько вариантов реализации механики списка и в итоге выбрали тот, который выглядит красиво и за счет некоторых упрощений потребляет ограниченное количество памяти – в данном случае мы просто ограничиваем число карт в стопке.

На практике оказалось, что нет смысла показывать в стопке все карты. Если их слишком много, размер видимой части карты в стопке стремится к нулю, и красоты это не добавляет. У нас есть постоянное количество дочерних View, и нам не страшен серый волк OutOfMemoryException.

Можно считать, что с задачей построения прототипа мы справились. Определили вариант механики списка, который выглядит хорошо, а главное — технически реализуем. И теперь мы знаем, как его сделать лучше.

Будем рады пообщаться с вами и обменяться идеями по теме. Мы решили не выкладывать весь код в посте, а остановиться на основных принципах. Если у вас остались вопросы, пожалуйста, пишите в комментариях.

Комментарии (3)


  1. Tiberal
    26.10.2017 20:30

    Почему не RecyclerView с кастомным LayoutManager?


    1. forceLain
      27.10.2017 07:39

      Поддерживаю, RecyclerView + кастомный LayoutManager точно решит эту задачу. Мы как то писали материал на эту тему habrahabr.ru/company/eastbanctech/blog/267497


    1. KarlKarl
      30.10.2017 10:52

      На тот момент уже был частично код. Было проще добить его до рабочего состояния. Сейчас, конечно, если делать заново или рефакторить, то кастомный LayoutManager это лучший выбор.