前言
在使用Compose开发过程中,有些场景需要把UI定位到某个位置,使用compose-layer可以轻松实现UI定位功能。
场景一
类似微信会话列表的长按菜单,可以根据长按位置,智能选择有足够空间的地方来展示,我们来实现一下这个功能。
设置LayerContainer容器
首先要设置一个LayerContainer容器用来显示弹出的Layer:
class SampleListMenu : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
// 设置Layer弹出层容器
LayerContainer {
Content()
}
}
}
}
}
点击坐标:
@Composable
private fun ListItem(
modifier: Modifier = Modifier,
text: String,
/** 坐标点回调 */
onOffset: (IntOffset?) -> Unit,
) {
val onOffsetUpdated by rememberUpdatedState(onOffset)
var coordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
Box(
modifier = modifier
.fillMaxWidth()
.height(50.dp)
.onGloballyPositioned { coordinates = it }
.pointerInput(Unit) {
detectTapGestures {
// 获取触摸点相对于Window的坐标
val offset = coordinates?.localToWindow(it)?.round()
// 回调坐标点
onOffsetUpdated(offset)
}
}
) {
Text(text = text, modifier = Modifier.align(Alignment.Center))
HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
}
ListItem是列表Item,点击时通过LayoutCoordinates计算点击位置相对于Window的坐标,并通知回调对象。
创建TargetLayer
@Composable
private fun Content() {
// 是否添加Layer
var attach by remember { mutableStateOf(false) }
// 点击坐标点
var offset: IntOffset? by remember { mutableStateOf(null) }
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(100) { index ->
ListItem(
text = index.toString(),
onOffset = {
// 保存点击坐标点
offset = it
// 添加Layer
attach = true
},
)
}
}
// 创建TargetLayer
TargetLayer(
// 目标坐标点
target = LayerTarget.Offset(offset),
// 是否添加Layer
attach = attach,
// Layer请求移除回调
onDetachRequest = { attach = false },
// 背景颜色透明
backgroundColor = Color.Transparent,
// 按返回键请求移除回调,默认值true
detachOnBackPress = true,
// 触摸背景区域请求移除回调,默认值false
detachOnTouchBackground = true,
// 显示在目标底部,水平居中对齐
alignment = TargetAlignment.BottomCenter,
// 如果[alignment]对齐方式溢出,会使用[smartAlignments]提供的位置按顺序查找溢出最小的位置
smartAlignments = SmartAliments.Default,
) {
// Layer内容,弹出菜单
Menus {
attach = false
}
}
}
调用TargetLayer创建弹出层,弹出层的内容是Menus菜单组合项,具体代码就不展示了。TargetLayer虽然是在Content组合中创建的,但它的内容实际上是显示在LayerContainer容器中。
上述代码已经实现功能了,代码不多,但参数比较多,下面一一解释:
-
attach变量用来控制是否显示Layer,即是否把Layer的内容添加到LayerContainer中,true添加,false移除,把它传给TargetLayer即可 -
offset变量用来保存点击的坐标点,即目标点,通过LayerTarget.Offset(offset)构建一个目标传给TargetLayer -
onDetachRequest是请求移除Layer的回调,例如按返回键或者触摸背景区域时回调onDetachRequest,在回调里把attach参数修改为false,即可移除Layer -
detachOnBackPress按返回键是否请求移除Layer,true请求移除;false不请求移除;null不处理返回键事件,默认值true。注意:true和false都会消费本次返回键事件,而null则完全忽略返回键事件,如果设置为null,按下返回键,会继续传播事件 -
detachOnTouchBackground触摸背景区域是否请求移除Layer,true请求移除;false不请求移除;null不处理事件,事件会穿透背景,默认值false。由于现在手机屏幕越来越大,可能误触背景区域导致移除Layer,所以默认值是false,可以根据实际需求配置 -
alignment可以设置Layer和目标的对齐位置,这里设置为BottomCenter表示显示在目标底部,水平方向和目标中心点对齐,它的默认值是Center中心点对齐
重点来啦,smartAlignments参数是智能对齐目标的意思,只有在alignment参数导致内容溢出时,才会从smartAlignments中按顺序查找内容溢出最小的位置,可以看一下这个类:
@Immutable
data class SmartAliments(
val aliments: List<SmartAliment>,
) {
constructor(vararg array: SmartAliment) : this(array.toList())
companion object {
/** 默认的智能对齐位置 */
val Default = SmartAliments(
SmartAliment(TargetAlignment.BottomEnd),
SmartAliment(TargetAlignment.BottomStart),
SmartAliment(TargetAlignment.TopEnd),
SmartAliment(TargetAlignment.TopStart),
)
}
}
@Immutable
data class SmartAliment(
/** 要对齐的位置 */
val alignment: TargetAlignment,
/** 动画 */
val transition: LayerTransition? = null,
)
可以看到SmartAliments内部实际上只是一个简单的列表,列表项是SmartAliment:
SmartAliment.alignment要对齐的位置SmartAliment.transition该位置对应的动画,默认值是null,会根据对齐位置自动选择合适的动画
SmartAliments.Default配置了4个默认位置,可以根据实际需求创建SmartAliments。
场景二
类似微信朋友圈的点赞和评论菜单,我们来实现一下这个功能。
首先需要在Item布局里面为按钮添加tag,添加之后这个tag对应的按钮就是Layer要定位的目标:
@Composable
private fun ListItem(
modifier: Modifier = Modifier,
/** 设置tag */
tag: String,
/** 点击回调tag */
onClick: (String) -> Unit,
) {
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(200.dp)
) {
IconButton(
onClick = { onClick(tag) },
modifier = Modifier.align(Alignment.BottomEnd)
// 为IconButton设置tag
.layerTag(tag)
) {
Icon(Icons.Default.MoreVert, "more")
}
HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
}
}
IconButton是更多按钮,通过Modifier.layerTag为它设置一个tag,在点击的时候回调tag。
创建TargetLayer:
@Composable
private fun Content() {
// 是否添加Layer
var attach by remember { mutableStateOf(false) }
// 目标tag
var tag by remember { mutableStateOf("") }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
// 监听触摸事件,触摸的时候,移除Layer
detectTapGestures(
onPress = {
attach = false
}
)
},
) {
items(100) { index ->
ListItem(
tag = index.toString(),
onClick = {
// 设置目标tag
tag = it
// 添加Layer
attach = true
},
)
}
}
TargetLayer(
// 目标tag
target = LayerTarget.Tag(tag),
attach = attach,
onDetachRequest = { attach = false },
backgroundColor = Color.Transparent,
// 允许事件穿透背景
detachOnTouchBackground = null,
// 显示在目标左侧,上下中心点居中
alignment = TargetAlignment.StartCenter,
) {
// Layer内容,菜单...
}
}
tag变量用来保存Item回调的目标,然后通过LayerTarget.Tag(tag)构建一个目标传递给LayerTarget。
微信朋友圈菜单弹出的时候,列表仍然可以滑动,并且滑动的时候会关闭菜单。可以把detachOnTouchBackground设置为null,让触摸事件就可以穿透背景区域,再通过detectTapGestures监听触摸事件移除Layer即可。
有个细节:列表滑动的时候,更多按钮已经滑走了,而Layer收回动画还在原来的位置,微信的做法是不显示收回动画,直接隐藏菜单。我们也可以通过设置动画来解决这个问题:
TargetLayer(
...
transition = LayerTransition.slideRightToLeft(
exit = ExitTransition.None
)
)
LayerTransition.slideRightToLeft会创建从右向左的enter(进入)动画,以及从左向右的exit(退出)动画,只要把exit设置为ExitTransition.None即可关闭退出动画。
@Immutable
data class LayerTransition(
/** 进入动画 */
val enter: EnterTransition,
/** 退出动画 */
val exit: ExitTransition,
)
场景三
输入框输入一些内容后,下拉框会有建议列表,这也是比较常见的场景,我们来实现一下这个功能。
@Composable
private fun Content() {
// 是否添加Layer
var attach by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
InputBar(
// 为输入框设置一个tag
tag = "abc",
showPopMenu = {
// 输入框回调是否显示Layer
attach = it
}
)
}
TargetLayer(
// 设置tag
target = LayerTarget.Tag("abc"),
attach = attach,
onDetachRequest = { attach = false },
detachOnTouchBackground = true,
// 显示在目标底部,水平方向中心点对齐
alignment = TargetAlignment.BottomCenter,
// 裁切Layer顶部方向的背景
clipBackgroundDirection = Directions.Top,
) {
// Layer内容...
}
}
InputBar是一个输入框,给它传递一个tag为"abc"参数,其内部还是通过Modifier.layerTag来为输入框设置tag,上面已经介绍过了这里就不赘述,然后监听输入框的回调showPopMenu是否显示Layer。
最后要把"abc"这个tag也传递给TargetLayer,这样子它才能定位到输入框这个目标。
这里多了一个还没见过的参数:clipBackgroundDirection,它的意思是要裁切Layer哪些方向的背景,设置为Directions.Top表示裁切Layer顶部方向的背景,如果不裁切的话输入框会被背景所遮挡。
Directions支持上下左右4个方向,支持同时裁剪多个方向的背景,如果要同时裁切顶部和底部的话,可以设置为:Directions.Top + Directions.Bottom
有时候需要在对齐目标之后做一些位置微调,例如输入框下面弹出的建议列表往下面挪一点,可以通过以下代码实现:
TargetLayer(
...
// Y方向往下移动200个像素
alignmentOffsetY = TargetAlignmentOffset.PX(200)
)
alignmentOffsetY表示Y方向偏移,alignmentOffsetX表示X方向偏移,参数类型是TargetAlignmentOffset:
@Immutable
sealed class TargetAlignmentOffset {
/** 按指定像素[value]偏移,支持正数和负数,以Y轴为例,大于0往下偏移,小于0往上偏移 */
data class PX(val value: Int) : TargetAlignmentOffset()
/** 按指定DP[value]偏移,支持正数和负数,以Y轴为例,大于0往下偏移,小于0往上偏移 */
data class DP(val value: Int) : TargetAlignmentOffset()
/** 按目标大小倍数[value]偏移,支持正数和负数字,以Y轴为例,1表示往下偏移1倍目标的高度,-1表示往上偏移1倍目标的高度 */
data class Target(val value: Float) : TargetAlignmentOffset()
}
-
TargetAlignmentOffset.PX表示按像素偏移,支持正数和负数,以Y轴为例,大于0往下偏移,小于0往上偏移 -
TargetAlignmentOffset.DP表示以dp为单位计算偏移,偏移逻辑和TargetAlignmentOffset.PX一样 -
TargetAlignmentOffset.Target表示按目标的大小偏移,支持正数和负数字,以Y轴为例,1表示往下偏移1倍目标的高度,-1表示往上偏移1倍目标的高度
TargetAlignment
我们已经知道可以通过TargetAlignment来设置要对齐的位置,具体都有哪些位置可以看下面的效果图:
TargetLayer vs Layer
这篇文章主要讲的是定位,所以用到的都是TargetLayer,实际上还有一个Layer,它没有定位功能,可以把它当作Compose Dialog来使用:
/**
* 创建Layer
*
* @param attach 是否添加Layer,true-添加;false-移除
* @param onDetachRequest [LayerDetach]触发的移除回调
* @param debug 是否调试模式,tag:FLayer
* @param detachOnBackPress 按返回键是否请求移除Layer,true-请求移除;false-请求不移除;null-不处理返回键逻辑,默认true
* @param detachOnTouchBackground 触摸背景区域是否请求移除Layer,true-请求移除;false-不请求移除;null-不处理,事件会透过背景,默认false
* @param backgroundColor 背景颜色
* @param alignment 对齐容器位置
* @param transition 动画(非响应式)
* @param zIndex [Modifier.zIndex]
* @param content 内容
*/
@Composable
fun Layer(
attach: Boolean,
onDetachRequest: (LayerDetach) -> Unit,
debug: Boolean = false,
detachOnBackPress: Boolean? = true,
detachOnTouchBackground: Boolean? = false,
backgroundColor: Color = Color.Black.copy(alpha = 0.3f),
alignment: Alignment = Alignment.Center,
transition: LayerTransition? = null,
zIndex: Float = 0f,
content: @Composable LayerContentScope.() -> Unit,
)
- 参数
alignment是Compose标准类Alignment,相当于在Box中为UI设置对齐参数 - 参数
zIndex可以设置Layer在z轴的层级,值越大显示在越上层
其他参数上面已经介绍过就不赘述了。
不管是TargetLayer还是Layer,最终它的内容都会显示在LayerContainer中,所以外层要设置LayerContainer容器,否则会抛异常。
结束
分享到此为止,如果有问题欢迎一起交流学习,感谢你的阅读。