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() {
// Camera permission state
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(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
// Android 12以上支持动态主题颜色(可以跟随系统桌面壁纸的主色调自动获取主题颜色)
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 {
// setStatusBarColor() and setNavigationBarColor() also exist
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
// Remember a SystemUiController
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
DisposableEffect(systemUiController, useDarkIcons) {
// Update all of the system bar colors to be transparent, and use
// dark icons if we're in light theme
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
}

此外可以使用 systemUiController.setStatusBarColor() 和 systemUiController.setNavigationBarColor() 分别设置状态栏和导航栏的颜色。

如果需要其他组件跟随系统主题颜色变化,最好使用MaterialTheme.colorScheme中的颜色属性。

Pager

对标传统View中的ViewPager组件。

1
2
3
dependencies {
implementation "com.google.accompanist:accompanist-pager:0.28.0"
}

HorizontalPager

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页")
}
}
}

VerticalPager

使用类似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的作用域内允许应用轻松引用currentPagecurrentPageOffset 这些值来计算动画效果。官方提供了一个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

// We animate the scaleX + scaleY, between 85% and 100%
lerp(
start = 0.85f,
stop = 1f,
fraction = 1f - pageOffset.coerceIn(0f, 1f)
).also { scale ->
scaleX = scale
scaleY = scale
}

// We animate the alpha, between 50% and 100%
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) {
// Collect from the pager state a snapshotFlow reading the currentPage
snapshotFlow { pagerState.currentPage }.collect { page ->
// do something with page index
}
}

VerticalPager(
count = 10,
state = pagerState,
) { page ->
Text(text = "Page: $page")
}

PagerIndicator

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()

// Display 10 items
HorizontalPager(
count = 10,
state = pagerState,
// Add 32.dp horizontal padding to 'center' the pages
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) {
// Display 10 items
VerticalPager(
count = 10,
state = pagerState,
// Add 32.dp vertical padding to 'center' the pages
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)
)
}
}
}

在这里插入图片描述

Pager结合Tab使用

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(
// Our selected tab is our current page
selectedTabIndex = pagerState.currentPage,
// Override the indicator, using the provided pagerTabIndicatorOffset modifier
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)
)
}
) {
val scope = rememberCoroutineScope()
// Add tabs for all of our pages
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"
}

可以自动换行的流式布局,使用也非常简单,分别提供了两种FlowRowFlowColumn布局,属性和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) }
)

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 如slideIntoContainerslideOutOfContainer)。

这里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",
// 从Red导航到Blue时,Blue的入场动画是slideInFromRight
enterTransition = { initialState.isRoute("Red").transition(slideInFromRight) },
// 从Blue导航到Red时,Blue的离场动画是slideOutToLeft
exitTransition = { targetState.isRoute("Red").transition(slideOutToLeft) },
// 从Red页面弹栈后显示Blue时,Blue的入场动画是slideInFromLeft
popEnterTransition = { initialState.isRoute("Red" ).transition(slideInFromLeft) },
// 从Blue页面弹栈后显示Red时,Blue的离场动画是slideOutToRight
popExitTransition = { targetState.isRoute("Red").transition(slideOutToRight) }
) { BlueScreen(navController) }

composable("Red",
// 从Blue导航到Red时,Red的入场动画是slideInFromRight
// 从Green导航到Red时,Red的入场动画是slideInFromBottom
enterTransition ={initialState.by(mapOf("Blue" to slideInFromRight, "Green" to slideInFromBottom )) },
// 从Red导航到Blue时,Red的离场动画是slideOutToLeft
// 从Red导航到Green时,Red的离场动画是slideOutToTop
exitTransition ={targetState.by(mapOf("Blue" to slideOutToLeft, "Green" to slideOutToTop)) },
// 从Blue页面弹栈后显示Red时,Red的入场动画是slideInFromLeft
// 从Green页面弹栈后显示Red时,Red的入场动画是slideInFromTop
popEnterTransition ={initialState.by(mapOf("Blue" to slideInFromLeft, "Green" to slideInFromTop )) },
// 从Red页面弹栈后显示Blue时,Red的离场动画是slideOutToRight
// 从Red页面弹栈后显示Green时,Red的离场动画是slideOutToBottom
popExitTransition ={targetState.by(mapOf("Blue" to slideOutToRight, "Green" to slideOutToBottom )) }
) { RedScreen(navController) }

