嵌套Scaffold中调用文件选择器后Snackbar不显示问题排查
我开发了页面级Composable组件DissertationsScreen,在LaunchedEffect(batchUploadState)代码块里,Error分支已经触发执行,但Snackbar始终不显示。测试流程如下:
用户导航至DissertationsScreen → 展开FAB菜单 → 选择上传文件 → 打开文件选择器 → 用户选择文件 → API返回500错误 → batchUploadState被设置为Error状态,LaunchedEffect的Error分支执行 → Snackbar未展示
该页面的Scaffold嵌套在处理底部导航的Activity的Scaffold中,其他采用相同实现方式的页面无此问题。
DissertationsScreen代码
@OptIn(ExperimentalMaterial3Api::class) @Composable fun DissertationsScreen( modifier: Modifier = Modifier, viewModel: DissertationsViewModel = koinInject(), onDissertationSelected: (Int) -> Unit, onAddDissertation: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() val batchUploadState by viewModel.batchUploadState.collectAsState() val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val listState = rememberLazyListState() val isFabExpanded by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } //todo:sp could be extracted to be generalised val pickBatchFileLauncher = rememberLauncherForActivityResult( ActivityResultContracts.GetContent() ) { fileUri -> if (fileUri != null) { val fileId = UUID.randomUUID().toString() val tempFile = context.createTempFileFromUri(fileUri, fileId) viewModel.uploadBatchFile(tempFile) { tempFile.deleteTempFiles(fileId) } } } LaunchedEffect(uiState) { if (uiState is UIState.Error) { scope.launch { snackbarHostState.showSnackbar((uiState as UIState.Error).message, duration = SnackbarDuration.Short) } } } LaunchedEffect(batchUploadState) { when (batchUploadState) { is UIState.Success -> { viewModel.refetchList() } is UIState.Error -> { scope.launch { snackbarHostState.showSnackbar((batchUploadState as UIState.Error).message, duration = SnackbarDuration.Short) } } else -> { // loading scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.file_upload_in_progress), duration = SnackbarDuration.Short) } } } } LaunchedEffect(key1 = Unit) { viewModel.fetchNextPage() } Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, floatingActionButton = { ExpandableFABMenu( modifier = modifier, isRootFABExpanded = isFabExpanded, rootFABIconRes = R.drawable.ic_add, rootFABLabelRes = R.string.add_dissertation_btn_txt, onRootFABClicked = { } ) { hideMenu -> UoCLabeledFAB( labelRes = R.string.add_dissertation_btn_txt, iconRes = R.drawable.ic_add, contentDescriptionRes = R.string.add_dissertation_btn_txt, hideMenu = hideMenu ) { onAddDissertation() } UoCLabeledFAB( labelRes = R.string.upload_batch_file, iconRes = R.drawable.ic_upload_file, contentDescriptionRes = R.string.upload_batch_file, hideMenu = hideMenu ) { pickBatchFileLauncher.launch(FilePickerConsts.JSON_MIME_TYPE) } } }, floatingActionButtonPosition = FabPosition.End, topBar = { CenterAlignedTopAppBar( title = { Text(text = DissertationsScreenDestination.title) }, actions = {} ) } ) { padding -> PaginatedLazyColumn( modifier .fillMaxSize() .padding( top = padding.calculateTopPadding(), start = padding.calculateStartPadding(LayoutDirection.Ltr), end = padding.calculateEndPadding(LayoutDirection.Ltr), ) .padding(start = 16.dp, end = 16.dp), listState = listState, items = (uiState as? UIState.Success)?.data ?: emptyList(), loading = uiState is UIState.Loading, contentWhenEmpty = { Text(stringResource(R.string.no_data_available_error_msg)) }, loadingItem = { CircularProgressIndicator() }, itemKey = { phd -> phd.id ?: UUID.randomUUID() }, itemContentIndexed = { phd, _ -> PhdDissertationListItem( id = phd.id, title = phd.title, pubDate = phd.date, authors = phd.authors, onDissertationSelected = onDissertationSelected ) } ) { viewModel.fetchNextPage() } } BackHandler {} }
MainActivity代码
val LocalTheme = compositionLocalOf { false } class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) installSplashScreen() enableEdgeToEdge() setContent { val viewModel: SettingsViewModel = koinViewModel() val themeSetting by viewModel.userThemeSetting.collectAsState(AppThemeSetting.MODE_AUTO) val isDarkModeEnabled = when (themeSetting) { AppThemeSetting.MODE_DAY -> false AppThemeSetting.MODE_NIGHT -> true AppThemeSetting.MODE_AUTO -> isSystemInDarkTheme() } CompositionLocalProvider(LocalTheme provides isDarkModeEnabled) { UOCResearchMobileAppTheme(darkTheme = isDarkModeEnabled) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route?.substringBeforeLast("/") var selectedItem by remember { mutableIntStateOf(0) } val bottomBarItems = listOf( DissertationsScreenDestination, UoCLabsScreenDestination, SearchScreenDestination, CollaboratingLabsScreenDestination, SettingsScreenDestination ) // if the user is logged-out, reset navbar selected item LaunchedEffect(key1 = currentRoute) { if (currentRoute == LoginScreenDestination.route) { selectedItem = 0 } } Scaffold( modifier = Modifier .fillMaxSize(), bottomBar = { if (currentRoute != LoginScreenDestination.route) { NavigationBar { bottomBarItems.forEachIndexed { index, item -> NavigationBarItem( icon = { Icon( painterResource(id = item.iconRes), contentDescription = item.title ) }, label = { Text(item.navLabel) }, selected = selectedItem == index, onClick = { selectedItem = index navController.navigate(item.route) }, ) } } } } ) { innerPadding -> UoCNavHost( modifier = Modifier.padding(innerPadding), navController = navController ) } } } } } }
解决方案
1. 统一SnackbarHost到外层Scaffold
嵌套Scaffold时,内层的SnackbarHost容易被外层布局遮挡,把snackbarHostState提升到MainActivity并通过CompositionLocal共享,确保所有页面使用同一个SnackbarHost:
- 在MainActivity中添加并传递全局SnackbarHostState:
// 定义全局CompositionLocal val LocalSnackbarHostState = compositionLocalOf { SnackbarHostState() } // 在MainActivity的setContent中: val snackbarHostState = remember { SnackbarHostState() } CompositionLocalProvider( LocalTheme provides isDarkModeEnabled, LocalSnackbarHostState provides snackbarHostState ) { UOCResearchMobileAppTheme(darkTheme = isDarkModeEnabled) { // ... 原有代码 Scaffold( bottomBar = { /* 原有底部导航 */ }, // 外层Scaffold配置SnackbarHost snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { innerPadding -> UoCNavHost(/* ... */) } } }
- 在DissertationsScreen中获取全局SnackbarHostState:
val snackbarHostState = LocalSnackbarHostState.current
2. 移除不必要的子协程启动
LaunchedEffect本身就在协程上下文里,不需要额外调用scope.launch,直接执行showSnackbar即可,避免协程上下文冲突:
修改LaunchedEffect(batchUploadState)代码:
LaunchedEffect(batchUploadState) { when (batchUploadState) { is UIState.Success -> viewModel.refetchList() is UIState.Error -> { snackbarHostState.showSnackbar( (batchUploadState as UIState.Error).message, duration = SnackbarDuration.Short ) } else -> { snackbarHostState.showSnackbar( context.getString(R.string.file_upload_in_progress), duration = SnackbarDuration.Short ) } } }
LaunchedEffect(uiState)也做同样修改,去掉scope.launch。
3. 修复空BackHandler的问题
空的BackHandler {}会拦截返回事件,可能导致组件状态异常,如果不需要拦截返回,直接删除该行代码;如果需要拦截,补充实际逻辑。
4. 确保状态正确重置
在ViewModel处理完上传错误后,把batchUploadState重置为初始状态(比如UIState.Idle),避免LaunchedEffect因状态未变化而无法重复触发:
// ViewModel示例代码 fun handleUploadError(message: String) { batchUploadState.value = UIState.Error(message) // 延迟重置,保证Snackbar能正常显示完成 viewModelScope.launch { delay(1000) batchUploadState.value = UIState.Idle } }
内容的提问来源于stack exchange,提问作者Stelios Papamichail

