В этой статье мы хотим поделиться опытом создания кастомного ViewGroup в Android, который мы разработали в рамках одного из проектов Программы «Единая фронтальная система». Перед нами стояла задача создать красивую галерею банковских карт. При этом обычный список, который предоставляет RecyclerView и LinearLayoutManager, не подходил. Была задумка показать нестандартную механику скролла карт, чтобы при переходе карты не уходили полностью за пределы экрана, а собирались в стопку. О том, как мы это сделали, читайте под катом.
В предыстории скажем, что наш первый вариант был тривиальным – использовать готовое решение. Например в Android уже давно есть похожий контрол StackView. Код приводить не будем, он достаточно простой, ищем в Activity StackView, сетим в него адаптер, который отдает View наших карт. Смотрим, что получается. Карты расположены по диагонали, плюс анимация какая-то странная. Совсем не то, что хотелось бы. В кастомизации этого класса разбираться долго, так что попробуем сами.
Механика списка
Методом проб и ошибок мы пришли к механике, где карты отображаются в похожем на список виде. При этом карты, которые не видны в обычном списке, когда уходят за его пределы, у нас складываются в стопку. Здесь важно ограничить использование памяти, точнее, держать в памяти не все дочерние View, а минимальное, желательно, постоянное количество.
Для простоты опишем механику стопки сверху при скролле вверх. Стопка снизу будет работать примерно так же — только карты будут подсовываться под нее, а не наезжать. Красная линия на рисунке показывает, где проходит граница начала стопки.
Для последующей работы введем обозначения:
- foldHeight — высота области для стопки;
- maxCardCountInFold — максимальное возможное количество карт в стопке, в нашем примере оно равно трем;
- cardFoldHeight = foldHeight / maxCardCountInFold — высота карты в стопке.
Состояния списка
- На экране целые карты. Одна за другой. Все как в обычном списке.
- Начинаем скроллить вверх. Синяя карта начинает наезжать на зеленую карту. Останавливается в положении, когда видимая часть зеленой карты становится равной cardFoldHeight. Сейчас есть одна карта в стопке.
- Продолжаем скроллить вверх. Первые две карты не двигаются. Розовая карта надвигается на синюю. Останавливается в положении, когда видимая часть синей карты, лежащей под ней, становится равна cardFoldHeight. Теперь в стопке две карты.
- Скроллим дальше. Только теперь видимая часть розовой карты становится равна 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.
Можно считать, что с задачей построения прототипа мы справились. Определили вариант механики списка, который выглядит хорошо, а главное — технически реализуем. И теперь мы знаем, как его сделать лучше.
Будем рады пообщаться с вами и обменяться идеями по теме. Мы решили не выкладывать весь код в посте, а остановиться на основных принципах. Если у вас остались вопросы, пожалуйста, пишите в комментариях.
Автор: EFS_programm