remember «Forever». Как сохранить скролл при уходе с экрана
Всем привет! Настало время рассказать про то о чем compose умалчивает. Это проблема сохранения состояний при переходе между экранами. В нашем приложении много скроллящихся экранов, поэтому первое, что мы хотели сохранить было именно состояние скролла. Идею можно пошарить на все, но говорить будем именно про скролл.
Немного о себе
Являюсь лидом мобильной команды разработки в финтех компании Peter Partner. Мы реализовали систему по автоматизации торговли, которая интегрирована с крупными торговыми брокерами. Проект локализован на множество языков и им пользуется свыше 1 млн. человек в странах Азии, Африки и Южной Америки.
Что мы имеем
Придумаем какое-то приложение с минимум экранов
У нас есть 3 экрана в нижней навигации и еще на два можно перейти последовательно. На каждом из экранов есть LazyColumn или еще чего-то, что умеет скроллиться
Стек у нас следующий:
Что хотим
Переход между экранами с запоминанием состояния скролла.
Переход на новый экран на «верх»
Если на экране есть вложенный скролл его тоже запомнить (horizontal pager, lazy row и другие)
Реализация
Делать все будем по порядку.
Для начала нам нужна какая-то модель того как будем хранить эти состояния. Я не смог придумать ничего лучше чем оставить это просто в статике.
Интересный факт. За два года существования решения это так и не вызвало никаких проблем.
// тут будет храниться все что скроллиться на экране.
// Ключ - название экрана, значение - список ScrollState
// на одном экране может быть больше одного такого элемента.
// Пример:
// LazyColumn{
// item{
// LazyRow{image()}
// }
// item{
// LazyRow{text()}
// }
// }
private val SaveMap = mutableMapOf>()
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 {
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, *> = 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)
}
}
Итог
Запускаем приложение и магия случалась. Все работает как и должно.
Спасибо всем кто дочитал до конца! Это не первая реализация данного метода, но в итоге получилось что-то действительно работающее с минимум вложений при написании. А если у Вас есть другое решение или идеи как улучшить это, то пишите в комментарии, буду рад почитать другие мнения!