# jetpack-compose **Repository Path**: msy649166/jetpack-compose ## Basic Information - **Project Name**: jetpack-compose - **Description**: android compose小项目,使用navigation、hilt、viewmodel、paging3、MVI框架等实现 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 2 - **Created**: 2022-11-23 - **Last Updated**: 2022-11-23 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## Android Jetpack Compose 使用```Jetpack```里面的```Compose```组件搭配其他```Jetpack```组件开发的应用。 主要是对```Compose```的使用。 ### NavHostController导航 使用```NavHostController```进行页面导航;统一管理导航key,路由跳转,底部导航栏的实现等。 #### 导航```key```统一管理 创建```meun```来管理页面导航的```key```,因为```NavHostController```需要的是```String```类型,所以这里可以不创建```menu```,可以直接使用```String```,看自己习惯来。 ```kotlin enum class RouteKey{ HOME, // 首页 PROJECT,//项目页 NAVI,//导航页 MINE,//个人中心页 LOGIN,//登录页 REGISTER,//注册页 COLLECTION,//收藏页 INTEGRAL//积分页 } ``` #### 路由跳转 在```Scaffold```脚手架的```content```里面添加```NavHost```控制路由跳转。 ```kotlin content = { NavHost( modifier = Modifier.background(MaterialTheme.colors.background), navController = navHostController, startDestination = RouteKey.HOME.toString() ) { composable(route = RouteKey.HOME.toString()) { HomePage(navHostController) } composable(route = RouteKey.PROJECT.toString()) { ProjectPage(navHostController = navHostController) } composable(route = RouteKey.NAVI.toString()) { NaviPage(navHostController = navHostController) } composable(route = RouteKey.MINE.toString()) { MinePage(navHostController = navHostController) } composable(route = RouteKey.LOGIN.toString()) { LoginPage(navHostController = navHostController) } } } ``` 路由的跳转以及回退。 ```kotlin object RouteNavigationUtil{ fun navigationTo( navCtrl: NavHostController, destinationName: String, args: Any? = null, backStackRouteName: String? = null, isLaunchSingleTop: Boolean = true, needToRestoreState: Boolean = true, ) { var singleArgument = "" if (args != null) { when (args) { is Parcelable -> { singleArgument = String.format("/%s", Uri.encode(args.toJson())) } is String -> { singleArgument = String.format("/%s", args) } is Int -> { singleArgument = String.format("/%s", args) } is Float -> { singleArgument = String.format("/%s", args) } is Double -> { singleArgument = String.format("/%s", args) } is Boolean -> { singleArgument = String.format("/%s", args) } is Long -> { singleArgument = String.format("/%s", args) } } } navCtrl.navigate("$destinationName$singleArgument") { if (backStackRouteName != null) { popUpTo(backStackRouteName) { saveState = true } } launchSingleTop = isLaunchSingleTop restoreState = needToRestoreState } } fun NavHostController.back() { navigateUp() } } ``` - navigationTo :跳转到某个页面 - NavHostController.back :回退栈 #### 底部导航栏的实现 1.创建底部导航栏所需要的资源,页面名称,页面icon等。 ```kotlin sealed class BottomTabBarRoute( var key: RouteKey, @StringRes var stringId:Int, var iconUnSelected: Int, var iconSelected: Int){ object Home: BottomTabBarRoute(RouteKey.HOME, R.string.bottom_bar_home, R.drawable.icon_home_unselect, R.drawable.icon_home_selected) object Project: BottomTabBarRoute(RouteKey.PROJECT, R.string.bottom_bar_project, R.drawable.icon_project_unselect, R.drawable.icon_project_selected) object Navi: BottomTabBarRoute(RouteKey.NAVI, R.string.bottom_bar_navi, R.drawable.icon_navi_unselected, R.drawable.icon_navi_selected) object Mine: BottomTabBarRoute(RouteKey.MINE, R.string.bottom_bar_mine, R.drawable.icon_mine_unselect, R.drawable.icon_mine_selected) } ``` ``` /** * @param key 路由key * @param stringId 导航栏显示的字符串 * @param iconUnSelected 未选中显示的图标 * @param iconSelected 选中显示的图标 */ ``` ```var iconUnSelected: Int```,这里使用的是```Int```是为了更方便使用本地资源图片。 2.使用```BottomNavigation```实现底部导航栏item的填充 ```kotlin @Composable fun BottomTabBar(navHostController: NavHostController,selectIndex:Int,onItemSelected:(position:Int)->Unit) { val tabBarList = listOf( BottomTabBarRoute.Home, BottomTabBarRoute.Project, BottomTabBarRoute.Navi, BottomTabBarRoute.Mine ) BottomNavigation( modifier = Modifier.height(45.dp) ) { val navBackStackEntry by navHostController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination tabBarList.forEachIndexed { index,screen -> BottomNavigationItem( modifier = Modifier .background(AppTheme.colors.themeUi) .offset(y = 5.dp), icon = { var iconRes = painterResource(id = screen.iconUnSelected) if(selectIndex == index){ iconRes = painterResource(id = screen.iconSelected) } Image(painter = iconRes, contentDescription = null, modifier = Modifier.height(20.dp), ) }, label = { var textColor = grey3 if (selectIndex == index) textColor = white Text(text = stringResource(screen.stringId), fontSize = 12.sp, color = textColor ) }, selected = currentDestination?.hierarchy?.any { it.route == screen.key.toString() } == true, onClick = { onItemSelected.invoke(index) if (currentDestination?.route != screen.key.toString()) { navHostController.navigate(screen.key.toString()) { popUpTo(navHostController.graph.findStartDestination().id) { saveState = true } launchSingleTop = true restoreState = true } } }) } } } ``` ```navHostController``` :导航控制器 ```selectIndex```:当前选中的```item 的 index``` ```onItemSelected```:```Item```点击事件的回调 ```tabBarList```:底部导航栏的子元素集合,要放多少直接往里放就行了 ```onItemSelected.invoke(index)```:点击事件回调出去 ```kotlin icon = { //获取图片资源 var iconRes = painterResource(id = screen.iconUnSelected) //当选中的index和当前item的index一样时,显示选中状态的icon if(selectIndex == index){ iconRes = painterResource(id = screen.iconSelected) } //这里使用image,因为icon会有默认背景把图片里面的内容覆盖掉,只留下纯色的icon Image(painter = iconRes, contentDescription = null, modifier = Modifier.height(20.dp), ) } ``` 3.在```Scaffold```脚手架里面使用底部导航栏 ``` var selectIndex by remember{ mutableStateOf(0) } ``` ```kotlin bottomBar = { when(currentDestination?.route){ RouteKey.HOME.toString() -> BottomTabBar(navHostController = navHostController, selectIndex = selectIndex){ selectIndex = it } RouteKey.PROJECT.toString() -> BottomTabBar(navHostController = navHostController, selectIndex = selectIndex){ selectIndex = it } RouteKey.NAVI.toString() -> BottomTabBar(navHostController = navHostController, selectIndex = selectIndex){ selectIndex = it } RouteKey.MINE.toString() -> BottomTabBar(navHostController = navHostController, selectIndex = selectIndex){ selectIndex = it } } } ``` 定义```selectIndex```来记录当前选中的下标,放在```BottomNavigation```外面是因为在外面值改变了才会引起重组,才会改变```BottomNavigation```里面```icon和label```的UI。 将底部导航栏放到```Scaffold```脚手架的```bottomBar```里面去。 #### 脚手架代码 ```kotlin fun MainScaffoldConfig(){ val navHostController = rememberNavController() val navBackStackEntry by navHostController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination val scaffoldState = rememberScaffoldState() var selectIndex by remember{ mutableStateOf(0) } Scaffold( modifier = Modifier .statusBarsPadding() .navigationBarsPadding(), bottomBar = { when(currentDestination?.route){ RouteKey.HOME.toString() -> BottomTabBar(navHostController = navHostController, selectIndex = selectIndex){ selectIndex = it } RouteKey.PROJECT.toString() -> BottomTabBar(navHostController = navHostController, selectIndex = selectIndex){ selectIndex = it } RouteKey.NAVI.toString() -> BottomTabBar(navHostController = navHostController, selectIndex = selectIndex){ selectIndex = it } RouteKey.MINE.toString() -> BottomTabBar(navHostController = navHostController, selectIndex = selectIndex){ selectIndex = it } } }, content = { NavHost( modifier = Modifier.background(MaterialTheme.colors.background), navController = navHostController, startDestination = RouteKey.HOME.toString() ) { composable(route = RouteKey.HOME.toString()) { HomePage(navHostController) } composable(route = RouteKey.PROJECT.toString()) { ProjectPage(navHostController = navHostController) } composable(route = RouteKey.NAVI.toString()) { NaviPage(navHostController = navHostController) } composable(route = RouteKey.MINE.toString()) { MinePage(navHostController = navHostController) } composable(route = RouteKey.LOGIN.toString()) { LoginPage(navHostController = navHostController) } } }, snackbarHost = { SnackbarHost( hostState = scaffoldState.snackbarHostState ) { data -> // println("actionLabel = ${data.actionLabel}") // AppSnackBar(data = data) } } ) } ``` #### 带动画的导航 ```Compose```在切换页面的时候可以给```NavHost```添加进入和退出的动画。 首先导入辅助库的依赖: ```kotlin implementation "com.google.accompanist:accompanist-navigation-animation:0.25.1" ``` 之前在使用```Navigation```进行导航的时候使用的是以下内容: ``` val navHostController = rememberNavController() NavHost( composable ``` 在使用```Animate```进行导航的时候需要替换成以下内容: ``` val navHostController = rememberAnimatedNavController() AnimatedNavHost( composable ``` 这里有一点需要注意```Navigation 对应的 composable 的包```和```Animate Navgation 对应的 composable的包```不是同一个包。 Navigation 对应的是下面三个包: ``` import androidx.navigation.compose.NavHost // navhost import androidx.navigation.compose.composable //compose import androidx.navigation.compose.rememberNavController //NavController ``` 而```Animate Navgation```对应的是下面三个包: ```kotlin import com.google.accompanist.navigation.animation.AnimatedNavHost // navhost import com.google.accompanist.navigation.animation.composable //compose import com.google.accompanist.navigation.animation.rememberAnimatedNavController //NavController ``` 记住不要导错包,不然可能程序能出来,但是界面出不来。 ```Animate composable```的参数如下: ```kotlin @ExperimentalAnimationApi public fun NavGraphBuilder.composable( route: String, arguments: List = emptyList(), deepLinks: List = emptyList(), enterTransition: (AnimatedContentScope.() -> EnterTransition?)? = null, exitTransition: (AnimatedContentScope.() -> ExitTransition?)? = null, popEnterTransition: ( AnimatedContentScope.() -> EnterTransition? )? = enterTransition, popExitTransition: ( AnimatedContentScope.() -> ExitTransition? )? = exitTransition, content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit ) ``` 可以通过以下四个方法,设置打开和关闭页面的动画: - enterTransition :从A页面进入其他页面的动画(可以理解为打开新页面的动画)。 - exitTransition :从其他页面返回A页面的动画(可以理解为关闭当前页面的动画)。 - popEnterTransition :当前页面在另一个页面弹出后重新出现的动画。 - popExitTransition :当前页面弹出栈后隐藏时的动画。 ### MVI ![](./screenshot/mvi.png) MVI框架主要内容如下: - `model` : `MVI`的`Model`主要指`UI`状态(`State`)。例如页面加载状态、控件位置等都是一种`UI`状态。 - `View` : 与其他`MVX`中的`View`一致,可能是一个`Activity`或者任意`UI`承载单元。`MVI`中的`View`通过订阅`Model`的变化实现界面刷新。 - `intent` :此`Intent`不是`Activity`的`Intent`,用户的任何操作都被包装成`Intent`后发送给`Model`层进行数据请求 怎么实现一个MVI框架循环呢? #### 1.定义MVI的model(M) 首先定义```model```,也就是页面的状态```state```:(下面都以HomePage为例) ```kotlin data class HomeViewState( var bannerList: List = emptyList(), val articleList: List = emptyList(), var pagingData: PagingCollect? = null, var isNeedReload: Boolean = false ) ``` 这里定义页面状态,只要数据发生变化就会刷新UI,目前包含以下几个状态: - bannerList :轮播图列表 - articleList :首页文章列表 - pagingData :首页文章列表,结合```paging3```实现 - isNeedReload :是否需要重构页面 #### 2.定义MVI的intent(I) 这里的页面```Intent```也就是用户的操作,当用户点击屏幕交互时触发,或者是某些操作后触发 ```kotlin sealed class HomeViewEvent{ object onStartEvent : HomeViewEvent() object onCollectionEvent : HomeViewEvent() object onSetReLoad : HomeViewEvent() } ``` #### 3.ViewModel实现 当```model```和```intent```都定义好之后就可以在```viewModel```里面实现了。 首先实现```model```也就是页面```state```, ```kotlin var viewState by mutableStateOf(HomeViewState()) set ``` 然后根据用户操作触发的```intent```派遣任务: ```kotlin fun dispatch(event: HomeViewEvent){ when(event){ HomeViewEvent.onStartEvent -> { getBannerList() } HomeViewEvent.onCollectionEvent ->{ //暂时没找到compose里面修改paging3数据的办法 // viewState = viewState.copy(pagingData = viewState.pagingData?. // ) } HomeViewEvent.onSetReLoad -> viewState = viewState.copy(isNeedReload = false) } } ``` #### 4.实现MVI的页面(V) 在```composable```页面使用```model```和触发```intent```。 首先要先注入```ViewModel``` ``` viewModel: HomeViewModel = hiltViewModel() ``` 然后通过页面状态去获取数据 ```kotlin val viewState = viewModel.viewState val bannerList = viewState.bannerList val articleDataList = viewState.pagingData?.collectAsLazyPagingItems() ``` 最后通过```viewModel```调用```dispatch```去派遣任务 ```kotlin DisposableEffect(Unit){ viewModel.dispatch(HomeViewEvent.onStartEvent) onDispose { } } //... viewModel.dispatch(HomeViewEvent.onCollectionEvent, collectionId = it) ``` #### 5.修改model的值 修改```model```的值,让页面状态```state```发生变化,刷新页面```UI```。 刷新基本数据类型,直接copy对应的值到对应的变量里面去就可以: ```kotlin viewState = viewState.copy(isNeedReload = false) ``` 刷新列表里面实体类的某个值,比如修改```bannerEntity```这个实体类的```id```,同样是要把值```copy```到对应变量,在```copy```的同时使用```mapIndexed```遍历之前的```List```并改变数据: ```kotlin viewState = viewState.copy(projectList = viewState.projectList.mapIndexed { index, bannerEntity -> bannerEntity.copy(id = 3)// } ) ``` 1. bannerEntity.copy(id = 3) :改变id。 2. projectList = viewState.projectList.mapIndexed :将改变后的```List``` ```Copy```到对应的变量里面去。 ### 切换主题 在```MaterialTheme```中,可以随意设置```MaterialTheme```的颜色、字体等,达到切换主题的功能。 1.首先定义多个主题颜色 ```kotlin fun appDarkColors( primary:Color = Color(0xffa9a9a9), primaryVariant: Color = Color(0xffa9a9a9), secondary: Color = Color(0xffd3d3d3), secondaryVariant: Color = Color(0xffd3d3d3), background: Color = Color(0xfff5f5f5), surface: Color = Color(0xFF232323), error: Color = Color(0xFFB00020), onPrimary: Color = Color(0xffffffff), onSecondary: Color = Color(0xFF3A3A3A), onBackground: Color = Color(0xfff5f5f5), onSurface: Color = Color(0xffffffff), onError: Color = Color(0xFFB00020), isLight: Boolean = false ):Colors = Colors( primary, primaryVariant, secondary, secondaryVariant, background, surface, error, onPrimary, onSecondary, onBackground, onSurface, onError, isLight ) fun appLightColors( primary:Color = Color(0xff272a36), primaryVariant: Color = Color(0xff272a36), secondary: Color = Color(0xff000000), secondaryVariant: Color = Color(0xff000000), background: Color = Color(0xffffffff), surface: Color = Color(0xFF232323), error: Color = Color(0xFFB00020), onPrimary: Color = Color(0xFF232323), onSecondary: Color = Color(0xFFD8D7D7), onBackground: Color = Color(0xFF232325), onSurface: Color = Color(0xFF232323), onError: Color = Color(0xFFB00020), isLight: Boolean = false ):Colors = Colors( primary, primaryVariant, secondary, secondaryVariant, background, surface, error, onPrimary, onSecondary, onBackground, onSurface, onError, isLight ) ``` 2.定义并通过状态保存主题的```key``` ```kotlin val themeState : MutableState by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED){mutableStateOf(ThemeKey.Light)} enum class ThemeKey{ Light, // 白天主题 Dark, // 黑夜主题 NewYear, // 新年主题 Grey // 灰色主题 } ``` ```themeState```这个状态因为每次杀死app重新启动都会重置,所以最好是保存到缓存里面。 3.状态发生改变时传入新的主题```key```,通过这个```key```去匹配对应的主题 ```kotlin @Composable fun AppTheme( themeKey: ThemeKey = ThemeKey.Light, isDarkTheme:Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { //判断系统当前主题是否是夜色主题,如果是则直接返回符合夜色主题的颜色 val targetColors = if(isDarkTheme){ appDarkColors() }else{ //如果不是,则判断当前的key来选择符合的颜色 when(themeKey){ ThemeKey.Light -> appLightColors() ThemeKey.Dark -> appDarkColors() else -> appLightColors() } } //添加颜色动画,在主题切换的时候能平滑的过渡,而不是一下就直接切换 //TweenSpec(500) 动画执行时间为500毫秒 val primary = animateColorAsState(targetValue = targetColors.primary, TweenSpec(500)) val primaryVariant = animateColorAsState(targetValue = targetColors.primaryVariant, TweenSpec(500)) val secondary = animateColorAsState(targetValue = targetColors.secondary, TweenSpec(500)) val secondaryVariant = animateColorAsState(targetValue = targetColors.secondaryVariant, TweenSpec(500)) val background = animateColorAsState(targetValue = targetColors.background, TweenSpec(500)) val surface = animateColorAsState(targetValue = targetColors.surface, TweenSpec(500)) val error = animateColorAsState(targetValue = targetColors.error, TweenSpec(500)) val onPrimary = animateColorAsState(targetValue = targetColors.onPrimary, TweenSpec(500)) val onSecondary = animateColorAsState(targetValue = targetColors.onSecondary, TweenSpec(500)) val onBackground = animateColorAsState(targetValue = targetColors.onBackground, TweenSpec(500)) val onSurface = animateColorAsState(targetValue = targetColors.onSurface, TweenSpec(500)) val onError = animateColorAsState(targetValue = targetColors.onError, TweenSpec(500)) val colors:Colors = Colors( primary.value, primaryVariant.value, secondary.value, secondaryVariant.value, background.value, surface.value, error.value, onPrimary.value, onSecondary.value, onBackground.value, onSurface.value, onError.value, themeState.value == ThemeKey.Dark || isSystemInDarkTheme() ) //将切换后的颜色替换掉系统MaterialTheme里面的colors MaterialTheme(colors = colors,content = content) } ``` 如果想要在主题切换的时候过渡平滑一点,可以给颜色添加动画等操作;最后将颜色添加到```MaterialTheme```里面去,达到替换系统默认颜色的效果。 4.改变```themeState```状态值的```key```,引起状态刷新,刷新会重组就会替换到指定```key```的颜色,达到切换主题的效果 ```kotlin themeState.value = if(themeState.value == ThemeKey.Light) ThemeKey.Dark else ThemeKey.Light ``` ### Paging3加载列表数据 在使用```PagingSource```之前,要先在```ViewModel```里面定义对应```model```接收: ```kotlin typealias PagingCollect = Flow> ``` ``` data class HomeViewState( .... var pagingData: PagingCollect? = null, .... ) ``` #### 1.创建```PagingSource``` ```kotlin class HomeArticleDataSource : PagingSource() { companion object{ const val pageSize = 10 //每次加载的条数 } override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { return try { val currentPage = params.key ?: 0 //获取当前的页数,若为空,默认值为 1 var dataList = retrofit.getArticleList(page = currentPage, pageSize) //请求数据 //上一页的key val prevKey: Int? = (currentPage - 1).run { if (this <= 0) {//说明当前是第一页数据 null } else { this } } //下一页的key val nextKey: Int? = when { params.loadSize > pageSize -> {//第一次加载的会多一些 // public val initialLoadSize: Int = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER, // 默认PagingConfig为pager分配初始获取数据的大小为pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER // 所以Pager配置时,如果initialLoadSize不指定,那么第一次加载数据并不是我们定义的pageSize val count = params.loadSize / pageSize count + 1 } !dataList.data!!.over -> {//dataList.data.over 是否加载完毕,如果没有则可以进行下一页,当前页码+1 currentPage + 1 } else -> { null } } //返回结果 LoadResult.Page(dataList.data!!.datas, prevKey, nextKey) }catch (e: Exception){ LoadResult.Error(e) } } } ``` #### 2.ViewMode配置pager获取数据 ```kotlin private val articleDataList = Pager(PagingConfig(pageSize = 10)){ HomeArticleDataSource() }.flow.cachedIn(viewModelScope) ``` #### 3.在composable里面获取并使用 ```kotlin val articleDataList = viewState.pagingData?.collectAsLazyPagingItems() ``` 在```LazyColumn```里面使用 ```kotlin articleDataList?.let { RefreshWidget(lazyPagingItems = articleDataList){ itemsIndexed(articleDataList){ index: Int, value: ArticleEntity? -> ArticleComponents( articleEntity = value!!, onItemClick = { }, onCollectionClick = { viewModel.dispatch(HomeViewEvent.onCollectionEvent, collectionId = it) } ) } } if (viewState.isNeedReload){ //重绘页面? } } ``` 注意,这里的```itemsIndexed```要是```paging```包下的。