横纵切换:UI动效的“罗生门”,你的布局该听谁的?

50 阅读11分钟

横纵切换:UI动效的“罗生门”,你的布局该听谁的?

关键词:横纵切换 / 声明式 UI / ArkUI / re-layout / transform / 预计算几何

说明:本文的"布局驱动 vs 几何驱动"思路是通用的,适用于任何声明式 UI 框架。但文中的代码示例和具体 API(如 positiontranslatescaleRowColumn 等)均基于 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 为什么布局驱动容易把动画写“飘”?

不是因为某个容器不好,而是因为布局驱动天然会带来三类不确定性:

  1. 端点不确定

    • 横纵切换后元素到底落在哪,往往要等布局算完才知道
    • 想“提前算好端点 + 插值”会很难,因为端点本身是布局输出
  2. 轨迹不可控

    • 系统可能会给出一个“看起来合理”的补间,但很难精确控制:
      • 谁先动、谁后动
      • 是否分段
      • 是否 overshoot
      • icon 与文字是否严格走同一时间轴
  3. 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:使用 translatescale,这种方式更直观,也是本系列文章中推荐优先使用的方式

建议先看 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')
  }
}

注意事项

  1. 独立运行:这是一个独立的 Demo,不依赖本系列文章中的其他复杂组件。
  2. 两种实现对比:代码中包含了 Component AComponent B 两个组件,它们都实现了相同的视觉效果,但方式不同:
    • Component A 使用 transform(matrix4),这是一种更底层、更强大的矩阵变换方式。
    • Component B 使用 translatescale,这种方式更直观,也是本系列文章中推荐优先使用的方式。
  3. 与文章思想的关联:示例为了简化,将两态的坐标(横向/纵向)直接硬编码在了组件内部。在真实的大型项目中,这些坐标值应该由独立的"几何计算层"(即后续文章中提到的 GeometryCalculator)来提供,UI 层只负责消费这些计算结果。这正是"几何驱动"架构的核心思想。

写在最后:这是《复杂tab动效组件架构设计》系列专栏的第1篇(前面有一篇序章),后续会继续展开每个技术点的实现细节。感兴趣的话可以关注专栏。欢迎在评论区分享你的经验和踩过的坑。如果这篇文章对你有启发,也欢迎点赞支持,这会是我继续分享的动力。