По мнению автора, статья может быть полезна таким же как он начинающим Android-разработчикам, совершающим свои первые шаги в такой увлекательной области. История предмета этой заметки началась с идеи оснастить учебный проект так называемым “вау-эффектом”. Насколько это удалось, судить вам. Всех любопытствующих прошу под кат.
Демонстрационный проект со всем этим безобразием можно найти на GitHub по ссылке.
В основе экрана, который нас интересует, лежит всеми любимый RecyclerView. А изюминка же состоит в том, чтобы при пролистывании списка, один полностью видимый верхний элемент масштабировался особым образом. Эта особенность характерна тем, что масштабирование происходит по разному для компонентов, составляющих элемент списка.
Впрочем, лучше один раз увидеть.
Рассмотрим элемент списка детально. В проекте он реализован как класс LaunchItemView унаследованный от CardView. Его разметка содержит следующие компоненты:
- Изображение — класс ScaledImageView, предназначен для отрисовки изображения с заданной высотой (масштабированием).
- Заголовок.
- Поясняющий текст.
Рис. 2. Структура элемента списка (LaunchItemView).
В процессе прокручивания списка происходит следующее:
- Изменяется высота элемента от минимальной величины (равна высоте заголовка с декорированием) до максимальной величины (равна высоте, позволяющей отобразить заголовок и поясняющий текст, с декорированием).
- Высота изображения равна высоте элемента за вычетом декорирования, ширина изменяется пропорционально.
- Относительное положение внутри элемента и размер поясняющего текста остается неизменным.
- Величина масштабирования ограничена сверху минимальным размером, достаточным для отображения всего контента с учетом декорирования и снизу минимальным размером, достаточным для отображения заголовка с учетом декорирования.
- Масштабирование, отличное от граничных значений, применяется к верхнему полностью видимому элементу списка. Элементы выше него имеют максимальный масштаб, ниже — минимальный.
Таким образом при прокручивании вверх создается эффект постепенного “вытягивания” содержимого элемента с пропорциональным масштабированием изображения. При пролистывании вниз наблюдается обратный эффект.
Поставленную таким образом задачу я решил путем создания LayoutManager-а для RecyclerView и двух дополнительных компонентов. Но обо всем по порядку.
LaunchLayoutManager
Мой учебный проект посвящен космической тематике, поэтому компоненты получили соответствующие имена.
Изучая тему создания произвольного LayoutManager-а нашел две хорошие статьи на эту тему [1, 2]. Повторять их содержимое я не буду. Вместо этого остановлюсь на наиболее интересных моментах моего решения.
Выполняя декомпозицию задачи, я выделил два основных этапа:
- Определение индекса первого и последнего элемента, которые полностью или частично видимы на экране.
- Отрисовка видимых элементов с необходимым масштабированием.
В общем случае расположение и размер элементов списка выглядит следующим образом:
Рис 3. RecyclerView и его элементы.
Определение замкнутого интервала индексов видимых элементов
На рис. 3 к видимым относятся элементы с индексами от 3 до 11 включительно. Причем, согласно нашему алгоритму, элементы с индексами 0-3 имеют максимальный размер, элементы 5-12 — минимальный, а элемент с индексом 4 — промежуточный между минимальным и максимальным.
Как нетрудно догадаться, одним из ключевых моментов в определении минимального и максимального индекса видимых элементов является смещение, на которое промотан список относительно верхней границы видимой области.
Рассмотрим метод calculateVisiblePositions, предназначенный для определения этих величин.
1 private void calculateVisiblePositions() {
2 if (mBigViewHeight != 0) {
3 mMaximumOffset = (getItemCount() - 1) * mBigViewHeight;
4 mFirstVisibleViewPosition = mOffset / mBigViewHeight;
5 if (mFirstVisibleViewPosition > getItemCount() - 1) {
6 mFirstVisibleViewPosition = 0;
7 mOffset = 0;
8 }
9
10 mLastVisibleViewPosition = mFirstVisibleViewPosition;
11 int emptyHeight = getHeight();
12 mFirstVisibleViewTopValue =
mBigViewHeight * mFirstVisibleViewPosition - mOffset;
13 int firstVisibleViewBottomValue =
mFirstVisibleViewTopValue + mBigViewHeight;
14 emptyHeight -= firstVisibleViewBottomValue;
15 int secondVisibleViewHeight =
getViewHeightByTopValue(firstVisibleViewBottomValue);
16 if (emptyHeight - secondVisibleViewHeight >= 0) {
17 emptyHeight -= secondVisibleViewHeight;
18 mLastVisibleViewPosition++;
19 int smallViewPosCount = emptyHeight / mSmallViewHeight;
20 mLastVisibleViewPosition += smallViewPosCount;
21 emptyHeight -= smallViewPosCount * mSmallViewHeight;
22 if (emptyHeight > 0) {
23 mLastVisibleViewPosition++;
24 }
25 }
26 if (mLastVisibleViewPosition > getItemCount() - 1) {
27 mLastVisibleViewPosition = getItemCount() - 1;
28 }
29 Timber.d("calculateVisiblePositions
mFirstVisibleViewPosition:%d,
mLastVisibleViewPosition:%d",
mFirstVisibleViewPosition,
mLastVisibleViewPosition);
30 }
31 }
Строка 2 — проверяем, определена ли высота элемента максимального размера, отображающего весь контент (заголовок, поясняющий текст и изображение). Если нет, то и продолжать смысла нет.
Строка 3 — вычисляем, сколько займут места прокрученные вверх все элемента списка, кроме одного — максимально допустимое смещение. Эта величина будет ограничивать значение смещения в методе scrollVerticallyBy.
Строка 4 — вычисляем индекс первого видимого элемента. Поскольку переменная mFirstVisibleViewPosition принадлежит к целочисленному типу, то за счет отбрасывания дробной части автоматически учитываем случай частично видимого первого элемента.
Строки 5-8 — проверяется, не превышает ли индекс первого видимого элемента индекс последнего имеющегося элемента в списке. Такое может случится, например, когда список сначала был прокручен вверх, а потом количество элементов уменьшилось, например, за счет применения фильтра. В этом случае просто “перематываем” список на начало.
Строка 10 — используем индекс первого видимого элемента как отправную точку для поиска индекса последнего.
Строка 11 — устанавливаем высоту видимой области. Эта величина будет уменьшаться в ходе поиска максимального индекса видимых элементов.
Строки 12, 13 — определяем координаты top и bottom прямоугольника отрисовки первого элемента.
Строка 14 — уменьшаем величину свободной видимой области на размер видимой части первого элемента. Т.е. как бы виртуально размещаем первый элемент на экране.
Строка 15 — вычисляем высоту второго видимого элемента. Именно этот элемент потенциально подлежит масштабированию (см. п.5 алгоритма). Метод getViewHeightByTopValue детально рассмотрен ниже.
Строка 16 — проверяем, останется ли еще свободное место после “виртуального размещения” второго элемента на экране.
Строка 17 — фиксируем, сколько осталось свободного места.
Строка 18 — инкрементируем индекс последнего видимого элемента.
Строка 19 — вычисляем наибольшее число элементов минимального размера, которые могут поместится в оставшееся свободное место и при этом будут полностью видимы.
Строка 20 — увеличиваем индекс последнего видимого элемента на вычисленную величину.
Строки 21-24 — проверяем, есть ли место для частичного размещения еще одного элемента. Если да, увеличиваем индекс еще на единицу.
Теперь о методе, который вычисляет высоту второго видимого элемента в зависимости от позиции на экране — координаты top прямоугольника отображения этого элемента.
1 private int getViewHeightByTopValue(int topValue) {
2 topValue -= mTopAndBottomMargins;
3 if (topValue > mBigViewHeight) {
4 topValue = mBigViewHeight;
5 } else if (topValue < 0) {
6 topValue = 0;
7 }
8 float scale = 1 - (float) topValue / mBigViewHeight;
9 int height =
(int) (mSmallViewHeight + scale * (mBigViewHeight - mSmallViewHeight));
10 Timber.d("getViewHeightByTopValue
topValue:%d, scale:%f, height:%d",
topValue, scale, height);
11 return height;
12 }
Строка 2 — отбрасываем нижний и верхний margin.
Строки 3-7 — для корректного вычисления масштаба ограничиваем сверху величину top максимальной высотой элемента, а снизу нулем.
Строка 8 — вычисляем коэффициент масштабирования, который принимает значение 1 для максимально развернутого элемента и 0 — для минимального. Для корректности именно этого результата нам требуются ограничения в строках 3-7.
Строка 9 — вычисляем высоту элемента как прибавку к минимальной высоте и разницу между максимальной и минимальной высотой с учетом коэффициента масштабирования. Т.е. при коэффициенте 0 — высота минимальная, а при 1 — минимальная + (максимальная — минимальная) = максимальная.
Итак, теперь мы знаем первый и последний индексы элементов, подлежащих отрисовке. Самое время этим заняться!
Отрисовка элементов с необходимым масштабированием
Поскольку процесс отрисовки носит циклический характер, то непосредственно перед отрисовкой мы прогреем кэш уже существующими элементами RecyclerView (если таковые конечно есть). Такой прием освещен в [1, 2] и здесь на нем останавливаться не буду.
Рассмотрим метод fillDown, предназначенный для отрисовки элементов двигаясь сверху вниз по имеющейся видимой области.
1 private void fillDown(RecyclerView.Recycler recycler) {
2 boolean isViewFromCache;
3 int topValue = mFirstVisibleViewTopValue;
4 int bottomValue;
5 int viewHeight;
6 try {
7 for (int curPos = mFirstVisibleViewPosition;
curPos <= mLastVisibleViewPosition; curPos++) {
8 isViewFromCache = true;
9 View view = mViewCache.get(curPos);
10 if (view == null) {
11 isViewFromCache = false;
12 view = recycler.getViewForPosition(curPos);
13 } else {
14 mViewCache.remove(curPos);
15 }
16 viewHeight = getViewHeightByTopValue(topValue);
17 bottomValue = topValue + viewHeight;
18 if (view instanceof LaunchItemView) {
19 ((LaunchItemView) view).updateContentSize(viewHeight);
20 }
21 if (isViewFromCache) {
22 if (view.getTop() != topValue) {
23 view.setTop(topValue);
24 }
25 if (view.getBottom() != bottomValue - mTopAndBottomMargins) {
26 view.setBottom(bottomValue - mTopAndBottomMargins);
27 }
28 attachView(view);
29 } else {
30 layoutView(view, topValue, bottomValue);
31 }
32 topValue = bottomValue;
33 }
34 } catch (Throwable throwable) {
35 Timber.d(throwable);
36 }
37 }
Строка 3 — инициируем переменную topValue координатой top первого видимого элемента. От печки этого значения и будем плясать дальше.
Строка 7 — инициируем цикл по индексам элементов, подлежащих отрисовке.
Строка 8 — оптимистично предполагаем, что найдем нужный нам элемент в кэше.
Строка 9 — смотрим в кэш (с надеждой).
Строка 10-12 — если в кэше нужного нам элемента не оказалось, запрашиваем его у экземпляра класса RecyclerView.Recycler, который возвращает view, инициализированной данными из адаптера для конкретной позиции.
Строка 14 — если элемент все же был в кэше, удаляем его оттуда.
Строка 16 — вычисляем высоту элемента в зависимости от его положения на экране.
Строка 17 — вычисляем нижнюю границу элемента.
Строки 18-20 — масштабируем контент элемента, если он умеет это делать.
Строка 21 — для нас важно понять, была ли ранее отрисована текущая view (взята из кэша) или же мы получили новый экземпляр. Эти два варианта требуют различных подходов.
Строки 22-28 — если view получена из кэша, то при необходимости мы изменяем значения координат top и bottom, и присоединяем view.
Строка 30 — если view не из кэша, то для отображения элемента мы используем метод layoutView, который рассмотрен ниже.
Строка 32 — смещаем topValue на нижнюю границу только что отрисованной view, чтобы это значение стало отправной точкой для следующей итерации цикла.
Теперь о методе layoutView, предназначенном для отображения новенького элемента списка, полученного у экземпляра класса RecyclerView.Recycler.
1 private void layoutView(View view, int top, int bottom) {
2 addView(view);
3 measureChildWithMargins(view, 0, 0);
4 int decoratedMeasuredWidth = getDecoratedMeasuredWidth(view);
5 RecyclerView.LayoutParams layoutParams =
(RecyclerView.LayoutParams) view.getLayoutParams();
6
7 layoutDecorated(view,
8 layoutParams.leftMargin,
9 top + layoutParams.topMargin,
10 decoratedMeasuredWidth + layoutParams.rightMargin,
11 bottom + layoutParams.bottomMargin);
12 }
Строка 2 — добавляем view в RecyclerView.
Строка 3 — измеряем view.
Строка 4 — определяем ширину view.
Строка 5 — получаем layout-параметры view.
Строка 7 — собственно отрисовываем view в полученных координатах.
Масштабирование содержимого
Из всей структуры элемента списка масштабированию подлежит только изображение. Логика этого масштабирования инкапсулирована в класс ScaledImageView, унаследованный от View.
В нашем случае выполнение масштабирования требуется в произвольные моменты времени, и зависит от внешних факторов, которые мы не можем контролировать, например от того, насколько интенсивно пользователь прокручивает список. Поскольку это очень органично ложится в парадигму реактивного программирования, я не смог упустить шанс попрактиковаться с RxJava и горячим источником данных.
Будем использовать PublishProcessor для создания потока целочисленных значений, которые определяют требуемую высоту изображения:
private PublishProcessor<Integer> mScalePublishProcessor;
Соответственно для выполнения масштабирования мы просто генерируем еще один элемент потока с требуемой величиной:
public void setImageHeight(int height) {
mScalePublishProcessor.onNext(height);
}
А вот как происходит асинхронная обработка этого потока:
1 private void initObserver() {
2 mScalePublishProcessor
3 .filter(value -> value > 0
&& value != mBitmapHeight
&& mOriginalBitmap != null)
4 .onBackpressureBuffer(1,
5 () -> Timber.d("initObserver: buffer overflow"),
BackpressureOverflowStrategy.DROP_OLDEST)
6 .observeOn(Schedulers.computation(), false, 1)
7 .map(this::createScaledBitmap)
8 .map(this::setScaledBitmap)
9 .subscribe(
10 (value) -> {
11 invalidate();
12 Timber.d("initObserver invalidate ThreadId:%d",
Thread.currentThread().getId());
13 }, Timber::d);
14 }
Строка 3 — выполняем первоначальную фильтрацию:
- Отбрасываем значения меньше или равные нулю.
- Не рассматриваем значения, величина которых равна высоте изображения, масштабированного ранее. Т.е. тот случай, когда масштабирование уже не требуется.
- Не выполняем масштабирование, когда оригинальное изображение не инициализировано. Инициализация рассмотрена ниже в методе setBitmap.
Строка 4 — используем обратное давление с размером буфера 1 элемент и стратегией вытеснения из буфера более старого элемента. За счет этого мы будем получать всегда самое актуальное значение высоты для масштабирования. В нашем случае это очень важно, поскольку имеем горячий источник, который в ответ на действия пользователя (например, интенсивное прокручивание списка), будет порождать элементы быстрее, чем сможем их обработать (выполнять масштабирование). В таких условиях нет смысла накапливать значения в буфере и обрабатывать эти элементы последовательно, поскольку они уже устарели, пользователь уже “промотал” это состояние.
Для иллюстрации и усиления эффекта я добавил задержку 25 мс в метод масштабирования изображения (createScaledBitmap) и ниже привел две визуализации: без использования обратного давления (слева) и с обратным давлением (справа). Интерфейс слева явно отстает от действий пользователя, живет какой-то своей жизнью. Справа — потерял в плавности из-за дополнительной задержке в методе масштабирования, но не в отзывчивости.
Без обратного давления | С обратным давлением |
Строка 6 — переносим работу в поток Schedulers.computation() с указанием размера буфера.
Строка 7 — выполняем масштабирование (описание метода см. ниже).
Строка 8 — устанавливаем масштабированную картинку для отображения.
Строка 9 — подписываемся на поток.
Строка 11 — в завершении масштабирования выполняем перерисовку элемента.
Метод createScaledBitmap, непосредственно занимающийся получением изображения нужного размера:
1 private Bitmap createScaledBitmap (Integer value) {
2 Timber.d("createScaledBitmap value:%d", value);
3 if (value > mHeightSpecSize) {
4 value = mHeightSpecSize;
5 }
6 return Bitmap.createScaledBitmap(mOriginalBitmap, value, value, false);
7 }
Строки 3-5 — ограничим максимальную высоту размером view, который вычисляется в методе onMeasure.
Строка 6 — создаем изображение нужного размера из оригинала.
В методе setScaledBitmap сохраняем масштабированное изображения для отображения во view:
1 private Boolean setScaledBitmap(Bitmap bitmap) {
2 try {
3 mBitmapLock.lock();
4 if (bitmap != mDrawBitmap && mDrawBitmap != null) {
5 mDrawBitmap.recycle();
6 }
7 mDrawBitmap = bitmap;
8 mBitmapHeight = bitmap.getHeight();
9 } catch (Throwable throwable) {
10 Timber.d(throwable);
11 }
12 mBitmapLock.unlock();
13 return true;
14 }
Строки 3, 12 — используем блокировку для синхронизации обращения к переменной, содержащей изображение, которое подлежит отрисовке на экране.
Строки 4-6 — утилизируем ранее созданное изображение.
Строки 7-8 — запоминаем новое изображение и его размер.
Метод setBitmap устанавливает оригинальное изображение:
1 public void setBitmap(Bitmap bitmap) {
2 if (bitmap != null) {
3 mOriginalBitmap = Bitmap.createScaledBitmap(bitmap,
mWidthSpecSize, mHeightSpecSize, false);
4 if (bitmap != mOriginalBitmap) {
5 bitmap.recycle();
6 }
7 int height = mBitmapHeight;
8 mBitmapHeight = 0;
9 setImageHeight(height);
10 }
11 }
Строка 3 — масштабируем оригинальное изображение до размеров view. Это позволит нам сохранить ресурсы при выполнении масштабирования в методе createScaledBitmap если оригинальное изображение превосходит view по размерам.
Строка 4-6 — утилизируем старое оригинальное изображение.
Строки 7-9 — обнуляем высоту для масштабирования, чтобы преодолеть фильтр в методе initObserver, и продуцируем элемент потока для перерисовки нового изображения в требуемом масштабе.
Итог
В рамках статьи я старался внятно изложить некоторые идеи, пришедшие ко мне в ходе работы над учебным проектом. Демонстрационный проект можно найти на GitHub по ссылке. С замечаниями, пожеланиями, предложениями и критикой прошу в комментарии.
Ссылки по теме
- Рецепты под Android: Как вкусно приготовить LayoutManager
- Building a RecyclerView LayoutManager – Part 1
Автор: Николай Иванов