Jetpack Compose – UI定位

前言

在使用Compose开发过程中,有些场景需要把UI定位到某个位置,使用compose-layer可以轻松实现UI定位功能。

场景一

录屏2024-09-26 03.24.11.gif

类似微信会话列表的长按菜单,可以根据长按位置,智能选择有足够空间的地方来展示,我们来实现一下这个功能。

设置LayerContainer容器

首先要设置一个LayerContainer容器用来显示弹出的Layer

class SampleListMenu : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      setContent {
         AppTheme {
            
            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 {
                
               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() {
   
   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
               
               attach = true
            },
         )
      }
   }

   
   TargetLayer(
      
      target = LayerTarget.Offset(offset),
      
      attach = attach,
      
      onDetachRequest = { attach = false },
      
      backgroundColor = Color.Transparent,
      
      detachOnBackPress = true,
      
      detachOnTouchBackground = true,
      
      alignment = TargetAlignment.BottomCenter,
      
      smartAlignments = SmartAliments.Default,
   ) {
      
      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按返回键是否请求移除Layertrue请求移除;false不请求移除;null不处理返回键事件,默认值true。注意:truefalse都会消费本次返回键事件,而null则完全忽略返回键事件,如果设置为null,按下返回键,会继续传播事件

  • detachOnTouchBackground触摸背景区域是否请求移除Layertrue请求移除;false不请求移除;null不处理事件,事件会穿透背景,默认值false。由于现在手机屏幕越来越大,可能误触背景区域导致移除Layer,所以默认值是false,可以根据实际需求配置

  • alignment可以设置Layer和目标的对齐位置,这里设置为BottomCenter表示显示在目标底部,水平方向和目标中心点对齐,它的默认值是Center中心点对齐

重点来啦,smartAlignments参数是智能对齐目标的意思,只有在alignment参数导致内容溢出时,才会从smartAlignments中按顺序查找内容溢出最小的位置,可以看一下这个类:

@Immutable
data class SmartAliments(
   val aliments: List,
) {
   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

场景二

录屏2024-09-25 20.16.51.gif

类似微信朋友圈的点赞和评论菜单,我们来实现一下这个功能。

首先需要在Item布局里面为按钮添加tag,添加之后这个tag对应的按钮就是Layer要定位的目标:

@Composable
private fun ListItem(
   modifier: Modifier = Modifier,
   
   tag: String,
   
   onClick: (String) -> Unit,
) {
   Box(
      modifier = modifier
         .fillMaxWidth()
         .heightIn(200.dp)
   ) {
      IconButton(
         onClick = { onClick(tag) },
         modifier = Modifier.align(Alignment.BottomEnd)
            
            .layerTag(tag)
      ) {
         Icon(Icons.Default.MoreVert, "more")
      }
      HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
   }
}

IconButton是更多按钮,通过Modifier.layerTag为它设置一个tag,在点击的时候回调tag

创建TargetLayer

@Composable
private fun Content() {
   
   var attach by remember { mutableStateOf(false) }
   
   var tag by remember { mutableStateOf("") }

   LazyColumn(
      modifier = Modifier
         .fillMaxSize()
         .pointerInput(Unit) {
            
            detectTapGestures(
               onPress = {
                  attach = false
               }
            )
         },
   ) {
      items(100) { index ->
         ListItem(
            tag = index.toString(),
            onClick = {
               
               tag = it
               
               attach = true
            },
         )
      }
   }

   TargetLayer(
      
      target = LayerTarget.Tag(tag),
      attach = attach,
      onDetachRequest = { attach = false },
      backgroundColor = Color.Transparent,
      
      detachOnTouchBackground = null,
      
      alignment = TargetAlignment.StartCenter,
   ) {
      
   }
}

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

场景三

录屏2024-09-25 23.45.22.gif

输入框输入一些内容后,下拉框会有建议列表,这也是比较常见的场景,我们来实现一下这个功能。

@Composable
private fun Content() {
   
   var attach by remember { mutableStateOf(false) }

   Column(modifier = Modifier.fillMaxSize()) {
      InputBar(
         
         tag = "abc",
         showPopMenu = {
            
            attach = it
         }
      )
   }

   TargetLayer(
       
      target = LayerTarget.Tag("abc"),
      attach = attach,
      onDetachRequest = { attach = false },
      detachOnTouchBackground = true,
      
      alignment = TargetAlignment.BottomCenter,
      
      clipBackgroundDirection = Directions.Top,
   ) {
      
   }
}

InputBar是一个输入框,给它传递一个tag"abc"参数,其内部还是通过Modifier.layerTag来为输入框设置tag,上面已经介绍过了这里就不赘述,然后监听输入框的回调showPopMenu是否显示Layer

最后要把"abc"这个tag也传递给TargetLayer,这样子它才能定位到输入框这个目标。

这里多了一个还没见过的参数:clipBackgroundDirection,它的意思是要裁切Layer哪些方向的背景,设置为Directions.Top表示裁切Layer顶部方向的背景,如果不裁切的话输入框会被背景所遮挡。

Directions支持上下左右4个方向,支持同时裁剪多个方向的背景,如果要同时裁切顶部和底部的话,可以设置为:Directions.Top + Directions.Bottom

有时候需要在对齐目标之后做一些位置微调,例如输入框下面弹出的建议列表往下面挪一点,可以通过以下代码实现:

TargetLayer(
   ...
   
   alignmentOffsetY = TargetAlignmentOffset.PX(200)
) 

image.png

alignmentOffsetY表示Y方向偏移,alignmentOffsetX表示X方向偏移,参数类型是TargetAlignmentOffset

@Immutable
sealed class TargetAlignmentOffset {
   
   data class PX(val value: Int) : TargetAlignmentOffset()

   
   data class DP(val value: Int) : TargetAlignmentOffset()

   
   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来设置要对齐的位置,具体都有哪些位置可以看下面的效果图:

录屏2024-09-26 00.32.45.gif

TargetLayer vs Layer

这篇文章主要讲的是定位,所以用到的都是TargetLayer,实际上还有一个Layer,它没有定位功能,可以把它当作Compose Dialog来使用:


@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,
)
  • 参数alignmentCompose标准类Alignment,相当于在Box中为UI设置对齐参数
  • 参数zIndex可以设置Layerz轴的层级,值越大显示在越上层

其他参数上面已经介绍过就不赘述了。

不管是TargetLayer还是Layer,最终它的内容都会显示在LayerContainer中,所以外层要设置LayerContainer容器,否则会抛异常。

结束

分享到此为止,如果有问题欢迎一起交流学习,感谢你的阅读。

阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.dandroid.cn/archives/22052,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?