// 从Blue导航到Inner时,Inner入场方式:expandIn放大进入
// 从Inner导航到Blue时,Inner离场方式:shrinkOut缩小退出
// Inner第一个屏幕是Green
navigation(
startDestination = "Green",
route = "Inner",
enterTransition = { expandIn(animationSpec = tween700ms()) },
exitTransition = { shrinkOut(animationSpec = tween700ms()) }
) {
composable(
"Green",
// 从Red导航到Green时,Green的入场动画是slideInFromBottom
enterTransition = { initialState.isRoute("Red").transition(slideInFromBottom) },
// 从Green导航到Red时,Green的离场动画是slideOutToTop
exitTransition = { targetState.isRoute("Red").transition(slideOutToTop) },
// 从Red页面弹栈后显示Green时,Green的入场动画是slideInFromTop
popEnterTransition = { initialState.isRoute("Red").transition(slideInFromTop) },
// 从Green页面弹栈后显示Red时,Green的离场动画是slideOutToBottom
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) {
// Use LocalLifecycleOwner.current as a proxy for the NavBackStackEntry
// associated with this Composable
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 == target

fun <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.NoneExitTransition.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 // 保持不动 fix for https://github.com/google/accompanist/issues/1159
},
animationSpec = animation
)

val IdentityExit = slideOutVertically(
targetOffsetY = {
-1 // 保持不动 fix for https://github.com/google/accompanist/issues/1159
},
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 {
// accompanist-insets 已经废弃,相关功能已经集成到了 androidx.compose.foundation 包中,可以直接使用
// implementation "com.google.accompanist:accompanist-insets:<version>"
// accompanist-insets-ui 仍然可继续使用,它提供了一些支持设置 contentPadding 的 Material 组件
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(),
)
},
...
)
}

在这里插入图片描述
同样的可以修改bottomBarBottomNavigation替换成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)
}
}
// ScaffoldExample()
}
}
}

那么也可能会出现被状态栏遮挡内容的情况:
在这里插入图片描述
可以使用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)
}
}
// ScaffoldExample()
}
}
}

在这里插入图片描述
或者直接设置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()) { // 关键点1
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(), // 关键点2
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()) { // 关键点1
LazyColumn(
modifier = Modifier.weight(1f).imeNestedScroll(), // 关键点2
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(), // 关键点3
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 {
// 使用 createRefs() 或 createRefFor() 创建引用
val (button, text) = remember { createRefs() }

Button(
onClick = { /* Do something */ },
// 使用 Modifier.constrainAs() 修饰符关联引用
// 使用 linkTo() 方法指定的约束条件
modifier = Modifier.constrainAs(button) {
top.linkTo(parent.top, margin = 16.dp) // 指定button顶部距离parent顶部为16dp
centerHorizontallyTo(parent)
}
) {
Text("Button")
}

// 将引用 "text" 分配到 Text 组件,并指定其约束为顶部在button的底部,距离为16dp
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,
// error = painterResource(),
onSuccess = { success ->

},
onError = { error ->

},
onLoading = { loading ->

},
modifier = Modifier.clip(CircleShape)
)
}
}

使用SubcomposeAsyncImage进行图片加载,通过painter.state可以判断当前状态是loadingerror还是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() {
// 加载网络svg
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() {
// 加载网络svg
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 }, // loading a network image using an URL.
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
// Modifier.blur() only supported on Android 12+ (API 31)
@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)}
// radius支持范围是[0..25]
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]


参考资料: