You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

嵌套Scaffold中调用文件选择器后Snackbar不显示问题排查

问题:嵌套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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 12:58:11