Jetpack Compose 应用程序范围的条件 TopAppBar 最佳实践

Posted

技术标签:

【中文标题】Jetpack Compose 应用程序范围的条件 TopAppBar 最佳实践【英文标题】:Jetpack Compose application-wide conditional TopAppBar best practice 【发布时间】:2021-11-23 23:57:14 【问题描述】:

我有一个使用 BottomNavigationTopAppBar 可组合组件的 android Jetpack Compose 应用程序。从通过BottomNavigation 打开的选项卡中,用户可以更深入地导航到导航图。

问题

TopAppBar 可组合对象必须代表当前屏幕,例如显示它的名字,实现一些特定于打开的屏幕的选项,如果屏幕是高级的,则返回按钮。但是,Jetpack Compose 似乎没有开箱即用的解决方案,开发者必须自己实现。

因此,显而易见的想法伴随着明显的缺点,有些想法比其他想法更好。

跟踪导航的基线,如 Google 的 suggested(至少对于 BottomNavigation),是一个 sealed 类,其中包含代表当前活动屏幕的 objects。专门针对我的项目,是这样的:

sealed class AppTab(val route: String, @StringRes val resourceId: Int, val icon: ImageVector) 
    object Events: AppTab("events_tab", R.string.events, Icons.Default.EventNote)
    object Projects: AppTab("projects_tab", R.string.projects, Icons.Default.Widgets)
    object Devices: AppTab("devices_tab", R.string.devices, Icons.Default.DevicesOther)
    object Employees: AppTab("employees_tab", R.string.employees, Icons.Default.People)
    object Profile: AppTab("profile_tab", R.string.profile, Icons.Default.AccountCircle)

现在TopAppBar 可以知道打开了哪个选项卡,前提是我们remember AppTab 对象,但是它如何知道屏幕是否从给定选项卡中打开?

解决方案 1 - 明显且明显错误

我们为每个屏幕提供自己的TopAppBar 并让它处理所有必要的逻辑。除了大量的代码重复之外,每个屏幕的TopAppBar 将在打开屏幕时重新组合,并且如this 帖子中所述,会闪烁。

解决方案 2 - 不太优雅

从现在开始,我决定在我的项目的***可组合项中添加一个 TopAppBar,这将取决于保存当前屏幕的 state。现在我们可以轻松实现 Tabs 的逻辑了。

为了解决从 Tab 中打开屏幕的问题,我扩展了 Google 的想法,实现了一个通用的 AppScreen 类,代表每个可以打开的屏幕:

// This class represents any screen - tabs and their subscreens.
// It is needed to appropriately change top app bar behavior
sealed class AppScreen(@StringRes val screenNameResource: Int) 
    // Employee-related
    object Employees: AppScreen(R.string.employees)
    object EmployeeDetails: AppScreen(R.string.profile)

    // Events-related
    object Events: AppScreen(R.string.events)
    object EventDetails: AppScreen(R.string.event)
    object EventNew: AppScreen(R.string.event_new)

    // Projects-related
    object Projects: AppScreen(R.string.projects)

    // Devices-related
    object Devices: AppScreen(R.string.devices)

    // Profile-related
    object Profile: AppScreen(R.string.profile)

然后,我将其保存到 TopAppBar 范围内***可组合项中的 state 并将 currentScreenHandler 作为 onNavigate 参数传递给我的 Tab 可组合项:

    var currentScreen by remember  mutableStateOf(defaultTab.asScreen()) 

    val currentScreenHandler: (AppScreen) -> Unit = navigatedScreen -> currentScreen = navigatedScreen
// Somewhere in the bodyContent of a Scaffold
                when (currentTab) 
                    AppTab.Employees -> EmployeesTab(currentScreenHandler)
                // And other tabs
                // ...
                

从 Tab 内部可组合:

    val navController = rememberNavController()

    NavHost(navController, startDestination = "employees") 
        composable("employees") 
            onNavigate(AppScreen.Employees)
            Employees(it.hiltViewModel(), navController)
        
        composable("employee/userId") 
            onNavigate(AppScreen.EmployeeDetails)
            Employee(it.hiltViewModel())
        
    

现在可组合根​​中的TopAppBar 知道更高级别的屏幕,并且可以实现必要的逻辑。但是对应用程序的每个子屏幕都这样做吗?大量的代码重复,以及此应用栏与它所代表的可组合项之间的通信架构(可组合项如何对应用栏上执行的操作作出反应)尚未组合(双关语)。

解决方案 3 - 最好的?

我实现了一个viewModel 来处理所需的逻辑,因为它似乎是最优雅的解决方案:

@HiltViewModel
class AppBarViewModel @Inject constructor() : ViewModel() 
    private val defaultTab = AppTab.Events
    private val _currentScreen = MutableStateFlow(defaultTab.asScreen())
    val currentScreen: StateFlow<AppScreen> = _currentScreen

    fun onNavigate(screen: AppScreen) 
        _currentScreen.value = screen
    

根可组合:

    val currentScreen by appBarViewModel.currentScreen.collectAsState()

但是并没有解决第二种方案的代码重复问题。首先,我必须将这个viewModelMainActivity 传递给可组合的根,因为似乎没有其他方法可以从可组合内部访问它。所以现在,我没有将currentScreenHandler 传递给Tab 组合,而是将viewModel 传递给它们,而不是在导航事件上调用处理程序,而是调用viewModel.onNavigate(AppScreen),所以还有更多代码!至少,我也许可以实现上一个解决方案中提到的通信机制。

