横纵切换:UI动效的“罗生门”,你的布局该听谁的?
关键词:横纵切换 / 声明式 UI / ArkUI / re-layout / transform / 预计算几何
说明:本文的"布局驱动 vs 几何驱动"思路是通用的,适用于任何声明式 UI 框架。但文中的代码示例和具体 API(如
position、translate、scale、Row、Column等)均基于 ArkUI(ArkTS)。其他框架的开发者可以将思路迁移到对应框架的等价 API 上。横纵布局切换动画方案选择:当把所有可行路线摆上桌面,会发现真正的分岔口只有一个——横纵布局变换到底交给谁来完成。
如果只做过静态 UI,会觉得“横排 ↔ 竖排”只是切换一下容器方向。
但一旦需要实现“吸顶折叠过程中的连续动画”(icon/文字/背景一起动,轨迹还要可控),横纵切换就不再是一个局部实现点,而是一个会决定后面所有设计的架构根节点。
方案可以归为两条路线:
- 布局驱动:让布局系统在两种状态下自动重排(例如 listDirection/Flex 方向切换/双模板切换等)
- 几何驱动:由开发者自己定义两态几何,动画期间主要用 transform 做插值(position + translate/scale)
为了快速建立直觉,可以先看两条路线的最小结构骨架(省略所有业务分支,只看结构差异):
路线 A:布局系统驱动(Layout-driven)的骨架
Row() {
// Tab 1
Stack() {
Image(bgImage) // 标签背景图
List() {
ListItem() {
Image(icon)
}
ListItem() {
Text(text)
}
}
.listDirection(shouldStickTop ? Horizontal : Vertical)
}
// Tab 2...
}
路线 B:几何驱动(Geometry-driven)的骨架
Row() {
Image(bgImage) // 标签背景图(独立在外层)
// Tab 1
Column() {
Image(icon)
.position({ x: 0, y: 0 })
.translate({ x: iconX, y: iconY })
.scale({ x: iconScale, y: iconScale })
Text(text)
.position({ x: 0, y: 0 })
.translate({ x: textX, y: textY })
}
// Tab 2...
}
这两段骨架的差异,基本已经把后面所有“为什么要预计算、为什么轨迹可控、为什么背景能解耦、为什么能高刷稳定”的结论提前剧透了。
下面再按“需求→候选路线→为什么分岔口在这里→如何落地”把逻辑讲完整。
1)需求一旦复杂,横纵切换就会绑定一串“连带问题”
一个典型的真实场景大概是这样:
- 未吸顶(展开态):icon 在上、文字在下
- 吸顶后(折叠态):icon 在左、文字在右
- 切换过程中要连续动画(位移/缩放/变色)
- 同时还有:
- 背景形状跟随激活项移动(展开是大梯形,折叠变扁梯形/胶囊)
- 首项固定(可选)
- 横向滚动居中
- 亮暗色、左中右不同物料
你会发现:横纵切换一旦做不好,后面所有东西都跟着不稳。
2)一开始就把候选路线摆出来(但不按“容器名字”分类)
很多文章喜欢按组件名对比:RelativeContainer、Flex、List、两套模板……
更有效的方式是按“控制权归属”分类,因为组件名字会随框架/版本变化,但控制权问题不会变。
路线 A:布局驱动(Layout-driven)
核心做法:
- 让系统通过“切换布局规则”来完成横纵切换
常见落地形态:
- RelativeContainer:在处理动态内容或复杂对齐时,其约束规则可能导致意外行为,排查成本较高。
- Flex direction 切换:存在内容超出容器被裁剪的风险,且官方文档提示频繁切换该属性可能带来的性能影响。
- List + listDirection:功能可行,但会引入额外层级(ListItem),且动效轨迹更依赖系统布局,难以精确控制。
- Row/Column 两套模板切换(显示/隐藏或销毁/重建):这种切换更像重建/显隐,难以实现平滑的连续位移和变形动画。
一句话概括:
开发者提供约束/方向,位置由布局算法决定。
路线 B:几何驱动(Geometry-driven)
核心做法:
- 不让布局系统决定“最终位置”
- 由开发者自己计算展开态/折叠态的目标坐标
- 动画期间主要用渲染变换插值(translate/scale)
一句话概括:
开发者直接提供位置结果,布局系统只提供坐标原点。
3)真正的分岔口:动画期间允许“布局系统”参与多少?
可以把判断标准收敛成一个非常实用的问题:
动画期间,是否允许 re-layout 频繁发生?
3.1 为什么布局驱动容易把动画写“飘”?
不是因为某个容器不好,而是因为布局驱动天然会带来三类不确定性:
-
端点不确定
- 横纵切换后元素到底落在哪,往往要等布局算完才知道
- 想“提前算好端点 + 插值”会很难,因为端点本身是布局输出
-
轨迹不可控
- 系统可能会给出一个“看起来合理”的补间,但很难精确控制:
- 谁先动、谁后动
- 是否分段
- 是否 overshoot
- icon 与文字是否严格走同一时间轴
- 系统可能会给出一个“看起来合理”的补间,但很难精确控制:
-
re-layout 干扰补间
- 动画中途若在改 width/margin/direction 等,会触发布局重排
- 轨迹会被布局结果离散修正,表现为偶发跳动/抖动(高刷更明显)
这些问题在需求简单时不显眼,但在“吸顶折叠 + 多元素联动”场景下会被放大。
3.2 为什么几何驱动能把复杂度“集中化”?
几何驱动的优点不是“更强”,而是“更明确”:
- 端点是算出来的,动画开始前就确定
- 轨迹是插值出来的,想怎么分段/延迟都可控
- 动画期间主要改 transform,尽量不触发布局重排
- UI 结构节点更少、职责更清晰
当然它也有代价:
- 需要 measureTextSize(文本宽高必须可预测)
- 需要建立预计算与缓存(否则运行时算会很重)
但这套代价一旦付了,后面背景、滚动居中、首项遮挡,都会顺着同一条路径解决。
4)如何落地“几何驱动”的横纵切换?
落地时可以抓住一个关键技巧:
4.1 让子元素脱离布局流:position({0,0})
在 tab 的容器里(例如 Column),icon 和 text 都设置:
position({ x: 0, y: 0 })
这一步的意义是:
- 不再依赖 Column/Row 的自动排布
- 容器只提供一个“共同坐标原点”
4.2 用 translate/scale 贴上两态几何
在计算层提前算出:
- 展开态:iconX/iconY、textX/textY、iconScale=1
- 折叠态:iconX/iconY、textX/textY、iconScale<1(或不同大小)
渲染层只控制两个绘制属性:
translate({ x, y })scale({ x, y })
动画期间,尽量不通过“切换容器/改布局属性”完成效果,而是让坐标插值带着元素走。
5)为什么这个根节点一旦选定,后面所有设计都变成“必然结果”?
这也是最值得强调的一点:
- 一旦选了几何驱动,UI 结构会自然变薄(少嵌套、少中间层)
- 自然会需要:
- 物料查表缓存(解决状态组合爆炸)
- 几何预计算缓存(两态端点提前确定)
- render 层只消费几何(modifier 只贴 transform)
- 自然会把:
- 背景当图层(独立于 tab 布局)
- targetScrollOffset 当布局输出(而不是运行时测量)
- 首项遮挡当交互状态机(而不是 zIndex/clip 补丁)
所以这篇并不是在推荐“某个 API”,而是在强调:
横纵切换的实现方式,会决定你到底是在“和布局系统博弈”,还是在“定义一套可控几何”。
6)小结
笛卡尔的含金量: 从用
margin/padding/spaceBetween这类“几何关系”去驱动布局 改为把几何关系“编译”成坐标数据,让translate成为唯一的几何接口。当“位置变化”只从一个统一接口(
translate)流出,会直接带来:
- 可推导/可回归:位置 bug 更像“x/y 算错了”,而不是一堆 margin/padding/align 的组合问题;
- 可组合:【吸顶、激活态增宽、首项固定让位】等效果可以在同一坐标值里做合成;
- 更符合 OCP:新增“位移需求”时,尽量不改渲染层结构,只扩展计算层输出(例如改
iconX的公式/插值),Render 层仍然只消费.translate({ x, y })。这种将“如何布局”(how)与“布局结果”(what)分离的做法,也侧面说明:很多设计原则首先是“对变化的管理方式”,并不依赖 OOP 语法本身。
现在的判断很简单:
- 需求简单、只要能切横纵、不追求轨迹:布局驱动足够
- 一旦需求出现“吸顶折叠 + 多元素联动 + 高刷稳定 + 轨迹可控”,横纵切换就应该走几何驱动
下一篇会继续把“几何驱动”讲透:
- 为什么预计算不是优化,而是必需品(measureTextSize、两态几何、确定性、缓存)
附录:可独立运行的“几何驱动”示例
为了更直观地展示“几何驱动”如何通过直接控制坐标来实现布局动画,下面提供一个完整的、可独立运行的 ArkUI 示例代码。你可以将代码复制到项目中,直接预览横纵切换效果。
说明:以下代码提供了两个组件(A 和 B),它们实现了相同的视觉效果,但方式不同:
- Component A:使用
transform(matrix4),这是一种更底层、更强大的矩阵变换方式- Component B:使用
translate和scale,这种方式更直观,也是本系列文章中推荐优先使用的方式建议先看 Component B,理解几何驱动的基本思路;如果对矩阵变换感兴趣,再看 Component A。
import { matrix4 } from '@kit.ArkUI'
@Entry
@Component
struct Transform02 {
build() {
Column() {
A()
B()
}
}
}
//计算基准值:
//容器宽高:containerWidth=100
// 纵向宽高:文字:70*20,图片:50*50,图下间距:5,字下间距:6
// 横向宽高:文字:70*25,图片:30*30,图左间距:3,字左间距:4
@Component
struct A {
@State trans: boolean = false
@State iconWidth: number = 50
@State containerWidth: number = 100
@State translateX: number = 0
@State translateY: number = 0
change = () => this.trans = !this.trans
vp(a: number) {
return this.getUIContext().vp2px(a)
}
private buildImageTransform() {
const matrix1 = matrix4.identity()
.translate({
x: this.vp((this.getContainerWidth() - 50) / 2),
y: this.vp(this.getContainerHeight() - 50 - 20 - 5 - 6)
})
const matrix2 = matrix4.identity()
.scale({
x: 30 / 50,
y: 30 / 50,
centerX: this.vp(-50 / 2), //缩放中心为左上角
centerY: this.vp(-50 / 2),
})
.translate({ x: this.vp(0 + 3), y: this.vp((this.getContainerHeight() - 30) / 2) }) //如果在scale之前,还要消除缩放系数
return this.trans ? matrix2 : matrix1
}
private buildTextTransform() {
const matrix1 = matrix4.identity()
.translate({
x: this.vp((this.getContainerWidth() - 70) / 2),
y: this.vp(this.getContainerHeight() - (20 + 6))
})
const matrix2 = matrix4.identity()
.translate({ x: this.vp(30 + 3 + 4), y: this.vp((this.getContainerHeight() - 20) / 2) }) //如果在scale之前,还要消除缩放系数
return this.trans ? matrix2 : matrix1
}
getContainerWidth() {
return this.trans ? (30 + 70 + 3 + 4) : Math.max(50, 70)
}
getContainerHeight() {
return this.trans ? Math.max(30, 25) : (20 + 50 + 5 + 6)
}
build() {
Column() {
Text('变形').onClick(() => this.getUIContext().animateTo({ duration: 300 }, this.change))
Column() {
Text('文字')
.borderWidth(1)
.size({ width: 70, height: 20 })
.position({ x: 0, y: 0 })
.transform(this.buildTextTransform())
.onClick(() => console.log('文字'))
Image($r('sys.media.AI_form'))
.position({ x: 0, y: 0 })
.size({ width: 50, height: 50 })
.borderWidth(1)
.backgroundColor('#ffd3a06e')
.transform(this.buildImageTransform())
.onClick(() => console.log('图片'))
}
.size({ width: this.getContainerWidth(), height: this.getContainerHeight() })
.borderWidth(1)
.onClick(() => console.log('容器'))
Blank().height(300).backgroundColor('#ffc0f3dd')
}.width('100%').backgroundColor('#fffff8d6')
}
}
@Component
struct B {
@State trans: boolean = false
change = () => this.trans = !this.trans
build() {
Column() {
Text('变形').onClick(() => this.getUIContext().animateTo({ duration: 300 }, this.change))
// 容器
Column() {
// 文字
Text('文字')
.size({ width: 70, height: 20 })
.position({ x: 0, y: 0 })
.translate(this.trans ?
{ x: 30 + 3 + 4, y: (Math.max(30, 25) - 20) / 2 } :
{ x: (Math.max(50, 70) - 70) / 2, y: 50 + 5 })
.borderWidth(1)
.onClick(() => console.log('文字'))
// 图片
Image($r('sys.media.AI_form'))
.size({ width: 50, height: 50 })
.position({ x: 0, y: 0 })
.scale({
x: this.trans ? 30 / 50 : 1,
y: this.trans ? 30 / 50 : 1,
centerX: 0,
centerY: 0
})
.translate(this.trans ?
{ x: 3, y: (Math.max(30, 25) - 30) / 2 } :
{ x: (Math.max(50, 70) - 50) / 2, y: 0 })
.borderWidth(1)
.backgroundColor('#ffd7b6e9')
.onClick(() => console.log('图片'))
}
.size({
width: this.trans ? (30 + 3 + 70 + 4) : Math.max(50, 70),
height: this.trans ? Math.max(30, 25) : (50 + 5 + 20 + 6)
})
.borderWidth(1)
.onClick(() => console.log('容器'))
Blank().height(300).backgroundColor('#ffc0f3dd')
}
.width('100%')
.backgroundColor('#fffff8d6')
}
}
注意事项
- 独立运行:这是一个独立的 Demo,不依赖本系列文章中的其他复杂组件。
- 两种实现对比:代码中包含了
Component A和Component B两个组件,它们都实现了相同的视觉效果,但方式不同:Component A使用transform(matrix4),这是一种更底层、更强大的矩阵变换方式。Component B使用translate和scale,这种方式更直观,也是本系列文章中推荐优先使用的方式。
- 与文章思想的关联:示例为了简化,将两态的坐标(横向/纵向)直接硬编码在了组件内部。在真实的大型项目中,这些坐标值应该由独立的"几何计算层"(即后续文章中提到的
GeometryCalculator)来提供,UI 层只负责消费这些计算结果。这正是"几何驱动"架构的核心思想。
写在最后:这是《复杂tab动效组件架构设计》系列专栏的第1篇(前面有一篇序章),后续会继续展开每个技术点的实现细节。感兴趣的话可以关注专栏。欢迎在评论区分享你的经验和踩过的坑。如果这篇文章对你有启发,也欢迎点赞支持,这会是我继续分享的动力。