Мы в Дринкит, в digital кофейне от бренда Додо, любим делать эмоциональный дизайн. Мы верим, что наше приложение должно быть не только удобным и функциональным, но и создавать классный эмоциональный опыт для наших клиентов.

Звезды сошлись таким образом, что произошло 2 события:

  • Настало время делать кардинальный редизайн одного из наших главных экранов — карточки продукта

  • Мы приняли решение переходить на стек Jetpack Compose в нашем Android приложении.

Меня зовут Дмитрий Максимов, я Android разработчик в Dodo Engineering. Больше 2-х лет я пробовал Jetpack Compose в пет-проектах, но хотелось прокачать свои знания по-полной и попробовать фреймворк в настоящем проде. В этой статье расскажу, как мы сделали сложный Compose экран с нестандартным скроллом и снаппингом контента.

Почему Compose?
Мы хотим делать лучший UI в нашем приложении. В Compose много удобных компонентов, которые мы можем использовать и быстрее разрабатывать, поэтому давно хотели его попробовать.
Обычно, переход на новый фреймворк начинают с простых экранов, но мы понимали, что это покажет не все возможности Compose и не научит нас ничему, поэтому ждали сложного интерфейса. Карточка продукта — то, что нужно.

Посмотрите, как изменился наш экран Карточки Продукта!

Рассмотрим дизайн подробнее

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

Давайте посмотрим, как выглядела карточка продукта раньше.

Карточка продукта хоть и позволяет сделать с напитком что угодно, но со стороны дизайна задача решена слабо:

  • Напиток выглядит скучно, в меню у нас видео напитков, а тут – просто картинка на белом фоне

  • Кастомизация как бы есть, но большие плитки лишь растягивают экран и не показывают богатство выбора

  • Широкая настройка из разных сиропов и посыпок скрыта за невзрачными плюсиками, которых не видно на первом экране при открытии

  • Много навигации: кастомайз открывается в отдельном окне, его нужно закрывать, потом открывать следующее окно

Старая карточка продукта
Старая карточка продукта

Все эти недостатки дизайна должна решить новая Карточка продукта:

Что мы видим в новой карточке:

Видео для каждого продукта. Хвастаемся тем, насколько вкусно умеем показывать продукты.

Новый подход к кастомизации. Изменить размер можно в один тап, кастомизация раскрывается на том же экране, в одной строке видно все, что выбрали. Все выглядит плавно и физически естественно.

Интересный и полезный контент. После свайпа откроется экран с историями про напиток – как его готовят и где мы делимся секретами вкуса, пищевая ценность, подогревающее аппетит описание и апселл, где подсказываем лучшие сочетания для продукта.

Новая карточка продукта
Новая карточка продукта

Кастомный снаппинг

Каркас нового экрана — деление его на три части. Изначально гость видит видео продукта и раздел кастомизации, которую еще можно полностью раскрыть при нажатии на ингредиент. При скролле можно перейти к дополнительному описанию, историям, калорийности.

В этой статье мы подробно разберем одну из UI-проблем, которую нам пришлось решить на Compose – как сделать вот такой список с скроллом со снаппингом ко второй половине экрана.

Посмотрите на видео:

Обратите внимание, что при выборе ингредиента открывается панель прямо в экране.

Почему это было первой и важной задачей:

  • Потому что это каркас и архитектура экрана, от нее зависит, то как мы будем дальше реализовывать все фичи

  • Она выглядела нестандартной, поэтому точно придется писать это самостоятельно и лучше раньше решить все вопросы

Проверить гипотезу

Сначала надо было проверить, что Jetpack Compose позволяет нам сделать основную навигацию и анимации на экране, и мы не столкнемся с проблемами. Для этого мы начали играться в sandbox, чтобы сделать скелет экрана.

Подход №1. BottomSheet

Первый дизайн из Figma
Первый дизайн из Figma



Когда мы в первый раз сели брейнштормить про экран карточки продукта, в первую очередь размышляли, как сделать 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,
    )
  }
}
Внешний вид подхода с BottomSheet
Внешний вид подхода с BottomSheet



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.5 Google переделал множество стандартных 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. Подписывайтесь, чтобы узнавать новости раньше всех.

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


  1. 0Bannon
    30.10.2023 14:31
    +1

    Интересно было, спасибо.


  1. sneg2015
    30.10.2023 14:31

    Выглядит красиво. Ещё бы поставить вместе картинки было-стало, иначе тяжело в статье улавливать нюансы.


    1. kartollika Автор
      30.10.2023 14:31

      Спасибо за отзыв!

      Можешь уточнить, где именно не хватило картинок было-стало? Старался оформлять именно с той целью, чтобы было несложно сравнивать варианты и сохранить структуру:)


      1. sneg2015
        30.10.2023 14:31

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


  1. Revellion
    30.10.2023 14:31

    А сэмпл апп на гите есть?


    1. kartollika Автор
      30.10.2023 14:31

      Нет, семпл апп мы не публиковали. Он у нас есть, но для внутреннего использования


  1. xZeddushka
    30.10.2023 14:31

    Спасибо за статью!

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


    1. kartollika Автор
      30.10.2023 14:31

      Да, есть такая боль. Но дизайн чаще один рисуется, так что это всегда перекос в чью либо сторону. Если и есть разделение, то в виде контролов навигации и мелких штук. Что конечно расстраивает

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


  1. GMaksym
    30.10.2023 14:31

    Подскажите пожалуйста, как лучше организовать код:

    1) Скинуть всё для одного экрана в один .kt файл или разбить на разные файлы по элементам?

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

    • с другой стороны, когда кидаешь компоненты в другие файлы, они становятся публичными функциями и тогда при рисовке другого экрана будут показыватся при поиске компонентов по имени. Например мне нужна кнопка, и я пишу Button, тогда мне покажутся и, предположим, ButtonSpecialForScreen1... Screen2, соответственно. Чего хотелось бы избежать, но сейчас работаю именно в таком ключе.

    2) Где хранить стейт для функции, в том же документе что и сама Composable функция или рядом в package models/states или где-то ещё?

    • Есть ещё вариант создания вложенного стейта, когда в один стейт экрана вкладывать другие стейты, из которых он состоит

    • Другой путь, писать отдельные стейты для функций а потом просто создать матрёшку из нужных стейтов.