remember «Forever». Как сохранить скролл при уходе с экрана

2b9d9a0263c58e115d364200e0a32c70.png

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

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

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

Что мы имеем

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

a7740b2c5cb867691653bd5887b7991e.png

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

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

Что хотим

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

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

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

Итог

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

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

© Habrahabr.ru