Привет! Меня зовут Владислав Фальзан, я работаю android-разработчиком в М2. Наша команда мобильной разработки развивает приложение — онлайн-платформу для решения вопросов с недвижимостью. Основные пользователи приложения — физические лица (B2C) и риелторы (B2B2C). Эта статья — технический гайд для android-разработчиков о том, как использовать нашу новую библиотеку по сторис с деталями и нюансами реализации. Из статьи вы поймете: как использовать библиотеку на полную мощность для своих задач и как она устроена изнутри.
Для удобства изучения статьи я решил разбить ее на блоки:
Цена усложнения: как поддержка сторис подтолкнула нас к библиотеке
Сторис как библиотека: смена парадигмы, UI на стороне пользователя, логика — из коробки
Как работает библиотека: входные параметры, нюансы реализации, корнер-кейсы
Почему решил написать статью
Эта статья — описание нашей библиотеки, результат развития фичи сторис в нашем приложении. Она является продолжением, я бы сказал даже эволюцией, первой и второй частей и состоит из описания, как ею пользоваться с примерами кода и гифками (куда без них). После прочтения статьи вы сможете с легкостью внедрить библиотеку в свое приложение, настроить под свои нужды и пользоваться.
Если вы хотите понимать, как эта реализация фичи сторис работает в принципе, или думаете написать самостоятельное решение вместо создания/использования библиотеки, то советую прочитать первые две части перед тем, как начать эту.
Для тех, кто хочет сразу начать читать эту статью, не стоит переживать, вам не нужно читать предыдущие две, чтобы понимать, как наша библиотека устроена и работает.
Я периодически буду делать референсы на участки первых двух частей, чтобы не перегружать статью.
Итак, сначала поговорим о том, как мы пришли к идее создания своей библиотеки и какие результаты показала фича сторис за 2025 год. Далее мы детально разберемся, как работает библиотека: какие входные параметры и в каких случаях надо использовать, какой получится результат, особенности реализации. В конце поделимся планами на будущее.
Цена усложнения: как поддержка сторис подтолкнула нас к библиотеке
Как писал в предыдущих частях, здесь и здесь, сторис зарекомендовали себя как эффективный инструмент для коммуникации с пользователями и работы с их вовлеченностью в продукты. Вот статистика за 2025 год на примере сторис, «Сделка онлайн»: саму сторис посмотрел каждый десятый пользователь, и из тех, кто посмотрел, 2% конвертировались в оплаченную сделку. Это отличный результат!
Итак, в процессе развития фичи мы увеличили количество сторис, поработали над качеством: пофиксили баги, провели рефакторинг кода, добавили новые фичи.
В определенный момент времени я посмотрел на наш код и понял — он огромный. Произошло это примерно так:

Оно и неудивительно — чтобы поддерживать и улучшать фичу, пришлось усложнять код, такова цена. Я подумал, что далеко не каждый разработчик захочет с этим возиться сможет выделить столько времени, чтобы погрузиться в фичу и понять ее «от корки до корки». Особенно это актуально, если он работает в компании, ведь в работе очень часто бывает необходимость сделать какое то решение, но не тратить на него уйму времени (и денег компании). Мы обсуждали это в первой статье здесь.
Насчет стоимости разработки есть интересный момент. Сторонний сервис, который мы рассматривали изначально, встал бы в 2.4 млн рублей в год. Собственное же решение за год оказалось в 1.7 раза дороже. С другой стороны, наше решение — независимое, мы можем оперативно и гибко менять его под себя. Также его стоимость будет уменьшаться, так как оно уже сделано и дальше идут только доработки, которые уже не будут столько стоить, а у стороннего стоимость окажется такой же. Ну и, конечно же, продвижение технического бренда компании — статьи, библиотека.
Так вот, чем сложнее становилась фича, тем чаще меня посещала мысль, что разработчик, увидев такой объем кода и взвесив стоимость между собственным решением и сторонним, склонится ко второму варианту. И тут я подумал: так почему бы не написать собственную библиотеку, которая будет удобной и гибкой для пользователя? Гениально.

Сторис как библиотека: смена парадигмы, UI на стороне пользователя, логика — из коробки

