【NowInAndroid架构拆解】番外篇2之Bottom Navigation底部导航

127 阅读4分钟

【NowInAndroid架构拆解】系列文章


底部导航栏是当代APP中常用的布局手段,Navigation组件同样支持这种场景下的导航跳转。

相比于之前的LoginScreen->MainScreen页面跳转,导航栏的场景在此基础上增加了复杂度。如上图,不仅要完成Screen之间的跳转,同时还要在底部导航栏里选中第一个Tab。

这时就要引入Navigation组件中的第三员大将——Navigation Graph导航图。

Navigation Graph 导航图

Navigation Graph(以下简称NavGraph)用于维护Screen数据。用“余杭地图”来做比喻——

  • 整个余杭区是NavHost
  • 余杭区的地铁、公交、步行线路是NavGraph
  • 建筑物的具体地址(如XX路XX号)是Route

正如余杭区也按层级细分为仓前板块-高教路-...等,NavGraph也具备层级关系。

嵌套Navigation Graph

针对底部Tabs,设计一个父类。

// AppRouter.kt
// Add another sealed class
sealed class TopLevelDestination(
    val route: String,
    val title: Int? = null,
    val selectedIcon: ImageVector? = null,
    val unselectedIcon: ImageVector? = null,
    val navArguments: List<NamedNavArgument> = emptyList()
)

将业务逻辑拆分为登录、主页两个模块,如图所示。在代码上也进行相应设计,结构更加清晰明确。

  • 登录(Auth)链条:包含Login、Register。
  • 主页(Main)链条:包含Bottom Navigation Destinations和其它Screen。
// AppRouter.kt
private object Routes {
    // First Graph Route
    const val AUTH = "auth"
    const val LOGIN = "login"
    const val REGISTER = "signup"

  // Second Graph Route
    const val MAIN = "main"
    const val HOME = "home"
    const val PROFILE = "profile"
    const val NOTIFICATION = "notification"
    const val PRODUCT_DETAIL = "productDetail/{${ArgParams.PRODUCT_ID}}"

}
// grouping AppScreen
sealed class AppScreen(val route: String) {
  
    object Auth : AppScreen(Routes.AUTH) {
        object Login : AppScreen(Routes.LOGIN)
        object Register : AppScreen(Routes.REGISTER)
    }

    object Main : TopLevelDestination(Routes.MAIN) {

        object Home : TopLevelDestination(
                      route = Routes.HOME,
                      title = R.string.home,
                      selectedIcon = AppIcons.HomeFilled,
                      unselectedIcon = AppIcons.HomeOutlined,
        ) 
  
        object Profile : TopLevelDestination(
                      route = Routes.PROFILE,
                      title = R.string.profile,
                      selectedIcon = AppIcons.ProfileFilled,
                      unselectedIcon = AppIcons.ProfileOutlined,
         )
    
        object Notification : TopLevelDestination(
                route = Routes.NOTIFICATION,
                title = R.string.notification,
                selectedIcon = AppIcons.NotificationFilled,
                unselectedIcon = AppIcons.NotificationOutlined,
          )
    
   }
}

1. Auth Navigation Graph

Auth NavGraph包含注册、登录。

// AuthNavGraph.kt

fun NavGraphBuilder.authNavGraph(
    navController: NavHostController
) {
    navigation(
        route = AppScreen.Auth.route
        startDestination = AppScreen.Auth.Login.route,     
    ) {
        composable( // 注册到NavGraph里
            route = AppScreen.Auth.Login.route
        ) {
            // route to main navigation graph
            LoginScreen(
                navigateToHome = {
                    navController.navigate(AppScreen.Main.route) {
                        popUpTo(AppScreen.Auth.route) {
                            inclusive = true
                        }
                    }
                },
                navigateToSignUp = {
                    navController.navigate(AppScreen.Auth.Register.route)
                },
            )
        }

        composable(
            route = AppScreen.Auth.Register.route
        ) {
            SignUpScreen(onNavigateBack = {
                navController.navigateUp()
            })
        }
    }

}

2. Main Navigation Graph

包含多个Tab,默认展示HomeTab。

// MainNavGraph.kt

fun NavGraphBuilder.mainNavGraph(
    navController: NavHostController
) {

    navigation(
        startDestination = AppScreen.Main.Home.route,
        route = AppScreen.Main.route
    ) {
        composable(
            route = AppScreen.Main.Home.route
        ) {
            HomeScreen(onProductClick = {
                val route = AppScreen.Main.ProductDetail.createRoute(productId = it)
                navController.navigate(route)
            })
        }

        composable(
            route = AppScreen.Main.Notification.route,
        ) {
            NotificationScreen()
        }

        // route back to auth graph
        composable(
            route = AppScreen.Main.Profile.route
        ) {
            ProfileScreen(navigateToLogin = {
                navController.navigate(AppScreen.Auth.route) {
                    popUpTo(AppScreen.Main.route) {
                        inclusive = true
                    }
                }
            })
        }

        dialog(
            route = AppScreen.Main.ProductDetail.route
        ) {
            // val productId = backStackEntry.arguments?.getString("productId")
            // value also can be retrieve directly from responsible view-model
            ProductDetail() {
                navController.navigateUp()
            }
        }

    }

}

