Всем привет, на связи Никита Пятаков, Android-разработчик в МТС Диджитал. В этой статье я расскажу вам о том, как в приложении Мой МТС была проведена работа над UI новой карточки услуги.

Рассказ мой будет последовательным – сначала про саму задачку, потом про решение, которое разбито на подпункты.

Постановка задачи

На карточке услуги первым блоком выводится хеддер, состоящий из баннера (картинки), названия, цены, описаний и кнопки «подключить». Необходимо «объединить» навбар и хеддер:

было
стало

Какие изменения в UI нужно было внедрить? Разделим всю работу на подзадачи и обсудим каждую отдельно:

1.   Реализация кроппа баннера хеддера при p2r и эффект параллакса (прокрутка баннера в 2 раза медленнее, чем всего остального контента);

2.   Эффектом сопротивления баннера при p2r;

3.   «Засветление» баннера хеддера по мере прокрутки контента;

3. Добавление динамического блюра для иконок в навбаре;

4. Вывод/удаление title с анимацией в навбаре при прокрутке контента до определенного порогового значения.

Кропп баннера при p2r и эффект параллакса

На compose мы можем подписываться на стейт p2r и настраивать его параметры:

val pullRefreshState = rememberPullRefreshState(
            refreshing = state.isRefreshing,          // ловим событие p2r    
            onRefresh = { onRefresh() },              // аналитика
            refreshingOffset = 
              HEADER_HEIGHT * 0.5f + INDICATOR_SIZE   // задаем высоту спиннера
    )

Введем несколько понятий: у нас есть базовая высота баннера и «добавочная», которая равна 0 без события p2r, но увеличивается при нем. У pullRefreshState определено поле progress, которое отвечает за изменение прогресса p2r (значение меняется от 0 до 3.5). Таким образом, необходимо, чтобы «добавочная» высота баннера была прямо пропорциональна полю progress. В случае, если пользователь захочет обновить содержимое экрана, за счет рекомпозиции будем получать новое (увеличенное) значение «добавочной» высоты.

Чтобы добиться эффекта параллакса, можно воспользоваться настройкой Modifier.graphicsLayer, где задать скорость вертикальной прокрутки контента.

По итогу код будет выглядеть следующим образом:

val additionalP2rPadding: Dp by animateDpAsState(Utils.calculateServiceCardHeaderHeight(progress).dp)

Box(modifier = Modifier
        .background(color = DesignSystemTheme.colors.backgroundSecondary)
        .fillMaxWidth()
        .height(HEADER_HEIGHT + additionalP2rPadding) // меняем высоту бокса в зависимости от прогресса p2r
        .graphicsLayer {
            translationY = HEADER_SCROLL_COEFFICIENT * offsetScroll // замедляем скролл в 2 раза
        }
        .semantics { testTagsAsResourceId = true }
        .testTag(HEADER_BANNER_TAG)
    ) {

(Про calculateServiceCardHeaderHeight будет сказано ниже).

Эффект сопротивления баннера при p2r

Как можно заметить на видео в начале статьи, когда пользователь хочет обновить страницу, увеличение баннера происходит с эффектом сопротивления – чем ниже тянем, тем меньше изменяется размер. Как это можно реализовать? Сейчас будет немного математики, но совсем несложной, не переключайтесь!

Давайте разберемся, как выглядит график зависимости добавочной высоты баннера от прогресса p2r. Мы уже выяснили, что зависимость должна быть прямо пропорциональной. Хорошо, а что значит эффект сопротивления на языке математики? Это значит, что скорость роста функции должна убывать – чем быстрее изменяется progress, тем медленнее изменяется значения добавочной высоты. То есть, график будет выглядеть примерно таким образом:

График нарисовали, отлично, а какую функцию в итоге использовать? Если помните высшую математику, за скорость роста функции отвечает производная. Нам нужно заглянуть в таблицу производных и найти функцию, производная которой будет убывать. Нашли? Я, вот, например, взял ln и корень. Наша функция:

Здесь x + 1 и 80 взяты для нормализации – мы хотим, чтобы при нулевом прогрессе p2r значение “добавочной” высоты было также нулевым, а при максимальном значении баннер заметно увеличивался. Функция calculateServiceCardHeaderHeight хранит в себе как раз эту формулу.

Эффект сопротивления готов! Говорили мне преподаватели, что вышмат в жизни пригодится…

«Засветление» баннера

Идея заключается в том, что мы поверх баннера выводим Box с белым фоном и динамической прозрачностью, которая будет обратно пропорционально зависеть от величины прокрутки контента. Используем scrollState, обращаемся к полю value, которое отвечает за прогресс скролла, и используем его в качестве значения для alpha:

val scrollState = rememberScrollState()
val offsetScroll by remember { derivedState0f { scrollState.value } }
Box(
        modifier = Modifier
                .fillMaxSize()
                .background(color = DesignSystemTheme.colors.backgroundPrimary.copy(alpha =
                (if (offsetScroll >= COVER_LAYER_MAX_HEIGHT) {
                    1f
                } else {
                    offsetScroll / COVER_LAYER_MAX_HEIGHT
                })))
)

Добавление динамического блюра для иконок в навбаре

Если присмотреться к кнопкам «назад» и «поделиться» в навбаре, можно увидеть, что они заблюрены. К сожалению, на данные момент на compose динамического блюра из коробки нет – надо придумывать что-то свое. И вот, что я придумал:

1) Берем баннер и блюрим его целиком, используя стороннюю библиотеку Blurry;

2) Из заблюренного баннера «вырезаем» нужные нам кусочки, которые будут использоваться в качестве фона иконок;

