Всем привет, на связи Никита Пятаков, 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 и корень. Наша функция:

y = \sqrt{\ln(x+1)}*80

Здесь 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-разработчику приложения Мой МТС, который консультировал меня по вопросам реализации изложенных выше идей!

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


  1. Rusrst
    19.09.2023 10:19

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


    1. nikita_pyatakov Автор
      19.09.2023 10:19
      +1

      Привет! Спасибо за комментарий, добавил анимацию.


  1. Rusrst
    19.09.2023 10:19

    Я тут вчитался в код и могу сказать одно - такое большое создание bitmaps явно не есть хорошо... С учётом того, что они никак с cache не связаны.


    1. nikita_pyatakov Автор
      19.09.2023 10:19

      Согласен, на отрисовку тратятся большие ресурсы из-за постоянного создания Bitmaps. Но на скорости работы это не сказывается, даже на относительно старых устройствах (тестировал на API 24) из-за выноса в фоновый поток. Что касается кэширования - занимало бы много памяти, эффект все-таки не такой значительный) Вообще была цель сделать динамический блюр без использования сторонних библиотек. Если у Вас есть идеи, как можно оптимизировать мое решение, буду очень благодарен совету!


      1. Rusrst
        19.09.2023 10:19
        +1

        На 12 Андроиде blur из коробки есть, хотя бы там можно просто modifier добавлять, без создания bitmaps. Ниже вообще вопрос насколько оно нужно.


        1. nikita_pyatakov Автор
          19.09.2023 10:19

          Вы немного не поняли постановку задачи - нужен динамический блюр, его нет ни на Android 12, ни на остальных версиях.


          1. Rusrst
            19.09.2023 10:19
            +1

            Можно узнать что вы понимаете под динамическим блюром и чем вы предполагаете его делают?

            Посмотрите modifier blur, это точно не то что вам нужно?


            1. nikita_pyatakov Автор
              19.09.2023 10:19

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


            1. nikita_pyatakov Автор
              19.09.2023 10:19

              Под динамическим блюром я понимаю кейс, при котором фон меняется, то есть область, которую надо размыть, динамична.


      1. yarston
        19.09.2023 10:19

        Блур тяжёлая операция, особенно без поддержки со стороны GPU или C/C++, скорее из-за него тормоза. Это можно поправить, размывая только вырезанную область кнопок.

        А чтобы не создавать многократно Btimap, можно сделать 1 раз

        val buttonBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(buttonBitmap)

        И затем при перерисовке подложки кнопки

        canvas.drawBitmap(backgroundNotBlurredBitmap, x, y, null) //отрисуется в buttonBitmap
        blur(buttonBitmap, ...)

        Тогда может и отдельный поток не нужен будет.


        1. Rusrst
          19.09.2023 10:19

          Ну так стати в critical native/fast native изменения и правда можно попробовать вынести (не факт что поможет), правда считать придется или самому (без bitmap factory) или библиотеками C/C++, задача интересная, я бы таким позанимался б.


  1. alexanderniki
    19.09.2023 10:19

    А какие критерии "современности" UI вы используете? Как отличаете "современный" от "фу, экскременты мамонта"?


    1. nikita_pyatakov Автор
      19.09.2023 10:19

      Добрый день, спасибо за комментарий! В первую очередь это сами эффекты - параллакс, динамический блюр (не видел такого на compose под Android), p2r с отрицательным скроллом и сопротивлением (как на IOS), навбар, интегрированный в элемент на карточке. Даешь больше плавности и анимаций! Также, для реализации некоторых эффектов использовались экспериментальные, на данный момент, Api. А у Вас какие критерии свежести UI?


      1. Rusrst
        19.09.2023 10:19

        Во вью это все как бэ тоже работает, over scroll называется. Блюр тоже можно сделать (в самом крайнем случае surface view всех спасет)

        Сам компоуз конечно хорош, спору нет, но и стандартный инструментарий возможностей даёт не мало.

        А то у compose транзишинов так и нет, анимаций у row/column тоже, анимации у lazy списков ну такое себе...


        1. Nek_12
          19.09.2023 10:19

          Вы не правы, транзишны уже почти готовы и их поддержка добавлена с версии 1.5 (около того)
          Анимаций у Row/Column тоже
          Анимации у lazy списков пофиксили довольно давно.

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


          1. Rusrst
            19.09.2023 10:19

            Я может чего не понимаю, но анимаций по документации у обычных списков нет

            https://developer.android.com/jetpack/compose/lists

            Анимации у lazy есть, но они выглядят гораздо хуже чем анимации rv.

            Поддержка транзишенов заявлена и разрабатывается, но пока ее нет. И когда будет не ясно.

            По поводу статьи - автор привел рабочее решение, можно с ним не соглашаться, но он молодец все же.


          1. nikita_pyatakov Автор
            19.09.2023 10:19

            Здравствуйте, спасибо за комментарий! Можете подсветить, в каких местах, как Вы считаете, были допущены ошибки, для конструктивной дискуссии?