返回

解决ListDetailPaneScaffold导航状态保持问题

Android

ListDetailPaneScaffold 导航状态保持问题及解决方案

使用 ListDetailPaneScaffold 时,经常会遇到一个问题:当 detail pane (详情窗格) 处于展开状态,跳转到另一个界面再返回后,detail pane 变成了隐藏状态。 这篇博客文章将深入分析这个问题的原因,并提供几种可行的解决方案。

一、 问题

在使用 ListDetailPaneScaffold 构建的列表-详情布局中, 如果在详情窗格展开(navigator.scaffoldValue.primary 为 expanded 状态)时导航到另一个界面,然后返回到 ListDetailPaneScaffold 界面,原本展开的详情窗格会变成隐藏状态(navigator.scaffoldValue.primary 会变为 hidden)。

核心问题是,rememberListDetailPaneScaffoldNavigator 并没有像预期那样,在界面重建时自动恢复之前的导航状态。即便它内部使用了 rememberSaveable,理应可以保存和恢复状态。

下面是重现问题的关键信息:

  • 初始状态(选中列表项后):
    navigator.currentDestination.content: 6
    navigator.currentDestination.pane: Primary
    PaneAdaptedValue(description=Expanded)
    
  • 跳转到其他界面再返回后:
    navigator.currentDestination.content: null
    navigator.currentDestination.pane: Secondary
    PaneAdaptedValue(description=Hidden)
    

二、问题原因分析

rememberListDetailPaneScaffoldNavigator 内部使用 rememberSaveable 来尝试保存状态。正常情况下,rememberSaveable应该在 Activity 或 Fragment 重建时(例如配置变更, 旋转屏幕), 或者进程被杀死后恢复时,自动恢复之前的状态. 但这里它并没有按照预期工作. 根本原因有几个:

  1. 跨导航保存 : rememberSaveable 的设计初衷是用于单个 Composable 的状态保存,主要针对配置更改(例如屏幕旋转),而非跨导航的状态保持。

  2. BackStackEntry 生命周期: 当导航到另一个界面时,ListDetailPaneScaffold 所在的 BackStackEntry 可能仍然存在于返回栈中,但也可能会因为内存压力被回收,从而导致rememberSaveable保存的状态丢失.

  3. currentDestination 重置: rememberListDetailPaneScaffoldNavigator 的实现里可能存在缺陷,返回的时候,它会基于某种默认逻辑设置 currentDestination, 从而覆盖了原本应该通过rememberSaveable恢复的状态.

三、 解决方案

针对上述问题,提供以下几种解决方案:

1. 手动保存和恢复 PaneScaffoldValue

最直接的办法是,不依赖 rememberListDetailPaneScaffoldNavigator 内部的保存机制,手动管理 ListDetailPaneScaffoldscaffoldValue

原理:

  • 使用 rememberSaveable 显式地保存 ListDetailPaneScaffold 的当前 PaneScaffoldValue (即是 ExpandedHidden 还是其他)。
  • 在 Composable 重新创建时,从保存的状态中恢复 PaneScaffoldValue,并传递给 ListDetailPaneScaffold

代码示例:

import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.navigation.compose.rememberNavController
import androidx.window.layout.DisplayFeature
import com.google.accompanist.adaptive.ListDetailPaneScaffold
import com.google.accompanist.adaptive.PaneScaffoldValue
import com.google.accompanist.adaptive.calculateListDetailPaneScaffoldState

@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun MyListDetailScreen() {

    var selectedItem: Int? by rememberSaveable { mutableStateOf(null) }

    val windowSizeClass = calculateWindowSizeClass()
    val displayFeatures = emptyList<DisplayFeature>() //可以根据实际情况添加

   val savedScaffoldValue = rememberSaveable {
        mutableStateOf(
            if (selectedItem != null && windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded)
                 PaneScaffoldValue.Primary
             else
                PaneScaffoldValue.Secondary //初始为Secondary, 根据需要修改
        )
   }

    val scaffoldState = calculateListDetailPaneScaffoldState(
        scaffoldValue = savedScaffoldValue.value,
        windowSizeClass = windowSizeClass,
        displayFeatures = displayFeatures
    )

      ListDetailPaneScaffold(
          scaffoldState = scaffoldState,
        // ...其他 ListDetailPaneScaffold 的配置...
          listPane = {
            //列表
            MyList(onItemSelected = { item ->
              selectedItem = item;
               savedScaffoldValue.value = PaneScaffoldValue.Primary
            })
        },
        detailPane = {
          selectedItem?.let {
              MyDetail(item = it) //详情页,传入选中的item
          }

        }

      )

    //如果需要对savedScaffoldValue进行更复杂控制(例如:导航到另一页),可以单独监听savedScaffoldValue.value.

}

@Composable
fun MyList(onItemSelected:(Int)->Unit)
{
 //具体列表实现, 当点击时调用 onItemSelected
}
@Composable
fun MyDetail(item:Int){
  //具体详情页实现.
}