Итак, у нас был готовый код фичи сторис. Кажется, что там сделать эту библиотеку? Вынес код в отдельный модуль, залил на github и все. На самом деле, все немного сложнее. Давайте разберем по порядку.
«Как переписать код так, чтобы было удобно пользователю?» Вот первый вопрос, который я задал себе. Посмотрел на нашу реализацию и понял: нужна смена концепции. Мы слишком привязаны к контексту нашего приложения, наш код завязан на сущностях, которые пользователю не нужны. Необходимо было перейти с нужд нашего приложения на потребности пользователей, потому что:
у каждого свое видение UI. Поэтому мы приняли решение — отрисовку UI, за исключением шкалы прогресса, полностью делегировать пользователю, так как им виднее, что и как рисовать.
Функционал сторис должен работать из коробки и не должен требовать дополнительных настроек от пользователя, быть прост в обращении, в противном случае это может отпугнуть его пользоваться нашей библиотекой.
Что было сделано:
Вынесли код сторис в отдельный модуль. Тут все просто. Задачей было вынести весь необходимый для библиотеки код в отдельный модуль, до этого он был в одном модуле с местом использования, и предоставить ему только необходимые gradle-зависимости. Все базово, без особенностей.
Почистили классы, которые указывали на какую-либо конкретику сторис, за рамки библиотеки. Сделали так, чтобы они были максимально обособленными от деталей.
Добавили много параметров для кастомизации UI пользователем под свои задачи, также добавили значения по-умолчанию.
Максимально упростили создание и использование сторис — сделали единую точку входа (точнее три — две во вью, одну — для репозитория, но об этом потом), убрали нужные ранее манипуляции пользователя.
Настроили видимость переменных. Нужно было дать понять пользователю, чем стоит пользоваться, а чем — нет. Тем сущностям, которыми пользоваться не нужно, мы закрыли доступ через модификатор internal.
Собственно, об этом мы и поговорим далее.
Как работает библиотека: входные параметры, нюансы реализации, корнер-кейсы
Для удобства объяснения я создал простой тестовый пример, его вы можете посмотреть в модуле app нашей библиотеки здесь.
Поведение превью и сторис сделано по аналогии с известной запрещенной соцсетью, принципы описаны здесь и здесь.
Для наглядности в примерах ниже я поменял цвета на отличные от дефолтной реализации.
Подключение
Чтобы подключить библиотеку, добавьте к себе следующий код:
в settings.gradle вашего проекта:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}в build.gradle вашего модуля, где вы хотите ее использовать:
dependencies {
implementation("com.github.m2-oss:StoriesCompose:Tag")
}, где Tag - это номер версии. Актуальные версии можно посмотреть здесь.
Превью сторис
Компонент
Начнем мы со StoriesPreviewList. Вот его сигнатура:
/**
* A simple horizontal list that displays preview of stories.
*
* @param previews List of basic data required for display.
* @param onClick Callback called in case of clicking on an item.
* @param storiesPreviewParams Optional parameters for UI customization.
*/
@Composable
fun StoriesPreviewList(
previews: List<UiStoriesPreviewData>,
onClick: (String) -> Unit,
storiesPreviewParams: UiStoriesPreviewParams = UiStoriesPreviewParams()
) {Здесь все просто:
previews — это данные для построения превью;
onClick — обычный коллбэк по нажатию, на вход он принимает id сторис, чтобы понимать, какую из них запустить;
storiesPreviewParams — параметры для ui-отображения.
Последний параметр — опциональный, его реализацию рассмотрим ниже.
Создание
Создадим простой тестовый пример. Вот список наших превью:
Код списка превью
val STORIES_PREVIEW_LIST = listOf(
UiStoriesPreviewData(
id = "id1",
imageData = R.drawable.ic_launcher_background,
title = "1",
),
UiStoriesPreviewData(
id = "id2",
imageData = R.drawable.ic_launcher_background,
title = "2",
),
UiStoriesPreviewData(
id = "id3",
imageData = R.drawable.ic_launcher_background,
title = "3",
),
UiStoriesPreviewData(
id = "id4",
imageData = R.drawable.ic_launcher_background,
title = "4",
)
)А здесь мы создаем непосредственно компонент:
StoriesPreviewList(
previews = STORIES_PREVIEW_LIST,
onClick = {
// обработка нажатия
}
)Результат:

Параметры
А пока посмотрим на UiStoriesPreviewData:
data class UiStoriesPreviewData(
val id: String,
val imageData: Any,
val title: String
)Его поля:
id — идентификатор сторис;
imageData — данные изображения. Тип Any указан потому, что в Coil при создании model внутри AsyncImage в качестве data можно прокидывать как внешний url, так и, например, путь к файлу изображения из памяти. Например, “R.drawable.ic_launcher_background”;
title — заголовок превью.
На коллбэке останавливаться смысла нет.
Глянем UiStoriesPreviewParams:
Код параметров превью
/**
* A set of parameters for stories preview UI customization
*/
data class UiStoriesPreviewParams(
/**
* Paddings of the list relative to its parent
*/
val listPaddings: PaddingValues = PaddingValues(
start = 16.dp,
top = 24.dp,
end = 16.dp,
bottom = 16.dp
),
/**
* Arrangement between elements of the list
*/
val listSpacedByArrangement: Dp = 8.dp,
/**
* Overall size of the frame's content
*/
val size: DpSize = DpSize(width = 104.dp, height = 144.dp),
/**
* Size of shown indicator
*/
val borderSize: Dp = 104.dp,
/**
* Thickness of shown indicator
*/
val borderWidth: Dp = 2.dp,
/**
* Color of shown indicator
*/
val borderColor: Color = Colors.systemOnWhiteOrange,
/**
* Shape of shown indicator
*/
val borderShape: Shape = RoundedCornerShape(16.dp),
/**
* Padding of the image relative to its parent
*/
val imagePadding: Dp = 4.dp,
/**
* Shape of image
*/
val imageShape: Shape = RoundedCornerShape(12.dp),
/**
* Size of image
*/
val imageSize: Dp = 96.dp,
/**
* Spacer between image and text
*/
val spacerSize: Dp = 8.dp,
/**
* Color of text
*/
val textColor: Color = Colors.systemOnWhiteStrong,
/**
* Style of text
*/
val textStyle: TextStyle = TextStyle.Default,
/**
* Alignment of text
*/
val textAlign: TextAlign = TextAlign.Center
)Все аргументы имеют значение по умолчанию, поэтому для их создания достаточно вызвать пустой конструктор. Однако вы можете поменять их на свое усмотрение, мы постарались сделать максимальную вариативность для универсальности.
Результат с параметрами по умолчанию вы могли видеть выше.
Давайте посмотрим на пример с подсвеченными областями UI, чтобы было легче разобраться:

Для наглядности я пометил участки разными цветами. Итак, по порядку:
listPaddings — отступы списка от контейнера;
listSpacedByArrangement — отступы между элементами списка;
size — размер элемента превью;
borderSize, borderWidth, borderColor, borderShape — рамка признака не просмотренной сторис через параметр shown;
imagePadding, imageShape, imageSize — само изображение, переданное через параметр imageData;
spacerSize — вертикальный отступ между картинкой и текстом;
textColor, textStyle, textAlign — текст, переданный через title.
Инициализация
Обратите внимание, то компонент превью инициализируется и пересоздается каждый раз при смене параметра previews:
LaunchedEffect(previews) {
viewModel.init(previews)
}То есть, если придет набор данных, отличный от текущего, то будет новый запрос в память по просмотренным сторис. Этот маневр выполняет две функции. Первая: дает возможность разработчику многократно и динамически пользоваться компонентом. Например, при pull-to-refresh на одном и том же экране у нас может прийти новая порция данных. Если данные те же, что и в предыдущий раз, компонент не обновится, однако если данные новые — нужен свежий запрос, что компонент и сделает. Вторая — это защита от лишних запросов при повторной рекомпозиции вью-компонента.
Однако имейте ввиду, что вью-модель, которая создается внутри, принадлежит не компоненту, а обрамляющему фрагменту/активити, а значит, и срок жизни — дольше. Например, вью была отрисована (а вью модель создана), затем мы вью скрыли (она пропала из дерева объектов), а вью модель останется, так как мы находимся на том же экране, к которому она прикреплена. Это не повлияет на launched effect, ведь он зависит от данных previews, а на вью-модель данный кейс никак не повлияет.
Взглянем на кусок вью-модели:
Код вью модели превью
fun init(previewsData: List<UiStoriesPreviewData>) {
storiesShownRepository.observe()
.flowOn(Dispatchers.IO)
.map { shownStories ->
previewsData.map { story ->
UiStoriesPreview(
id = story.id,
imageData = story.imageData,
title = story.title,
shown = shownStories.any {
it.storiesId == story.id && it.shown
}
)
}
.sortedBy { it.shown }
.toList()
}
.onEach {
mutableStateFlow.value = stateFlow.value.previews(it)
}
.catch {
mutableStateFlow.value = stateFlow.value.previews(emptyList())
}
.launchIn(viewModelScope)
.also { job ->
currentJob?.let {
if (it.isActive) {
it.cancel()
}
}
currentJob = job
}
}Здесь мы подписываемся на динамическое изменение просмотренных сторис через observe. Поле currentJob решает проблему пересоздания запросов: если предыдущий запрос не выполнится и придет новый набор данных, он отменяется, и новый запрос выполняется.
Контейнер сторис
Компонент
Теперь поговорим про StoriesContainer. Вот его сигнатура:
Код контейнера сторис
/**
* A container creating base functionality for stories such as:
* taps and swipes, progress bar transition, storing/selecting slides to display logic.
* Note that UI display should be on user side.
* @param data Basic data required for stories playback
* @param storiesParams UI customization
* @param onStoriesChanged callback when every story changes.
* Next story id and slide index will be sent.
* @param onFinished callback when the last story ends.
* @param content UI part of a slide of a story. The scope is to place components relative to the container.
* First arguments, story id and slide current index, are to find current story,
* and the third one, progress bar height, is to place your content under it if necessary
*/
@Composable
fun StoriesContainer(
data: UiStoriesData,
storiesParams: UiStoriesParams = UiStoriesParams(),
onStoriesChanged: (String, Int) -> Unit = { _, _ -> },
onFinished: () -> Unit = {},
content: @Composable BoxScope.(String, Int, Dp) -> Unit
) {Пройдемся по полям:
data — набор данных для построения сторис;
storiesParams — набор параметров для UI наших сторис;
onStoriesChanged вызывается при смене сторис, в параметрах у нее id (String) и индекс (Int) новой сторис, которая будет просмотрена. Удобна, например, для отправки аналитики по факту открытия новой сторис;
onFinished — коллбэк при окончании последней сторис;
За отрисовку UI отвечает новый параметр — лямбда content. Он передает Scope от контейнера, внутри которого будут рисоваться сторис. Считаю BoxScope здесь лучшим решением, так как абсолютное расположение элементов более удобно для разработчика, а также три параметра — индексы сторис и слайда, необходимых для отрисовки, а также высота нашего прогресс-бара, она опциональна и нужна, чтобы контент располагался под ним; UiStoriesParams — это набор параметров для кастомизации UI наших сторис, о них мы поговорим ниже.
Создание
Создадим простой тестовый пример. Вот заготовленные заранее константы:
private const val SLIDES_COUNT = 3
private const val STORIES_DURATION_SEC = 10
private val SLIDES_COLORS = listOf(
Color.LightGray,
Color.Gray,
Color.DarkGray
)А вот создание компонента:
Код создания контейнера
StoriesContainer(
data = UiStoriesData(
storiesId = storiesId, // id of the story clicked before
stories = buildMap {
val ids = STORIES_PREVIEW_LIST.map { it.id }
ids.forEach {
put(it, SLIDES_COUNT)
}
},
durationInSec = STORIES_DURATION_SEC
),
onFinished = onFinished
) { stories, slide, progressBar ->
Box(
modifier = Modifier
.fillMaxSize()
.background(SLIDES_COLORS[slide])
) {
Text(text = "$stories, $slide", modifier = Modifier.align(Alignment.Center))
}
}В качестве storiesId вы можете из класса UiStoriesPreviewData.
Вот результат:

Параметры
Рассмотрим класс UiStoriesData:
/**
* Basic data required for stories playback.
* @param storiesId id determining which story will be displayed
* @param stories pairs of [storiesId] as a key and number of slides as a value.
* Eventually, size of keys equals to size of stories and values are size of slides in each of them.
* @param durationInSec display time for every slide of every story
*/
data class UiStoriesData(
val storiesId: String,
val stories: Map<String, Int>,
val durationInSec: Int
)storiesId — идентификатор нашей сторис, которую мы хотим запустить.
Далее обратите внимание на параметр stories — теперь это не громоздкие сущности UiStories, которые были раньше, а просто map. Здесь ключ с типом String — это id нашей сторис, а значение с типом Int — это количество слайдов. Почему именно так? Дело в той самой концепции сторис, о которой было написано ранее. Задача — дать пользователю песочницу, контейнер с необходимым функционалом, который будет работать из коробки. При этом важно, чтобы отрисовка UI-оставляющей, за исключением полосы прогресса, была полностью на стороне пользователя, тем самым давая ему максимальную возможность кастомизировать UI под себя, не обременяя его лишними элементами. А для этого нужно сделать модель максимально абстрактной.
durationInSec — длительность в секундах.
Теперь глянем параметры сторис UiStoriesParams:
Код параметров контейнера
/**
* A set of parameters for stories UI customization
*/
data class UiStoriesParams(
/**
* Considering status and navigation bar paddings for UI components.
* IMPORTANT:
* 1. On older android platforms, don't forget to enable edge-to-edge mode in your activity, otherwise it doesn't work;
* 2. This flag will set only library UI components to full screen mode. It's up to you to do the same with content on your side if necessary.
* @see <a href="https://developer.android.com/develop/ui/compose/system/setup-e2e">Edge-to-edge</a>
*/
val fullScreen: Boolean = false,
/**
* Enabling 3d graphics transitions between stories. If set false, default behavior of swipes
* will be used
* @see <a href="https://developer.android.com/develop/ui/compose/layouts/pager#horizontalpager">Default swipe behavior</a>
*/
val graphicsTransition: Boolean = true,
/**
* Enabling transparency of stories background. It helps see the previous screen during stories being closed.
* IMPORTANT: It's up to you to make the stories screen transparent. E.g. you can use FragmentTransaction's add function.
* @see <a href="https://developer.android.com/guide/fragments/transactions">Fragment transactions</a>
*/
val transparentBackground: Boolean = false,
/**
* Paddings of the progress bar relative to its parent
*/
val progressBarPaddings: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 14.dp),
/**
* Arrangement between each progress bar item
*/
val progressBarSpacedByArrangement: Dp = 4.dp,
/**
* Height of the progress bar
*/
val progressBarHeight: Dp = 32.dp,
/**
* Height of each progress bar item
*/
val progressHeight: Dp = 4.dp,
/**
* Shape of each progress bar item
*/
val progressShape: Shape = RoundedCornerShape(8.dp),
/**
* Color of each unviewed progress bar item
*/
val progressColor: Color = Colors.systemWhite,
/**
* Color of each viewed progress bar item
*/
val progressTrackColor: Color = STEPPER_TRACK_COLOR
)Та же история, что и с превью — все параметры имеют значение по умолчанию.
Результат по умолчанию вы могли видеть выше.
Давайте посмотрим на пример с подсвеченными областями UI, чтобы было легче разобраться:

fullScreen — учитывает отступы status и navigation bar для растягивания компонентов сторис внутри библиотеки, в нашем случае это только полоса прогресса, на весь экран. Это важный момент, который мы обсудим ниже.
graphicsTransition — это 3d-анимация наших переходов, не имеет цвета, отчетливо мы можем ее видеть по образованному углу при свайпе.
transparentBackground — данный параметр устанавливает задний фон для пространства вне сторис, будь то свайп между элементами или выход за границы сторис. По умолчанию он — false, и применяется черный цвет. Однако при установке в true он делает фон прозрачным. Это помогает достичь эффекта, когда при закрытии сторис мы можем увидеть предыдущий экран. Об этом будет сказано ниже.
progressBarPaddings, progressBarHeight — отступы от контейнера и в��сота контейнера нашей полосы прогресса сторис
progressBarSpacedByArrangement — горизонтальный отступ между полосками прогресса
progressHeight, progressShape, progressColor — высота, форма и цвет каждой из наших полосок прогресса
progressTrackColor — цвет просмотренной части полоски прогресса
Ниже обозначен параметр content, который мы описали ранее в сигнатуре контейнера сторис.
3D анимация
По-умолчанию у нас работает 3D анимация свайпов между сторис. Ее работу вы могли видеть на скриншотах выше. За это отвечает параметр graphicsTransition. По умолчанию он true, если он будет false, то применяется стандартная андроидная смена сторис.
Давайте добавим данный параметр:
StoriesContainer(
...
storiesParams = UiStoriesParams().copy(graphicsTransition = false)
)Результат:

Полноэкранный режим
Давайте рассмотрим случай, когда мы хотим получить полноэкранный режим.
В первую очередь надо добавить enableEdgeToEdge при необходимости (подробнее здесь):
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
enableEdgeToEdge()
}Теперь установим параметр fullScreen = true:
StoriesContainer(
...
storiesParams = UiStoriesParams().copy(fullScreen = true)
)Также давайте добавим на слайд изображение для наглядности:
StoriesContainer(
...
) { stories, slide, progressBar ->
Box(
modifier = Modifier
.fillMaxSize()
.background(SLIDES_COLORS[slide])
) {
Image(
painter = painterResource(R.drawable.ic_launcher_background),
contentDescription = null
)
Text(text = "$stories, $slide", modifier = Modifier.align(Alignment.Center))
}
}Вот что получается:

