Всем привет! Настало время рассказать про то о чем compose умалчивает. Это проблема сохранения состояний при переходе между экранами. В нашем приложении много скроллящихся экранов, поэтому первое, что мы хотели сохранить было именно состояние скролла. Идею можно пошарить на все, но говорить будем именно про скролл.

Немного о себе

Являюсь лидом мобильной команды разработки в финтех компании Peter Partner. Мы реализовали систему по автоматизации торговли, которая интегрирована с крупными торговыми брокерами. Проект локализован на множество языков и им пользуется свыше 1 млн. человек в странах Азии, Африки и Южной Америки.

Что мы имеем

Придумаем какое-то приложение с минимум экранов

У нас есть 3 экрана в нижней навигации и еще на два можно перейти последовательно. На каждом из экранов есть LazyColumn или еще чего-то, что умеет скроллиться

Стек у нас следующий:

  • Compose

  • KMP

  • навигация любая где есть доступ к backStack

Что хотим

  1. Переход между экранами с запоминанием состояния скролла.

  2. Переход на новый экран на "верх"

  3. Если на экране есть вложенный скролл его тоже запомнить(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)
    }
}

Итог

Запускаем приложение и магия случалась. Все работает как и должно.

Спасибо всем кто дочитал до конца! Это не первая реализация данного метода, но в итоге получилось что-то действительно работающее с минимум вложений при написании. А если у Вас есть другое решение или идеи как улучшить это, то пишите в комментарии, буду рад почитать другие мнения!

Комментарии (2)


  1. Rusrst
    26.12.2023 06:23

    Scroll и так сохраняется при открытии экранов и возврате назад, зачем этот велосипед? В каких условиях scroll не сохранит свое состояние?


  1. karenkov_id
    26.12.2023 06:23

    А какую библиотеку навигации вы используете? Выглядит так, что костыля не нужно было бы писать, если бы корректно работал SavedStateHandle, который как рас таки отвечает за работу rememberSaveable. Возможно вы просто неправильно готовите свою библиотеку навигации и вместо переключения экрана удаляете его, а потом добавляете заново. Это легко проверить, например сделать rememberSaveable с Random, и отобразить его