安全建议:

  • 此方法需要手动处理 PaneScaffoldValue 的所有可能情况。
  • 应仔细考虑不同窗口大小下的初始状态,以及用户交互后的状态变化。

2. 使用 ViewModel 保存状态

另一种方案是使用 ViewModel 来保存状态,这样可以更可靠地跨越配置更改和导航。

原理:

  • 在 ViewModel 中维护选中的条目和 PaneScaffoldValue
  • ListDetailPaneScaffold 从 ViewModel 中读取这些状态。
  • 由于 ViewModel 的生命周期独立于 Composable,因此即使 Composable 被销毁和重新创建,状态也会保留。

代码示例:

import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.window.layout.DisplayFeature
import com.google.accompanist.adaptive.ListDetailPaneScaffold
import com.google.accompanist.adaptive.PaneScaffoldValue
import com.google.accompanist.adaptive.calculateListDetailPaneScaffoldState

class MyViewModel : ViewModel() {
    var selectedItem: Int? by mutableStateOf(null)
    var scaffoldValue: PaneScaffoldValue by mutableStateOf(PaneScaffoldValue.Secondary) //初始化为Secondary
}

@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun MyListDetailScreen(viewModel: MyViewModel) {

  val windowSizeClass = calculateWindowSizeClass()
  val displayFeatures = emptyList<DisplayFeature>() //可以根据需要修改

    val scaffoldState = calculateListDetailPaneScaffoldState(
        scaffoldValue = viewModel.scaffoldValue,
        windowSizeClass = windowSizeClass,
        displayFeatures = displayFeatures
    )

    ListDetailPaneScaffold(
        scaffoldState = scaffoldState,
      // ...其他 ListDetailPaneScaffold 的配置...
         listPane = {
              //列表
              MyList(onItemSelected = {item ->
                viewModel.selectedItem = item;
                viewModel.scaffoldValue = PaneScaffoldValue.Primary
            })
          },
         detailPane = {
            viewModel.selectedItem?.let{
                MyDetail(item = it)
            }
        }
    )
}

@Composable
fun MyList(onItemSelected:(Int)->Unit)
{
 //具体列表实现, 当点击时调用 onItemSelected
}
@Composable
fun MyDetail(item:Int){
  //具体详情页实现.
}

安全建议:

  • 确保在 ViewModel 中正确处理状态更新, 特别是当多个 Composable 可以修改状态时。

3. 进阶技巧:结合自定义 Saver (较复杂)

如果需要对保存和恢复过程进行更精细的控制,可以为 rememberSaveable 创建自定义的 Saver

原理:

  • 创建一个自定义 Saver,用于明确定义如何保存和恢复 PaneScaffoldValue 和当前选中内容.
  • 将自定义 SaverrememberSaveable 一起使用。

注意 此方法涉及较底层的API, 相对复杂, 不做为首选方案.
示例代码: (仅做演示,实际可能更复杂)


import androidx.compose.runtime.saveable.Saver
import com.google.accompanist.adaptive.PaneScaffoldValue

data class ScaffoldStateData(
  val scaffoldValue: PaneScaffoldValue,
  val selectedItem:Int?
)
object ScaffoldStateSaver : Saver<ScaffoldStateData, Map<String, Any?>> {
    private const val SCAFFOLD_VALUE_KEY = "scaffoldValue"
    private const val SELECTED_ITEM_KEY = "selectedItem"

    override fun restore(value: Map<String, Any?>): ScaffoldStateData? {
        val scaffoldValueRaw = value[SCAFFOLD_VALUE_KEY] as? Int ?: return null
         val selectedItem = value[SELECTED_ITEM_KEY] as? Int

        val scaffoldValue = when (scaffoldValueRaw) {
          0-> PaneScaffoldValue.Secondary
          1 -> PaneScaffoldValue.Primary
          //可以扩展其他状态
          else -> PaneScaffoldValue.Secondary //默认
        }
        return ScaffoldStateData(scaffoldValue, selectedItem)

    }

    override fun SaverScope.save(value: ScaffoldStateData): Map<String, Any?> {
       val scaffoldValueInt = when (value.scaffoldValue){
            PaneScaffoldValue.Secondary -> 0
            PaneScaffoldValue.Primary -> 1
           //可以扩展其他
           else -> 0
       }
        return mapOf(
           SCAFFOLD_VALUE_KEY to scaffoldValueInt,
           SELECTED_ITEM_KEY to value.selectedItem
        )

    }
}
//用法示例:

val scaffoldData = rememberSaveable(saver = ScaffoldStateSaver) {
   mutableStateOf(ScaffoldStateData(PaneScaffoldValue.Secondary,null))
}

总结:

以上三种方案, 可以按需采用, 通常第一和第二种就能满足大部分的需求. 手动处理和ViewModel提供了比较可靠且方便的方式来保存状态, 适合大多数场景。