Привет! Меня зовут Владислав Фальзан, я работаю android-разработчиком в М2. Наша команда мобильной разработки развивает одноименное приложение — онлайн-платформу для решения вопросов с недвижимостью. Мы помогаем проводить сделки проще, быстрее и безопаснее. Основные пользователи приложения — физические лица (B2C) и риелторы (B2B2C). Эта статья — технический гайд для android-разработчиков, которые хотят реализовать и внедрить полный цикл сторис у себя в приложении с использованием: Compose, MVVM, Coroutines flow и правил чистой архитектуры.
С чего все началось: почему решил написать статью
Когда писал фичу, я столкнулся с проблемой, что ни в русскоязычном, ни в англоязычном интернете не нашел гайдлайна о том, как написать сторис с нуля на современном стеке с применением правил чистой архитектуры, далее в коде мы будем подсвечивать эти моменты. И затем я решил, что будет полезно поделиться своим опытом с сообществом и самому написать такую статью.
Прошу не судить строго, это моя первая статья. Хочу подсветить, что это — не лучшее решение, которое можно было придумать, а скорее наше видение реализации фичи. Фича постоянно улучшается, поэтому я буду рад вашим комментариям с подобным опытом и предложениям по улучшению.
Для удобства изучения статьи я решил разбить ее на блоки:
1. Почему решили делать сторис
2. Требования к фиче: как устроены превью и сторис
3. Как мы делали превью
4. Как мы делали сторис
5. Итог
Почему вообще решили делать сторис
На рынке давно уже есть такое решение, как сторис. За основу мы взяли пример одной небезызвестной соцсети — «нельзяграм». Эта фича стала стандартом и узнаваемой механикой для донесения новостей и продвижения нужной информации.
Раньше у нас в приложении была карусель с баннерами, откуда можно было перейти на продукты.
Однако было несколько проблем. Во-первых, баннеры давали очень низкую конверсию — далее вы увидите, что они в разы проигрывают сторис. Во-вторых, это устаревший формат, и многие другие компании решают те же задачи с помощью сторис, и это работает.
О каких задачах речь:
— Информирование пользователя об обновлениях в приложении
— Рассказ про акции и новости: промокоды, ивенты, аналитика, статьи
Итак, начнем с того, какие варианты мы рассматривали:
1) Готовое решение.
Почему бы не использовать что-то готовое — например, sdk для сторис?
Плюсы:
— Экономия времени. Не нужно ничего придумывать, достаточно просто подключить стороннюю библиотеку и начать пользоваться.
Минусы:
— Зависимость от чужого кода. Любые баги, которые будут в библиотеке, становятся вашими. Если из-за этого бага пользователь вылетит из приложения, то он не сможет понять, что это ошибка сторонней библиотеки, а посчитает, что ошибка — ваша. Как следствие, может подпортить оценку в сторах, статистику по багам в приложении, и ему придется ждать исправления в новой версии.
— Также это жесткость в изменениях. Мы не можем кастомизировать решение под свои нужды и ограничены контрактом, который идет наружу из библиотеки. Если библиотеку перестанут поддерживать — никаких улучшений/фиксов не будет вовсе. Такой код не протестировать по понятным причинам: код реализации от нас закрыт. Соответственно, такой код не будет: поддерживаемым, расширяемым и тестируемым, что является базисом чистой архитектуры. А один из главных ее посылов — убрать зависимость на все внешнее.
— Еще один минус — денежные затраты. Прикинули по финансам — невыгодно, насчитали порядка 2.4 млн в год на содержание такой фичи.
2) Собственное решение.
Поняли, что хотим двигаться в сторону своего решения: собрались командой и набрейнштормили идеи по реализации.
Плюсы:
— Плюсы данного подхода — все то, что было описано выше. В первую очередь, это возможность поддержки, расширения и тестирования кода. Мы его полностью контролируем, меняем как и когда захотим, покрываем тестами.
— Нет дополнительных затрат для компании.
Минусы:
— Необходимы время и компетенции разработчика для реализации фичи. В отличие от готового решения, которым можно сразу пользоваться, собственное нужно писать с нуля, сталкиваясь с различными нюансами реализации, фиксить баги в процессе тестирования и запуска. Однако и эти минусы можно превратить в плюсы, ведь это возможность для профессионального роста и написания статьи, как эта.
Изначально мы реализовали сторис на View с использованием сторонней библиотеки. Но затем, когда решили делать полноценную фичу, уже перешли на Compose, и новый код стали писать на нем. Переиспользовать старое решение на View мы не захотели, так как оно не нравилось нам с точки зрения дизайна, функциональности (например, не хватало свайпов) и кода (переделывать/развивать устаревший код, да еще и на View, было дорого). Поэтому это решение стало для нас неактуальным. Новое решение должно было быть на актуальном стеке, соответствовать бизнес требованиям и в дальнейшем развиваться и поддерживаться.
Посмотрев пару библиотек на Compose, я прикинул, сколько потребуется сил и времени на самостоятельное решение согласно требованиям. Compose — это фреймворк, который облегчает построение и обновление пользовательского интерфейса, используя декларативный подход. Он сокращает количество кода для достижения нужного результата, а также предоставляет удобные средства оптимизации UI: preview, lie edit и т. д. Это экономит время разработки. Поэтому мы и решили перейти на него вместо старого подхода с View. В контексте нашей фичи Compose предлагает удобные компоненты «из коробки», такие как HorizontalPager, LinearProgressIndicator, которые облегчают процесс разработки — необходимо меньше кода (и времени соответственно) для отрисовки нужного UI. Взвесив все за и против, я решил все-таки писать собственное решение.
Требования к фиче: как устроены превью и сторис
Фича состоит двух частей — это превью и сами сторис. По умолчанию сторис считается непросмотренной, поэтому вокруг элемента присутствует рамка. Когда пользователь посмотрел первый (последний слайд) — сторис считается просмотренной, и рамка исчезает. По нажатию на превью пользователю на отдельном экране показывается сторис. Сторис состоит из одного и более слайдов. Данная фича является актуальным и удобным способом публиковать новости о нововведениях в приложении и привлекать новую аудиторию.
Какие у нас были требования:
Превью должно быть на первом экране, а сторис — на втором.
1) Навигация по слайдам и сторис
-
Тап влево:
Если это не первый слайд, переходим на предыдущий.
Если это первый слайд, но не первая сторис — открываем предыдущую сторис.
Если это первая сторис, возвращаемся к первому слайду первой сторис.
-
Тап вправо:
Если это не последний слайд, переходим на следующий.
Если это последний слайд, но не последняя сторис — переходим к следующей сторис.
Если это последняя сторис, закрываем экран сторис.
2) Свайпы
-
Свайп влево:
Если это не первая сторис, открываем предыдущую.
Если это первая сторис, закрываем экран сторис.
-
Свайп вправо:
Если это не последняя сторис, открываем следующую.
Если это последняя сторис, закрываем экран.
Свайп сверху вниз: закрывает экран сторис.
3) Дополнительные действия
Долгое нажатие: пауза на текущем слайде.
Кнопка внутри сторис: позволяет переходить по внешней ссылке.
4) Отображение сторис
При просмотре сторис должна становиться просмотренной.
Должна быть возможность регулировать длительность слайда удаленно через конфиг.
Для каждого профиля должна вестись своя история просмотренности, чтобы отслеживать, какие слайды и сторис уже были показаны.
На данном этапе сторис доступны только для частных риэлторов и партнёров (агентств недвижимости), с возможностью добавить физические лица в будущем.
-
Если у пользователя нет доступных сторис, секция с их превью должна быть скрыта.
Как мы делали превью
Бекенда для сторис у нас нет. Так как мы не собираемся использовать сторис для web и других каналов, а разработка бекенда под данную фичу — это дополнительные доработки, то эта идея для нас — нецелесообразна.
Порядок получения сторис простой:
— Есть доменная модель, которая передается по цепочке data source — use case — view model.
— Data source передает захардкоженные сторис (если вдруг в будущем появится бекенд, то мы будем готовы и сменим их получение на API бэка).
— Во вью модели происходит маппинг в presentation модель. Она ответственна за набор параметров, необходимых для отображения сторис. Все стандартно, поэтому эти участки кода мы пропустим и перейдем сразу к presentation модели.
@Parcelize
data class UiStories(
val id: String,
@DrawableRes val previewImageRes: Int,
@StringRes val previewTitleRes: Int,
val slides: List<UiSlide>,
val previewBackgroundColor: ULong = Colors.systemOnWhiteStrong.value,
val current: Boolean = false,
val shown: Boolean = false
) : Parcelable
sealed class StoriesType {
data class Content(val content: UiStories) : StoriesType()
data object Fake : StoriesType()
}
Рассмотрим поля класса UiStories:
Уникальный id сторис
Текст
url картинки
Задний фон для превью
Список слайдов для просмотра
сurrent — это текущая просматриваемая сторис, shown — уже просмотренная сторис, она необходима для отрисовки рамки на превью. Также у нас есть класс StoriesType, который позволяет отличать реальную сторис от фейковой. Фейковая сторис — это наше самостоятельное решение, оно нужно для свайпов на крайних элементах, чтобы закрыть экран сторис, так как стандартное поведение compose pager не включает в себя этого. По сути, это пустышка, переход на которую будет сигнализировать о том, что контент кончился, и экран сторис можно закрыть.
@Parcelize
data class UiSlide(
val backgroundColor: Long,
@DrawableRes val imageRes: Int,
@StringRes val titleRes: Int,
@StringRes val subtitleRes: Int,
val buttonVisible: Boolean,
@StringRes val buttonText: Int,
val buttonColorsULong: ButtonColorsULong,
val progressState: ProgressState = ProgressState.START,
val current: Boolean = false,
val progress: Float = 0f
) : Parcelable {
enum class ProgressState {
REFRESHED, START, RESUME, PAUSE, COMPLETE
}
}
Слайд состоит из набора атрибутов для отрисовки заднего фона.
progressState — текущее состояние воспроизведения слайда, они могут быть:
REFRESHED — слайд перезагружен. Необходим для реализации бизнес требования: при тапе влево на первом слайде первой сторис данный слайд должен быть перезапущен.
START — начальное состояние каждого слайда.
RESUME — слайд проигрывается.
PAUSE — пауза, пользователь нажал на слайд и не убирает палец.
COMPLETE — слайд завершился.
current — по аналогии со сторис, progress — числовое отображение прогресса от 0 (0% заполнения шкалы) до 1 (100% заполнения шкалы).
Чтобы понимать, отрисовывать рамку или нет, необходимо знать информацию о просмотренных сторис. Для хранения данной информации можно использовать любое удобное вам key-value хранилище, мы решили использовать shared preferences, так как это удобный и быстрый способ получения информации, часто применяющийся в android-разработке. Идеальный кандидат — это id.
class StoriesShownRepositoryImpl(
private val keyValueStorage: KeyValueStorage
) : StoriesShownRepository {
private val shownStoriesMap = mutableMapOf<String, MutableStateFlow<Set<String>>>()
override suspend fun set(userId: String, storiesId: String) {
if (userId.isEmpty()) return
val ids = keyValueStorage.getStringSet(STORIES_SHOWN_KEY + userId).toMutableSet()
ids.add(storiesId)
keyValueStorage.putStringSet(STORIES_SHOWN_KEY + userId, ids)
getFlow(userId).emit(ids)
}
override fun observe(userId: String): Flow<Set<String>> =
if (userId.isEmpty()) {
flowOf(emptySet())
} else {
getFlow(userId).asStateFlow()
}
private fun getFlow(userId: String): MutableStateFlow<Set<String>> =
shownStoriesMap[userId] ?: createFlow(userId)
private fun createFlow(userId: String): MutableStateFlow<Set<String>> =
MutableStateFlow(keyValueStorage.getStringSet(STORIES_SHOWN_KEY + userId)).also {
shownStoriesMap[userId] = it
}
}
Итак, все просто — кладем набор из id в shared preferences через set и забираем через get. В нашем приложении у одного пользователя может быть несколько учетных записей и, соответственно, если он посмотрел сторис в одной, то в другой она не должна быть просмотренной. Чтобы этого достичь, немного усложним наш репозиторий: теперь мы знаем и храним в модели информацию, какие сторис просмотрены, и можем приступить к их получению и отрисовке превью.
Код юзкейсов по получению id просмотренных сторис и юзкейс для их сеттинга приводить не буду, так как это обычные прокси в репозиторий. Давайте посмотрим на список наших сторис:
val STORIES = listOfNotNull(
if (isToggleEnabled(FeatureToggle.FIND_PROFESSIONAL)) findProf() else null,
indexOfMood(),
promo(),
earnWithPrimary()
)
private fun promo() = UiStories(
id = PROMO_STORIES_ID,
previewImageRes = R.drawable.ic_stories_preview_promo,
previewTitleRes = R.string.stories_promo_preview_title,
slides = listOf(
UiSlide(
backgroundColor = BRAND_M2_PURPLE_COLOR,
imageRes = R.drawable.img_stories_promo,
titleRes = R.string.stories_promo_title,
subtitleRes = R.string.stories_promo_subtitle,
buttonVisible = true,
buttonColorsULong = ButtonColors.orangeBrand.map(),
buttonText = R.string.stories_promo_button_text
)
)
)
private fun earnWithPrimary() = UiStories(
id = EARN_WITH_PRIMARY_ID,
previewImageRes = R.drawable.ic_stories_earn_with_primary,
previewTitleRes = R.string.stories_earn_preview_title,
slides = listOf(
UiSlide(
backgroundColor = ADDITIONAL_M2_MINT_COLOR,
imageRes = R.drawable.img_earn_with_primary,
titleRes = R.string.stories_earn_title,
subtitleRes = R.string.stories_earn_subtitle,
buttonVisible = true,
buttonColorsULong = ButtonColors.white.map(),
buttonText = R.string.stories_earn_button_text
)
)
)
private fun indexOfMood() = UiStories(
id = INDEX_OF_MOOD_ID,
previewImageRes = R.drawable.ic_stories_index_of_mood,
previewTitleRes = R.string.stories_index_preview_title,
slides = listOf(
UiSlide(
backgroundColor = BRAND_M2_BLUE_COLOR,
imageRes = R.drawable.img_index_of_mood,
titleRes = R.string.stories_index_title,
subtitleRes = R.string.stories_index_subtitle,
buttonVisible = true,
buttonColorsULong = ButtonColors.white.map(),
buttonText = R.string.stories_index_button_text
)
)
)
private fun findProf() = UiStories(
id = FIND_PROF_ID,
previewImageRes = R.drawable.ic_service_find_professional,
previewTitleRes = R.string.stories_find_prof_preview_title,
previewBackgroundColor = Colors.brandM2Purple3.value,
slides = listOf(
UiSlide(
backgroundColor = BRAND_M2_BLUE_COLOR,
imageRes = R.drawable.img_find_prof_story,
titleRes = R.string.stories_find_prof_title,
subtitleRes = R.string.stories_find_prof_subtitle,
buttonVisible = true,
buttonColorsULong = ButtonColors.white.map(),
buttonText = R.string.stories_find_prof_button_text
)
)
)
Далее получаем наши сторис:
private fun observeStories(userId: String, userRole: RoleType) {
observeShownStoriesUseCase(userId)
.flowOn(dispatchers.io())
.onEach { shownStoriesIds ->
val stories = getStories(userRole, shownStoriesIds)
setState { stories(stories) }
}
.catch {
emit(emptySet())
}
.launchSerially(SHOWN_STORIES_TAG)
}
private suspend fun getStories(
userRole: RoleType,
shownStoriesIds: Set<String>
): List<UiStories> {
if (!userRole.isRealtorOrProfessional()) return emptyList()
val stories = STORIES.toMutableList()
val registrationDate = withContext(dispatchers.io()) {
try {
getUserPersonalDataUseCase().registrationDate
} catch (expected: Exception) {
logger.error(expected)
Date()
}
}
if (userRole != RoleType.PROFESSIONAL || registrationDate <= REGISTRATION_DATE_THRESHOLD) {
stories.removeIf { it.id == PROMO_STORIES_ID }
}
return stories.map {
it.copy(shown = shownStoriesIds.contains(it.id))
}
}
Сначала мы получаем список id просмотренных сторис для дальнейшей отрисовки рамки. Также необходимо проверить дату регистрации пользователя.
Полученным сторис присваиваем признак: просмотрена/не просмотрена. Затем мы через стейт передаем во фрагмент. Не будем останавливаться на стейте экрана превью, так как он простой. Далее во фрагменте мы отрисовываем наши превью:
if (vitrineState.stories.isNotEmpty()) {
StoriesPreviewList(
userId = vitrineState.userId,
stories = vitrineState.stories,
onClick = { id -> viewModel.openStoriesScreen(id) }
)
}
@Composable
fun StoriesPreviewList(
userId: String,
stories: List<UiStories>,
onClick: (String) -> Unit
) {
val scrollState = rememberSaveable(
inputs = arrayOf(userId),
saver = ScrollState.Saver
) {
ScrollState(initial = 0)
}
Row(
modifier = Modifier
.horizontalScroll(scrollState)
.padding(start = 14.dp, top = 14.dp, end = 14.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
stories.forEach { story ->
StoriesPreview(stories = story, onClick = onClick)
}
}
}
Здесь можно добавить, что scrollState у нас зависит от профиля пользователя, и если он его сменит, то скролл обнулится.
@Composable
fun StoriesPreview(stories: UiStories, onClick: (String) -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
Column(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {
onClick(stories.id)
}
)
) {
Box(
modifier = Modifier
.size(84.dp)
.run {
if (stories.shown) {
this
} else {
border(
width = 1.dp,
color = Colors.systemOnWhiteBlue,
shape = Shapes.roundedCorners6
)
}
}
.padding(2.dp)
.clip(shape = Shapes.roundedCorners4)
) {
Image(
painter = painterResource(id = stories.previewImageRes),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.background(color = Color(stories.previewBackgroundColor))
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
modifier = Modifier
.padding(horizontal = 2.dp)
.width(80.dp),
text = stringResource(id = stories.previewTitleRes),
color = Colors.systemOnWhiteStrong,
style = TextStyles.caption2Regular,
textAlign = TextAlign.Center
)
}
}
Как мы делали сторис
Перейдем непосредственно к экрану самих сторис. И начнем мы с сердца этого экрана — это стейт экрана сторис.
data class StoriesState(
val duration: Int,
val storiesType: List<StoriesType>
) {
val currentStories =
(storiesType.first { it is StoriesType.Content && it.content.current } as StoriesType.Content)
.content
val currentStoriesIndex =
storiesType.indexOfFirst { it is StoriesType.Content && it.content.current }
private val slides = currentStories.slides
val currentSlide = slides.first { it.current }
val currentSlideIndex = slides.indexOfFirst { it.current }
val slidesCount = slides.size
fun nextSlide(newSlideIndex: Int): StoriesState =
progress(progressState = UiSlide.ProgressState.COMPLETE, progress = 1f)
.progress(
progressState = UiSlide.ProgressState.START,
progress = 0f,
newSlideIndex = newSlideIndex
)
.currentSlide(newSlideIndex)
fun refreshSlide(): StoriesState =
progress(progressState = UiSlide.ProgressState.REFRESHED, progress = 0f)
fun previousSlide(newSlideIndex: Int): StoriesState =
progress(progressState = UiSlide.ProgressState.START, progress = 0f)
.progress(
progressState = UiSlide.ProgressState.START,
progress = 0f,
newSlideIndex = newSlideIndex
)
.currentSlide(newSlideIndex)
fun finish(): StoriesState =
progress(progressState = UiSlide.ProgressState.COMPLETE, progress = 1f)
fun nextStories(newStoriesIndex: Int, newSlideIndex: Int): StoriesState =
progress(progressState = UiSlide.ProgressState.START, progress = 0f)
.progress(
progressState = UiSlide.ProgressState.START,
progress = 0f,
newStoriesIndex = newStoriesIndex,
newSlideIndex = newSlideIndex
)
.currentStory(newStoriesIndex)
fun previousStories(newStoriesIndex: Int, newSlideIndex: Int): StoriesState =
progress(progressState = UiSlide.ProgressState.START, progress = 0f)
.progress(
progressState = UiSlide.ProgressState.START,
progress = 0f,
newStoriesIndex = newStoriesIndex,
newSlideIndex = newSlideIndex
)
.currentStory(newStoriesIndex)
fun pause(): StoriesState =
progress(
progressState = UiSlide.ProgressState.PAUSE, progress = currentSlide.progress
)
fun resume(progress: Float = currentSlide.progress): StoriesState =
progress(progressState = UiSlide.ProgressState.RESUME, progress = progress)
private fun currentSlide(newCurrentIndex: Int): StoriesState =
copy(
storiesType = storiesType.mapIndexed { storiesIndex, uiStories ->
if (storiesIndex == currentStoriesIndex && uiStories is StoriesType.Content) {
uiStories.copy(
content = uiStories.content.copy(
slides = uiStories.content.slides.mapIndexed { slideIndex, uiSlide ->
uiSlide.copy(current = slideIndex == newCurrentIndex)
}
)
)
} else {
uiStories
}
}
)
private fun currentStory(newStoriesIndex: Int): StoriesState =
copy(
storiesType = storiesType.mapIndexed { index, uiStories ->
if (uiStories is StoriesType.Content) {
uiStories.copy(
content = uiStories.content.copy(current = index == newStoriesIndex)
)
} else {
uiStories
}
}
)
private fun progress(
progressState: UiSlide.ProgressState,
progress: Float,
newStoriesIndex: Int = currentStoriesIndex,
newSlideIndex: Int = currentSlideIndex
): StoriesState =
copy(
storiesType = storiesType.mapIndexed { storiesIndex, uiStories ->
if (storiesIndex == newStoriesIndex && uiStories is StoriesType.Content) {
uiStories.copy(
content = uiStories.content.copy(
slides = uiStories.content.slides.mapIndexed { slideIndex, uiSlide ->
if (slideIndex == newSlideIndex) {
uiSlide.copy(progressState = progressState, progress = progress)
} else {
uiSlide
}
}
)
)
} else {
uiStories
}
}
)
companion object {
fun initial(durationInSec: Int, stories: List<UiStories>, storiesId: String): StoriesState =
StoriesState(
duration = durationInSec * 1000,
storiesType = stories.map { uiStories ->
uiStories.copy(
slides = uiStories.slides.mapIndexed { slideIndex, uiSlide ->
uiSlide.copy(current = slideIndex == 0)
},
current = uiStories.id == storiesId
)
}.addFakeStories()
)
private fun List<UiStories>.addFakeStories(): List<StoriesType> {
val list = this.map {
StoriesType.Content(it)
} as List<StoriesType>
return list.toMutableList().apply {
add(0, StoriesType.Fake)
add(StoriesType.Fake)
}
}
}
}
У нас есть два поля: duration — это продолжительность каждого слайда, stories — список сторис. Все внутренние поля данного стейта — детализация поля сторис. Начальный стейт — это функция initial. Сторис, которую нажал пользователь, становится текущей (через id). Также первый слайд каждой сторис становится текущим.
Далее рассмотрим три основные функции — currentSlide/Stories и progress. currentStories заменяет текущую сторис, slide — слайд в текущей сторис, progress — по переданной сторис/слайду выставляет progressState и progress, иначе по дефолту сторис/слайд будут текущими (для pause, resume, refreshed).
Когда мы переключаем слайд, необходимо сначала завершить текущий слайд. Для этого мы ставим ему progress state complete и progress = 1. Необходимость в выставлении этих параметров мы увидим позже.
Для нового же слайда надо выставить start и 0. Еще нужно сменить текущий слайд на новый. Вообще выставление значения прогресса зависит от требований бизнеса, поэтому варианты могут быть разные. В случае со сторис нужно сменить не только сторис, но и слайд, так как у каждой сторис есть свой текущий слайд.
Теперь посмотрим на код вью модели.
class StoriesViewModel(
stories: List<UiStories>,
storiesId: String,
storiesConfig: StoriesConfig,
private val userId: String,
private val setStoriesShownUseCase: SetStoriesShownUseCase,
private val router: Router,
private val oneClickDealScreenFactory: OneClickDealScreenFactory,
private val analytics: Analytics,
private val aboutFindProfScreenFactory: AboutFindProfScreenFactory
) : StatefulViewModel<StoriesState>(
StoriesState.initial(
durationInSec = storiesConfig.getDuration().toInt(),
stories = stories,
storiesId = storiesId
)
) {
fun setStoriesShown(page: Int) {
when (val storiesType = stateFlow.value.storiesType[page]) {
is StoriesType.Content -> {
launch(
block = {
withContext(dispatchers.io()) {
setStoriesShownUseCase(
userId = userId,
storiesId = storiesType.content.id
)
}
}
)
}
StoriesType.Fake -> {}
}
}
fun back() {
router.back()
}
fun setNextSlide() {
with(stateFlow.value) {
val nextSlideIndex = currentSlideIndex + 1
if (slidesCount == nextSlideIndex) {
setNextStories()
} else {
analytics.send(
CoreMainStoriesTap(
id = currentStories.id,
page = nextSlideIndex + 1
)
)
setState { nextSlide(nextSlideIndex) }
}
}
}
fun setPreviousSlide() {
with(stateFlow.value) {
if (currentSlideIndex == 0) {
val contentOnly = storiesType.filterIsInstance<StoriesType.Content>()
val currentIndex = contentOnly.indexOfFirst { it.content.current }
if (currentIndex == 0) {
analytics.send(
CoreMainStoriesTap(
id = currentStories.id,
page = 0
)
)
setState { refreshSlide() }
} else {
setPreviousStories()
}
} else {
val previousSlideIndex = currentSlideIndex - 1
analytics.send(
CoreMainStoriesTap(
id = currentStories.id,
page = previousSlideIndex + 1
)
)
setState { previousSlide(previousSlideIndex) }
}
}
}
private fun setNextStories() {
with(stateFlow.value) {
val newStoriesIndex = currentStoriesIndex + 1
when (val storiesType = storiesType[newStoriesIndex]) {
is StoriesType.Content -> {
val storiesId = storiesType.content.id
val slideIndex = storiesType.content.slides.indexOfFirst { it.current }
analytics.send(
CoreMainStoriesTap(
id = storiesId,
page = slideIndex + 1
)
)
setState {
nextStories(newStoriesIndex = newStoriesIndex, newSlideIndex = slideIndex)
}
}
StoriesType.Fake -> {
setState { finish() }
back()
}
}
}
}
private fun setPreviousStories() {
with(stateFlow.value) {
val newStoriesIndex = currentStoriesIndex - 1
when (val storiesType = storiesType[newStoriesIndex]) {
is StoriesType.Content -> {
val storiesId = storiesType.content.id
val slideIndex = storiesType.content.slides.indexOfFirst { it.current }
analytics.send(
CoreMainStoriesTap(
id = storiesId,
page = slideIndex + 1
)
)
setState {
previousStories(newStoriesIndex = newStoriesIndex, newSlideIndex = slideIndex)
}
}
StoriesType.Fake -> {
setState { finish() }
back()
}
}
}
}
fun setPaused() {
setState { pause() }
}
fun setResumed() {
with(stateFlow.value) {
val validStates = listOf(
UiSlide.ProgressState.START,
UiSlide.ProgressState.PAUSE,
UiSlide.ProgressState.REFRESHED
)
if (currentSlide.progressState !in (validStates)) return
setState { resume() }
}
}
fun setStories(page: Int) {
with(stateFlow.value) {
when {
page > currentStoriesIndex -> {
setNextStories()
}
page < currentStoriesIndex -> {
setPreviousStories()
}
else -> {
}
}
}
}
fun setProgress(progress: Float) {
if (stateFlow.value.currentSlide.progressState != UiSlide.ProgressState.RESUME) return
setState { resume(progress) }
}
fun openOneClickDeal() {
router.openScreen(
screen = oneClickDealScreenFactory.create(source = OneClickDealSource.STORIES),
replace = true
)
}
fun openFindProf() {
router.openScreen(
screen = aboutFindProfScreenFactory.create(),
replace = true)
}
fun sendCoreMainStoriesButtonTapEvent(id: String, index: Int) {
analytics.send(CoreMainStoriesButtonTap(id = id, page = index + 1))
}
}
1) Инициализация вью модели
При создании вью модели задается первоначальный стейт (initial), определяющий начальные параметры работы сторис.
Основное внимание уделено функциям для управления слайдами и сторис.
2) Функции управления слайдами
a. setNextSlide
-
Проверяет, является ли текущий слайд последним в сторис:
Если да — переключается на следующую сторис.
Если нет — вызывает nextSlide для текущего стейта.
b. setPreviousSlide
-
Определяет, является ли текущий слайд первым:
Если это первый слайд первой сторис, запускается текущий слайд заново.
Если текущая сторис не первая — переключается на предыдущую сторис.
В остальных случаях — переключается на предыдущий слайд.
3) Функции управления сторис
a. setNextStories
-
Обрабатывает переключение на следующую сторис:
Если текущая сторис последняя, экран сторис закрывается.
В остальных случаях запускается следующая сторис.
b. setPreviousStories
-
Обрабатывает переключение на предыдущую сторис:
Если текущая сторис первая, экран сторис закрывается.
В остальных случаях запускается предыдущая сторис.
4) Синхронизация воспроизведения
a. resume
-
Ключевая логика синхронизации:
Запрещает воспроизведение сторис, кроме видимой.
-
Управляет воспроизведением в состояниях:
start (изначальное состояние),
pause (пауза),
refreshed (повторный запуск первого слайда первой сторис).
b. setStories
-
Предназначена для синхронизации состояния при свайпе:
При свайпах и тапах вызываются разные коллбэки, что требует согласованности.
-
Логика работы:
Если номер страницы от пейджера больше текущей — устанавливается следующая сторис.
Если номер меньше текущей — устанавливается предыдущая сторис.
Если равен текущей — ничего не происходит (возможная причина: вызов двух launchedEffect).
5) Управление прогрессом
a. setProgress
-
Контролирует прогресс выполнения сторис:
Прогресс изменяет только видимая и воспроизводимая сторис.
-
Запрещает изменение прогресса невидимых сторис:
Это предотвращает ошибки отображения.
6) Особенности работы с фрагментом
-
Использование пейджера:
Тапы и таймауты обновляют индекс слайда/сторис, пейджер синхронизируется с вью моделью.
При свайпе пейджер сначала обновляет стейт и уведомляет вью модель, которая затем подстраивается.
7) Проблемы и решение
-
Если сторис вне синхронизации, это приводит к некорректному отображению:
Пример — сбой прогресса невидимой сторис.
Решение — строгий контроль стейтов воспроизведения.
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
private fun StoriesContent(
stateFlow: StateFlow<StoriesState>,
onToolbarClick: () -> Unit,
onButtonClick: (String, Int) -> Unit,
onPaused: () -> Unit,
onResumed: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onProgress: (Float) -> Unit
) {
AppMaterialTheme {
val storiesState = stateFlow.collectAsStateWithLifecycle().value
val pagerState =
rememberPagerState(
pageCount = { storiesState.storiesType.size },
initialPage = storiesState.currentStoriesIndex
)
// смена сторис, слайда при тапе, а также их запуск
LaunchedEffect(
storiesState.currentStoriesIndex,
storiesState.currentSlideIndex,
pagerState.isScrollInProgress
) {
if (storiesState.currentStoriesIndex != pagerState.currentPage) {
pagerState.animateScrollToPage(storiesState.currentStoriesIndex)
}
if (pagerState.isScrollInProgress) {
onPaused()
} else {
onResumed()
}
}
// смена сторис при свайпе
LaunchedEffect(pagerState) {
snapshotFlow {
pagerState.currentPage
}.collect {
viewModel.setStoriesShown(it)
// вызывается для синхронизации стейта вм и pager'а
viewModel.setStories(it)
}
}
val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
val screenWidthPx = with(LocalDensity.current) { screenWidthDp.toPx() }
val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
val screenHeightPx = with(LocalDensity.current) { screenHeightDp.toPx() }
val offsetY = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.background(ComposeColor.Black)
.offset { IntOffset(0, offsetY.value.roundToInt()) }
.pointerInput(Unit) {
detectTapGestures(
onPress = {
onPaused()
// ждем tap up событие от пользователя, на результат не смотрим
tryAwaitRelease()
onResumed()
},
onTap = { offset ->
if (offset.x < screenWidthPx / 2) {
onPrevious()
} else {
onNext()
}
}
)
}
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragStart = {},
onDragEnd = {
if (offsetY.value != 0f) {
val ratio = screenHeightPx / offsetY.value
if (ratio <= RATIO_TO_DISMISS) {
scope.launch {
offsetY.animateTo(
targetValue = screenHeightPx,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = StiffnessLow
)
)
}
viewModel.back()
} else {
scope.launch {
offsetY.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = StiffnessLow
)
)
}
}
}
},
onDragCancel = {},
onVerticalDrag = { _, dragAmount ->
val newOffset = offsetY.value + dragAmount
if (newOffset >= 0) {
scope.launch {
offsetY.snapTo(newOffset)
}
}
}
)
}
) { preloadedStoriesIndex ->
HorizontalPagerContent(
pagerState,
preloadedStoriesIndex,
storiesState,
onToolbarClick,
onButtonClick,
onNext,
onProgress
)
}
}
}
Рассмотрим функцию StoriesContent.
1) Инициализация состояния пейджера
Сначала мы вызываем rememberPageState, чтобы создать и отслеживать состояние пейджера. В параметры передаются:
Общее количество сторис.
Текущий индекс сторис, чтобы учесть, что пользователь может начать просмотр с любого превью.
LaunchedEffect для тапов
Отслеживает изменения текущего слайда или сторис при тапе.
-
Из-за подгрузки страниц есть разница между видимой сторис и той, которая ещё не обновлена:
Тап: сначала меняется слайд, затем сторис, затем обновляется состояние вью модели.
Чтобы синхронизировать отображение пейджера с вью моделью, используем animateScrollToPage.
2) Функция setResumed
Эта функция возобновляет воспроизведение сторис/слайдов. Запускается в двух случаях:
При смене сторис или слайда.
При снятии с паузы.
Но только тогда, когда состояния пейджера и вью модели синхронизированы.
3) Обработка свайпов
Отдельный LaunchedEffect отслеживает горизонтальные свайпы:
Сначала обновляется состояние пейджера.
Затем синхронизируем его с ViewModel через setStories.
Здесь setResumed не используется, потому что в нашей реализации логика паузы и воспроизведения уже встроена в detectTapGestures:
При касании экран ставится на паузу.
Если жест распознаётся как свайп, воспроизведение возобновляется автоматически.
4) Компонент HorizontalPager
Для обработки жестов используется кастомный detectTapGestures. Обработка вертикальных свайпов реализована стандартным способом.
detectTapGestures: логика работы
-
Два коллбэка: onPress и onTap.
-
onPress: срабатывает, если палец удерживается на экране.
Ставит сторис на паузу.
Ждёт, пока пользователь отпустит экран (tryAwaitRelease()).
Возобновляет воспроизведение.
-
onTap: срабатывает при коротком нажатии.
-
Экран делится на две зоны:
Левая часть — переход к предыдущей сторис.
Правая часть — переход к следующей сторис.
-
-
5) Обработка вертикальных свайпов
Реализована в detectVerticalDragGestures:
Нам интересен свайп сверху вниз (для закрытия сторис).
-
Логика в onDragEnd:
Проверяем, достигло ли смещение контента (offsetY) порога закрытия (заданного через RATIO_TO_DISMISS).
-
Если да:
Контент сдвигается до конца вниз.
Экран сторис закрывается.
-
Если нет:
Контент возвращается в исходное положение.
-
Логика в onVerticalDrag:
Проверяем направление свайпа.
Если свайп идёт сверху вниз, обновляем offsetY, чтобы анимация сдвига контента отображалась корректно.
Обратите внимание, что при создании передается preloadedStoriesIndex. Я его не зря так назвал, так как бесшовного свайпа между элементами списка pager необходимо знать и прогрузить страницы, следующие за текущей (видимой). Это называется предзагрузкой элементов. Наглядно результат можно увидеть ниже:
Как видите, помимо текущей (видимой) страницы 4, мы также видим частично страницы 3 и 5. Так вот этот параметр, что нам передается, — не текущая страница, а подгружаемая. Разница в том, что нам для сторис, которая невидима нашему глазу, не нужно запускать никаких анимаций, подсчетов и т.д., хотя и нужна отрисовка элементов. Она происходит ниже в коде. Однако управление подсчетами, логикой анимации у нас занимается вью модель. По сути, как и должно быть по клину, View — максимально простая, а логикой представления занимается вью модель, и там решается, когда и что запустить. Таким образом, сторис по бокам у нас отрисуются, но не запустятся, и только видимая сможет запуститься.
Рассмотрим теперь следующую часть фрагмента:
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
private fun HorizontalPagerContent(
pagerState: PagerState,
preloadedStoriesIndex: Int,
storiesState: StoriesState,
onToolbarClick: () -> Unit,
onButtonClick: (String, Int) -> Unit,
onNext: () -> Unit,
onProgress: (Float) -> Unit
) {
// preloadedStoriesIndex - страница из pager'а для обработки, тк
// работает pre-fetcher (мб != видимой странице)
val storyType = storiesState.storiesType[preloadedStoriesIndex]
if (storyType is StoriesType.Content) {
val preloadedStory = storyType.content
val preloadedSlide = preloadedStory.slides.first { it.current }
val preloadedSlideIndex = preloadedStory.slides.indexOfFirst { it.current }
Column(
modifier = Modifier
.fillMaxSize()
.background(ComposeColor(preloadedSlide.backgroundColor))
.statusBarsPadding()
.navigationBarsPadding()
.graphicsLayer {
val pageOffset = pagerState.getOffsetFractionForPage(preloadedStoriesIndex)
val offScreenRight = pageOffset < 0f
val interpolated = FastOutLinearInEasing.transform(pageOffset.absoluteValue)
rotationY =
interpolated * if (offScreenRight) ROTATION_DEGREES else -ROTATION_DEGREES
transformOrigin = TransformOrigin(
pivotFractionX = if (offScreenRight) 0f else 1f,
pivotFractionY = .5f
)
}
) {
Stepper(
storiesIndex = preloadedStoriesIndex,
slides = preloadedStory.slides,
duration = storiesState.duration,
onNext = onNext,
onProgress = onProgress
)
Header(onToolbarClick)
Illustration(
imageRes = preloadedSlide.imageRes,
modifier = Modifier.weight(1f)
)
TitleAndDescription(
titleRes = preloadedSlide.titleRes,
subtitleRes = preloadedSlide.subtitleRes
)
if (preloadedSlide.buttonVisible) {
Button(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 24.dp),
buttonStyle = ButtonStyle.xLargeStyle,
buttonColors = preloadedSlide.buttonColorsULong.map(),
title = stringResource(id = preloadedSlide.buttonText),
onClick = {
onButtonClick(preloadedStory.id, preloadedSlideIndex)
}
)
}
}
}
}
Логика работы с типом сторис и анимацией
-
Проверка типа сторис
-
В первую очередь определяем, содержит ли сторис контент:
Если сторис без контента, её отрисовка не выполняется.
Важно: текущую сторис или слайд мы получаем, основываясь на состоянии пейджера, а не вью модели.
-
Это обусловлено работой prefetcher’а пейджера, который подгружает следующие сторис заранее. Таким образом, сторис, видимая пейджером, может отличаться от текущей сторис во вью модели.
-
Бесшовный переход
-
Благодаря предзагрузке сторис, переход между ними происходит плавно:
Когда одна сторис скрывается, а другая становится видимой (по умолчанию больше, чем на 50%), пейджер обновляет currentPage.
Срабатывает LaunchedEffect, который синхронизирует состояния пейджера и вью модели.
-
-
Анимация свайпа
-
Для создания эффектов анимации используется graphicsLayer:
-
rotationY: отвечает за вращение контента вокруг вертикальной оси.
Чем дальше пользователь свайпает сторис, тем ближе угол к значению ROTATION_DEGREES(например, 45 градусов).
transformOrigin: задаёт смещение центра трансформации по осям X и Y для более естественного движения.
-
-
Теперь рассмотрим часть, отвечающую за отрисовку полоски прогресса сторис:
private const val INITIAL_ANIMATION_VALUE = 0f
private const val TARGET_ANIMATION_VALUE = 1f
private val STEPPER_TRACK_COLOR = Color(0x52FFFFFF)
@Composable
fun Stepper(
storiesIndex: Int,
slides: List<UiSlide>,
duration: Int,
onNext: () -> Unit,
onProgress: (Float) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
slides.forEach {
LinearProgressIndicator(
progress = { it.progress },
modifier = Modifier
.weight(1f)
.height(4.dp)
.clip(Shapes.roundedCorners8),
color = Colors.systemWhite,
trackColor = STEPPER_TRACK_COLOR,
strokeCap = StrokeCap.Butt,
gapSize = 0.dp,
drawStopIndicator = {}
)
}
}
Progress(
storiesIndex = storiesIndex,
slides = slides,
duration = duration,
onNext = onNext,
onProgress = onProgress
)
}
@Composable
private fun Progress(
storiesIndex: Int,
slides: List<UiSlide>,
duration: Int,
onNext: () -> Unit,
onProgress: (Float) -> Unit
) {
val slideIndex = slides.indexOfFirst { it.current }
val slide = slides[slideIndex]
val progressAnimatable = remember(slideIndex, storiesIndex) {
Animatable(INITIAL_ANIMATION_VALUE)
}
val progressState = slide.progressState
LaunchedEffect(keys = arrayOf(slideIndex, storiesIndex, progressState)) {
when (progressState) {
UiSlide.ProgressState.START, UiSlide.ProgressState.REFRESHED -> {
}
UiSlide.ProgressState.RESUME -> {
progressAnimatable.snapTo(slide.progress)
val durationMillis = (TARGET_ANIMATION_VALUE - slide.progress) * duration
progressAnimatable.animateTo(
targetValue = TARGET_ANIMATION_VALUE,
animationSpec = tween(
durationMillis = durationMillis.toInt(),
easing = LinearEasing
)
) {
onProgress(value)
}
onNext()
}
UiSlide.ProgressState.PAUSE -> {
progressAnimatable.stop()
}
UiSlide.ProgressState.COMPLETE -> {
progressAnimatable.snapTo(TARGET_ANIMATION_VALUE)
}
}
}
}
Для отображения прогресса мы используем LinearProgressIndicator — это встроенная вью, которая значительно упрощает реализацию по сравнению с кастомными решениями. В процессе воспроизведения сторис мы отслеживаем изменения индекса слайда, текущей сторис и состояния прогресса. Как только сторис начинает воспроизводиться, прогресс обновляется: закрашивается уже просмотренная часть, рассчитывается оставшееся время анимации (в миллисекундах), после чего запускается плавная анимация.
Функция animateTo отвечает за эту анимацию, и её выполнение продолжается до тех пор, пока не произойдет изменение одного из ключевых параметров в LaunchedEffect. Значение текущего прогресса обновляется через коллбэк onProgress, который записывает его в стейт и визуально обновляет отображение полосы прогресса. Когда значение достигает целевого (TARGET_ANIMATION_VALUE = 1), срабатывает функция onNext, переключающая на следующий слайд или сторис.
Этот подход обеспечивает плавное и предсказуемое обновление прогресса с использованием встроенных инструментов, что упрощает код и делает его более читаемым и поддерживаемым.
Теперь опишем кастомный detectTapGestures:
/**
* Своя реализация [androidx.compose.foundation.gestures.detectTapGestures], из изменений:
* - добавлен параметр requireUnconsumed = false в `awaitFirstDown`, чтобы обрабатывался свайп
* внутри свайпа (пока 1ый не кончился, делаем второй)
* - удалены коллбэки `onDoubleTap`, `onLongPress` из-за ненадобности
* - добавлен вызов `consumeUntilUp` при отмене события tap-up, чтобы при свайпах сохранялась
* пауза, а после их окончания - воспроизведение
*/
suspend fun PointerInputScope.detectTapGestures(
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null
) = coroutineScope {
val pressScope = PressGestureScopeImpl(this@detectTapGestures)
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
down.consume()
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture) launch {
pressScope.onPress(down.position)
}
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
var upOrCancel: PointerInputChange? = null
try {
// wait for first tap up or long press
upOrCancel = withTimeout(longPressTimeout) {
waitForUpOrCancellation()
}
if (upOrCancel == null) {
consumeUntilUp()
launch {
pressScope.cancel() // tap-up was canceled
}
} else {
upOrCancel.consume()
launch {
pressScope.release()
}
}
} catch (_: PointerEventTimeoutCancellationException) {
consumeUntilUp()
launch {
pressScope.release()
}
}
if (upOrCancel != null) {
onTap?.invoke(upOrCancel.position)
}
}
}
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { }
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()
event.changes.fastForEach { it.consume() }
} while (event.changes.fastAny { it.pressed })
}
private class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
/**
* Called when a gesture has been canceled.
*/
fun cancel() {
isCanceled = true
mutex.unlock()
}
/**
* Called when all pointers are up.
*/
fun release() {
isReleased = true
mutex.unlock()
}
/**
* Called when a new gesture has started.
*/
suspend fun reset() {
mutex.lock()
isReleased = false
isCanceled = false
}
override suspend fun awaitRelease() {
if (!tryAwaitRelease()) {
throw GestureCancellationException("The press gesture was canceled.")
}
}
override suspend fun tryAwaitRelease(): Boolean {
if (!isReleased && !isCanceled) {
mutex.lock()
mutex.unlock()
}
return isReleased
}
}
Теперь давайте поговорим про обработку касаний. У нас были требования:
При касании ставится пауза, и она должна продолжаться до тех пор, пока не будет отпущен палец
Должны быть горизонтальный/вертикальные свайпы
Однако есть проблема, и она в том, что стандартный detectTapGestures не подходит под наши требования, так как в начале свайпа тап будет считаться прерванным, а пауза закончится.
Еще одна проблема в том, что Compose не дает возможностей для настройки поведения детектов, поэтому приходится писать свою реализацию.
Наш видоизмененный detectTapGestures отличается от стандартного.
Чем именно:
Наличием uncomsumed = false. Это нужно, чтобы обрабатывался свайп внутри свайпа. Когда свайпнули один раз, и свайп не отработал до конца, мы сразу делаем второй, иначе второй свайп заблокируется.
Также удалены лишние коллбэки onDoubleTap, onLongPress, так как они не нужны.
Добавлен вызов consumeUntilUp при отмене события tap-up, чтобы при свайпах сохранялась пауза, а после их окончания — воспроизведение.
Как вы могли заметить, detectVerticalDragGestures мы используем стандартный, так как нам подходит его поведение и блокируются только жесты, связанные с вертикальными свайпами, что не противоречит логике detectTapGestures.
Сложности:
Первая проблема — в процессе реализации оказалось сложно засинхронизировать состояние компоузовского пейджера и нашей вью модели.
Первое — это то, что компоузовский пейджер обрабатывает не только текущую (видимую) страницу, но и следующие за ней. Важно отрисовывать такие страницы, но не запускать, ожидая, когда они станут видимыми. Иначе мы получим подергивания в анимациях. Например, видимая страница получала неактуальное состояние другой страницы, что вызывало подергивание полоски прогресса. То же было и со следующей страницей при свайпе.
Второе — разница между свайпами и тапами. Их коллбэки вызываются по-разному. Если с тапами все было достаточно просто, то со свайпами оказалось сложнее, так как колбэки вызываются в другом порядке. Например, в случае тапа вызывается коллбэк onNext, затем setNextSlide нашей вью модели, в которой стейту вью модели присваивается новое значение слайда/сторис. После этого вызывается LaunchedEffect, где мы при необходимости меняем сторис на пейджере (заодно актуализируя его стейт) через animateScrollToPage. То есть идет цепочка: вью модель — пейджер. Однако при свайпе мы в первую очередь автоматически меняем состояние пейджера (LaunchedEffect с currentPage), и уже вдогонку актуализируем состояние вью модели через setStories, которая в уже вызывает setNextStories. То есть тут цепочка: пейджер — вью модель.
По причине разного порядка колбэков, например, сторис могла не запуститься из-за того, что progress state не успел перейти в resume, или были моргания степпера из-за конфликта значения прогресса между вью моделью и animatable.
Решение было в 2 вещах:
Запускать следующую сторис/слайд только после обновления соответствующих полей вью модели, не пейджера.
Отрисовывать, но не запускать следующие сторис, пока они не станут видимыми — это достигалось за счет правильного выстраивания progress state у каждого слайда.
Третья сложность — это обработка жестов и необходимость её реализовать в соответствии с бизнес-требованиями.
Стандартный detectTapGestures из коробки не дает возможностей для кастомизации, мы не можем унаследоваться от какого-то базового класса и настроить поведение по-своему, поэтому остается только workaround в виде копипасты файла TapGestureDetector.
Pager из коробки не умеет в свайпы на крайних элементах — пришлось придумывать, как обойти это ограничение . Был выбор между разделением сторис на «с контентом» и «без», или создать сторис с «моковым» набором данных. Первый способ кажется более правильным с архитектурной точки зрения, так как мы четко выделяем два типа сторис, однако это усложняет стейт. Второй способ делает стейт более простым, однако все равно придется как то определить, что данная сторис — «моковая», например, через дополнительный параметр mock: boolean в классе UiStories, что выглядит не совсем лаконично.
Отдельная рекомендация по запуску фичи: подготовьте план по наполнению сторис, чтобы не испытывать контентного голода. Должен быть план от маркетинга по наполнению хотя бы на 3 месяца, если его нет — лучше пока не реализовывать фичу, иначе столкнетесь с тем, что фича — готова, а новых сторис появляться не будет.
Что в итоге
Возьмем за пример нашу сторис «М2Про Новостройки» — сервис для риелторов по заработку на продаже новостроек. Сравним ее эффективность с баннером. Мы взяли статистику по баннеру за 8 месяцев на проде, а по сторис — всего за 2.5 месяца.
Оказалось, что даже так сторис превзошла баннер по уникальным пользователям в 39 раз, а по кликам — в 8.5 раз.
Таким образом, гипотеза о повышении конверсии подтвердилась, и проблема была решена.
Так как статистика показала хорошие результаты, и гипотеза подтвердилась, мы планируем дальнейшее развитие идеи сторис. Их количество будет увеличиваться. Например, мы уже презентовали новую фичу — соцсеть для профессионалов рынка недвижимости. С помощью нее можно осуществлять поиск специалистов в сфере недвижимости для последующего партнерства. Собираемся поработать над UX и добавить прозрачный фон с overlay анимацией (предыдущий экран) при свайпах вниз и на крайних элементах. Также в планах добавить интерактив с клиентами, короткие видео вместо картинок. Еще в текущей реализации есть технический нюанс — на крайних сторис, если мы делаем свайп влево на первом и вправо на последнем элементах, то независимо от того, отпустил пользователь палец или нет, по достижению свайпа в половину экрана (когда фейковая сторис станет видимой), экран сторис закрывается. Над этим мы сейчас работаем. Вообще мы сейчас активно улучшаем кодовую базу нашей фичи и вскоре планируем обновление статьи, так что оставайтесь на связи, чтобы его не пропустить.
Надеюсь, данная статья помогла вам понять, как реализовать сторис у себя в приложении. Напомню, что это лишь интерпретация, которых может быть множество, и это хорошо. А вам приходилось реализовывать сторис в своем приложении? С какими трудностями столкнулись? Помогли ли они выполнить желаемые бизнес-задачи? Поделитесь своим опытом в комментариях, снимем завесу тайны по этому вопросу, интересно почитать. Или может у вас есть предложения по улучшению моего примера? Буду рад вашим отзывам/предложениям. Больше способов реализации — больше опыта - тем лучше для android-сообщества. Успехов вам в реализации сторис. Спасибо за внимание!
Комментарии (4)
vorontsovanast
21.01.2025 13:46Отличная статья, Владислав! Как вы планируете модифицировать текущую кодовую базу для устранения технического нюанса с закрытием экрана сторис при свайпе влево на первом и вправо на последнем элементах?
vfalzan Автор
21.01.2025 13:46Спасибо! Есть мысль перенести добавление и работу с фейковыми сторис из стейта во фрагмент/пейджер. таким образом, свайпы на крайних элементах будут работать так же, как и на остальных + упростим стейт с вью моделью. Планирую в следующей статье подробнее об этом рассказать.
OnlyPinkman
Спасибо за развернутую статью, а учитывали ли вы поведение пользователей после первого взаимодействия или анализировали только первичное вовлечение?
vfalzan Автор
В данном случае мы смотрели только на первичные клики, то есть сколько пользователей заинтересовались предложением. В случае с баннером там несколько иной бизнес процесс, и на самом деле это не совсем корректно сравнивать.