
Всем привет! Настало время рассказать про то о чем compose умалчивает. Это проблема сохранения состояний при переходе между экранами. В нашем приложении много скроллящихся экранов, поэтому первое, что мы хотели сохранить было именно состояние скролла. Идею можно пошарить на все, но говорить будем именно про скролл.
Немного о себе
Являюсь лидом мобильной команды разработки в финтех компании Peter Partner. Мы реализовали систему по автоматизации торговли, которая интегрирована с крупными торговыми брокерами. Проект локализован на множество языков и им пользуется свыше 1 млн. человек в странах Азии, Африки и Южной Америки.
Что мы имеем
Придумаем какое-то приложение с минимум экранов

У нас есть 3 экрана в нижней навигации и еще на два можно перейти последовательно. На каждом из экранов есть LazyColumn или еще чего-то, что умеет скроллиться
Стек у нас следующий:
Compose
KMP
навигация любая где есть доступ к backStack
Что хотим
Переход между экранами с запоминанием состояния скролла.
Переход на новый экран на "верх"
Если на экране есть вложенный скролл его тоже запомнить(horizontal pager, lazy row и другие)
Реализация
Делать все будем по порядку.
Для начала нам нужна какая-то модель того как будем хранить эти состояния. Я не смог придумать ничего лучше чем оставить это просто в статике.
Интересный факт. За два года существования решения это так и не вызвало никаких проблем.
// тут будет храниться все что скроллиться на экране. // Ключ - название экрана, значение - список ScrollState // на одном экране может быть больше одного такого элемента. // Пример: // LazyColumn{ // item{ // LazyRow{image()} // } // item{ // LazyRow{text()} // } // } private val SaveMap = mutableMapOf<String, MutableList<KeyParams>>() private val lastScreenName: String? get() = здесь нам нужен уникальный ключ для текущего экрана. под текущим понимается тот куда переходим. private class KeyParams( // Это ключ для вложенного списка. // Если на экране будет только один скроллящийся элемент // это поле будет пустым val params: String, val index: Int, val scrollOffset: Int, )
Теперь нам нужно это как-то заполнить. Рассмотрим на примере классического ScrollState.
@Composable fun rememberForeverScrollState( params: String = "", ): ScrollState { // вероятно у вас lastScreenName всегда будет не null, // но в нашем случае это поле может быть null из-за того, // что первый экран не фиксирован и определяется во время splash screen val key = lastScreenName ?: return rememberScrollState() // rememberSaveable - кому интересно сам сможет почитать // в чем разница между ним и обычным remember val scrollState = rememberSaveable(saver = ScrollState.Saver) { val savedValue = getSavedValue(key, params) // получаем новый экземпляр ScrollState с нужным нам состоянием ScrollState(initial = savedValue?.scrollOffset.orDefault()) } // Как только мы ушли с экрана нам нужно // сохранить текущее состояние DisposableEffect(Unit) { onDispose { val lastOffset = scrollState.value // кладем значение в SaveMap addNewValue( key = key, params = KeyParams( params = params, index = 0,// у ScrollState условно только один // элемент в списке scrollOffset = lastOffset ) ) } } return scrollState } // Ищем сохраненное значение. // key - название экрана // params - тег элемента private fun getSavedValue(key: String, params: String): KeyParams? = SaveMap[key]?.firstOrNull { it.params == params } private fun addNewValue(key: String, params: KeyParams) { val backStack = //ваша реализация для получения backStack экранов // если мы нажали назад на экране то и сохранять ничего не нужно. // Могут быть и другие варианты перехода. // например, дальше без возможности вернуться (с очисткой стека) if (backStack.none { it.name == key }) return val savedList = SaveMap[key] when { //нету сохраненных значений savedList == null -> SaveMap[key] = mutableListOf(params) //не знаю как, но обработать надо savedList.isEmpty() -> savedList.add(params) else -> { val existsValueIndex = savedList.indexOfFirst { it.params == params.params } if (existsValueIndex >= 0) { //обновление существующего элемента savedList[existsValueIndex] = params } else { //добавление нового savedList.add(params) } } } }
Еще несколько реализаций
LazyListState
@Composable fun rememberForeverLazyListState( params: String = "", ): LazyListState { val key = lastScreenName ?: return rememberLazyListState() val scrollState = rememberSaveable(saver = LazyListState.Saver) { val savedValue = getSavedValue(key, params) LazyListState( savedValue?.index.orDefault(), savedValue?.scrollOffset.orDefault() ) } DisposableEffect(params) { onDispose { val lastIndex = scrollState.firstVisibleItemIndex val lastOffset = scrollState.firstVisibleItemScrollOffset addNewValue(key, KeyParams(params, lastIndex, lastOffset)) } } return scrollState }
PagerState
@Composable fun rememberForeverPagerState( initialPage: Int = 0, params: String = "", pageCount: () -> Int, ): PagerState { val pagerParams = params + "Pager" val key = lastScreenName ?: return rememberPagerState( initialPage = initialPage, pageCount = pageCount, ) val savedValue = remember { getSavedValue(key, pagerParams) } val pagerState = rememberPagerState( initialPage = savedValue?.index.orDefault(initialPage), pageCount = pageCount, ) DisposableEffect(pagerParams) { onDispose { val lastIndex = pagerState.currentPage addNewValue(key, KeyParams(pagerParams, lastIndex, 0)) } } return pagerState }
CollapseState
@Composable fun rememberForeverCollapseState( isCollapsed: Boolean = true, params: String = "", ): MutableState<Boolean> { val pagerParams = params + "Collapse" val key = lastScreenName ?: return remember { mutableStateOf(isCollapsed) } val collapseState = rememberSaveable(saver = CollapseStateSaver) { val savedValue = getSavedValue(key, pagerParams) mutableStateOf(savedValue?.index?.let { it == 0 }.orDefault(isCollapsed)) } DisposableEffect(pagerParams) { onDispose { val lastIndex = if (collapseState.value) 0 else 1 addNewValue(key, KeyParams(pagerParams, lastIndex, 0)) } } return collapseState } val CollapseStateSaver: Saver<MutableState<Boolean>, *> = Saver( save = { it.value }, restore = { mutableStateOf(it) } )
Как пример того что так можно хранить не только скролл.
Как это все вызвать? Смотрим ниже
val pagerState = rememberForeverPagerState() { tabs.size } HorizontalPager( state = pagerState, ) { page -> LazyColumn( state = rememberForeverLazyListState(params = page.name), ){} }
Теперь пункты один и три выполнены. Мы можем сохранять все, что хотели. Но проблема в том, что теперь появился пункт два которого изначально не было.
// Обработчик вашей навигации LaunchedEffect { navigation .collect { screen -> //Удаляем все лишнее invalidateScrollSaveMap() //Навигируемся туда куда нужно navController.value = screen } } // Удаляем все экраны из памяти которых там нету. // Так как по одному ключу хранятся состояния всех ScrollState, // то весь экран будет сброшен до стандартных значение fun invalidateScrollSaveMap() { val keys = SaveMap.keys val backStackNow = backStack.map { it.screen.name } val keysForRemove = keys.filterNot { backStackNow.contains(it) } keysForRemove.forEach { SaveMap.remove(it) } }
Итог
Запускаем приложение и магия случалась. Все работает как и должно.
Спасибо всем кто дочитал до конца! Это не первая реализация данного метода, но в итоге получилось что-то действительно работающее с минимум вложений при написании. А если у Вас есть другое решение или идеи как улучшить это, то пишите в комментарии, буду рад почитать другие мнения!
