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

—

от автора

Всем привет! Настало время рассказать про то о чем 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)     } } 

Итог

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/783004/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *