尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Jetpack Compose 自定义布局

Jetpack Compose 自定义布局 Jetpack Compose 系列第 15 篇。Jetpack Compose 系列文章写到现在入门的知识点已经都分享给了大家后续想继续分享一些进阶但未来开发中必定会遇到的知识。比如今天要分享的是——使用 Jetpack Compose 实现自定义布局。一、前言当 Jetpack Compose 内置的布局Column、Row、Box无法满足需求时根据在传统 Android View 时代的开发经验一般就要进行自定义布局——自定义ViewGroup而在 Jetpack Compose 时代一个Layout组件解决所有。本文会先回忆 Android View 时代自定义布局的老套路、看清它的痛点再介绍 Compose 的Layout包含“测量–定尺寸–摆放”三步法最后带来两个实战布局自动换行FlowLayout、带间距的SpacedColumn并聊聊Modifier.layout和进阶注意事项。二、回忆旧时代——ViewGroup 自定义布局在传统 Android View 开发中要实现自定义布局一般按照下面的步骤进行创建继承ViewGroup的类如MyFrameLayout.java重写onMeasure()确定当前ViewGroup和所有子View的尺寸解析MeasureSpec的三种模式EXACTLY/AT_MOST/UNSPECIFIED重写onLayout()确定所有子View的屏幕位置child.layout(left,top,right,bottom)在XML中使用完整类名引用添加自定义属性res/values/attrs.xml这个套路的缺点十分明显——代码量大而且几乎都是样板代码。哪怕你只是想实现一个简单的布局效果几百行代码都是必须要的。即使后来采用kotlin编程语言进行开发代码量减少约30%的前提下对于一个功能丰富的自定义布局类依旧有着复杂的计算逻辑和代码量。更致命的是View 体系中允许父 View 多次调用子 View 的measure()如LinearLayout中的weight会测量两次这无疑对性能有很大影响。Jetpack Compose 的到来给了我们新的选择它用一个Layout来解决一切。在 Jetpack Compose 中不需要考虑onMeasure和onLayout的调用时机、测量模式的分发规则、各个 child 与 parent 之间的约束只需要遵照一个核心原则即可——你给我子项和约束我把它们量好、摆好、显示好。三、认识核心——LayoutLayout是一个 Composable可组合项它把“测量child 摆放child”的全部逻辑封装进一个MeasurePolicy里。官方提供的布局组件Column、Row和Box等底层其实都是基于Layout来实现的。那么就来认识一下Layout函数ComposableinlinefunLayout(content:ComposableUiComposable()-Unit,modifier:ModifierModifier,measurePolicy:MeasurePolicy,)Layout函数中三个参数含义解释❗注意重点理解 content measurePolicy 的概念content子 Composable 的内容modifier修饰符measurePolicy核心它定义了如何测量和放置子元素MeasurePolicy是一个接口其关键方法截取如下StableJvmDefaultWithCompatibilityfuninterfaceMeasurePolicy{funMeasureScope.measure(measurables:ListMeasurable,constraints:Constraints):MeasureResult}measurables所有需要测量的子元素一个List结构类型constraints父布局对当前布局的尺寸约束minWidth/minHeight、maxWidth/maxHeight返回值 MeasureResult包含布局自身尺寸和子元素的放置位置来看一个最小示例——所有子项叠在一起的MyStack即类似官方提供的Box组件效果ComposablefunMyStack(modifier:ModifierModifier,content:Composable()-Unit){// ⭐Layout是一个Composable函数Layout(modifiermodifier,contentcontent){measurables,constraints-// 1. 测量把每个子项测量成 Placeablevalplaceablesmeasurables.map{it.measure(constraints)}// 2. 计算容器尺寸取所有子项的最大宽高valwidthplaceables.maxOfOrNull{it.width}?:0valheightplaceables.maxOfOrNull{it.height}?:0// 3. 摆放先 layout() 提交尺寸再逐个 placelayout(width,height){placeables.forEach{placeable-placeable.placeRelative(0,0)// 全部堆叠在左上角}}}}// 使用方式与内置布局没有区别ComposablefunMyStackDemo(){MyStack{Text(第一层,Modifier.background(Color.Red))Text(第二层,Modifier.background(Color.Green))Text(第三层,Modifier.background(Color.Blue))}}很简单的示例代码我们再用表格来对比下巩固对Compose里布局逻辑的理解Android View 体系Jetpack Compose职责onMeasure()measurables.map{it.measure(constraints)}测量子项MeasureSpecConstraints父级尺寸约束onLayout()placeable.placeRelative(x,y)摆放childLayoutParamsModifierchild对父布局的诉求使用placeRelative()而不是place()前者支持在RTL从右到左的布局下自动镜像坐标是官方推荐的写法后者不会跟随RTL翻转。四、理解 Constraints 约束上面的最小示例代码MyStack.kt中Layout()函数最后一个参数即measurePolicy的 lambda 表达式参数是measurables,constraints -测量阶段得到的constraints是一个Constraints对象它的职责就是告诉布局“你可以有多大”。其函数方法如下classConstraints(valminWidth:Int,valmaxWidth:Int,valminHeight:Int,valmaxHeight:Int)它只有四个值minWidth / maxWidth父级允许的最小/最大宽度minHeight / maxHeight父级允许的最小/最大高度它就相当于 Android View 中的MeasureSpec但Constraints不需要去解析MeasureSpec的 mode 和 Size ,它直观的的告诉布局最小和最大的宽高父布局根据自己的约束和布局意图如fillMaxWidth()会把maxWidth确定下来计算出每个子项的约束范围。一个关键原则单次测量Compose 规定每个子元素在一个布局传递中只能被测量一次如果对一个Measurable重复调用measure()会直接抛出异常。Compose 的这个原则从根本上“消灭”了 Android View 时代重复测量带来的性能隐患。// ❌ 错误多次测量同一个 measurablevalplaceable1measurable.measure(constraints1)valplaceable2measurable.measure(constraints2)// ✅ 正确每个 measurable 只测量一次valplaceablemeasurable.measure(constraints)// 然后使用这个 placeable 的尺寸来做布局决策五、认识 Modifier.layout有时候我们并不想花大力气完全重写组件行为而是只想修改某个组件的某一个行为比如——一个Box的宽度永远是父布局可用宽度的一半且高度自适应。类似这样的需求可以考虑使用Modifier.layout它在单个节点的测量与摆放层面做手脚。// 自定义修饰符宽度强制为父约束最大宽度的 50%funModifier.halfWidth()layout{measurable,constraints-// 1. 获取父布局的最大宽度valmaxWidthconstraints.maxWidth// 2. 计算新宽度取一半但不能小于最小宽度限制valnewWidth(maxWidth*0.5f).toInt().coerceAtLeast(constraints.minWidth)// 3. 创建新的约束仅修改宽度高度保持不变valnewConstraintsconstraints.copy(minWidthnewWidth,maxWidthnewWidth)// 4. 用新约束重新测量子组件valplaceablemeasurable.measure(newConstraints)// 5. 以测量后的尺寸提交布局layout(placeable.width,placeable.height){placeable.place(0,0)}}Box(modifierModifier.fillMaxWidth()){// 父容器撑满全屏Text(text我只有屏幕一半宽,modifierModifier.halfWidth().background(Color.Yellow))}Modifier.layout本质上是给节点加了一层自定义布局它有权修改传给子节点的约束也有权决定最终尺寸与摆放位置。(回忆之前的文章《Jetpack Compose Modifier 修饰符完全指南从入门到静通》里提到的“Modifier 顺序影响效果”)什么时候用Layout什么时候用Modifier.layout可以根据下表判断场景API选用需要同时摆放多个子项如瀑布流、FlexboxLayout{…}只需要微调某一个按钮、图标或文字的尺寸/位置/对齐方式Modifier.layout{…}六、实战1——手写FlowLayoutCompose 自定义布局概念和步骤清楚后下面就是经典实战环节——实现一个自动换行的流式布局FlowLayout——搜索历史标签、偏好设置那种效果子项从左到右、从上到下依次排列当前行放不下就换行。代码如下ComposablefunMyFlowLayout(modifier:ModifierModifier,horizontalGap:Dp8.dp,verticalGap:Dp8.dp,content:Composable()-Unit){Layout(modifiermodifier,contentcontent){measurables,constraints-valhGaphorizontalGap.roundToPx()//valvGapverticalGap.roundToPx()// 1. 测量所有子项把最小约束削减为 0防止子项被强迫撑满valplaceablesmeasurables.map{measurable-measurable.measure(constraints.copy(minWidth0,minHeight0))}// 2. 模拟逐行摆放计算出容器总宽高varx0vary0varrowHeight0vartotalWidth0vartotalHeight0for(placeableinplaceables){// 当前行放不下换行if(xplaceable.widthconstraints.maxWidth){x0yrowHeightvGap rowHeight0}totalWidthmaxOf(totalWidth,xplaceable.width)totalHeightmaxOf(totalHeight,yplaceable.height)rowHeightmaxOf(rowHeight,placeable.height)xplaceable.widthhGap}// 3. 提交尺寸真正摆放换行逻辑与第 2 步一致layout(totalWidth,totalHeight){x0y0rowHeight0for(placeableinplaceables){if(xplaceable.widthconstraints.maxWidth){x0yrowHeightvGap rowHeight0}placeable.placeRelative(x,y)// ⭐真正摆放rowHeightmaxOf(rowHeight,placeable.height)xplaceable.widthhGap}}}}ComposablefunMyFlowDemo(){MyFlowLayout(Modifier.padding(16.dp)){repeat(8){index-Text(text标签$index,modifierModifier.padding(4.dp).background(Color(0xFFE3F2FD),RoundedCornerShape(8.dp)).padding(horizontal10.dp,vertical6.dp))}}}调用方式也很简单classCustomViewActivity:ComponentActivity(){overridefunonCreate(savedInstanceState:Bundle?){super.onCreate(savedInstanceState)enableEdgeToEdge()setContent{KnownAndroidTheme{Scaffold(modifierModifier.fillMaxSize()){innerPadding-Column(modifierModifier.padding(innerPadding)){MyFlowDemo()}}}}}}效果如下七、实战2——带间距的Column再写一个更实用的自定义布局——一个支持自定义子元素间距的Column。ComposablefunSpacedColumn(modifier:ModifierModifier,verticalSpacing:Dp0.dp,horizontalAlignment:Alignment.HorizontalAlignment.Start,content:Composable()-Unit){Layout(modifiermodifier,contentcontent){measurables,constraints-valspacingPxverticalSpacing.roundToPx()// 测量所有子元素valplaceablesmeasurables.map{measurable-measurable.measure(constraints)}// 计算总高度valtotalHeightplaceables.fold(0){acc,placeable-accplaceable.heightspacingPx}-spacingPx// 找到最大宽度valmaxChildWidthplaceables.maxOfOrNull{it.width}?:0layout(widthconstraints.maxWidth,heighttotalHeight.coerceAtMost(constraints.maxHeight)){vary0placeables.forEach{placeable-valxwhen(horizontalAlignment){Alignment.Start-0Alignment.CenterHorizontally-(maxChildWidth-placeable.width)/2Alignment.End-maxChildWidth-placeable.widthelse-0}placeable.place(x,y)yplaceable.heightspacingPx}}}}ComposablefunSpacedColumnDemo(){SpacedColumn(verticalSpacing16.dp,horizontalAlignmentAlignment.CenterHorizontally){repeat(5){index-Box(modifierModifier.size(80.dp).background(Color.hsl(hueindex*60f,saturation0.7f,lightness0.5f),shapeRoundedCornerShape(8.dp))){Text(text${index1},colorColor.White,fontSize24.sp,modifierModifier.align(Alignment.Center))}}}}运行效果如下八、进阶和注意事项8.1 更强大的 SubcomposeLayoutLayout的局限在于测量之前所有子项必须已经组合完成。有些场景需要根据约束条件再去组合子项——比如BoxWithConstraints、LazyColumn的底层实现。这就轮到SubcomposeLayout出场了它能在测量阶段动态决定要组合哪些内容代价是逻辑更复杂、性能开销更大。它的工作方式很有意思在测量阶段拿到约束后再调用subcompose()按需组合出子项相当于把组合也变成了布局流程的一部分。感兴趣的读者朋友可以自己去官方了解本文不展开讲了。8.2 自定义布局的约定写自定义布局时有些约定要记得不要重复测量同一个 Measurable 在一个测量阶段里只能 measure() 一次重复调用直接抛异常。尊重最小约束父级给的 minWidth / minHeight 不要轻易忽略让内容决定尺寸当约束允许时容器尺寸应由子项测量结果推导避免在 measure 中读取 State在measurelambda 中读取了State当状态变化时会触发整个布局的重新测量使用remember缓存计算结果布局计算涉及复杂逻辑可以用remember缓存中间结果九、总结在自定义布局这个主题路线下文章介绍了 Android View 的弊端介绍了Compose用核心API——Layout如何完成自定义布局measure、layout、placeRelative还帮助理解constraints约束强调了单次测量最后介绍了Modifier.layout处理单个节点和带来两个实战示例。要强调的是在 Compose 中自定义布局的核心是Layout这个 Composable 函数。它把测量和放置合并到了一个 Lambda 表达式里——测量完立刻放置逻辑连贯一目了然。也要提醒自定义布局是“最后的手段不是第一选择”理解了机制、能看懂官方源码远比造一个轮子更有价值。十、参考资料https://developer.android.google.cn/develop/ui/compose/layouts/custom?hlzh-cnhttps://developer.android.google.cn/develop/ui/compose/layouts/custom?hlzh-cn#create-customhttps://googledownloads.cn/cn-devsite/l6rAoph5UgI.mp4https://github.com/android/compose-sampleshttps://gatti.dev/en/posts/compose-custom-layout/https://juejin.cn/post/6963262309699420173?searchId20260811130855F3D711244ADC0F3D22A3十一、往期系列文章Jetpack Compose Navigation 3.0 完全指南Jetpack Compose 使用Navigation实现导航Jetpack Compose 主题与样式Jetpack Compose 手势处理揭秘写给新手的 Jetpack Compose 动画手册Jetpack Compose 副作用Jetpack Compose 重组机制Jetpack Compose 状态管理指南Jetpack Compose 组件大观园Jetpack Compose 常用UI组件实战演练Jetpack Compose Modifier 修饰符完全指南从入门到精通Jetpack Compose 核心机制Composable与ModifierJetpack Compose 入门指南Jetpack Compose 前探
返回列表