Мы в Дринкит, в digital кофейне от бренда Додо, любим делать эмоциональный дизайн. Мы верим, что наше приложение должно быть не только удобным и функциональным, но и создавать классный эмоциональный опыт для наших клиентов.
Звезды сошлись таким образом, что произошло 2 события:
-
Настало время делать кардинальный редизайн одного из наших главных экранов — карточки продукта
-
Мы приняли решение переходить на стек Jetpack Compose в нашем Android приложении.
Меня зовут Дмитрий Максимов, я Android разработчик в Dodo Engineering. Больше 2-х лет я пробовал Jetpack Compose в пет-проектах, но хотелось прокачать свои знания по-полной и попробовать фреймворк в настоящем проде. В этой статье расскажу, как мы сделали сложный Compose экран с нестандартным скроллом и снаппингом контента.
Почему Compose?
Мы хотим делать лучший UI в нашем приложении. В Compose много удобных компонентов, которые мы можем использовать и быстрее разрабатывать, поэтому давно хотели его попробовать.
Обычно, переход на новый фреймворк начинают с простых экранов, но мы понимали, что это покажет не все возможности Compose и не научит нас ничему, поэтому ждали сложного интерфейса. Карточка продукта — то, что нужно.
Посмотрите, как изменился наш экран Карточки Продукта!
Рассмотрим дизайн подробнее
Карточка продукта — это основная часть приложения, где раскрывается уникальная ценность и кофейни и самого приложения, поэтому оно должно работать идеально. На экране много возможностей для настройки напитка: менять размеры, менять кофе, молоко, чашку, температуру, добавлять и убирать сиропы, посыпки и прочие добавки. Кофе заказывают каждый день, поэтому это хорошая возможность не надоесть.
Давайте посмотрим, как выглядела карточка продукта раньше.
Карточка продукта хоть и позволяет сделать с напитком что угодно, но со стороны дизайна задача решена слабо:
-
Напиток выглядит скучно, в меню у нас видео напитков, а тут – просто картинка на белом фоне
-
Кастомизация как бы есть, но большие плитки лишь растягивают экран и не показывают богатство выбора
-
Широкая настройка из разных сиропов и посыпок скрыта за невзрачными плюсиками, которых не видно на первом экране при открытии
-
Много навигации: кастомайз открывается в отдельном окне, его нужно закрывать, потом открывать следующее окно
Все эти недостатки дизайна должна решить новая Карточка продукта:
Что мы видим в новой карточке:
Видео для каждого продукта. Хвастаемся тем, насколько вкусно умеем показывать продукты.
Новый подход к кастомизации. Изменить размер можно в один тап, кастомизация раскрывается на том же экране, в одной строке видно все, что выбрали. Все выглядит плавно и физически естественно.
Интересный и полезный контент. После свайпа откроется экран с историями про напиток – как его готовят и где мы делимся секретами вкуса, пищевая ценность, подогревающее аппетит описание и апселл, где подсказываем лучшие сочетания для продукта.
Кастомный снаппинг
Каркас нового экрана — деление его на три части. Изначально гость видит видео продукта и раздел кастомизации, которую еще можно полностью раскрыть при нажатии на ингредиент. При скролле можно перейти к дополнительному описанию, историям, калорийности.
В этой статье мы подробно разберем одну из UI-проблем, которую нам пришлось решить на Compose – как сделать вот такой список с скроллом со снаппингом ко второй половине экрана.
Посмотрите на видео:
Обратите внимание, что при выборе ингредиента открывается панель прямо в экране.
Почему это было первой и важной задачей:
-
Потому что это каркас и архитектура экрана, от нее зависит, то как мы будем дальше реализовывать все фичи
-
Она выглядела нестандартной, поэтому точно придется писать это самостоятельно и лучше раньше решить все вопросы
Проверить гипотезу
Сначала надо было проверить, что Jetpack Compose позволяет нам сделать основную навигацию и анимации на экране, и мы не столкнемся с проблемами. Для этого мы начали играться в sandbox, чтобы сделать скелет экрана.
Подход №1. BottomSheet
Когда мы в первый раз сели брейнштормить про экран карточки продукта, в первую очередь размышляли, как сделать snapping эффекта при пролистывании кастомайза. Это было нечто похожее на BottomSheet с установленным peekHeight, или на ViewPager с страничным снеппингом, но здесь только 2 страницы, причем вторая потенциально бесконечная.
Похожий пример можно увидеть на главной страничке Яндекс Музыке – там как раз используется BottomSheet для этих целей.
Как это в Яндекс.Музыке. Выглядит похоже, но так ли это?
Ну и раз есть идея с BottomSheet, то почему бы не проверить ее? То, что нижняя панель должна быть всегда видна и вся карточка продукта должна снаппится примерно также, как BottomSheet, заставили нас думать в эту сторону.
В библиотеке compose-material можно найти компонент BottomSheetScaffold, который выполняет роль контейнера для BottomSheet.
@OptIn(ExperimentalMaterialApi::class) @Composable fun ProductCard() { val bottomSheetScaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = BottomSheetState(BottomSheetValue.Collapsed) ) BottomSheetScaffold( scaffoldState = bottomSheetScaffoldState, sheetContent = { Box( modifier = Modifier .padding(top = 64.dp) .fillMaxHeight() .fillMaxWidth() .background(Color.LightGray) ) }, sheetPeekHeight = 160.dp ) { Image( modifier = Modifier .fillMaxHeight() .fillMaxWidth() .padding(bottom = 160.dp), painter = painterResource (id = R.drawable.drinkit_image_example), contentDescription = null, contentScale = ContentScale.Crop, ) } }
scaffoldState принимает стейт, который описывает состояние BottomSheet – сдвиг, текущее состояние, и прочие.
sheetContent – контент внутри самого BottomSheet
content – то, что рисуется позади, на основном Surface
Такой подход через BottomSheet дает похожую анимацию раскрытия и снаппинга контента, который находится снизу экрана. Реализация такого прототипа заняла день-два.
Сложностью здесь может послужить организация работы блока кастомайза. Он должен быть единым целым с общим списком — при скролле и открытии BottomSheet он должен перемещаться вместе с контентом, а также уметь скрываться под остальной контент и выдвигаться из под него.
Мы не придумали простой способ, как всё это сделать с BottomSheet, поэтому мы на время оставили эту идею и пошли за новыми решениями.
Другой подход. Единый список
Попробуем другой подход. Что, если сделать весь этот экран сплошным списком с видео позади? В нашей песочнице мы использовали фото для упрощения тестирования, но в финальной версии там будет видео.
Это даст нам преимущество в управлении всем стейтом экрана, да и по дизайну это более подходящее решение. Видео мы вынесем вне списка на фон. Сам список сделаем с помощью LazyColumn, но если ваш экран небольшой, то можно обойтись обычным Column, однако с LazyColumn будет проще, потому что мы делаем снаппинг, а в LazyListState есть стандартный метод для скролла к элементу в списке.
Попробуем?
@Composable fun ProductCard() { val lazyState = rememberLazyListState() val isDragged by lazyState.interactionSource.collectIsDraggedAsState() val isScrollingUp = lazyState.isScrollingUp() Box { // Изображение или видео на фоне Image( modifier = Modifier .fillMaxWidth(), /* ... */ ) /* * Создали свой хендлер для обработки события, когда мы отпускаем палец * Основная идея – если двигаемся вниз и преодолели границу, * то делаем автоскролл до следующего элемента * * Иначе если скроллим наверх и видим первый элемент, * то возвращаемся на самый верх */ LaunchedEffect(isDragged) { if (isDragged) return@LaunchedEffect if (lazyState.firstVisibleItemIndex != 0) return@LaunchedEffect if (lazyState.firstVisibleItemScrollOffset > 200 && !isScrollingUp) { lazyState.animateScrollToItem(1) } else if (isScrollingUp) { lazyState.animateScrollToItem(0) } } Surface( modifier = Modifier, color = Color.Transparent, content = { LazyColumn( state = lazyState, modifier = Modifier .fillMaxHeight() ) { item { /* * Контейнер для блока кастомизации. * Включает в себя панель с ингредиентами и табы - группы ингредиентов */ Column { CustomizePanel( modifier = Modifier .requiredHeight(580.dp), ) // Контейнер с табами CustomizeTabs( modifier = Modifier .requiredHeight(130.dp), color = Color.Cyan ) } } // Остальной контент item { Stories(Color.Magenta, Modifier.height(200.dp)) } /* ... */ } } ) } }
Получаем вот такое поведение.
Благодаря блоку внутри LaunchedEffect, список снаппится на нужную нам позицию, что напоминает поведение BottomSheet.
Сама реализация логики снаппинга может измениться в дальнейшем, структура экрана очень удобна для встраивания новых блоков вокруг нее, поэтому мы решили остановиться на этом варианте, наращивая его потенциал.
В такой структуре можно легко сделать так, чтобы первый элемент списка – в нашем случае это блок кастомайза с ингредиентами, выдвигался из под контента по команде. Такого поведения можно достигнуть с помощью изменения translationY у контейнера:
Проекция экрана и структура блоков на нем. Здесь видео статично стоит на месте, но в конечной реализации оно также двигается.
У нас есть такое дерево:
LazyColumn | item | Customize(Column) | CustomizePanel | CustomizeTabs | item | Stories | ...
Изменением translationY у CustomizePanel мы достигаем такого эффекта.
К тому же, чтобы понять, насколько далеко надо его скрыть для полной невидимости, необходимо предварительно измерить высоту блока. Делаем это с помощью Modifier.onPlaced
В коде это выглядит так:
val customizationTranslationY = remember { Animatable(0f) } var customizationSize by remember { mutableStateOf(0) } LazyColumn( state = lazyState, modifier = Modifier .fillMaxHeight() ) { item { Column { CustomizePanel( modifier = Modifier .requiredHeight(580.dp) .graphicsLayer { // Вертикально сдвигаем панель кастомайза. // Получается эффект, как на схеме выше translationY = customizationTranslationY.value } .onPlaced { // Сразу после измерения нужно сохранить размер и скрыть кастомайз coroutineScope.launch { if (customizationSize == IntSize.Zero) { customizationSize = it.size customizationTranslationY.snapTo(customizationSize.toFloat()) } } } ) CustomizeTabs( modifier = Modifier .requiredHeight(130.dp) .clickable { coroutineScope.launch { // На клик мы анимируем выдвигающийся кастомайз val target = if (customizationTranslationY.value == 0f) { customizationSize.toFloat() } else { 0f } customizationTranslationY.animateTo(target) } }, color = Color.Cyan ) } }
Видео продукта кстати тоже легко сделать сдвигаемым по translationY, наблюдая за LazyListState.firstVisibleItemScrollOffset или за анимацией, если она активна
Image( modifier = Modifier .fillMaxWidth() .graphicsLayer { // Когда анимация работает, то мы сдвигаем изображение координировано с ней translationY = if (customizationTranslationY.isRunning) { customizationTranslationY.value - customizationSize.toFloat() } else if (lazyState.firstVisibleItemIndex == 0) { // Иначе картинка следует за ручным скроллом -lazyState.firstVisibleItemScrollOffset.toFloat() } else { 0f } }, /* ... */ )
Напомню, что это было нашей первой реализацией скелета Карточки продукта, поэтому много вещей к релизу поменялось, но концепция осталось неизменной.
Например, мы вынесли в отдельный стейт все, что связано с скроллом и снаппингом.
Hidden text
enum class CustomizationValue { Closed, Open, Initial, ; } private const val DUMP_VELOCITY_THRESHOLD = 0.2f private const val ANIMATION_SPRING_STIFFNESS = 600f @Composable fun rememberProductCardPageState( @IntRange(from = 0) initialItemIndex: Int = 0, ): ProductCardPageState = rememberSaveable(saver = Companion.Saver) { ProductCardPageState(initialItemIndex) } @Stable class ProductCardPageState( @IntRange(from = 0) val initialItemIndex: Int = 0, ) { private var customizeCurrentValue by mutableStateOf(Initial) internal val lazyListState = LazyListState(firstVisibleItemIndex = initialItemIndex) /** * [Animatable], через который происходит изменение раскрытие блока с кастомайзом * Для взаимодействия с ним, используйте [closeCustomize] и [openCustomize] */ private val customizeAnimatable = Animatable(0f) /** * Величина кастомайза * Также является максимальным сдвигом для состояния, чтобы кастомайз был закрыт */ val customizeSize: Float get() = customizePanelSize - customizeTitleSize /** * Размер контейнера кастомайза => ингредиенты + надпись */ var customizePanelSize by mutableStateOf(0f) /** * Полный размер кастомайза => панель с ингредиентами + список категорий * Нужен для того, чтобы знать, сколько места занимает полностью кастомайз в списке */ var customizeFullSize by mutableStateOf(0f) /** * Размер заголовка, находящегося в контейнере кастомайза * Нужен для того, чтобы задвинутый кастомайз немного * выдвинуть на размер заголовка, чтобы он (заголово) был виден */ var customizeTitleSize by mutableStateOf(0f) /** * Величина сдвига кастомайза * - (0) – кастомайз открыт * - (customizeSize) – кастомайз закрыт */ val customizeTranslationY: Float get() = customizeAnimatable.value /** * Величина сдвига фонового видео * - (0) – кастомайз закрыт, фоновое видео видно полностью * - (-customizeSize) – кастомайз открыт, фоновое видео синхронно с ним сдвинуто наверх */ val customizeTranslationImageY: Float get() = customizeTranslationY - customizeSize /** * */ val customizeScrollOffset: Int get() = lazyListState.firstVisibleItemScrollOffset /** * Степень скролла относительно кастомайза в карточке * - (0) – скролл находится в самой верхней точке * - (1) – мы полностью проскроллили кастомайз */ val customizeScrollFraction: Float get() { if (lazyListState.firstVisibleItemIndex > 0) { return 1f } if (customizeFullSize == 0f) { return 0f } return customizeScrollOffset / customizeFullSize } /** * Степень "открытости" кастомайза. * Показывает, насколько он выдвинут и виден пользователю * P.S Это не означает, что он готов общаться и заводить друзей * * - (0) – кастомайз закрыт, видим только торчащий заголовок * - (1) – кастомайз полностью открыт */ val customizeOpenFraction: Float get() { if (customizeSize == 0f) { return 0f } return abs(customizeTranslationImageY) / customizeSize } /** * [Float], который является максимумом между [customizeOpenFraction] и [customizeScrollFraction] * Это нужно, потому что некоторые Composable одинаково * реагируют как на скролл, так и на открытие кастомайза */ val customizeFractionCombined: Float get() = max(customizeOpenFraction, customizeScrollFraction) /** * Открыть кастомайз * * - [animated] – закрыть анимированно или нет */ suspend fun openCustomize(animated: Boolean = true) { customizeCurrentValue = Open if (animated) { customizeAnimatable.animateTo(0f) } else { customizeAnimatable.snapTo(0f) } } private suspend fun closeCustomize( animated: Boolean = true, ) { customizeCurrentValue = Closed if (animated) { customizeAnimatable.animateTo(customizeSize) } else { customizeAnimatable.snapTo(customizeSize) } } companion object { val Saver: Saver<ProductCardPageState, *> = listSaver( save = { listOf<Any>( it.lazyListState.firstVisibleItemIndex, ) }, restore = { ProductCardPageState( initialItemIndex = it[0] as Int, ) } ) } }
А штуку, которая отвечает за снаппинг, мы переделали на FlingBehavior, потому что использование LaunchedEffect вызывало сложности. Второй клик не обрабатывался после того, как список заснаппился. Этот FlingBehavior уже передается как параметр в LazyColumn и все работает идеально!
За основу мы взяли информацию из этой статьи про VelocityBased анимации
Hidden text
val flingDecay = rememberSplineBasedDecay<Float>() val isScrollingUp by rememberUpdatedState( newValue = productCardPageState.lazyListState.isScrollingUp() ) val consumeVelocityFlingBehavior = remember { object : FlingBehavior { private val DefaultScrollMotionDurationScaleFactor = 1f val DefaultScrollMotionDurationScale = object : MotionDurationScale { override val scaleFactor: Float get() = DefaultScrollMotionDurationScaleFactor } override suspend fun ScrollScope.performFling(initialVelocity: Float): Float = run { handleFling(initialVelocity) } private suspend fun ScrollScope.handleFling(initialVelocity: Float): Float = run { if (isCustomizeVisible(productCardPageState.lazyListState.firstVisibleItemIndex)) { handleFlingIfCustomizeVisible(initialVelocity) } else { // Если кастомайз не видим, то скроллим как обычно. // Когда скролл завершится, то нам нужно сделать ручную проверку, // что пользователь видит кастомайз, и заснаппиться, если нужно handleFlingIfCustomizeNotVisible(initialVelocity) } } private suspend fun ScrollScope.handleFlingIfCustomizeNotVisible( initialVelocity: Float, ): Float { var velocityLeft = initialVelocity return withContext(DefaultScrollMotionDurationScale) { if (abs(initialVelocity) > 1f) { var lastValue = 0f AnimationState( initialValue = 0f, initialVelocity = initialVelocity, ).animateDecay( animationSpec = flingDecay, sequentialAnimation = true ) { val delta = value - lastValue val consumed = scrollBy(delta) lastValue = value velocityLeft = this.velocity // Округляем и останавливаем анимацию, если изменения очень маленькие if (abs(delta - consumed) > 0.5f) this.cancelAnimation() } // Скролл закончен, и мы должны понять, нужно ли нам куда-то заснаппится // Если мы остановились и мы видим кастомайз (firstVisibleItemIndex == 0), то нужно сделать снап if (isCustomizeVisible(productCardPageState.lazyListState.firstVisibleItemIndex)) { // Мы должны заснаппится куда-либо, вниз или вверх. Зависит от velocity handleFlingIfCustomizeVisible(velocityLeft) } else { // Иначе просто возвращаем velocity, что ничего не предпринимаем velocityLeft } } else { 0f } } } private suspend fun ScrollScope.handleFlingIfCustomizeVisible(initialVelocity: Float): Float { val scrollTo = when { !isScrollingUp -> { productCardPageState.customizeFullSize - productCardPageState.customizeScrollOffset } productCardPageState.lazyListState.scrollPassedThreshold(productCardPageState) -> { -productCardPageState.lazyListState.firstVisibleItemScrollOffset.toFloat() } else -> { productCardPageState.customizeFullSize - productCardPageState.customizeScrollOffset } } var lastValue = 0f val animationState = AnimationState( initialValue = 0f, initialVelocity = initialVelocity, ) animationState.animateTo( targetValue = scrollTo, animationSpec = spring( stiffness = Spring.StiffnessMediumLow, ), sequentialAnimation = true, ) { val delta = value - lastValue val consumed = scrollBy(delta) lastValue = value // Округляем и останавливаем анимацию, если изменения очень маленькие if (abs(delta - consumed) > 0.5f) this.cancelAnimation() } return 0f } } }
Как конечный результат, мы получаем такое крутое и плавное поведение снаппинга на LazyColumn и открытия кастомайза. Смотрим и кайфуем ❤️
Заключение
Цель статьи – рассказать, а стоит ли переводить ваши проекты на Jetpack Compose?
По нашему опыту реализации Карточки продукта, эксперимент пойти с Compose в редизайн себя оправдал:
-
С фреймворком легко начать работать, декларативный UI очень удобный в написании и поддержке, а также легко поддается изменениям и переиспользованию.
-
Наличие Preview, для которых не обязательно запускать приложение.
-
И вообще более сложный UI с анимациями создавать и отлаживать стало гораздо проще, чем на View. Прототип снаппинга мы сделали за 1-2 дня, что довольно быстро.
Несмотря на все заметные плюсы, Compose также не лишен минусов. Вот некоторые из них:
-
Оптимизация. Сложно понять, как Compose вообще оптимизировать. Только с прошествием времени начинаешь привыкать к правильным практикам. Но поначалу мы делали как просто делает Google и другие гайды. В итоге сделали рабочий прототип карточки, но очень тормозящий даже в релизной сборке. Пришлось уделить пару спринтов на ресерч и составление best practices. Забавное совпадение, но как раз с этого времени начали чаще выходить статьи и разборы с “оптимизициями”. Я мнемонически назвал это время “эпоха осознанного Compose”.
-
Медленная инициализация. Написанный UI на Jetpack Compose инициализируется медленнее, чем на View. Если делаете как мы, а именно запускаете фрагмент с Compose View, то знайте, что Compose это unbundled library. Это означает что Compose Runtime код загружается при первом заходе на этот экран. Это приводит к тому, что первый запуск экрана будет медленным. Над скоростью работы Jetpack Compose гугловцы постоянно работают и что-то улучшают. Например, с недавним
Compose 1.5Google переделал множество стандартныхModifierна новый механизм, что в *их замерах* позволило ускорить Compose до 80%. В целом, View обычно НЕ медленнее. Часто View и Compose практически одинаковые, особенно в релизе + baseline + R8. -
Работа с картинками. Картинки и их отрисовка доставляет немало проблем в Compose. Легко, если у вас есть картинка и она статична, но если меняется контент в ней, то морганий не избежать. Если для первого сценария мы как-то придумали решение, то для второго – еще нет. Если придумаем, напишем статью в рамках этой серии.
-
Некоторые проблемы вскрываются только по ходу. Не имея практики, иногда можно натыкаться на какие-то ограничения Compose, которые на View бы не возникли. Мы тоже натыкались, решали проблемы на ходу. Важно отметить, что инструмент достаточно новый, и Google много ресурсов тратит на продвижение и оптимизацию Compose. Не исключено, что в будущем им можно будет пользоваться также предсказуемо, как и системой View (к слову, и во View до сих пор можно встретить непредсказуемые поведения).
Конечно, есть еще другие, но во время разработки это были самые больные.
Несмотря на все трудности, результат оказался не хуже, чем в Фигме. А на самом деле в 100 раз лучше: продакты думали, что смотрят на iOS, а не на Android.
В статье я рассмотрел, как мы начинали разработку Карточки продукта, и конкретно как мы искали лучший подход для реализации скелета экрана, который позволил бы нам реализовать базовое поведение экрана и остальный детали.
Спрототипировали несколько подходов, и придумали собственное решение:
-
Скролл + снаппинг, сделанный в LazyColumn ручными анимированием скролла
-
Анимированное раскрытие элемента через translationY
И это лишь одна из задач, с которыми нам пришлось столкнуться. Наша новая карточка продукта наполнена компонентами разной степени сложности и нестандартными решениями. Причем для каждого пункта можно написать свою статью с разбором.
Пишите в комментариях, если вы хотите увидеть разбор каких-то определенных контролов, реализованных нами на Jetpack Compose. Таблицу с ними я прикладываю ниже, или вы можете обратиться к видео, которое было прикреплено выше.
У нас в Додо намечается еще множество проектов с дерзкими и красивыми дизайнами, и поэтому мы ищем в наши команды мобильных UI экспертов – гуру, знающих о дизайне на мобилках все и способных вести команду в сторону построения правильного и красивого интерфейса
Если это про вас – смело заходите и откликайтесь на наши позиции, и ждем вас на собеседованиях 🙂
В каналах Dodo Mobile и Мобильное чтиво мы рассказываем про разработку приложений в Dodo. Подписывайтесь, чтобы узнавать новости раньше всех.
ссылка на оригинал статьи https://habr.com/ru/articles/764540/
Добавить комментарий