然后再根NavGraph中注册上述2个NavGraph,同样要声明startDestination是登录Screen。

@Composable
fun RootNavGraph(navHostController: NavHostController) {
    RootNavHost(
        navController = navHostController,
        startDestination = AppScreen.Auth.route
    ) {
        authNavGraph(navHostController)
        mainNavGraph(navHostController)

    }
}

3. 增加底部导航

@Composable
fun BottomBar(
    navController: NavHostController,
) {
    val navigationScreen = listOf(
        AppScreen.Main.Home, AppScreen.Main.Notification, AppScreen.Main.Profile
    )

    NavigationBar {

        val navBackStackEntry by navController.currentBackStackEntryAsState()
        val currentRoute = navBackStackEntry?.destination?.route

        navigationScreen.forEach { item ->

            NavigationBarItem(
                selected = currentRoute == item.route,

                label = {
                    Text(text = stringResource(id = item.title!!), style = MaterialTheme.typography.displaySmall)
                },
                icon = {

                    BadgedBox(badge = { }) { }

                    Icon(
                        imageVector = (if (item.route == currentRoute) item.selectedIcon else item.unselectedIcon)!!,
                        contentDescription = stringResource(id = item.title!!)
                    )
                },

                onClick = {
                    navController.navigate(item.route) { // 一级页面之间跳转,不入栈
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                },
            )
        }
    }
}

修改MainActivity,支持底部Tab。

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    private val TAG: String = AppLog.tagFor(this.javaClass)
    private lateinit var navController: NavHostController
    private val mainViewModel: MainViewModel by viewModels()

    private var isAuthenticated = false

    override fun onCreate(savedInstanceState: Bundle?) {
        val splashScreen = installSplashScreen()
        super.onCreate(savedInstanceState)
        setContent {
            FireflyComposeTheme {
                navController = rememberNavController()
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentRoute = navBackStackEntry?.destination?.route

                val scope = rememberCoroutineScope()
                val snackbarHostState = remember { SnackbarHostState() }

                val bottomBarState = rememberSaveable { (mutableStateOf(true)) }
                val topBarState = rememberSaveable { (mutableStateOf(true)) }

                // Control TopBar and BottomBar
                when (navBackStackEntry?.destination?.route) {
                    AppScreen.Main.Home.route -> {
                        bottomBarState.value = true
                        topBarState.value = true
                    }

                    AppScreen.Main.Profile.route -> {
                        bottomBarState.value = true
                        topBarState.value = true
                    }

                    AppScreen.Main.Notification.route -> {
                        bottomBarState.value = true
                        topBarState.value = true
                    }

                    else -> {
                        bottomBarState.value = false
                        topBarState.value = false
                    }
                }
                Scaffold( // 脚手架函数设置界面显示
                    snackbarHost = {
                        SnackbarHost(hostState = snackbarHostState)
                    },
                    bottomBar = {
                        if (bottomBarState.value) {
                            BottomBar(navController = navController)
                        }
                    }) { paddingValues ->
                    Box(
                        modifier = Modifier.padding(paddingValues)
                    ) {
                        RootNavHost(navHostController = navController)
                    }
                }

            }
        }
    }
}

导航过程中的条件分支

在执行导航时,存在根据当前用户的不同状态,执行不同分支的场景。例如进程启动时,如果是首次使用,则跳转到登录页面;如果用户近期登录过,且token仍然处于有效期,则直接进入MainScreen。通过startDestination可以实现上述需求。

@Composable
fun RootNavGraph(isLoggedIn: Boolean, navHostController: NavHostController) {
    NavHost(
        navController = navHostController,
        startDestination =  if(isLoggedIn) AppScreen.Main.route else AppScreen.Auth.route
    ) {
        AppLog.showLog("Nav Graph Setup")

        authNavGraph(navHostController)
        mainNavGraph(navHostController)

    }
}

类型安全导航

可以使用单例对象来作为路由,代替String的使用。

@Serializable
object Login

@Serializable
data class Home(val userId: Int)


@Composable
fun RootNavHost() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = Login) {
        composable<Login> {
            LoginScreen(
              onLoginClick = { navController.navigate(Home(it.userId)) },
            )
        }

        composable<Home> { backStackEntry ->
            val home = backStackEntry.toRoute<Home>()

            HomeScreen(
                bookId = home.userId,
            )
        }
    }
}

参考资料