3) Поверх баннера выводим эти кусочки в тех местах, где выводятся иконки, таким образом получается трехслойный бутерброд – баннер, кусочек заблюренного баннера и сверху иконка. Чтобы это работало в динамике, кусочки должны «вырезаться» с учетом прогресса скролла.

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

Сначала разберемся с тем, как правильно обрезать заблюренный баннер при p2r, из которого в дальнейшем мы будем вырезать фон для иконок. Сложность здесь заключается в вычислении величины, на которую должна уменьшится ширина баннера. Если решить эту незамысловатую геометрическую задачу, то получим такое значение:

val croppedWidth = (additionalP2rPadding.dpToPx.toDouble() * headerBitmaps.headerBitmap.width /
  (headerBitmaps.headerBitmap.height + additionalP2rPadding.dpToPx.toDouble())
  ).toInt() // пересчёт ширины

Где additionalP2rPadding – «дополнительная» высота баннера при p2rсоответственно.

Далее, берем исходный баннер (headerBitmap), создаем новую уже обрезанную битмапу и подгоняем ее под размеры экрана. В коде это выглядит следующим образом:

fun cropBitmap(
            headerBannerBitmaps: HeaderBannerBitmaps,
            croppedWidth: Int,
            croppedHeight: Int,
            newHeight: Int
    ): HeaderBannerBitmaps {
        val headerBitmap = headerBannerBitmaps.headerBitmap
        val croppedBitmap = Bitmap.createBitmap(  // обрезаем заблюренную картинку
                headerBannerBitmaps.blurredHeaderBitmap,
                croppedWidth / 2,
                0,
                headerBitmap.width - croppedWidth, croppedHeight
        )
        return HeaderBannerBitmaps(
                headerBitmap,
                headerBannerBitmaps.blurredHeaderBitmap,
                Bitmap.createScaledBitmap(    // подгоняем под нужный размер
                  croppedBitmap, 
                  headerBitmap.width, 
                  newHeight, 
                  true
                ),
        )
    }

Вуаля, у нас есть копия баннера с блюром, который выводится на экране!

Теперь разбираемся с блюром иконок непосредственно. Нам нужно из заблюренного баннера вырезать необходимые кусочки, положение которых зависит от размера баннера, положения иконок, прогресса p2r и скролла! Звучит жутко… но реализуемо!

Используем класс Path для выделения кусочков баннера:

private fun DrawScope.addBlurredIcon(path: Path, xPadding: Float, yPadding: Float) {
    path.addRoundRect(
            RoundRect(
                    Rect(
                            Offset(xPadding, yPadding),
                            Size(NAVBAR_ICON_SIZE.dp.toPx(), NAVBAR_ICON_SIZE.dp.toPx())
                    ),
                    CornerRadius(NAVBAR_ICON_BOARDER_RADIUS.dp.toPx())
            )
    )
}

Мы можем добавить контур иконки в path, используя функцию addRoundRect. В Rect передаем координаты левой верхней точки нашей иконки и длину с шириной, не забываем задать радиус скругления углов.

Для отрисовки этой красоты будем использовать Canvas:

val yPadding = getStatusBarHeight + additionalScrollPadding * HEADER_SCROLL_COEFFICIENT // пересчитываем высоту
    Canvas(
            modifier = Modifier.fillMaxSize()
    ) {
        val path = Path()  // класс для создания контуров

        addBlurredIcon(path, (screenWidthDp - NAVBAR_ICON_SIZE - NAVBAR_ICON_PADDING_WIDTH).dp.toPx(), yPadding)
        addBlurredIcon(path, NAVBAR_ICON_PADDING_WIDTH.dp.toPx(), yPadding)
           // передаем координаты для добавления нужной части картинки
        clipPath(path, ClipOp.Intersect) {  // Intersect - пересечение с контуром
            drawImage(
                    image = bmpBlurred.asImageBitmap(),
                    colorFilter = monochromeFilterOrNull(isArchive)
            )
        }
    }

Координаты верхних левых углов по ординате у иконок совпадают и равны сумме отступа самих иконок и значения скролла, умноженного на 0,5. Откуда взялся этот коэффициент? Мы помним, что реализовали эффект параллакса, баннер прокручивается в 2 раза медленнее, чем остальной контент.

После этого добавляем в path два контура наших иконок и с помощью функции clipPath выводим эти кусочки заблюренного баннера bmpBlurred.

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

Вывод/удаление title с анимацией в навбаре

Для анимации появления и исчезновения текста можно использовать функцию AnimatedVisibility:

AnimatedVisibility(
                        visible = scrollStateValue.pxToDp.value.absoluteValue >= MAX_SCROLL_UNTIL_TITLE_HIDDEN,
                          // условие вывода title
                        enter = fadeIn(),
                        exit = fadeOut()
                ) {
                    HtmlText(
                            color = DesignSystemTheme.colors.textHeadline,
                            textSize = 17.dp,
                            lineHeight = 24.dp,
                            fontRes = RDesignFont.mts_compact_medium,
                            text = title,
                            truncateAt = TextUtils.TruncateAt.END
                    )
                }

В visible снова передаем значение скролла — при достижении значения граничного значения, текст будет появляться/исчезать. В качестве анимационного эффекта выбираем fadeIn для вывода (плавно появляющийся текст) и fadeOut удаления (плавно затухающий текст).

На этом все. Надеюсь, эта статья была интересна и познавательна. Если есть какие-то вопросы – с радостью отвечу на них в комментариях!

P.S. Отдельное спасибо хочу высказать Юрию Шефтелю, Android-разработчику приложения Мой МТС, который консультировал меня по вопросам реализации изложенных выше идей!