Что будет если команда, не видавшая Compose, решила делать новую сложную фичу на нём?

Мы в Дринкит, в 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 = listSaver(
        save = {
          listOf(
              it.lazyListState.firstVisibleItemIndex,
          )
        },
        restore = {
          ProductCardPageState(
              initialItemIndex = it[0] as Int,
          )
        }
    )
  }
}

А штуку, которая отвечает за снаппинг, мы переделали на FlingBehavior, потому что использование LaunchedEffect вызывало сложности. Второй клик не обрабатывался после того, как список заснаппился. Этот FlingBehavior уже передается как параметр в LazyColumn и все работает идеально!

За основу мы взяли информацию из этой статьи про VelocityBased анимации

Hidden text

val flingDecay = rememberSplineBasedDecay()
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. Подписывайтесь, чтобы узнавать новости раньше всех.

© Habrahabr.ru