Как мы видим, теперь status и nav bar окрашены в цвет нашего контента. Как я упомянул ранее, наш параметр content обернут в Box, а значит, что по дефолту у нас будет абсолютное расположение элементов. При этом: полоска прогресса все также находится под нашими status и nav bar (учитывает их отступ), а картинка — нет. Почему так? Причина в этом:
Stepper(
modifier = if (storiesParams.fullScreen) {
Modifier.statusBarsPadding().navigationBarsPadding()
} else {
Modifier
},Если параметр fullScreen = true, то при создании полосы прогресса мы для нее учитываем отступы, связанные с bar, однако данный параметр не распространяется на контент, создаваемый пользователем. Таким образом, при создании своего UI вы можете использовать весь экран, что удобно.
Следовательно, если мы поставим fullScreen = false, то наш прогресс бар заедет на статус бар:

Если же мы хотим, чтобы наш контент учитывал отступы bar, нужно добавить эти же параметры на вызывающей стороне, внутри параметра content. Добавим их в наш код:
StoriesContainer(
...
) { stories, slide, progressBar ->
Box(
modifier = Modifier
.fillMaxSize()
.background(SLIDES_COLORS[slide])
.statusBarsPadding()
.navigationBarsPadding()
) {
...
}
}Получаем следующий результат:

Здорово, но все еще не идеально. Теперь начало нашего изображения совпадает с началом нашей полосы прогресса. Чтобы это исправить, в качестве одного из параметров content мы отправляем высоту нашего прогресс-бара. Добавим данный отступ в наш код:
StoriesContainer(
...
) { stories, slide, progressBar ->
Box(
modifier = Modifier
.fillMaxSize()
.background(SLIDES_COLORS[slide])
.statusBarsPadding()
.navigationBarsPadding()
.offset(y = progressBar)
) {
...
}
}Получаем результат:

Готово, теперь наш контент располагается ровно под прогресс-баром и учитывает отступы наших системных баров.
Собственно, если нам не нужен полноэкранный режим, то достаточно убрать строки, учитывающие системные бары, оставив отступ для прогресса, и мы получим это же расположение картинки под прогрессом.
Прозрачный фон
Если мы установим параметр transparentBackground = true:
StoriesContainer(
...
storiesParams = UiStoriesParams().copy(transparentBackground = true)
), то получим следующий результат:

Как видите, теперь при закрытии сторис (горизонтальный свайп на крайних элементах или вертикальный свайп сверху-вниз на любых элементах) мы видим предыдущий экран. Учтите, чтобы увидеть предыдущий экран, необходимо правильно обработать навигацию назад, например, через функцию add у фрагмента, без этого задний фон мы не увидим:

Остальные параметры просты в понимании и, как мне кажется, не требуют пояснений.
Разумеется, все эти параметры можно использовать как по отдельности, так и вместе.
Инициализация
Инициализация компонента происходит так же, как и у превью:
LaunchedEffect(data) {
viewModel.init(data)
}Смотрим на параметр data и в случае значения, отличного от предыдущего, запускаем компонент в работу:
Код вью-модели контейнера
fun init(data: UiStoriesData) {
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
Log.e(LOG_TAG, "Failed loading shown stories", throwable)
mutableStateFlow.value = stateFlow.value.ready(ReadyState.ERROR)
}) {
val shownStories = withContext(Dispatchers.IO) {
storiesShownRepository.get()
}
mutableStateFlow.value = StoriesState.initial(
durationInSec = data.durationInSec,
stories = data.stories.map { story ->
val uiSlides = mutableListOf<UiSlide>().apply {
repeat(story.value) {
add(UiSlide())
}
}
UiStories(
id = story.key,
slides = uiSlides
)
},
storiesId = data.storiesId,
shownStories = shownStories
)
}.also { job ->
currentJob?.let {
if (it.isActive) {
it.cancel()
}
}
currentJob = job
}
}И здесь та же история — запрашиваем список просмотренных сторис, в случае невыполнения предыдущего запроса currentJob решает данную проблему.
Работа с компонентом сторис, вью-моделью и пейджером, подробно описана здесь и здесь.
Как мы храним просмотренные сторис
По умолчанию всю работу за вас делает библиотека. Если вы заметили, то в моделях для вью-компонентов не было ничего сказано про признак просмотренности. Это сделано для того, чтобы создать single source of truth для работы с ним и не вносить смуту.
Работа с БД
Чтобы отслеживать и влиять на актуальное состояние просмотренных сторис, необходимо использовать StoriesShownRepository. Давайте взглянем на контракт:
Код интерфейса репозитория
/**
* Class for stories storage.
*/
interface StoriesShownRepository {
/**
* Putting shown stories into memory.
* @param shownStories info about shown story
*/
fun set(shownStories: List<ShownStories>)
/**
* Observing (async operation) shown stories from memory.
*/
fun observe(): Flow<List<ShownStories>>
/**
* Fetching (sync operation) shown stories from memory.
*/
fun get(): List<ShownStories>
/**
* Updating info about shown stories by replacing old stories with new ones.
* @param storiesIds set of ids to retain in memory.
*/
fun actualize(storiesIds: List<String>)
/**
* Deletes all shown stories' data from memory
*/
fun deleteAll()
}По сравнению с предыдущей итерацией слегка изменен контракт: добавлена функция полного удаления просмотренных сторис. Также мы поменяли реализацию репозитория: раньше мы хранили список просмотренных сторис в префсах, сейчас мы переехали на Room в качестве БД. Глянем код:
Код реализации репозитория
internal class StoriesShownRepositoryImpl(db: StoriesDatabase) : StoriesShownRepository {
private val shownStoriesDao by lazy { db.shownStoriesDao() }
override fun set(shownStories: List<ShownStories>) {
shownStoriesDao.set(shownStories.map { it.map() })
}
override fun observe(): Flow<List<ShownStories>> {
return shownStoriesDao.observe().map { list ->
list.map { it.map() }
}
}
override fun get(): List<ShownStories> {
return shownStoriesDao.get().map { it.map() }
}
override fun actualize(storiesIds: List<String>) {
shownStoriesDao.actualize(storiesIds)
}
override fun deleteAll() {
shownStoriesDao.deleteAll()
}
}Все функции просты: SQL запрос через Dao объект (+ маппинг в доменную модель).
Код Dao объекта выглядит так:
Код Dao объекта
@Dao
interface ShownStoriesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun set(shownStories: List<ShownStoriesDto>)
@Query("SELECT * FROM ShownStories")
fun observe(): Flow<List<ShownStoriesDto>>
@Query("SELECT * FROM ShownStories")
fun get(): List<ShownStoriesDto>
@Query("DELETE FROM ShownStories WHERE storiesId NOT IN (:ids)")
fun actualize(ids: List<String>)
@Query("DELETE FROM ShownStories")
fun deleteAll()
}Все просто:
set записывает в память список просмотренных сторис. В случае совпадения модели по первичному ключу будет происходить перезапись данных.
observe позволяет динамически следить за изменениями данных.
get одноразово получает данные.
actualize нужна, чтобы очищать БД от старых сторис. Представим, что у вас были сторис с id: 1, 2 и 3, но в какой то момент времени вам стал приходить новый список из id: 3, 4, 5. Данная функция удалит из памяти сторис с id 1 и 2, так как они стали неактуальными.
deleteAll удаляет все записи из таблицы ShownStories.
Учтите, что запросы необходимо выполнять в рабочем потоке, иначе вернется ошибка.
Чтобы создать данный репозиторий, нужно использовать фабрику:
Код фабрики репозитория
/**
* Factory for [StoriesShownRepository] creation, entry point to work with stories cache
*/
class StoriesShownRepositoryFactory private constructor() {
companion object {
@Volatile
private var instance: StoriesShownRepository? = null
fun getInstance(context: Context): StoriesShownRepository {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = StoriesShownRepositoryImpl(
Room.databaseBuilder(
context,
StoriesDatabase::class.java,
"StoriesDatabase"
).build()
)
}
}
}
return instance!!
}
}
}DI у нас нет, чтобы не тянуть лишние зависимости самим и заставлять тянуть их, соответственно, вам. Экземпляр создается один, поэтому у вас не возникнет проблем с обращением к репозиторию из разных мест приложения, что удобно.
Вариативность
Удобно то, что пользователю библиотеки необязательно использовать оба компонента - и превью, и контейнер. Именно по этой причине мы оставили интерфейс репозитория открытым. К примеру, вы хотите использовать только компонент сторис-контейнер, а превью-лист придумать самому. Вы можете это сделать при условии самостоятельной работы с синхронизацией данных через репозиторий. Логику по синхронизации вы можете взять из код-сниппетов по работе с вью-компонентами, приведенными выше.
Что в итоге
Считаю, что мы справились с поставленной задачей. Сторис не раз показала свою эффективность как фича. Это был вопрос времени, когда наша фича станет слишком большой и более сложной для понимания. Логичным следствием стало написание библиотеки. Пришлось отвязаться от контекста приложения: избавиться от ненужных классов, зависимостей, пересмотреть концепцию входных данных. Например:
1) Отказаться от идеи точного описания сторис через класс UiStories и сократить лишь до мапы;
2) Оставить UI-составляющую полностью на пользователе, за исключением Stepper. Он оказался очень важным и сильно связанным с вью-моделью;
3) Пофиксить пару багов. При отказе от контекста приложения появилась необходимость в коллбэках: о смене сторис, закрытии сторис при тапе на последнем элементе.
На текущий момент библиотека работает как швейцарские часы исправно и готова к использованию на проектах любого уровня, от локальной песочницы до промышленного приложения.
Какие преимущества у нее преимущества:
она достаточно простая для изучения и понимания потенциальным пользователем:
входные параметры организованы в классы по смысловой нагрузке;
есть дефолтные реализации аргументов, чтобы легче начать работу;
весь функционал по логике работы сторис или работы с их состоянием просмотренности уже сделан и не нужно о нем задумываться.
она функциональная и гибкая в настройках:
есть как сами сторис, так и превью;
аргументов для кастомизации много;
UI полностью делегируется пользователю;
можно использовать как оба вью компонента вместе, так и по отдельности.

Что дальше? Конечно же развивать библиотеку. В первую очередь, от обратной связи пользователей. Дальше у нас есть продуктовые планы на сторис, например, видео-формат, что повлечет за собой модернизацию библиотеки — создание отдельного UI-компонента, который будет отвечать за просмотр видео. Также в рамках расширения библиотеки планирую ослабить ее связь с БД через многомодульность (создание api/impl модулей) для того, чтобы пользователь мог сам ��ешать, какой способ хранения ему использовать.
Прошу не судить строго, так как это первая моя библиотека. Буду вам очень признателен, если вы перейдете на страницу на github и посмотрите ее. Может быть у вас возникнут какие-то вопросы, предложения по улучшению. А может быть у вас появится желание подключить ее к своему проекту и попробовать, что будет вообще супер! В любом случае, не стесняйтесь оставлять комментарии здесь или заводить issues на странице библиотеки, любому фидбэку я буду рад и обязательно прочитаю/прислушаюсь. Ведь чем больше будет обсуждений, тем лучше станет библиотека, а значит, станет легче и удобнее ее использовать. А это плюс для всего нашего Android-сообщества. Успехов вам и спасибо за внимание!
