一、前言
最近一段时间一直在忙于其他事情,博客写的很少了。当然,传统UI布局和Compose UI布局在过去的文章中也写了很多了。传统布局方面,从普通的View绘制到布局定义,从布局定义再到LayoutManager定义,我们几乎都有所涉及,可以说囊括了方方面面。Compose UI方面,我们从基础的用法到绘制,以及一些简单的布局也有涉及,就Compose UI而言,其组件丰富度上还是不错的,官方实现了很多布局效果。
作为合格的Android开发者,理解Android 协调机制是非常必要的。
本篇效果
1.1 本篇意义
这种布局的意义在于,其本身是一种【基础构型】,就相当于给你一个开发框架,你就可以DIY出自己想要的效果。
具体可以改造出哪些效果呢?这里我们简单列举一下
- 上拉加载
- 下拉刷新
- Header + Tab吸顶效果
- Footer + Tab吸底效果
- 协调者布局行为效果
当然,还能解决轮播效果中,类似ViewPager被回收后如何恢复等问题。
1.2 要点
1.2.1 名词解释
- Header:布局中最顶部的Item
- Footer:布局中最底部的Item
- LazyList:可以自身滑动的列表,如LazyColumn等,本篇统称LazyList
1.2.2 为什么使用ScrollConnection
之所以写本篇文章,主要是在之前的文章中,我们有一篇文章是《Android 为RecyclerView添加可吸顶Header》,这篇文章中通过Android Scrolling机制的协调实现,为RecyclerView实现了一个可以吸顶的HeaderView。
在本篇,我们也会使用到Scrolling机制,当然,在Compose UI中,Scrolling机制具体实现就是ScrollConnection,可想而知,无论是传统布局还是Compose UI布局,Scrolling机制是非常简单易用,使用起来非常高效的,不然Compose UI就不会移植这一套机制了。
二、原理
本篇比之前的文章将更进一步,这里我们不仅仅添加HeaderView,还会添加Footer。可能你会问,具体原理是什么呢?
2.1 结构特征
首先,我们看下本篇的结构
2.1.1 列表露出时效果
可以看出,LazyList和父布局大小相等
2.1.2 滑动时效果
可以看到,LazyList和Header、Footer还会协调滑动,下面我们做一下总结。
特点如下:
- LazyList和父布局的高度相等
- Header和Footer大小可以随意
- 父布局中UI能滑动的最大范围为Header + Footer的总高度
- LazyList自身滑动、Item复用不受影响
- 当LazyList不能滑动时,Header和Footer会联动,直到最大和最小偏移位置
- Header和Footer不会被回收
三、代码实现
本篇我们当然需要用到ScrollConnection,当然与其他文章不同的是,本篇我们会使用到Compose自定义布局,使用的组件是Layout,在之前的文章《Compose自定义旋转菜单》中我们就使用Layout自定义了一个环形旋转菜单,有兴趣的开发者可以看看。
3.1 ScrollConnection 机制
Compose中Modifier的nestedScroll修饰符定义嵌套滚动层次结构来提高灵活性。这部分和传统的View布局相似,但是相比而言要简单的多一些。
这套机制是一套应答机制,从滑动前到滑动后的整个过程,协调父布局、爷孙布局、子布局产生更加连贯的滑动动作。
object : NestedScrollConnection {
//用于提前拦截,当然,LazyList也会按照自身优先级选择不调用此方法
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return super.onPreScroll(available, source)
}
// 用于消费后事件处理,比如LazyList已经到了不能滑动的位置了,就会回调此方法
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return super.onPostScroll(consumed, available, source)
}
//和onPreScroll 类似,这里我们暂时不处理,因为要计算瞬时速度,还是比较麻烦
override suspend fun onPreFling(available: Velocity): Velocity {
return super.onPreFling(available)
}
//和onPostScroll 类似,这里我们暂时不处理,因为要计算瞬时速度,还是比较麻烦
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return super.onPostFling(consumed, available)
}
}
onPreScroll:预处理滑动事件,先交给父组件消费后再交由子组件 available:当前可用滑动偏移量 source:滑动类型 返回值:当前消费的滑动偏移量,如果不想消费可返回Offset.Zero。
onPostScroll:子组件滑动后的回调 consumed:之前件消费滑动偏移量 available:当前剩余可用滑动偏移量 source:滑动事件的类型 返回值:当前消费的滑动偏移量,如果不想消费可返回Offset.Zero,则剩下偏移量会继续交由父组件进行处理
onPreFling 惯性滚动事件预处理。 available:开始时的速度 返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero
onPostFling 惯性滚动事件处理 consumed:之前消费的所有速度 available:当前剩余可用的速度 返回值:当前组件消费的速度,如果不想消费可返回Velocity.Zero,则剩下速度会继续交由父组件进行处理。
3.2 LazyList状态监控
实际上在Compose自定义布局中,无法拿到具体的Compose组件,就很难获取LazyList相关状态,因此,在初始化时,我们不得不做一些耦合度方面的妥协。
通过下面的方式,我们将LazyListState传递给NestedScrollConnection子类
val state: LazyListState = rememberLazyListState()
nestedScrollModifierNode.initLazyState(state)
LazyColumn (
state = state,
verticalArrangement = Arrangement.spacedBy(1.dp)
){
}
3.3 记录Compose Node基本信息
这里我们简单标记下header、footer、content的最大高度、当前偏移位置
data class ComposeNestedOffset(var key:String, var max: Float, var value: Float)
3.3 滚动逻辑
在代码中,我们要实现滚动逻辑其实很简单,只需要计算相应的偏移量即可,这里我们使用了协程,为什么要使用协程呢?因为在实际的滑动过程,不使用协程会导致卡顿。
private fun scroll(target: ComposeNestedOffset, offset : ComposeNestedOffset, canConsumed: Float): Offset {
return if (canConsumed.absoluteValue > 0.0f) {
target.value += canConsumed
//在这里更新而不是在协程中,避免同步事件触发多次
coroutineScope.launch {
contentOffset.value = lazyListState?.firstVisibleItemScrollOffset?.toFloat() ?: 0f;
graphicYOffset.value = target.value + offset.value //更新偏移距离
lazyListState?.apply {
if((this.firstVisibleItemIndex + this.layoutInfo.visibleItemsInfo.size) == this.layoutInfo.totalItemsCount){
loadMore(); //利用公式触发加载更多
}
}
}
Offset(0f, canConsumed)
} else {
Offset.Zero
}
}
拿到偏移量graphicYOffset之后,我们滚动父布局即可。
这里要说的是,和传统布局一样,Compose UI也分两种滚动,一种基于父布局Matrix变换,另一种是LazyList的中子Item的滑动。前者在Item少的情况下性能更高,但是Item多的话性能就会变差,这也是为什么有ScrollView之后还需要RecyclerView最根本的原因。
当然,我们这里是使用基于Matrix的变换,因为父布局只有Header、Footer、LazyList三个子Item
graphicsLayer {
translationY = connection.graphicYOffset.value
Log.d(TAG, "translationY = ${connection.graphicYOffset}")
}
3.3 测量和布局
我们使用Compose Layout组件一个好处就是可以从整体上控制Item自身的大小和位置。
关键部分请看注释。
val placeables = measurables.mapIndexed { index, measurable ->
if (contentIndex == index) {
//LazyList保证和父布局大小一致
val boxWidth = constraints.maxWidth
val boxHeight = constraints.maxHeight
val matchParentSizeConstraints = Constraints(
minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
maxWidth = boxWidth,
maxHeight = boxHeight
)
connection.contentOffset.max = boxHeight.toFloat()
measurable.measure(matchParentSizeConstraints)
} else {
val measure = measurable.measure(constraints)
if(index < contentIndex){
//header 大小记录
connection.headerOffset.max = measure.height.toFloat()
}else if(index > contentIndex){
// footer 大小记录
connection.footerOffset.max = measure.height.toFloat()
}
measure
}
}
// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
var yPosition = 0
placeables.forEach() { placeable ->
//从上到下布局Header、Footer、LazyList
placeable.placeRelative(x = 0, y = yPosition)
yPosition += placeable.height
}
}
3.4 Header和Footer事件处理
Header和Footer默认是无法滑动的,因此我们需要监听时间,触发滑动方法,以此来实现Header、Footer、LazyList的协调滑动。
pointerInput("header-footer-capture"){
//由于事件存在优先级,lazyList的优先级更高,我们只需要处理header和footer即可
detectDragGestures(onDragStart = {
if(findDragTarget(connection,it) == connection.headerOffset){
Log.d(TAG,"onDragStart Header $it")
connection.dispatchUserDragger(connection.headerOffset)
}else if(findDragTarget(connection,it) == connection.footerOffset){
Log.d(TAG,"onDragStart Footer $it")
connection.dispatchUserDragger(connection.footerOffset)
}
}, onDragEnd = {
connection.dispatchUserDragger(null)
}){change, dragAmount ->
Log.d(TAG,"onDrag $dragAmount")
connection.dispatchUserScroll(dragAmount);
}
}
3.5 核心控制逻辑
我们使用ScrollConnection,必然要处理相关回调,在这部分由于代码太长,这里我们不会写很多原理性的东西,而是对一些重要代码加入注释,方便理解。
这一部分相当关键,也是我们后续如果要实现下拉刷新、吸顶逻辑的关键,因此一定要认真阅读。
class SimpleNestedScrollConnection(
var coroutineScope: CoroutineScope
) : NestedScrollConnection{
private var dragger: ComposeNestedOffset? = null
private var lazyListState: LazyListState? = null
val headerOffset = ComposeNestedOffset("header",0F, 0F)
val footerOffset = ComposeNestedOffset("footer",0F, 0F)
var contentOffset = ComposeNestedOffset("content",0F, 0F)
var graphicYOffset = mutableFloatStateOf(0F)
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
Log.d(TAG,"$available")
return when {
available.y < 0 && headerOffset.max != 0f -> {
if(headerOffset.value > -headerOffset.max) {
//拦截向上滑动,不能超过最大范围,这里只处理header ,因为header优先级较高于footer,同时防止被lazylist消费
val offset = if(available.y + headerOffset.value < -headerOffset.max){
-headerOffset.max - headerOffset.value
}else{
available.y
}
scroll(headerOffset, ComposeNestedOffset("",0F, 0F), offset)
}else{
Offset.Zero
}
}
available.y > 0 -> {
if(lazyListState?.canScrollForward == false && footerOffset.max != 0f){
//footer向下滚动需要提前拦截,否则可能导致被LazyList消费,这时footer比header优先级高
val offset = if(available.y + footerOffset.value > 0){
abs(footerOffset.value)
}else{
available.y
}
scroll(footerOffset, headerOffset, offset)
}else{
Offset.Zero
}
}
else ->{
Offset.Zero
}
}
}
//下面是处理没有被lazylist 消费的事件
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return when {
available.y < 0 -> {
//拦截向上滑动,不能超过最大范围,这里只处理footer ,因为footer这个时候优先级最低
if(lazyListState?.canScrollForward == false && footerOffset.max != 0f) {
val offset = if(available.y + footerOffset.value < -footerOffset.max){
-footerOffset.max - footerOffset.value //保证不小于边界值
}else{
available.y
}
// 这个时候底部漏出来,那么translationY 是两者之和
scroll(footerOffset, headerOffset, offset)
}else{
Offset.Zero
}
}
available.y > 0 -> {
//拦截向上滑动,不能超过最大范围,这里只处理header ,因为header这个时候优先级最低
if(lazyListState?.canScrollBackward == false && headerOffset.max != 0f && headerOffset.value < 0){
val offset = if(available.y + headerOffset.value > 0){
abs(headerOffset.value) //保证不大于边界值
}else{
available.y
}
//说明在顶部,这时候footerOffset理论上也是0,这里写成这样为了更加直观
scroll(headerOffset, ComposeNestedOffset("",0F, 0F), offset)
}else{
Offset.Zero
}
}
else -> {
Offset.Zero
}
}
}
fun dispatchUserScroll(dragAmount: Offset){
when{
dragAmount.y < 0 -> {
if(dragger == headerOffset && headerOffset.max != 0f) {
//向上时,header优先拦截
onPreScroll(dragAmount,NestedScrollSource.Drag);
}else if(dragger == footerOffset && footerOffset.max != 0f){
//向下时,footer优先拦截
onPostScroll(Offset(0f,0f),dragAmount,NestedScrollSource.Drag)
}
}
dragAmount.y > 0 ->{
if(dragger == headerOffset && headerOffset.max != 0f) {
//向下时,header优先拦截
onPostScroll(Offset(0f,0f),dragAmount,NestedScrollSource.Drag)
}else if(dragger == footerOffset && footerOffset.max != 0f){
onPreScroll(dragAmount,NestedScrollSource.Drag);
}
}
else -> {
Offset.Zero
}
}
}
private fun scroll(target: ComposeNestedOffset, offset : ComposeNestedOffset, canConsumed: Float): Offset {
return if (canConsumed.absoluteValue > 0.0f) {
target.value += canConsumed
//在这里更新而不是在协程中,避免同步事件触发多次
coroutineScope.launch {
contentOffset.value = lazyListState?.firstVisibleItemScrollOffset?.toFloat() ?: 0f;
graphicYOffset.value = target.value + offset.value //更新偏移距离
lazyListState?.apply {
if((this.firstVisibleItemIndex + this.layoutInfo.visibleItemsInfo.size) == this.layoutInfo.totalItemsCount){
loadMore(); //利用公式触发加载更多
}
}
}
Offset(0f, canConsumed)
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
return super.onPreFling(available)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return super.onPostFling(consumed, available)
}
fun loadMore(){
}
fun initLazyState(state: LazyListState) {
lazyListState = state
}
fun dispatchUserDragger(dragger: ComposeNestedOffset?) {
this.dragger = dragger;
}
}
以上就是核心逻辑,基本上到这里核心逻辑就完成了。
四、使用
上面我们实现了核心逻辑,在定义完成之后,就能方便使用了,前面说过有一丁点的耦合逻辑无法避免,在下面的代码中也指出来了。
setContent {
SwipeRefreshColumn(headerIndicator = {
Box (modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.White),
contentAlignment = Alignment.Center
){
Text(text = "Hi, I am header")
}
}, footerIndicator = {
Box (modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.White),
contentAlignment = Alignment.Center){
Text(text = "ooh,long time no see")
}
}) { nestedScrollModifierNode ->
val state: LazyListState = rememberLazyListState()
nestedScrollModifierNode.initLazyState(state)
//耦合逻辑,用于监控LazyColumn的状态
LazyColumn (
state = state,
verticalArrangement = Arrangement.spacedBy(1.dp)
){
val list = (0..25).map { it.toString() }
items(count = list.size) {
Box (modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.background(Color.LightGray),
contentAlignment = Alignment.CenterStart){
Text(
text = list[it],
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
}
}
}
}
}
上面代码中有25个item,同样,当列表Item数量为5的时候,其本身也是可以滑动的
五、总结
本篇自定义布局特点
- 相比市面上的实现,代码灵活度更高
- 层次清洗,可维护性强
- 易于扩展
- 耦合度低
到这里本篇就结束了,本篇涉及到两个重点,一个是ScrollConnection的用法,另一个是使用Compose的Layout组件自定义布局。
自定义Layout和ScrollConnection是必须要要掌握的知识点,为什么这么说呢,因为这就相当于工具箱和基础材料的关系。如果熟练掌握,可以更加方便的面向老板编程。
六、本篇源码
由于代码量太长,后续我们会在Github中加入