问题

目前,就代码量而言,第二种解决方案似乎是最好的,但第三种解决方案可以为一些尚未被请求的功能提供通信和更大的灵活性。我可能会遗漏一些明显而优雅的东西。你认为我的哪个实现最好,如果没有,你会怎么解决这个问题?

谢谢。

【问题讨论】:

【参考方案1】:

我在 Scaffold 中使用单个 TopAppBar,并通过从 Composables 引发事件来使用不同的标题、下拉菜单、图标等。这样,我可以只使用一个具有不同值的 TopAppBar。这是一个例子:

    val navController = rememberNavController()
    var canPop by remember  mutableStateOf(false) 

    var appTitle by remember  mutableStateOf("") 
    var showFab by remember  mutableStateOf(false) 

    var showDropdownMenu by remember  mutableStateOf(false) 
    var dropdownMenuExpanded by remember  mutableStateOf(false) 
    var dropdownMenuName by remember  mutableStateOf("") 
    var topAppBarIconsName by remember  mutableStateOf("") 

    val scaffoldState = rememberScaffoldState()
    val scope = rememberCoroutineScope()

    val tourViewModel: TourViewModel = viewModel()
    val clientViewModel: ClientViewModel = viewModel()

    navController.addOnDestinationChangedListener  controller, _, _ ->
        canPop = controller.previousBackStackEntry != null
    

    val navigationIcon: (@Composable () -> Unit)? =
        if (canPop) 
            
                IconButton(onClick =  navController.popBackStack() ) 
                    Icon(
                        imageVector = Icons.Filled.ArrowBack,
                        contentDescription = "Back Arrow"
                    )
                
            
         else 
            
                IconButton(onClick = 
                    scope.launch 
                        scaffoldState.drawerState.apply 
                            if (isClosed) open() else close()
                        
                    
                ) 
                    Icon(Icons.Filled.Menu, contentDescription = null)
                
            
        

    Scaffold(
        scaffoldState = scaffoldState,
        drawerContent = 
            DrawerContents(
                navController,
                onMenuItemClick =  scope.launch  scaffoldState.drawerState.close()  )
        ,
        topBar = 
            TopAppBar(
                title =  Text(appTitle) ,
                navigationIcon = navigationIcon,
                elevation = 8.dp,
                actions = 
                    when (topAppBarIconsName) 
                        "ClientDirectoryScreenIcons" -> 
                            // search icon on client directory screen
                            IconButton(onClick = 
                                clientViewModel.toggleSearchBar()
                            ) 
                                Icon(
                                    imageVector = Icons.Filled.Search,
                                    contentDescription = "Search Contacts"
                                )
                            
                        
                    

                    if (showDropdownMenu) 
                        IconButton(onClick =  dropdownMenuExpanded = true ) 
                            Icon(imageVector = Icons.Filled.MoreVert, contentDescription = null)

                            DropdownMenu(
                                expanded = dropdownMenuExpanded,
                                onDismissRequest =  dropdownMenuExpanded = false 
                            ) 

                                // show different dropdowns based on different screens
                                when (dropdownMenuName) 
                                    "ClientDirectoryScreenDropdown" -> ClientDirectoryScreenDropdown(
                                        onDropdownMenuExpanded =  dropdownMenuExpanded = it )
                                
                            
                        
                    
                
            )
        ,
...
   )  paddingValues ->

        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)

        ) 
            NavHost(
                navController = navController,
                startDestination = Screen.Tours.route
            ) 
                composable(Screen.Tours.route) 
                    TourScreen(
                        tourViewModel = tourViewModel,
                        onSetAppTitle =  appTitle = it ,
                        onShowDropdownMenu =  showDropdownMenu = it ,
                        onTopAppBarIconsName =  topAppBarIconsName = it 
                    )
                

然后像这样从不同的屏幕设置 TopAppBar 值:

@Composable
fun TourScreen(
    tourViewModel: TourViewModel,
    onSetAppTitle: (String) -> Unit,
    onShowDropdownMenu: (Boolean) -> Unit,
    onTopAppBarIconsName: (String) -> Unit
) 
    LaunchedEffect(Unit) 
        onSetAppTitle("Tours")
        onShowDropdownMenu(false)
        onTopAppBarIconsName("")
    
...

这可能不是完美的方法,但没有重复的代码。

【讨论】:

我可以看到您正在使用解决方案 2(传递事件处理程序)和解决方案 3(也实现通信的视图模型)的某种组合。我通常不喜欢解决方案 2,因为将处理程序传递给可组合对象,以便再次传递,依此类推。但我现在知道实现通信的视图模型方式并不令人畏惧并且也被使用。谢谢! 您可以将视图模型、导航等传递到屏幕的第一级,但无需再向下传递。改为使用状态提升并从顶层处理较低级别屏幕的操作。这样,您就不需要到处传递资源,这是推荐的方式。

以上是关于Jetpack Compose 应用程序范围的条件 TopAppBar 最佳实践的主要内容,如果未能解决你的问题,请参考以下文章

Jetpack Compose Navigation - 底部导航多个返回堆栈 - 查看模型范围问题

Jetpack Compose - Slider

jetpack compose 接收返回参数

Jetpack Compose 和 Compose Navigation 如何处理 Android 活动?

《Jetpack Compose系列学习》-2 Compose编程思想

Google开源,Android Jetpack Compose最新开发应用指南