accompanist是Jetpack Compose官方提供的一个辅助工具库,以提供那些在Jetpack Compose sdk中目前还没有的功能API。
权限 依赖配置:
1 2 3 4 5 6 7 repositories { mavenCentral() } dependencies { implementation "com.google.accompanist:accompanist-permissions:0.28.0" }
单个权限申请 例如,我们需要获取相机权限,可以通过rememberPermissionState(Manifest.permission.CAMERA)创建一个 PermissionState对象,然后通过PermissionState.status.isGranted判断权限是否已获取,并通过调用permissionState.launchPermissionRequest()来申请权限。
1 2 3 4 5 <manifest xmlns:android ="http://schemas.android.com/apk/res/android" > <uses-permission android:name ="android.permission.CAMERA" /> .... </manifest >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @OptIn(ExperimentalPermissionsApi::class) @Composable fun PermissionExample () { val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) if (cameraPermissionState.status.isGranted) { Text("Camera permission Granted" ) } else { Column(horizontalAlignment = Alignment.CenterHorizontally) { val textToShow = if (cameraPermissionState.status.shouldShowRationale) { "未获取相机授权将导致该功能无法正常使用。" } else { "该功能需要使用相机权限,请点击授权。" } Text(textToShow) Spacer(Modifier.height(8. dp)) Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { Text("请求权限" ) } } } }
多个权限申请 类似的,通过rememberMultiplePermissionsState获取到 PermissionsState之后, 通过调用permissionsState.launchMultiplePermissionRequest()来请求权限。
1 2 3 4 5 6 7 <manifest xmlns:android="http://schemas.android.com/apk/res/android" > <!-- 别忘了在清单文件中添加权限声明 --> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> ... </manifest>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 @OptIn(ExperimentalPermissionsApi::class) @Composable fun MultiplePermissionsExample () { val multiplePermissionsState = rememberMultiplePermissionsState( listOf( android.Manifest.permission.READ_EXTERNAL_STORAGE, android.Manifest.permission.CAMERA, ) ) if (multiplePermissionsState.allPermissionsGranted) { Text("相机和读写文件权限已授权!" ) } else { Column(modifier = Modifier.padding(10. dp)) { Text( getTextToShowGivenPermissions( multiplePermissionsState.revokedPermissions, multiplePermissionsState.shouldShowRationale ), fontSize = 16. sp ) Spacer(Modifier.height(8. dp)) Button(onClick = { multiplePermissionsState.launchMultiplePermissionRequest() }) { Text("请求权限" ) } multiplePermissionsState.permissions.forEach { Divider() Text(text = "权限名:${it.permission} \n " + "授权状态:${it.status.isGranted} \n " + "需要解释:${it.status.shouldShowRationale} " , fontSize = 16. sp) } Divider() } } } @OptIn(ExperimentalPermissionsApi::class) private fun getTextToShowGivenPermissions ( permissions: List <PermissionState >, shouldShowRationale: Boolean ) : String { val size = permissions.size if (size == 0 ) return "" val textToShow = StringBuilder().apply { append("以下权限:" ) } for (i in permissions.indices) { textToShow.append(permissions[i].permission).apply { if (i == size - 1 ) append(" " ) else append(", " ) } } textToShow.append( if (shouldShowRationale) { " 需要被授权,以保证应用功能正常使用." } else { " 被拒绝使用. 应用功能将不能正常使用." } ) return textToShow.toString() }
以上代码请求了两个权限,所以运行后系统会分别弹出两次授权弹窗。
定位权限申请:
1 2 3 4 <manifest xmlns:android ="http://schemas.android.com/apk/res/android" > <uses-permission android:name ="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name ="android.permission.ACCESS_BACKGROUND_LOCATION" /> </manifest >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @OptIn(ExperimentalPermissionsApi::class) @Composable fun LocationPermissionsExample () { val locationPermissionsState = rememberMultiplePermissionsState( listOf( android.Manifest.permission.ACCESS_COARSE_LOCATION, android.Manifest.permission.ACCESS_FINE_LOCATION, ) ) if (locationPermissionsState.allPermissionsGranted) { Text("定位权限已授权" ) } else { Column(horizontalAlignment = Alignment.CenterHorizontally) { val textToShow = if (locationPermissionsState.shouldShowRationale) { "无法获取定位权限将导致应用功能无法正常使用" } else { "该功能需要定位授权" } Text(text = textToShow) Spacer(Modifier.height(8. dp)) Button(onClick = { locationPermissionsState.launchMultiplePermissionRequest() }) { Text("请求授权" ) } } } }
注意:定位权限在 Android 10 以后就被拆分为前台权限Manifest.permission.ACCESS_FINE_LOCATION和后台权限Manifest.permission.ACCESS_BACKGROUND_LOCATION,如果要申请后台权限,首先minSdk配置必须是29以上(也就是Android 10.0,不过这一点很多公司应该不会选择,因为兼容的手机版本高了)且在 Android 11 后两个权限不能同时申请,也就是说要先请求前台权限之后才能申请后台权限。
SystemUiController 该库可以设置应用顶部状态栏和底部导航栏的颜色。
1 2 3 dependencies { implementation "com.google.accompanist:accompanist-systemuicontroller:0.28.0" }
例如,可以设置状态栏和导航栏的颜色随着手机系统设置的主题改变而变化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Composable fun MyComposeApplicationTheme ( isDarkTheme: Boolean = isSystemInDarkTheme() , dynamicColor: Boolean = true , content: @Composable () -> Unit ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } isDarkTheme -> DarkColorScheme else -> LightColorScheme } val systemUiController = rememberSystemUiController() SideEffect { systemUiController.setSystemBarsColor( color = if (isDarkTheme) Color.Black else Color.White, ) } MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) }
也可以设置icons的颜色
1 2 3 4 5 6 7 8 9 10 11 val systemUiController = rememberSystemUiController()val useDarkIcons = !isSystemInDarkTheme() DisposableEffect(systemUiController, useDarkIcons) { systemUiController.setSystemBarsColor( color = Color.Transparent, darkIcons = useDarkIcons ) }
此外可以使用 systemUiController.setStatusBarColor() 和 systemUiController.setNavigationBarColor() 分别设置状态栏和导航栏的颜色。
如果需要其他组件跟随系统主题颜色变化,最好使用MaterialTheme.colorScheme中的颜色属性。
对标传统View中的ViewPager组件。
1 2 3 dependencies { implementation "com.google.accompanist:accompanist-pager:0.28.0" }
1 2 3 4 5 6 7 8 9 10 11 @OptIn(ExperimentalPagerApi::class) @Composable fun HorizontalPagerExample () { HorizontalPager(count = 10 ) { page -> Box(Modifier.background(colors[page % colors.size]).fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "Page: $page " ,color = Color.White,fontSize = 22. sp) } } }
在模拟器 中运行的时候,有时会出现卡住在中间的情况,不知道是不是模拟器的原因: 如果想跳转到指定页面,可以使用 pagerState.scrollToPage(index) 或者pagerState.animateScrollToPage(index) 这两个挂起方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @OptIn(ExperimentalPagerApi::class) @Composable fun HorizontalPagerExample2 () { val scope = rememberCoroutineScope() val pagerState = rememberPagerState() Column(horizontalAlignment = Alignment.CenterHorizontally) { HorizontalPager( count = 10 , state = pagerState, modifier = Modifier.height(300. dp) ) { page -> Box(Modifier.background(colors[page % colors.size]).fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "Page: $page " , color = Color.White, fontSize = 22. sp) } } Button(onClick = { scope.launch { pagerState.animateScrollToPage(2 ) } }) { Text(text = "跳转到第3页" ) } } }
使用类似HorizontalPager
1 2 3 4 5 6 7 8 9 10 11 @OptIn(ExperimentalPagerApi::class) @Composable fun VerticalPagerExample () { VerticalPager(count = 10 ) { page -> Box(Modifier.background(colors[page % colors.size]).fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "Page: $page " ,color = Color.White,fontSize = 22. sp) } } }
HorizontalPager 和 VerticalPager 背后是基于 LazyRow 和 LazyColumn 实现的,不在当前屏幕显示的页面会从容器中移除。
contentPadding HorizontalPager 和 VerticalPager 支持设置 contentPadding , 如果设置start padding,则当前页的开头会显示上一页的部分内容,如果设置horizontal padding,则当前页的开头和结尾会分别显示上一页和下一页的部分内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @OptIn(ExperimentalPagerApi::class) @Composable fun HorizontalPagerExample () { HorizontalPager( count = 10 , contentPadding = PaddingValues(start = 64. dp), ) { page -> Box(Modifier.background(colors[page % colors.size]).fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "Page: $page " , color = Color.White, fontSize = 22. sp) } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @OptIn(ExperimentalPagerApi::class) @Composable fun HorizontalPagerExample () { HorizontalPager( count = 10 , contentPadding = PaddingValues(horizontal = 64. dp), ) { page -> Box(Modifier.background(colors[page % colors.size]).fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "Page: $page " , color = Color.White, fontSize = 22. sp) } } }
item滚动效果 Pager的作用域内允许应用轻松引用currentPage和currentPageOffset 这些值来计算动画效果。官方提供了一个calculateCurrentOffsetForPage()扩展函数来计算给定页面的偏移量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @OptIn(ExperimentalPagerApi::class) @Composable fun ItemScrollEffect () { HorizontalPager(count = 10 ) { page -> Card( Modifier.graphicsLayer { val pageOffset = calculateCurrentOffsetForPage(page).absoluteValue lerp( start = 0.85f , stop = 1f , fraction = 1f - pageOffset.coerceIn(0f , 1f ) ).also { scale -> scaleX = scale scaleY = scale } alpha = lerp( start = 0.5f , stop = 1f , fraction = 1f - pageOffset.coerceIn(0f , 1f ) ) } ) { Box(Modifier .background(colors[page % colors.size]) .fillMaxWidth(0.85f ).height(500. dp), contentAlignment = Alignment.Center ) { Text(text = "Page: $page " , color = Color.White, fontSize = 22. sp) } } } }
注:上面代码中使用到的函数lerp需要单独添加一个依赖库androidx.compose.ui:ui-util
监听页面切换 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 val pagerState = rememberPagerState()LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage }.collect { page -> } } VerticalPager( count = 10 , state = pagerState, ) { page -> Text(text = "Page: $page " ) }
Accompanist库提供了HorizontalPagerIndicator 和 VerticalPagerIndicator组件可以分别搭配HorizontalPager 和 VerticalPager 使用,需要单独导入依赖库accompanist-pager-indicators,当然,你也可以自己监听页面切换状态写一个。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 @OptIn(ExperimentalPagerApi::class) @Composable fun HorizontalPagerIndicatorExample () { Scaffold( modifier = Modifier.fillMaxSize() ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { val pagerState = rememberPagerState() HorizontalPager( count = 10 , state = pagerState, contentPadding = PaddingValues(horizontal = 32. dp), modifier = Modifier .weight(1f ) .fillMaxWidth(), ) { page -> Box(Modifier .background(colors[page % colors.size]) .fillMaxWidth() .height(500. dp), contentAlignment = Alignment.Center ) { Text(text = "Page: $page " , color = Color.White, fontSize = 22. sp) } } HorizontalPagerIndicator( pagerState = pagerState, modifier = Modifier .align(Alignment.CenterHorizontally) .padding(16. dp), ) } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @OptIn(ExperimentalPagerApi::class) @Composable fun VerticalPagerIndicatorExample () { Scaffold( modifier = Modifier.fillMaxSize() ) { padding -> val pagerState = rememberPagerState() Row(verticalAlignment = Alignment.CenterVertically) { VerticalPager( count = 10 , state = pagerState, contentPadding = PaddingValues(vertical = 32. dp), modifier = Modifier .weight(1f ) .height(300. dp) ) { page -> Box(Modifier .background(colors[page % colors.size]) .fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "Page: $page " , color = Color.White, fontSize = 22. sp) } } VerticalPagerIndicator( pagerState = pagerState, modifier = Modifier.padding(16. dp) ) } } }
HorizontalPager 可以结合 TabRow 或 ScrollableTabRow 使用,但是好像没有找到垂直的TabRow可以结合VerticalPager使用的,需要的话只能自己写一个了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 @OptIn(ExperimentalPagerApi::class) @Composable fun PagerWithScrollableTabRow () { Column { val pagerState = rememberPagerState() val titles = ('A' ..'Z' ).toList() ScrollableTabRow( selectedTabIndex = pagerState.currentPage, indicator = { tabPositions -> TabRowDefaults.Indicator( Modifier.pagerTabIndicatorOffset(pagerState, tabPositions) ) } ) { val scope = rememberCoroutineScope() titles.forEachIndexed { index, title -> Tab( text = { Text("$title " ) }, selected = pagerState.currentPage == index, onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, ) } } HorizontalPager( count = titles.size, state = pagerState, modifier = Modifier .weight(1f ) .fillMaxWidth() ) { page -> Box(Modifier .background(colors[page % colors.size]) .fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "Page: ${titles[page]} " , color = Color.White, fontSize = 22. sp) } } } }
FlowLayout 1 2 3 dependencies { implementation "com.google.accompanist:accompanist-flowlayout:0.28.0" }
可以自动换行的流式布局,使用也非常简单,分别提供了两种FlowRow和FlowColumn布局,属性和Row跟Column组件的用法都差不多
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 @Composable fun FlowRowExample () { FlowRow( Modifier.padding(15. dp), mainAxisSpacing = 8. dp, crossAxisSpacing = 8. dp ) { SampleContent() } } @Composable fun FlowColumnExample () { FlowColumn( Modifier.padding(15. dp), mainAxisSpacing = 8. dp, crossAxisSpacing = 8. dp ) { SampleContent2() } } @Composable internal fun SampleContent () { repeat(30 ) { it -> Box( modifier = Modifier .width(Random.nextInt(150 ).coerceAtLeast(50 ).dp) .clip(RoundedCornerShape(50 )) .background(Color.Green) , contentAlignment = Alignment.Center, ) { Text(it.toString(), fontSize = 22. sp) } } } @Composable internal fun SampleContent2 () { repeat(30 ) { it -> Box( modifier = Modifier .width(50. dp) .height(Random.nextInt(150 ).coerceAtLeast(50 ).dp) .clip(RoundedCornerShape(50 )) .background(Color.Green) , contentAlignment = Alignment.Center, ) { Text(it.toString(), fontSize = 22. sp) } } }
WebView 1 2 3 dependencies { implementation "com.google.accompanist:accompanist-webview:0.28.0" }
使用超级简单:
1 2 3 4 5 @Composable fun WebViewExample () { val state = rememberWebViewState("https://m.baidu.com" ) WebView(state) }
开启JavaScript:
1 2 3 4 5 WebView( state = webViewState, onCreated = { it.settings.javaScriptEnabled = true } )
设置是否捕获拦截返回按键:
1 2 3 4 5 WebView( state, onCreated = { it.settings.javaScriptEnabled = true }, captureBackPresses = true )
这个要夸一夸了,captureBackPresses这个值默认为true, 也就是默认会捕获拦截返回按键,相信做过原生与H5混合开发的都知道,传统的View体系中的WebView组件是不拦截返回键的,这就会导致你在网页里点击了好几层看的正高兴时,一不小心按了手机的back键就会直接关闭退出整个网页!简直受不了!解决这个尴尬的问题往往需要开发者在持有WebView的Activity中自己手动拦截KeyEvent事件进行处理,现在好了,这个问题终于不用我们去处理了。
还可以自定义WebView
1 2 3 4 5 WebView( ... factory = { context -> CustomWebView(context) } )
navigation-animation 1 2 3 dependencies { implementation "com.google.accompanist:accompanist-navigation-animation:0.28.0" }
navigation-animation 库提供了一种可为Navigation Compose添加自定义转场动画的方法。
1 2 3 4 5 6 7 8 9 10 11 val navController = rememberAnimatedNavController() AnimatedNavHost(navController) { composable("routeName" , enterTransition = {...}, exitTransition = {...}, popEnterTransition = {...}, popExitTransition = {...} ) { SomeScreen(navController) } }
在AnimatedNavHost 中的每个composable中通过enterTransition可以设置当前页面的入场效果,通过exitTransition参数可以设置当前页面的离场效果。
其中,在每个Transition参数的AnimatedContentScope<NavBackStackEntry> 作用域中,可通过 initialState 和 targetState 属性来精确的自定义要运行的Transition效果(可使用特定的转场Api 如slideIntoContainer和slideOutOfContainer)。
这里initialState 和 targetState 的含义:
initialState:转场动画的起始状态,从哪个页面开始的
targetState:转场动画的结束状态,结束后是跳转到哪个页面的
在composable配置中四个Transition参数的含义:
enterTransition:控制当前页面作为 targetState 时的入场动画 (到达的屏幕)
exitTransition:控制当前页面作为 initialState 时的离场动画 (出发的屏幕)
popEnterTransition:控制当前页面作为targetState因弹栈而入场动画 (别的页面被弹出后,当前页面被显示),如果不设置,则默认值同enterTransition。
popExitTransition:控制当前页面作为initialState弹栈时的离场动画 (当前页面被弹出),如果不设置,默认值同exitTransition。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 @OptIn(ExperimentalAnimationApi::class) @Composable fun ExperimentalAnimationNav () { val navController = rememberAnimatedNavController() AnimatedNavHost(navController, startDestination = "Blue" ) { composable("Blue" , enterTransition = { initialState.isRoute("Red" ).transition(slideInFromRight) }, exitTransition = { targetState.isRoute("Red" ).transition(slideOutToLeft) }, popEnterTransition = { initialState.isRoute("Red" ).transition(slideInFromLeft) }, popExitTransition = { targetState.isRoute("Red" ).transition(slideOutToRight) } ) { BlueScreen(navController) } composable("Red" , enterTransition ={initialState.by (mapOf("Blue" to slideInFromRight, "Green" to slideInFromBottom )) }, exitTransition ={targetState.by (mapOf("Blue" to slideOutToLeft, "Green" to slideOutToTop)) }, popEnterTransition ={initialState.by (mapOf("Blue" to slideInFromLeft, "Green" to slideInFromTop )) }, popExitTransition ={targetState.by (mapOf("Blue" to slideOutToRight, "Green" to slideOutToBottom )) } ) { RedScreen(navController) } navigation( startDestination = "Green" , route = "Inner" , enterTransition = { expandIn(animationSpec = tween700ms()) }, exitTransition = { shrinkOut(animationSpec = tween700ms()) } ) { composable( "Green" , enterTransition = { initialState.isRoute("Red" ).transition(slideInFromBottom) }, exitTransition = { targetState.isRoute("Red" ).transition(slideOutToTop) }, popEnterTransition = { initialState.isRoute("Red" ).transition(slideInFromTop) }, popExitTransition = { targetState.isRoute( "Red" ).transition(slideOutToBottom) } ) { GreenScreen(navController) } } } } @ExperimentalAnimationApi @Composable fun AnimatedVisibilityScope.BlueScreen (navController: NavHostController ) { Column(Modifier.fillMaxSize().background(Color.Blue)) { val buttonModifier = Modifier.wrapContentWidth().then(Modifier.align(Alignment.CenterHorizontally)) Spacer(Modifier.height(25. dp)) NavigateButton("Navigate Horizontal" , buttonModifier) { navController.navigate("Red" ) } Spacer(Modifier.height(25. dp)) NavigateButton("Navigate Expand" ,buttonModifier) { navController.navigate("Inner" ) } MyText("Blue" , Modifier.weight(1f )) PopBackButton(navController) } } @ExperimentalAnimationApi @Composable fun AnimatedVisibilityScope.RedScreen (navController: NavHostController ) { Column(Modifier.fillMaxSize().background(Color.Red)) { val buttonModifier = Modifier.wrapContentWidth().then(Modifier.align(Alignment.CenterHorizontally)) Spacer(Modifier.height(25. dp)) NavigateButton("Navigate Horizontal" , buttonModifier) { navController.navigate("Blue" ) } Spacer(Modifier.height(25. dp)) NavigateButton("Navigate Vertical" , buttonModifier) { navController.navigate("Green" ) } MyText("Red" , Modifier.weight(1f )) PopBackButton(navController) } } @ExperimentalAnimationApi @Composable fun AnimatedVisibilityScope.GreenScreen (navController: NavHostController ) { Column(Modifier.fillMaxSize().background(Color.Green)) { val buttonModifier = Modifier.wrapContentWidth().then(Modifier.align(Alignment.CenterHorizontally)) Spacer(Modifier.height(25. dp)) NavigateButton("Navigate to Red" , buttonModifier) { navController.navigate("Red" ) } MyText("Green" , Modifier.weight(1f )) PopBackButton(navController) } } @ExperimentalAnimationApi @Composable fun AnimatedVisibilityScope.MyText (text: String , modifier: Modifier = Modifier) { Text(text, modifier.fillMaxWidth().animateEnterExit( enter = fadeIn(animationSpec = tween(250 , delayMillis = 450 )), exit = ExitTransition.None ), color = Color.White, fontSize = 80. sp, textAlign = TextAlign.Center ) } @Composable fun NavigateButton (text: String , modifier: Modifier = Modifier, listener: () -> Unit = {}) { Button( onClick = listener, colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray), modifier = modifier ) { Text(text = text) } } @Composable fun PopBackButton (navController: NavController ) { if (navController.currentBackStackEntry == LocalLifecycleOwner.current && navController.previousBackStackEntry != null ) { Button( onClick = { navController.popBackStack() }, colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray), modifier = Modifier.fillMaxWidth() ) { Text(text = "Go to Previous screen" ) } } } @Stable fun <T> tween700ms () : TweenSpec<T> = tween(700 )@OptIn(ExperimentalAnimationApi::class) val AnimatedContentScope<NavBackStackEntry>.slideInFromBottom get () = slideIntoContainer(SlideDirection.Up, animationSpec = tween700ms()) @OptIn(ExperimentalAnimationApi::class) val AnimatedContentScope<NavBackStackEntry>.slideInFromTop get () = slideIntoContainer(SlideDirection.Down, animationSpec = tween700ms()) @OptIn(ExperimentalAnimationApi::class) val AnimatedContentScope<NavBackStackEntry>.slideInFromRight get () = slideIntoContainer(SlideDirection.Left, animationSpec = tween700ms()) @OptIn(ExperimentalAnimationApi::class) val AnimatedContentScope<NavBackStackEntry>.slideInFromLeft get () = slideIntoContainer(SlideDirection.Right, animationSpec = tween700ms()) @OptIn(ExperimentalAnimationApi::class) val AnimatedContentScope<NavBackStackEntry>.slideOutToTop get () = slideOutOfContainer(SlideDirection.Up, animationSpec = tween700ms()) @OptIn(ExperimentalAnimationApi::class) val AnimatedContentScope<NavBackStackEntry>.slideOutToBottom get () = slideOutOfContainer(SlideDirection.Down, animationSpec = tween700ms()) @OptIn(ExperimentalAnimationApi::class) val AnimatedContentScope<NavBackStackEntry>.slideOutToLeft get () = slideOutOfContainer(SlideDirection.Left, animationSpec = tween700ms()) @OptIn(ExperimentalAnimationApi::class) val AnimatedContentScope<NavBackStackEntry>.slideOutToRight get () = slideOutOfContainer(SlideDirection.Right, animationSpec = tween700ms()) fun NavBackStackEntry.isRoute (target: String ) : Boolean = this .destination.route == targetfun <T> Boolean .transition (transition: T ) : T? = if (this ) transition else null fun <T> NavBackStackEntry.by (map: Map <String , T>) : T? = map[this .destination.route]
对于每个Transition参数,如果返回null,则将使用父导航元素的Transition,从而允许您在导航图级别设置一组全局转换,该转换将应用于该图中的每个composable。如果父导航也返回null,则会一直向上寻找直到根AnimatedNavHost,它控制所有目标和未指定目标的嵌套导航图的全局转换。
注意:这意味着如果想要在目标页面之间立即跳转,它应该返回EnterTransition.None或ExitTransition.None,表示不应该运行任何转场效果,而不是返回null。
通过Modifier.zIndex可指定高度层级(值越大在屏幕上层级越高)转场动画时会根据层级进行覆盖(高层级的覆盖低层级的):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 object Destinations { const val First = "first" const val Second = "second" const val Third = "third" } @OptIn(ExperimentalAnimationApi::class) @Composable fun NavTestZIndexScreen () { val navController = rememberAnimatedNavController() AnimatedNavHost(navController, Destinations.First, Modifier.fillMaxSize()) { composable( Destinations.First, enterTransition = { NavigationTransition.slideInFromBottom }, exitTransition = { NavigationTransition.IdentityExit }, popEnterTransition = { NavigationTransition.IdentityEnter }, popExitTransition = { NavigationTransition.slideOutToBottom }, ) { Button(onClick = { navController.navigate(Destinations.Second) }) { Text(text = "First" , fontSize = 22. sp) } } composable( route = Destinations.Second, enterTransition = { NavigationTransition.slideInFromBottom }, exitTransition = { NavigationTransition.IdentityExit }, popEnterTransition = { NavigationTransition.IdentityEnter }, popExitTransition = { NavigationTransition.slideOutToBottom }, ) { Button(onClick = { navController.navigate(Destinations.Third) }, colors = ButtonDefaults.buttonColors(backgroundColor = Color.Yellow), modifier = Modifier.zIndex(100f ) ) { Text(text = "Second" , fontSize = 22. sp) } } composable( route = Destinations.Third, enterTransition = { NavigationTransition.slideInFromBottom }, exitTransition = { NavigationTransition.IdentityExit }, popEnterTransition = { NavigationTransition.IdentityEnter }, popExitTransition = { NavigationTransition.slideOutToBottom }, ) { Button(onClick = { navController.popBackStack() }, colors = ButtonDefaults.buttonColors(backgroundColor = Color.Blue), modifier = Modifier.zIndex(200f ) ) { Text(text = "Third" , fontSize = 22. sp) } } } } object NavigationTransition { private val animation: FiniteAnimationSpec<IntOffset> = tween( easing = LinearOutSlowInEasing, durationMillis = 1000 , ) val IdentityEnter = slideInVertically( initialOffsetY = { -1 }, animationSpec = animation ) val IdentityExit = slideOutVertically( targetOffsetY = { -1 }, animationSpec = animation ) var slideInFromBottom = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }, animationSpec = animation) var slideOutToBottom = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }, animationSpec = animation) }
Insets 1 2 3 4 5 6 dependencies { implementation "com.google.accompanist:accompanist-insets-ui:0.28.0" }
insets库主要用来调整系统状态栏、导航栏等的padding以更加友好的适配屏幕内的组件。本来accompanist提供了相应的支持库,但是现在已经标记为deprecated废弃了,因为其功能已经集成到了compose ui的sdk中了,不需要单独使用accompanist版本的insets库了。
相关设置: 1)在Activity中设置WindowCompat.setDecorFitsSystemWindows(window, false)禁止dector适应系统,交由我们自己去适配,同时需要使用 System UI Controller 库将系统栏的背景设为透明。
1 2 3 4 5 override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false ) }
2)如果需要支持IME键盘相关的insets支持,需要在清单文件中添加如下配置:
1 2 3 4 <activity android:name =".MyActivity" android:windowSoftInputMode ="adjustResize" > </activity >
3)修改Theme.kt文件
1 2 3 4 5 val systemUiController = rememberSystemUiController() SideEffect { systemUiController.setSystemBarsColor(Color.Transparent) }
此时可能会发现页面中的组件出现一些被遮挡的情况,如顶部的TopAppBar: 在添加accompanist-insets-ui依赖库之后,就可以使用其提供的com.google.accompanist.insets.ui.TopAppBar,它可以设置一个contentPadding,将contentPadding设置为WindowInsets.statusBars.asPaddingValues()即可
1 2 3 4 5 6 7 8 9 10 11 12 @Composable fun ScaffoldExample () { Scaffold( topBar = { TopAppBar( title = { Text("首页" , color = MaterialTheme.colors.onPrimary) }, contentPadding = WindowInsets.statusBars.asPaddingValues(), ) }, ... ) }
同样的可以修改bottomBar将BottomNavigation替换成accompanist-insets-ui中的对应组件:
1 2 3 4 5 6 7 Scaffold( topBar = {...}, bottomBar = { BottomNavigation(contentPadding = WindowInsets.navigationBars.asPaddingValues()) {...} } ... }
常用的系统 inset bar:
WindowInsets.navigationBars
WindowInsets.statusBars
WindowInsets.ime
WindowInsets.systemBars
WindowInsets.displayCutout(挖孔)
WindowInsets.waterfall
WindowInsets.captionBar
如果根部局不是一个Scaffold而是其他Composable组件,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false ) setContent { MyComposeApplicationTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { Box(Modifier.background(Color.Red)) { Text("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" , fontSize = 20. sp) } } } } }
那么也可能会出现被状态栏遮挡内容的情况: 可以使用Modifier.windowInsetsPadding(WindowInsets.statusBars)来解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false ) setContent { MyComposeApplicationTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { Box(Modifier.background(Color.Red).windowInsetsPadding(WindowInsets.statusBars)) { Text("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" , fontSize = 20. sp) } } } } }
或者直接设置Modifier.systemBarsPadding() 上下bar同时适配:
1 2 3 Box(Modifier.background(Color.Red).systemBarsPadding()) { Text("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" , fontSize = 20. sp) }
常用的系统 inset padding 修饰符:
Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top))
Modifier.systemBarsPadding()
Modifier.statusBarsPadding()
Modifier.navigationBarsPadding()
Modifier.imePadding()
Modifier.displayCutoutPadding()
Modifier.navigationBarsPadding().imePadding()
由于状态栏被设置成了透明,所以另一种方案是可以放一个假的状态栏在我们自己的布局的顶部,例如:
1 2 3 4 5 6 7 8 9 10 11 Column { Spacer( Modifier .background(Color.Red.copy(alpha = 0.7f )) .windowInsetsTopHeight(WindowInsets.statusBars) .fillMaxWidth()) Box(Modifier.background(Color.Red).fillMaxSize()) { Text("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" , fontSize = 20. sp) } }
这样也可以达到类似的效果。
常用的系统 inset height 修饰符:
Modifier.windowInsetsTopHeight(WindowInsets.statusBars)
Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)
Modifier.windowInsetsStartWidth(WindowInsets.navigationBars) / Modifier.windowInsetsEndWidth(WindowInsets.navigationBars)
WindowInsets.statusBars.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top).asPaddingValues()
WindowInsets.ime.getBottom(LocalDensity.current)
IME相关适配: 来看以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @OptIn(ExperimentalLayoutApi::class) @Composable fun IMEPaddingExample () { val listItems = remember { mutableStateListOf<String>().apply { addAll(('A' ..'Z' ).toList().map { "$it " .repeat(15 ) }) } } Column(Modifier.systemBarsPadding()) { Box(Modifier.weight(1f )) { LazyColumn( modifier = Modifier.fillMaxSize(), reverseLayout = true , verticalArrangement = Arrangement.spacedBy(10. dp) ) { items(listItems) { it -> Card { Text(it, Modifier.fillMaxWidth().height(50. dp), fontSize = 22. sp) } } } } var value by remember { mutableStateOf("" ) } TextField( value = value, onValueChange = { value = it}, modifier = Modifier.fillMaxWidth(), placeholder = { Text("IME is Visible: ${WindowInsets.isImeVisible} " )}, textStyle = TextStyle(fontSize = 20. sp) ) } }
运行以上代码会发现输入框被键盘遮挡:
注意:以上代码在清单文件中配置了Activity标签的android:windowSoftInputMode="adjustResize" 属性,如果windowSoftInputMode配置的属性值是adjustPan则以上代码输入框不会被键盘遮挡。
所以第一种解决方案就是设置android:windowSoftInputMode="adjustPan" 另外一种方法是我们可以通过Modifier.imePadding()或者Modifier.navigationBarsPadding().imePadding()修饰符来解决:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @OptIn(ExperimentalLayoutApi::class) @Composable fun IMEPaddingExample () { val listItems = remember { mutableStateListOf<String>().apply { addAll(('A' ..'Z' ).toList().map { "$it " .repeat(15 ) }) } } Column(Modifier.systemBarsPadding()) { Box(Modifier.weight(1f )) { LazyColumn( modifier = Modifier.fillMaxSize(), reverseLayout = true , verticalArrangement = Arrangement.spacedBy(10. dp) ) { items(listItems) { it -> Card { Text(it, Modifier.fillMaxWidth().height(50. dp), fontSize = 22. sp) } } } } var value by remember { mutableStateOf("" ) } TextField( value = value, onValueChange = { value = it}, modifier = Modifier.fillMaxWidth().imePadding(), placeholder = { Text("IME is Visible: ${WindowInsets.isImeVisible} " )}, textStyle = TextStyle(fontSize = 20. sp) ) } }
另外,在以上代码中使用了WindowInsets.isImeVisible这个api可以很方便的获取键盘显示隐藏的状态,这相比传统View体系真的要方便的多了,要知道在以前根本没有可靠的方法来判断键盘的显示和隐藏状态。
在API 30+以上的设备上,支持使用Modifier.imeNestedScroll()来控制键盘的显示隐藏和LazyList列表进行联动效果的动画:
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @OptIn(ExperimentalLayoutApi::class) @Composable fun IMEScrollAnimationExample () { val listItems = remember { mutableStateListOf<String>().apply { addAll(('A' ..'Z' ).toList().map { "$it " .repeat(15 ) }) } } Column(Modifier.statusBarsPadding().navigationBarsPadding()) { LazyColumn( modifier = Modifier.weight(1f ).imeNestedScroll(), reverseLayout = true , verticalArrangement = Arrangement.spacedBy(10. dp) ) { items(listItems) { it -> Card { Text(it, Modifier.fillMaxWidth().height(50. dp), fontSize = 22. sp) } } } var value by remember { mutableStateOf("" ) } TextField( value = value, onValueChange = { value = it}, modifier = Modifier.fillMaxWidth().imePadding(), placeholder = { Text("IME is Visible: ${WindowInsets.isImeVisible} " )}, textStyle = TextStyle(fontSize = 20. sp) ) } }
Safe Inset Api 什么是Safe Inset Api,比如屏幕顶部是显示相机挖孔屏的手机设备,我们的应用中的组件内容不应当绘制到该区域,因为该区域是看不到界面的。所以需要使用系统提供的一些api,在组件布局时绕开该区域的显示。
常用的Safe Insets:
WindowInsets.safeDrawing
WindowInsets.safeGestures
WindowInsets.safeContent
常用的Safe Insets Padding:
Modifier.safeDrawingPadding()
Modifier.safeContentPadding()
Modifier.safeGesturesPadding()
例如,以下代码中,第一层的Box组件排除了系统顶部状态栏区域的padding,第二层Box组件排除挖孔位置的padding区域,这样就使我们绕开了顶部状态栏和挖孔区
接下来就可以在第三层中“安全”地绘制我们真正的组件内容了:
在compose.material3的许多组件中都内置了对inset的支持,所以未来最好还是使用Material3的组件而不是Material1的。
constraintlayout-compose constraintlayout-compose是官方提供compose版本的Constraintlayout约束布局组件,但是这个库不在Accompanist组件包中,而是单独提供了一个依赖:
1 2 3 dependencies { implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1" }
ConstraintLayout官网介绍:https://developer.android.google.cn/jetpack/compose/layouts/constraintlayout
使用非常简单, 首先通过 createRefs() 或 createRefFor() 创建引用,ConstraintLayout 中的每个元素都需要关联一个引用,然后使用 Modifier.constrainAs() 修饰符提供约束,将引用作为它的参数进行关联, 在 lambda 中指定其约束条件。约束条件是使用 linkTo() 或其他有用的方法指定的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Composable fun ConstraintLayoutExp01 () { ConstraintLayout { val (button, text) = remember { createRefs() } Button( onClick = { }, modifier = Modifier.constrainAs(button) { top.linkTo(parent.top, margin = 16. dp) centerHorizontallyTo(parent) } ) { Text("Button" ) } Text("Text" , Modifier.constrainAs(text) { top.linkTo(button.bottom, margin = 16. dp) centerHorizontallyTo(parent) }) } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 @Composable fun ConstraintLayoutExp02 () { ConstraintLayout( Modifier .width(300. dp) .height(100. dp) .padding(10. dp) ) { val (headImg, nameText, descText) = remember { createRefs() } Image( painter = painterResource(id = R.drawable.ic_head), contentDescription = null , modifier = Modifier.constrainAs(headImg) { top.linkTo(parent.top) bottom.linkTo(parent.bottom) start.linkTo(parent.start) } ) Text( text = "用户名" , fontSize = 16. sp, maxLines = 1 , modifier = Modifier.constrainAs(nameText) { top.linkTo(headImg.top) start.linkTo(headImg.end, 10. dp) } ) Text( text = "个人描述个人描述个人描述个人描述个人描述个人描述aaa" , fontSize = 14. sp, color = Color.Gray, fontWeight = FontWeight.Light, modifier = Modifier.constrainAs(descText) { top.linkTo(nameText.bottom, 5. dp) start.linkTo(headImg.end, 10. dp) end.linkTo(parent.end, 5. dp) width = Dimension.fillToConstraints } ) } }
Barrier分界线使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 @Composable fun BarrierDemo () { ConstraintLayout( modifier = Modifier .width(400. dp) .padding(10. dp) ) { val (usernameTextRef, passwordTextRef, usernameInputRef, passWordInputRef, dividerRef) = remember { createRefs() } val barrier = createEndBarrier(usernameTextRef, passwordTextRef) Text( text = "用户名" , fontSize = 14. sp, textAlign = TextAlign.Left, modifier = Modifier .constrainAs(usernameTextRef) { top.linkTo(parent.top) start.linkTo(parent.start) } ) Divider( Modifier .fillMaxWidth() .constrainAs(dividerRef) { top.linkTo(usernameTextRef.bottom) bottom.linkTo(passwordTextRef.top) }) Text( text = "密码" , fontSize = 14. sp, modifier = Modifier .constrainAs(passwordTextRef) { top.linkTo(usernameTextRef.bottom, 19. dp) start.linkTo(parent.start) } ) OutlinedTextField( value = "" , onValueChange = {}, modifier = Modifier.constrainAs(usernameInputRef) { start.linkTo(barrier, 10. dp) top.linkTo(usernameTextRef.top) bottom.linkTo(usernameTextRef.bottom) height = Dimension.fillToConstraints } ) OutlinedTextField( value = "" , onValueChange = {}, modifier = Modifier.constrainAs(passWordInputRef) { start.linkTo(barrier, 10. dp) top.linkTo(passwordTextRef.top) bottom.linkTo(passwordTextRef.bottom) height = Dimension.fillToConstraints } ) } }
Guideline引导线:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 @Composable fun GuidelineDemo () { ConstraintLayout( modifier = Modifier .fillMaxSize() .background(Color.LightGray), ) { val (userPortraitBackgroundRef, userPortraitImgRef, welcomeRef) = remember { createRefs() } val guideLine = createGuidelineFromTop(0.2f ) Box(modifier = Modifier .constrainAs(userPortraitBackgroundRef) { top.linkTo(parent.top) bottom.linkTo(guideLine) height = Dimension.fillToConstraints width = Dimension.matchParent } .background(Color(0xFF1E9FFF )) ) Image(painter = painterResource(id = R.drawable.ic_head), contentDescription = "portrait" , modifier = Modifier .constrainAs(userPortraitImgRef) { top.linkTo(guideLine) bottom.linkTo(guideLine) start.linkTo(parent.start) end.linkTo(parent.end) } .size(100. dp) .clip(CircleShape) .border(width = 2. dp, color = Color(0xFF5FB878 ), shape = CircleShape)) Text( text = "Compose 技术爱好者" , color = Color.White, fontSize = 26. sp, modifier = Modifier.constrainAs(welcomeRef) { top.linkTo(userPortraitImgRef.bottom, 20. dp) start.linkTo(parent.start) end.linkTo(parent.end) } ) } }
Chain连接约束:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 @Composable fun ChainDemo (chainStyle: ChainStyle = ChainStyle.Spread) { ConstraintLayout( modifier = Modifier .fillMaxSize() .background(Color.Gray) ) { val (quotesFirstLineRef, quotesSecondLineRef, quotesThirdLineRef, quotesForthLineRef) = remember { createRefs() } createVerticalChain(quotesFirstLineRef, quotesSecondLineRef, quotesThirdLineRef, quotesForthLineRef, chainStyle = chainStyle) Text( text = "寄蜉蝣于天地," , color = Color.White, fontSize = 30. sp, fontWeight = FontWeight.Bold, modifier = Modifier.constrainAs(quotesFirstLineRef) { start.linkTo(parent.start) end.linkTo(parent.end) } ) Text( text = "渺沧海之一粟。" , color = Color.White, fontSize = 30. sp, fontWeight = FontWeight.Bold, modifier = Modifier.constrainAs(quotesSecondLineRef) { start.linkTo(parent.start) end.linkTo(parent.end) } ) Text( text = "哀吾生之须臾," , color = Color.White, fontSize = 30. sp, fontWeight = FontWeight.Bold, modifier = Modifier.constrainAs(quotesThirdLineRef) { start.linkTo(parent.start) end.linkTo(parent.end) } ) Text( text = "羡长江之无穷。" , color = Color.White, fontSize = 30. sp, fontWeight = FontWeight.Bold, modifier = Modifier.constrainAs(quotesForthLineRef) { start.linkTo(parent.start) end.linkTo(parent.end) } ) } } @Preview(showBackground = true) @Composable fun ChainDemoPreview () { ChainDemo(ChainStyle.Spread) } @Preview(showBackground = true) @Composable fun ChainDemoPreview2 () { ChainDemo(ChainStyle.SpreadInside) } @Preview(showBackground = true) @Composable fun ChainDemoPreview3 () { ChainDemo(ChainStyle.Packed) }
coil 图片加载 coil 图片加载库不属于Accompanist库的一部分,是一个三方库,但是由于图片加载比较常用,这里放一起记录一下
1 2 3 4 dependencies { implementation "io.coil-kt:coil-compose:2.2.2" implementation "io.coil-kt:coil-svg:2.2.2" }
coil (Coroutine Image Loader)主要是基于kotlin协程框架的图片加载器,相比于传统的基于View体系的Glide等图片加载框架,它更加适合于Compose。当然 coil 不仅仅是只能用于 Compose 项目,传统项目中也可以使用它,如需了解更多可以查看其官方文档 。
可以直接使用coil 提供的AsyncImageCompose组件加载网络图片,或通过rememberAsyncImagePainter结合Image组件使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 @Composable fun CoilImageLoaderExample () { Row { Image( painter = rememberAsyncImagePainter("https://picsum.photos/300/300" ), contentDescription = null ) AsyncImage( model = "https://picsum.photos/300/300" , contentDescription = null ) AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data ("https://picsum.photos/300/300" ) .crossfade(true ) .build(), placeholder = painterResource(R.drawable.ic_launcher_background), contentDescription = null , contentScale = ContentScale.Crop, onSuccess = { success -> }, onError = { error -> }, onLoading = { loading -> }, modifier = Modifier.clip(CircleShape) ) } }
使用SubcomposeAsyncImage进行图片加载,通过painter.state可以判断当前状态是loading、error还是success
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Composable fun CoilImageLoaderExample2 () { Column(horizontalAlignment = Alignment.CenterHorizontally) { SubcomposeAsyncImage( model = "https://picsum.photos/350/350" , loading = { CircularProgressIndicator() }, contentDescription = null , modifier = Modifier.size(200. dp) ) SubcomposeAsyncImage( model = "https://picsum.photos/400/400" , contentDescription = null , modifier = Modifier.size(200. dp) ) { val state = painter.state when (state) { is AsyncImagePainter.State.Loading -> CircularProgressIndicator() is AsyncImagePainter.State.Error -> Text("${state.result.throwable} " ) is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() is AsyncImagePainter.State.Empty -> Text("Empty" ) } } } }
SubcomposeAsyncImage会根据组件的约束空间来确定图片的最终大小,这说明在图片装载前,需要预先获取SubcomposeAsyncImage的约束信息, 而Subcomposelayout可以在子组件合成前,获取到父组件的约束信息或其他组件的约束信息。SubcomposeAsyncImage就是依靠Subcomposelayout的能力来实现的,子组件就是我们传入的content内容,它会在SubcomposeAsyncImage组件测量时进行组合。
如果指定了图片加载到内存时的尺寸大小,那么在加载时就不会再获取组件的约束信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Composable fun CoilImageLoaderExample2 () { SubcomposeAsyncImage( model = ImageRequest.Builder(LocalContext.current) .data ("https://picsum.photos/800/600" ) .size(800 , 600 ) .crossfade(true ) .build(), contentDescription = null , ) { val state = painter.state when (state) { is AsyncImagePainter.State.Loading -> CircularProgressIndicator() is AsyncImagePainter.State.Error -> Text("${state.result.throwable} " ) is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() is AsyncImagePainter.State.Empty -> Text("Empty" ) } } }
另外,Coil也支持矢量图SVG的加载:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Composable fun CoilSVGExample () { val context = LocalContext.current val imageLoader = ImageLoader.Builder(context) .components { add(SvgDecoder.Factory()) } .build() Image( painter = rememberAsyncImagePainter ( "https://coil-kt.github.io/coil/images/coil_logo_black.svg" , imageLoader = imageLoader ), contentDescription = null , modifier = Modifier.size(100. dp) ) }
Landscapist 图片加载 使用coil 在svg放大和缩小时有问题,不是矢量图,可以使用 Landscapist
1 2 3 dependencies { implementation "com.github.skydoves:landscapist-coil:2.0.3" }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @Composable fun CoilImageSVGExample () { val context = LocalContext.current val imageLoader = ImageLoader.Builder(context) .components { add(SvgDecoder.Factory()) } .build() var flag by remember { mutableStateOf(false ) } val size by animateDpAsState(targetValue = if (flag) 300. dp else 100. dp) CoilImage( imageModel = { "https://coil-kt.github.io/coil/images/coil_logo_black.svg" }, imageOptions = ImageOptions( contentScale = ContentScale.Crop, alignment = Alignment.Center ), modifier = Modifier .size(size) .clickable( onClick = { flag = !flag }, indication = null , interactionSource = MutableInteractionSource() ), imageLoader = { imageLoader } ) }
landscapist 可以结合 Glide 和 Fresco 一起使用,下面是结合 Glide 的用法
1 2 3 dependencies { implementation "com.github.skydoves:landscapist-glide:2.1.0" }
1 2 3 4 5 6 7 GlideImage( imageModel = { imageUrl }, imageOptions = ImageOptions( contentScale = ContentScale.Crop, alignment = Alignment.Center ) )
注:landscapist-glide 内部依赖了 4.14.2 版的 Glide。因此,请确保您的项目使用相同的Glide版本,或排除Glide依赖项以适应您的项目。此外,请确保使用最新的Jetpack Compose版本。
Cloudy Blur模糊效果 Cloudy 是一个专门处理Jectpack Compose中的Blur高斯模糊效果的支持库,它可以向后兼容低版本,由于官方的SDK中的Modifier.blur()修饰符只能支持运行在Android 12+的设备上才有效果,所以可以使用该库做兼容支持。
Cloudy 也不属于 Accompanist 库的一部分,是一个三方库,这里仅做记录。
首先看一下系统自带的Modifier.blur()修饰符的效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @Preview(showBackground = true) @Composable fun ModifierBlurExample () { Column( Modifier.padding(15. dp), horizontalAlignment = Alignment.CenterHorizontally ) { var progress by remember{ mutableStateOf(0f )} val radius by animateDpAsState(targetValue = (progress * 10f ).dp) Text( text = "高斯模糊效果" .repeat(10 ), Modifier.blur( radius = radius, edgeTreatment = BlurredEdgeTreatment.Unbounded ), fontSize = 20. sp ) Image( painter = painterResource(id = R.drawable.ic_sky), contentDescription = null , modifier = Modifier .height(200. dp) .fillMaxWidth() .blur( radius = radius, edgeTreatment = BlurredEdgeTreatment.Unbounded ), ) Slider( value = progress, onValueChange = { progress = it }, ) } }
效果还是很不错的,其中Modifier.blur()修饰符支持的模糊半径radius参数是一个dp值。
然后再看一下 Cloudy 库的使用:
1 2 3 dependencies { implementation "com.github.skydoves:cloudy:0.1.1" }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 @Composable fun BlurByCloudyLibExample () { Column( Modifier.padding(15. dp), horizontalAlignment = Alignment.CenterHorizontally ) { var radius by remember{ mutableStateOf(0 )} Cloudy(radius = radius) { Column { AsyncImage( model = "https://picsum.photos/300/200" , contentDescription = null , onSuccess = { radius = 1 }, modifier = Modifier .clip(RoundedCornerShape(15 )) .fillMaxWidth() ) Text(text = "高斯模糊效果" .repeat(10 ), fontSize = 20. sp) } } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { Button(onClick = { if (radius > 0 ) radius-- }) { Text(text = "Minus" ) } Text(text = "radius: $radius " , fontSize = 20. sp) Button(onClick = { if (radius < 25 ) radius++ }) { Text(text = "Add" ) } } } }
Cloudy 库的使用也很简单,使用提供的Composable组件Cloudy将需要应用Blur效果的组件包起来即可,它也提供一个radius模糊半径参数,不过这个radius是一个Int值,且范围是[0, 25]。
参考资料: