Мы в Дринкит, в digital кофейне от бренда Додо, любим делать эмоциональный дизайн. Мы верим, что наше приложение должно быть не только удобным и функциональным, но и создавать классный эмоциональный опыт для наших клиентов.
Звезды сошлись таким образом, что произошло 2 события:
Настало время делать кардинальный редизайн одного из наших главных экранов — карточки продукта
Мы приняли решение переходить на стек Jetpack Compose в нашем Android приложении.
Меня зовут Дмитрий Максимов, я Android разработчик в Dodo Engineering. Больше 2-х лет я пробовал Jetpack Compose в пет-проектах, но хотелось прокачать свои знания по-полной и попробовать фреймворк в настоящем проде. В этой статье расскажу, как мы сделали сложный Compose экран с нестандартным скроллом и снаппингом контента.
Почему Compose?
Мы хотим делать лучший UI в нашем приложении. В Compose много удобных компонентов, которые мы можем использовать и быстрее разрабатывать, поэтому давно хотели его попробовать.
Обычно, переход на новый фреймворк начинают с простых экранов, но мы понимали, что это покажет не все возможности Compose и не научит нас ничему, поэтому ждали сложного интерфейса. Карточка продукта — то, что нужно.
Посмотрите, как изменился наш экран Карточки Продукта!
Рассмотрим дизайн подробнее
Карточка продукта — это основная часть приложения, где раскрывается уникальная ценность и кофейни и самого приложения, поэтому оно должно работать идеально. На экране много возможностей для настройки напитка: менять размеры, менять кофе, молоко, чашку, температуру, добавлять и убирать сиропы, посыпки и прочие добавки. Кофе заказывают каждый день, поэтому это хорошая возможность не надоесть.
Давайте посмотрим, как выглядела карточка продукта раньше.
Карточка продукта хоть и позволяет сделать с напитком что угодно, но со стороны дизайна задача решена слабо:
Напиток выглядит скучно, в меню у нас видео напитков, а тут – просто картинка на белом фоне
Кастомизация как бы есть, но большие плитки лишь растягивают экран и не показывают богатство выбора
Широкая настройка из разных сиропов и посыпок скрыта за невзрачными плюсиками, которых не видно на первом экране при открытии
Много навигации: кастомайз открывается в отдельном окне, его нужно закрывать, потом открывать следующее окно
![Старая карточка продукта Старая карточка продукта](https://habrastorage.org/getpro/habr/upload_files/5ba/445/d02/5ba445d0219a26fa886c77adf8cd5db7.png)
Все эти недостатки дизайна должна решить новая Карточка продукта:
Что мы видим в новой карточке:
Видео для каждого продукта. Хвастаемся тем, насколько вкусно умеем показывать продукты.
Новый подход к кастомизации. Изменить размер можно в один тап, кастомизация раскрывается на том же экране, в одной строке видно все, что выбрали. Все выглядит плавно и физически естественно.
Интересный и полезный контент. После свайпа откроется экран с историями про напиток – как его готовят и где мы делимся секретами вкуса, пищевая ценность, подогревающее аппетит описание и апселл, где подсказываем лучшие сочетания для продукта.
![Новая карточка продукта Новая карточка продукта](https://habrastorage.org/getpro/habr/upload_files/34c/cdc/a47/34ccdca4713dc4acf2abb038566a6927.png)
Кастомный снаппинг
Каркас нового экрана — деление его на три части. Изначально гость видит видео продукта и раздел кастомизации, которую еще можно полностью раскрыть при нажатии на ингредиент. При скролле можно перейти к дополнительному описанию, историям, калорийности.
В этой статье мы подробно разберем одну из UI-проблем, которую нам пришлось решить на Compose – как сделать вот такой список с скроллом со снаппингом ко второй половине экрана.
Посмотрите на видео:
Обратите внимание, что при выборе ингредиента открывается панель прямо в экране.
Почему это было первой и важной задачей:
Потому что это каркас и архитектура экрана, от нее зависит, то как мы будем дальше реализовывать все фичи
Она выглядела нестандартной, поэтому точно придется писать это самостоятельно и лучше раньше решить все вопросы
Проверить гипотезу
Сначала надо было проверить, что Jetpack Compose позволяет нам сделать основную навигацию и анимации на экране, и мы не столкнемся с проблемами. Для этого мы начали играться в sandbox, чтобы сделать скелет экрана.
Подход №1. BottomSheet
![Первый дизайн из Figma Первый дизайн из Figma](https://habrastorage.org/getpro/habr/upload_files/8fd/cfe/a30/8fdcfea30377c51fcd407c3bb0e2e130.png)
Когда мы в первый раз сели брейнштормить про экран карточки продукта, в первую очередь размышляли, как сделать 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](https://habrastorage.org/getpro/habr/upload_files/9f3/280/c1c/9f3280c1c9b13982e4cc5361c70f7869.png)
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. Таблицу с ними я прикладываю ниже, или вы можете обратиться к видео, которое было прикреплено выше.
![Наши компоненты в дизайн системе для этой Карточки продукта Наши компоненты в дизайн системе для этой Карточки продукта](https://habrastorage.org/getpro/habr/upload_files/c24/3ab/b5d/c243abb5d13bd31856aa46e88e1d520c.png)
У нас в Додо намечается еще множество проектов с дерзкими и красивыми дизайнами, и поэтому мы ищем в наши команды мобильных UI экспертов – гуру, знающих о дизайне на мобилках все и способных вести команду в сторону построения правильного и красивого интерфейса
Если это про вас – смело заходите и откликайтесь на наши позиции, и ждем вас на собеседованиях :)
В каналах Dodo Mobile и Мобильное чтиво мы рассказываем про разработку приложений в Dodo. Подписывайтесь, чтобы узнавать новости раньше всех.