一、概念
1.1 什么时候用到
四大使用场景:
- 修改外观(尺寸、样式、布局、行为)。
- 添加额外信息(如无障碍标签)。
- 添加交互功能(点击、滚动、拖拽、缩放)。
- 处理用户输入。
1.2 为组合函数添加 Modifier 参数
任何一个组合项都应该有一个 Modifier 参数,以便让调用方进行调整。
- 放在第一个可选参数位置:由于是可选参数,放在所有必传参数后面,这样调用方就可以选择是否传递那些有默认值的可选参数,否则就必须被强制性的先指定Modifier。
- 作用于内部根节点上:调用方一般只需要调整根节点的布局,对于子元素别的配置可通过传递其它的参数。
- 避免重复使用:将同一个 Modifier 传递给不同的可组合共享,可能引起不必要的重组。
- 使用时设置的效果会覆盖掉传入的Modifier效果:调用方将设置过 size 的 Modifier 传入,内部使用的时候时候又设置了 size,这样就会覆盖掉传入的效果,即可组合项内部设置的效果无法被外部控制。
@Composable
fun ParentLayout(modifier: Modifier = Modifier) {
//调用时指定对齐方式
Avatar(Modifier.align(Alignment.CenterHorizontally))
}
@Composable
fun Avatar(modifier: Modifier = Modifier) {
Image(
painter = painterResource(id = R.drawable.icon),
contentDescription = "Icon Image",
//使用时,用传入的modifier
modifier = modifier
.wrapContentSize()
.background(Color.Gray)
.padding(18.dp)
.border(5.dp, Color.Magenta, CircleShape)
.clip(CircleShape)
)
}
@Composable
fun TestComposable(a: Int, b: String, modifier: Modifier = Modifier) {...}
1.3 底层结构原理
Layout 阶段, Modifier#then 创建 Element 加入 Modifier chain 中。Element 是无状态的,重组中会重新生成,Element 会在组合中创建有状态的 Modifier Node。Modifier Node 有状态,重组中仅当状态发生变化时被更新,否则不会重新生成。Modifier Node 是 Compose 1.5 引入的新优化,目的就是通过存储 Modifier 状态参与比较,提升重组性能。
interface Modifier { companion object : Modifier {...} class CombinedModifier {...} fun then() {...} fun Modifier.composed() {...} | |
Element 子接口 | 调用 Modifier 不同的配置方法会返回各种 Modifier 的实现类对象(如 .size() 返回 SizeElement、.background() 返回 BackgroundElement),这些实现类又都是 Element 类型。 |
companion 伴生对象 | 伴生对象实现了 Modifier,因此类名 Modifier 可以用作链式调用的开头。 |
CombinedModifier | class CombinedModifier( 内部维护的数据结构,用于连接调用链中的每个 Element 结点,类似于俄罗斯套娃一样的装饰者模式。 |
then() 函数 | 用于连接两个 Element 的方法,底层就是用的 CombinedModifier 结构。 |
composed() 扩展函数 | fun Modifier.composed( 内部持有一个工厂 Lambda 来生产 Modifier,用于实现内部有状态的 Modifier,如监听手势的修饰符 .pointerInput() 底层就是用到 composed()。 |
1.3.1 链式调用
Modifier.size(100.dp).background(Color.Red).padding(10.dp)
当链式调用 Modifier 的时候,先调用的会包裹后调用的,最里层是 Layout Node。
- 调用 .size() 生成 SizeElement。
- 调用 .background() 生成 CombinedModifier(outer = SizeElement, inner = BackgroundElement)。
- 调用 .padding() 生成 CombinedModifier(outer = CombinedModifier(SizeElement, BackgroundElement), inner = PaddingElement)。
1.3.2 自定义 Modifier
//自定义测量和摆放
fun Modifier.XXX(): Modifier = then(
layout { measurable, constraints ->
//TODO...
}
}
//去掉涟漪效果
fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = composed {
clickable(
onClick = onClick,
interactionSource = remember { MutableInteractionSource() },
indication = null
)
}
二、修改外观
通过链式调用串接API,因此顺序会影响最终结果(例如边距padding)。
2.1 尺寸
2.1.1 指定具体值
设置首选值(如果指定的大小不满足父布局的约束,则尺寸将会无效。如果强制设置而不考虑父控件约束使用 requiredSize)。
.width(width: Dp) .width(intrinsicSize: IntrinsicSize) //参数为 IntrinsicSize.Min 或 IntrinsicSize.Max |
.height(height: Dp) .height(intrinsicSize: IntrinsicSize) |
.size(size: Dp) //同时设置宽高 .size(width: Dp, height: Dp) //分开设置宽高 |
Modifier.width(5.dp).height(5.dp)
Modifier.size(5.dp) //同时设置宽高
@Composable
fun Demo() {
Box(
Modifier
.background(Color.Blue)
.width(50.dp)
.height(IntrinsicSize.Min) //高度
){
Column{//不管这里是Column还是Row,不管上面是MIn还是Max,蓝色高度上都是包裹效果
Box(
Modifier
.background(Color.Red)
.size(25.dp)
)
Box(
Modifier
.background(Color.Green)
.size(10.dp)
)
}
}
}
2.1.2 强制使用指定值
.requiredWidth(width: Dp) .requiredHeight(height: Dp) .requiredSize(size: Dp) |
@Preview(showBackground = true)
@Composable
fun Demo1() {
Column(modifier = Modifier.size(100.dp)) {
Image(
painter = painterResource(id = R.drawable.logo_wechat_rectangle),
contentDescription = null,
modifier = Modifier.size(150.dp) //大于父控件尺寸,无效
)
}
}
@Preview(showBackground = true)
@Composable
fun Demo2() {
Column(modifier = Modifier.size(100.dp)) {
Image(
painter = painterResource(id = R.drawable.logo_wechat_rectangle),
contentDescription = null,
modifier = Modifier.requiredSize(150.dp) //强制使用指定值
)
}
}
2.1.3 占可用空间百分比
取值范围0.0~1.0。
.fillMaxWidth(fraction: Float = 1f) .fillMaxHeight(fraction: Float = 1f) .fillMaxSize(fraction: Float = 1f) |
Modifier.fillMaxWidth(0.5f).fillMaxHeight(0.5f)
Modifier.fillMaxSize(0.5f) //同时设置宽高
2.1.4 设置范围
限定在最大值和最小值之间。
.widthIn(min: Dp = Dp.Unspecified, max: Dp = Dp.Unspecified) .heightIn(min: Dp = Dp.Unspecified, max: Dp = Dp.Unspecified) .sizetIn(min: Dp = Dp.Unspecified, max: Dp = Dp.Unspecified) |
父容器和子元素width一致,只看height | |
父heightIn(min=10、max=40) | 子 < 父:子显示3,父显示10 |
子 = 父:高度显示一致 | |
子 > 度:都为父最大值40 | |
父heightIn(min=50) | 子 < 父:子显示30,父显示50 |
子 = 父:高度显示一致 | |
子 > 度:都为子高度100 | |
父height(max=50) | 子 < 父:都为子高度30 |
子 = 父:高度显示一致 | |
子 > 度:都为父最大值50 |
@Composable
fun Demo() {
Box(Modifier
.background(Color.Blue)
.width(50.dp)
.heightIn(min = 10.dp,max = 40.dp)
){
Box(Modifier
.background(Color.Red)
.width(25.dp)
// .height(30.dp) //子元素在范围内,蓝色和红色一样高
// .height(900.dp) //子元素高于最大值,红色蓝色都显示40dp
.height(3.dp) //子元素低于最小值,红色显示3dp,蓝色显示10dp
)
}
}
2.1.5 权重 weight
设置 weight 时,fill = true/false 的区别。其它组件正常设置尺寸,设置了 weight(1F) 的组件会填充剩余布局。
2.1.6 根据自身内容决定大小
例如可以让 Image 根据自身内容来决定控件的大小。根据子元素的宽高来确定自身的大小,如果自身设置了最小宽高的话则会被忽略。当无边界 unbounded = true 的时候,自身设置了最大宽高的话也会被忽略。
.wrapContentWidth( align: Alignment.Horizontal = Alignment.CenterHorizontally, //对齐方式 unbounded: Boolean = false ) |
.wrapContentHeight( align: Alignment.Vertical = Alignment.CenterVertically, unbounded: Boolean = false ) |
.wrapContentSize( align: Alignment = Alignment.Center, unbounded: Boolean = false ) |
@Composable
fun Demo() {
Column(modifier = Modifier.width(50.dp)) {
Image(
painter = painterResource(id = R.drawable.logo_baidu),
contentDescription = null,
modifier = Modifier.wrapContentSize()
)
}
}
2.2 样式
2.2.1 边距 padding
由于调整链式调用的顺序就能实现内外边距,因此是没有margin的。
分别设置:上下左右 | .padding( start = 0.dp, top = 0.dp, end: Dp = 0.dp, bottom: Dp = 0.dp ) |
分别设置:水平、垂直 | .padding( horizontal: Dp = 0.dp, vertical: Dp = 0.dp ) |
同时设置 | .padding(all: Dp) |
val paddingValues = PaddingValues(10.dp,20.dp,30.dp,40.dp)
@Composable
fun Demo() {
//传入一个PaddingValues对象
Box(Modifier.padding(paddingValues)) {}
}
2.2.3 背景 background
.background( color: Color, //颜色 shape: Shape = RectangleShape //形状 ) |
.background( brush: Brush, // shape: Shape = RectangleShape, @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f ) |
2.2.4 剪裁 clip
根据传入的 Shape 可以裁剪成对应的形状。
.clip(shape: Shape) //CircleShape、RectangleShape |
@Composable
fun Demo() {
Image(
painter = painterResource(id = R.drawable.logo_wechat_square),
contentDescription = null,
modifier = Modifier.clip(CircleShape)
)
}
2.2.5 边框 border
.border(width: Dp, brush: Brush, shape: Shape) |
@Composable
fun Demo() {
Image(
painter = painterResource(id = R.drawable.logo_wechat_square),
contentDescription = null,
modifier = Modifier.border(
width = 2.dp,
color = Color.Blue,
shape = CircleShape
)
)
}
2.2.6 阴影 shadow
.shadow( elevation: Dp, //阴影高度 shape: Shape = RectangleShape, //阴影形状 clip: Boolean = elevation > 0.dp, ambientColor: Color = DefaultShadowColor, spotColor: Color = DefaultShadowColor, ) |
@Composable
fun Demo() {
Column(
modifier = Modifier.size(150.dp).background(Color.White),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = {},
modifier = Modifier.shadow(elevation = 10.dp)
){
Text(text = "Button")
}
}
}
2.2.7 透明度 alpha
fun Modifier.alpha( alpha: Float //取值范围0~1F ) |
@Composable
fun Demo() {
Column {
Image(
painterResource(id = R.drawable.logo_wechat_square),
contentDescription = null,
modifier = Modifier.alpha(0.8F)
)
Image(
painterResource(id = R.drawable.logo_wechat_square),
contentDescription = null,
modifier = Modifier.alpha(0.3F)
)
}
}
2.3 布局
2.3.1 子元素对齐 align
Compose能理解当前代码所处作用域,例如给一个纵向布局设置子元素对齐,IDE给出的选项自动变成 Alignment.Horizontal,说明只能在水平方向上指定对齐方式。
具体选项和效果详见
.align(alignment: Alignment) |
2.4 行为
2.4.1 偏移 offset
.offset(x: Dp = 0.dp, y: Dp = 0.dp) |
@Composable
fun Demo() {
Image(
painter = painterResource(id = R.drawable.logo_wechat_square),
contentDescription = null,
modifier = Modifier.offset(x = 10.dp, y = 30.dp)
)
}
2.4.2 旋转 rotate
围绕其中心点旋转的角度。
.rotate(degrees: Float) |
@Composable
fun Demo() {
Image(
painter = painterResource(id = R.drawable.logo_wechat_square),
contentDescription = null,
modifier = Modifier.rotate(180F)
)
}
三、添加额外信息
在Compose的内部,是用树型结构来存储一次重组过程中每个Composable函数节点的。一颗就是我们现在看到的重组树,另外一颗则是我们看不到的语义树。
语义树完全不参与绘制和渲染工作,因此是完全不可见的,它只为 Accessibility 和 Test 服务。Accessibility需要根据语义树的节点内容进行发音,Test则需要根据语义树找到想要测试的节点来执行测试逻辑。
绝大部分情况下不需要专门为语义树去做什么事情,标准的组合项已经在内部处理好了这些工作(Button嵌套一个Text,它俩是独立控件,Talkback会单独发声,但只要控件可点击,就会自动将所有子节点合并)。若使用了一些底层API自行绘制界面(日历选中8号,只会发音选中日历),这些工作就得自己来做了。
.semantics( 允许向当前Compose控件添加键值对形式的额外信息,但是不能覆写。 |
.clearAndSetSemantics( 相对用得更多一些,它会把Compsoe控件之前携带的一些额外信息都清除掉。 |
四、添加交互功能
4.1 点击 clickable
允许应用检测对该元素的点击。
单击 | fun Modifier.clickable( enabled: Boolean = true, onClickLabel: String? = null, role: Role? = null, onClick: () -> Unit ) |
单击 (可去除涟漪效果) | .clickable( 将indication = null, interactionSource = remember { MutableInteractionSource() } 。可以自定义一个修饰符封装起来(具体代码见上面1.2.2)。 |
长按、双击 | fun Modifier.combinedClickable( enabled: Boolean = true, onClickLabel: String? = null, role: Role? = null, onLongClickLabel: String? = null, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, onClick: () -> Unit ) |
@Composable
fun Demo() {
var count by remember { mutableStateOf(0) }
//单击
Text(
modifier = Modifier.clickable { count += 1 },
text = count.toString()
)
//去除涟漪
Text(
modifier = Modifier.clickable (
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = { count += 1 }
),
text = count.toString(),
)
}
4.2 滚动
4.2.1 verticalScroll、horizontalScroll
类似与 ScrollView,可以让内容边界大于最大尺寸约束时滚动里面的元素,借助 ScrollState 还可以更改滚动位置或获取当前状态。
@Composable
fun ScrollBoxes() {
val scrollState = rememberScrollState()
LaunchedEffect(Unit) { scrollState.animateScrollTo(100) }
Column(
modifier = Modifier
.background(Color.LightGray)
.size(100.dp)
// .verticalScroll(rememberScrollState()) //使用默认参数
.verticalScroll(scrollState) //一显示就会自动滚动100px
) {
repeat(10) {
Text("Item $it", modifier = Modifier.padding(2.dp))
}
}
}
4.2.2 scrollable
只检测手势不偏移内容。构造时需要提供一个 consumeScrollDelta( ) 函数,该函数在每个滚动步骤都会调用,以像素为单位,返回所消耗的距离。
@Composable
fun ScrollableSample() {
var offset by remember { mutableStateOf(0f) }
Box(
Modifier
.size(150.dp)
.scrollable(
orientation = Orientation.Vertical,
state = rememberScrollableState { delta ->
//拿到每次滑动的偏移量delta
offset += delta
delta
}
)
.background(Color.LightGray),
contentAlignment = Alignment.Center
) {
Text(offset.toString())
}
}
4.2.3 嵌套滚动
4.2.3.1 自动嵌套滚动
简单的嵌套滚动无需额外操作,当子元素无法进一步滚动时手势会由父元素处理,手势会自动从子元素传播到父元素。
//父Box嵌套10个子Box,子Box滚动到边界会滚动父Box
@Composable
fun ScrollableSample() {
//设置渐变色方便观察子Box滚动(蓝→黄1000级)
val gradient = Brush.verticalGradient(0f to Color.Blue, 1000f to Color.Yellow)
Box(
modifier = Modifier
.background(Color.LightGray)
.verticalScroll(rememberScrollState())
.padding(32.dp)
) {
Column {
repeat(10) {
Box(
modifier = Modifier
.height(128.dp)
.verticalScroll(rememberScrollState())
) {
Text(
text = "Scroll here",
color = Color.Red,
modifier = Modifier
.border(12.dp, Color.DarkGray)
.background(brush = gradient)
.padding(24.dp)
.height(150.dp)
)
}
}
}
}
}
4.2.3.2 nestedScroll
4.2.3.3 嵌套滚动互操作性(v1.2.0)
4.3 拖拽
只检测手势不偏移内容(需要保存状态并在屏幕上表示,例如通过 offset 修饰符移动元素),以像素为单位。
4.3.1 线性拖拽(一维) draggable
使元素在水平或垂直方向上拖拽,并可以监听拖拽距离。
.draggable( state: DraggableState, orientation: Orientation, //拖动方向 enabled: Boolean = true, //是否启用 interactionSource: MutableInteractionSource? = null, startDragImmediately: Boolean = false, onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, //拖拽开始时的回调 onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, //拖拽结束时的回调 reverseDirection: Boolean = false //反转方向 ) |
var offsetX by remember { mutableStateOf(0f) }
Text(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), 0) }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
offsetX += delta
}
),
text = "Drag me!"
)
4.3.2 平面拖动(二维)pointerInput
改为使用更加底层的 pointerInput( ) 。
//父Box中拖动蓝色子Box
@Composable
fun ScrollableSample() {
Box(modifier = Modifier.fillMaxSize()) {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Box(
Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Blue)
.size(50.dp)
.pointerInput(Unit) {
//监听用户拖拽手势
detectDragGestures { change, dragAmount ->
change.consume() //由于是底层API很多事情需要自己做,这里要消费掉
offsetX += dragAmount.x //水平距离
offsetY += dragAmount.y //垂直距离
}
}
)
}
}
4.4 滑动 swipeable
只检测手势不偏移内容(需要保存状态并在屏幕上表示,例如通过 offset 修饰符移动元素)。具有惯性,释放后会朝着锚点呈现动画效果,常见用途是滑动关闭。
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeableSample() {
val squareSize = 48.dp //子Box的大小
val swipeableState = rememberSwipeableState(0)
val sizePx = with(LocalDensity.current) { squareSize.toPx() } //DP转PX
//设置锚点(key是像素,value是索引)
val anchors = mapOf(0f to 0, sizePx to 1)
Box(
modifier = Modifier
.width(96.dp)
.swipeable(
state = swipeableState,
anchors = anchors,
//阈值(超过就会自己滑到底,达不到就会滑回来)
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Horizontal
)
.background(Color.LightGray)
) {
Box(
Modifier
.offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
.size(squareSize)
.background(Color.DarkGray)
)
}
}
4.5 多点触控 transformable
只检测手势不转换元素。平移、缩放、旋转。
@Composable
fun TransformableSample() {
var scale by remember { mutableStateOf(1f) } //缩放
var rotation by remember { mutableStateOf(0f) } //旋转
var offset by remember { mutableStateOf(Offset.Zero) } //平移
val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
Box(
Modifier
.graphicsLayer(
scaleX = scale, //等比缩放
scaleY = scale, //等比缩放
rotationZ = rotation,
translationX = offset.x,
translationY = offset.y
)
.transformable(state = state)
.background(Color.Blue)
.fillMaxSize()
)
}
4.6 下拉刷新 pullRefresh
五、处理用户输入 pointerInput
这里的输入不是文本,而是用户手指在屏幕上滑动点击(手势)。当上层API无法满足时(第四部分系统提供的交互功能),就需要自己调用底层API定制了。
.pointerInput( key1: Any?, //至少要传一个key(其它重载传更多key),变化时函数会重新执行(不需要就传Unit)。 block: suspend PointerInputScope.() -> Unit ) |
5.1 点击、拖拽
两个函数都是阻塞的,不能同时写在同一个 pointerInput( ) 中。
监听点击事件 | detectTapGestures( |
监听拖拽事件 | detectDragGestures( |
Box(modifier = Modifier
.size(200.dp)
.background(Color.Blue)
.pointerInput(Unit) {
detectTapGestures { offset ->
Log.d("PointerInputEvent", "Tap")
}
//注意:检测函数是阻塞的,此处代码不可达
}.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
Log.d("PointerInputEvent", "Dragging")
}
//注意:检测函数是阻塞的,此处代码不可达
}
)
5.2 更底层处理(自定义)
写法过于底层,基本没有太多场景我们需要使用。
启动协程作用域 | suspend fun <R> awaitPointerEventScope( block: suspend AwaitPointerEventScope.() -> R ): R |
等待用户输入事件 | suspend fun awaitPointerEvent( ): PointerEvent |