☰
Texture 布局过渡 API(Layout Transition API)完全指南:从布局差异到无缝动画
2026/9/26 10:29:17 网站建设 项目流程
  • 移动开发
  • UI组件

【免费下载链接】Texture

Smooth asynchronous user interfaces for iOS apps.

项目地址:https://gitcode.com/gh_mirrors/te/Texture
点击查看免费下载

导读

在 Texture(AsyncDisplayKit)中,Layout Transition API 是专为"让一切动画都变得简单"而设计的布局动画体系——它甚至能让你把一整组视图平滑地变换为另一组完全不同的视图。本文以官方文档 layout-transition-api.md 为主线,完整讲解transitionLayoutWithAnimation:与transitionLayoutWithSizeRange:animated:两个核心入口、animateLayoutTransition:自定义动画钩子、ASContextTransitioning上下文对象,并结合仓库源码(ASDisplayNode+Layout.mm、ASLayoutTransition.mm、ASContextTransitioning.h)剖析其底层实现。读完你将掌握:如何让节点响应内部状态变化在布局间自动插拔子节点并做动画,如何响应旋转与尺寸变化,以及何时该重写哪个回调。

前置条件:必须开启 Automatic Subnode Management(ASM)

官方文档明确指出:使用 Layout Transition API 必须开启 Automatic Subnode Management(ASM)。详见 automatic-subnode-mgmt.md。

开启方式是在节点初始化时设置automaticallyManagesSubnodes = YES。开启后你不再需要手动调用addSubnode:或removeFromSupernode,节点的存在与否完全由layoutSpecThatFits:返回的 layout spec 决定:

- (instancetype)init { self = [super init]; if (self) { self.automaticallyManagesSubnodes = YES; // 只需创建子节点并持有强引用,不再 addSubnode: } return self; }
override init() { super.init() automaticallyManagesSubnodes = true }

需要注意的是,ASM 开启后绝不应对该节点再调用addSubnode:/removeFromSupernode,否则可能触发 "A flattened layout must consist exclusively of node sublayouts" 异常。也正因如此,layoutSpecThatFits:必须能正确描述"有这些元素 / 没有这些元素"时的 UI 形态——这正是 Layout Transition API 判断插入、移除、移动的依据(automatic-subnode-mgmt.md 中给出了基于数据是否返回而条件组合 children 的完整示例)。

Layout Transition API 的核心思想

使用这套 API 时,你只需要指定期望的新布局,Texture 会负责完成以下工作:

  1. 自动计算新旧布局之间的差异;
  2. 自动插入新出现的元素;
  3. 在过渡结束后自动移除不再需要的元素;
  4. 自动更新仍存在元素的位置。

同时,API 还提供易于使用的钩子,允许你完全自定义新元素进入时的起始位置、被移除元素的结束位置。文档原文的表述是:"designed to make all animations with Texture easy — even transforming an entire set of views into a completely different set of views!"(设计目标甚至包含把整组视图变换成完全不同的一组视图)。

在 ASDisplayNode.h 的ASDisplayNode(ASLayoutTransitioning)分类中,这套 API 由以下成员构成:

成员作用
transitionLayoutWithAnimation:使当前约束尺寸下的布局失效并重算,触发过渡
transitionLayoutWithSizeRange:animated:以新的ASSizeRange重算布局并触发过渡
animateLayoutTransition:自定义动画的回调钩子(新节点已插入)
didCompleteLayoutTransition:过渡完成后的清理回调(默认执行移除)
cancelLayoutTransition取消所有进行中的布局过渡(可在任意线程调用)
defaultLayoutTransitionDuration/Delay/Options默认过渡动画的时长(默认 0.2s)、延迟(默认 0.0)、选项

场景一:响应内部状态,在两个布局之间切换动画

官方文档用一个"注册表单"场景说明核心用法:容器节点SignupNode内包含两个可编辑文本字段节点(nameField与ageField)和一个按钮节点,通过属性fieldState决定layoutSpecThatFits:返回哪个字段。

其内部 layout spec 如下:

- (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize { FieldNode *field; if (self.fieldState == SignupNodeName) { field = self.nameField; } else { field = self.ageField; } ASStackLayoutSpec *stack = [[ASStackLayoutSpec alloc] init]; [stack setChildren:@[field, self.buttonNode]]; UIEdgeInsets insets = UIEdgeInsetsMake(15.0, 15.0, 15.0, 15.0); return [ASInsetLayoutSpec insetLayoutSpecWithInsets:insets child:stack]; }
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec { let fieldNode: FieldNode if self.fieldState == .signupNodeName { fieldNode = self.nameField } else { fieldNode = self.ageField } let stack = ASStackLayoutSpec() stack.children = [fieldNode, buttonNode] let insets = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) return ASInsetLayoutSpec(insets: insets, child: stack) }

当用户点击"下一步"时,更新fieldState并调用transitionLayoutWithAnimation:触发过渡:

self.signupNode.fieldState = SignupNodeAge; [self.signupNode transitionLayoutWithAnimation:YES];
self.signupNode.fieldState = .signupNodeName self.signupNode.transitionLayout(withAnimation: true, shouldMeasureAsync: true)

该方法会使当前已计算的布局失效,并用栈中现在包含ageField的新布局重新计算。

默认行为的局限:需要自定义动画块

文档特别说明:API 的默认实现会重算新布局,并用其 sublayouts 去设置子节点的尺寸和位置,但这个过程不带动画。文档同时指出未来版本可能会内置默认布局间动画,并欢迎社区反馈。因此在注册表单这类场景中,必须自己实现动画块。

注意:仓库源码中的默认animateLayoutTransition:实现(ASDisplayNode+Layout.mm)其实已经提供了一套"淡入淡出 + 移动 + 尺寸变化"的默认动画(基于defaultLayoutTransitionDuration、defaultLayoutTransitionDelay、defaultLayoutTransitionOptions三个属性,时长默认 0.2s);文档中"默认无动画"的表述针对的是布局间元素替换的通用场景,具体行为以你重写或调用 super 的实现为准。

自定义动画:重写 animateLayoutTransition:

animateLayoutTransition:在transitionLayoutWithAnimation:完成新布局计算后调用。在实现中,我们可以根据触发动画前设置的fieldState执行特定动画。下面是一个在SignupNode中重写该方法的完整示例:

- (void)animateLayoutTransition:(id<ASContextTransitioning>)context { if (self.fieldState == SignupNodeName) { CGRect initialNameFrame = [context initialFrameForNode:self.ageField]; initialNameFrame.origin.x += initialNameFrame.size.width; self.nameField.frame = initialNameFrame; self.nameField.alpha = 0.0; CGRect finalAgeFrame = [context finalFrameForNode:self.nameField]; finalAgeFrame.origin.x -= finalAgeFrame.size.width; [UIView animateWithDuration:0.4 animations:^{ self.nameField.frame = [context finalFrameForNode:self.nameField]; self.nameField.alpha = 1.0; self.ageField.frame = finalAgeFrame; self.ageField.alpha = 0.0; } completion:^(BOOL finished) { [context completeTransition:finished]; }]; } else { CGRect initialAgeFrame = [context initialFrameForNode:self.nameField]; initialAgeFrame.origin.x += initialAgeFrame.size.width; self.ageField.frame = initialAgeFrame; self.ageField.alpha = 0.0; CGRect finalNameFrame = [context finalFrameForNode:self.ageField]; finalNameFrame.origin.x -= finalNameFrame.size.width; [UIView animateWithDuration:0.4 animations:^{ self.ageField.frame = [context finalFrameForNode:self.ageField]; self.ageField.alpha = 1.0; self.nameField.frame = finalNameFrame; self.nameField.alpha = 0.0; } completion:^(BOOL finished) { [context completeTransition:finished]; }]; } }
override func animateLayoutTransition(_ context: ASContextTransitioning) { if fieldState == .signupNodeName { let initialNameFrame = context.initialFrame(for: ageField) nameField.frame = initialNameFrame nameField.alpha = 0 var finalAgeFrame = context.finalFrame(for: nameField) finalAgeFrame.origin.x -= finalAgeFrame.size.width UIView.animate(withDuration: 0.4, animations: { self.nameField.frame = context.finalFrame(for: self.nameField) self.nameField.alpha = 1 self.ageField.frame = finalAgeFrame self.ageField.alpha = 0 }, completion: { finished in context.completeTransition(finished) }) } else { var initialAgeFrame = context.initialFrame(for: nameField) initialAgeFrame.origin.x += initialAgeFrame.size.width ageField.frame = initialAgeFrame ageField.alpha = 0 var finalNameFrame = context.finalFrame(for: ageField) finalNameFrame.origin.x -= finalNameFrame.size.width UIView.animate(withDuration: 0.4, animations: { self.ageField.frame = context.finalFrame(for: self.ageField) self.ageField.alpha = 1 self.nameField.frame = finalNameFrame self.nameField.alpha = 0 }, completion: { finished in context.completeTransition(finished) }) } }

上下文对象 ASContextTransitioning:过渡前后的完整信息

回调中传入的context实现了 ASContextTransitioning 协议,包含判断过渡前后节点状态所需的全部信息。协议提供的方法如下(对应 ASContextTransitioning.h):

方法说明
isAnimated本次过渡是否带动画
layoutForKey:获取 "from"(ASTransitionContextFromLayoutKey)或 "to"(ASTransitionContextToLayoutKey)的原始ASLayout对象
constrainedSizeForKey:获取过渡前 / 过渡后的约束尺寸ASSizeRange
subnodesForKey:获取过渡前 / 过渡后布局中的子节点数组
insertedSubnodes本次过渡中新插入的子节点
removedSubnodes本次过渡中将被移除的子节点
initialFrameForNode:节点在过渡开始前的 frame;若过渡前不在层级中则返回CGRectNull
finalFrameForNode:节点在过渡完成时的 frame;若过渡后已不在层级中则返回CGRectNull
completeTransition:必须在动画结束后调用,通知过渡完成

在SignupNode示例中,我们正是用initialFrameForNode:/finalFrameForNode:获取每个字段的 frame,从而把它们"从画面外移入 / 移出"。

关键点:务必调用 completeTransition:

在动画完成后必须对 context 调用completeTransition:——它会执行必要的内部步骤,使新计算出的布局正式成为当前的calculatedLayout。从源码看,默认实现会在动画 completion 中调用它(ASDisplayNode+Layout.mm),自定义实现时必须自己补上这一调用。

隐式的子节点插入与移除

在整个过渡过程中,你没有调用过任何addSubnode:或removeFromSupernode。这是因为 Layout Transition API 会分析新旧布局之间节点层级的差异,通过 ASM隐式地完成节点插入与移除:

  • 插入发生在你的animateLayoutTransition:被调用之前——因此这是你手动调整层级(例如重新排序)的好时机;
  • 移除发生在didCompleteLayoutTransition:中——也就是你调用completeTransition:之后。

如果你需要手动执行删除,可以重写didCompleteLayoutTransition:并执行自定义操作。注意这会覆盖默认行为,官方建议要么调用super,要么遍历 context 的removedSubnodesgetter 完成清理。默认实现(ASDisplayNode+Layout.mm)正是通过_pendingLayoutTransition调用applySubnodeRemovals来移除不再需要的节点。

无动画调用与 isAnimated

向transitionLayoutWithAnimation:传NO,仍然会走完你的animateLayoutTransition:与didCompleteLayoutTransition:实现,只是context的isAnimated属性为NO。如何处理这种情形完全由你决定。一个提供默认行为的简单方式是调用super:

- (void)animateLayoutTransition:(id<ASContextTransitioning>)context { if ([context isAnimated]) { // perform animation } else { [super animateLayoutTransition:context]; } }
override func animateLayoutTransition(_ context: ASContextTransitioning) { if context.isAnimated() { } else { super.animateLayoutTransition(context) } }

默认实现的非动画分支会直接执行_layoutSublayouts并调用completeTransition:YES(ASDisplayNode+Layout.mm),即"无动画直接落地新布局"。

场景二:响应 constrainedSize 变化

当你想响应节点自身 bounds 的变化并动画重算布局时,应调用transitionLayoutWithSizeRange:animated:。它与transitionLayoutWithAnimation:类似,但如果传入的ASSizeRange与当前constrainedSizeForCalculatedLayout相等,则不会触发动画(源码层面是直接 noop,见 ASDisplayNode.h 的注释)。这非常适合响应旋转事件和控制器尺寸变化:

- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) { [self.node transitionLayoutWithSizeRange:ASSizeRangeMake(size, size) animated:YES]; } completion:nil]; }
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: { context in self.node.transitionLayout(with: ASSizeRange(min: size, max: size), animated: true, shouldMeasureAsync: true) }) }

从源码理解过渡的内部流程

transitionLayoutWithSizeRange:animated:shouldMeasureAsync:measurementCompletion:的实现位于 ASDisplayNode+Layout.mm,其流程大致如下:

  1. 前置校验:必须在主线程调用(ASDisplayNodeAssertMainThread);若constrainedSize.max宽度或高度小于等于 0 则直接忽略;若节点正处于子节点的布局过渡中(ASHierarchyStateIncludesLayoutPending)则断言并返回。
  2. 失效计算布局:调用invalidateCalculatedLayout——源码注释明确说明这是节点的"动画版 setNeedsLayout",避免后续layoutThatFits:返回过期布局。
  3. 分配过渡 ID:每次新过渡生成transitionID,后续新过渡会基于该 ID 判定并取消旧过渡;同时将所有子节点置为ASHierarchyStateLayoutPending并记录pendingTransitionID。
  4. 测量阶段:以传入的 constrained size 执行完整布局创建(calculateLayoutThatFits:)。shouldMeasureAsync为YES时保证在后台线程测量(ASPerformBlockOnBackgroundThread),否则在调用线程测量。整个测量带ASLayoutElementContext(携带 transitionID)上下文。
  5. 主线程提交:更新_calculatedDisplayNodeLayout为新布局,构造ASLayoutTransition(持有 previousLayout 与 pendingLayout,见 ASLayoutTransition.h)与_ASTransitionContext(持有 animated 标记、layout delegate 与 completion delegate,见 _ASTransitionContext.h)。
  6. 回调测量完成:触发_layoutTransitionMeasurementDidFinish,然后执行可选的measurementCompletion(官方注释:它总是在主线程、在animateLayoutTransition:之前被调用)。
  7. 立即应用插入与移动:调用applySubnodeInsertionsAndMoves把新节点真正挂进层级,以便后续动画能作用于它们。
  8. 触发动画:调用animateLayoutTransition:context,进入你的自定义动画实现。

差异计算:新旧布局如何对比

ASLayoutTransition的calculateSubnodeOperationsIfNeeded(ASLayoutTransition.mm)负责计算插入、删除与移动:

  • 若编译了 IGListDiffKit,使用线性复杂度 O(m+n) 的IGListDiff对 previousLayout 与 pendingLayout 的 sublayouts 做 diff;
  • 否则使用内置的 NSArray+Diffing 的asdk_diffWithArray:得到 insertions、deletions 与 moves;
  • 结果保存在_insertedSubnodes、_removedSubnodes、_insertedSubnodePositions、_subnodeMoves中,随后applySubnodeInsertionsAndMoves按目标索引顺序插入/重排节点,applySubnodeRemovals通过_removeFromSupernodeIfEqualTo:安全移除节点(仅当子节点仍属于该父节点时才移除,见 ASLayoutTransition.mm)。

此外,ASLayoutTransition的isSynchronous会 BFS 遍历整个布局栈,只要存在一个canLayoutAsynchronous == NO的 layout element,就判定该过渡无法异步执行(ASLayoutTransition.mm),从而把子节点插入/移除跳转到主线程执行。

取消进行中的过渡

cancelLayoutTransition可在任意线程调用(ASDisplayNode+Layout.mm):它清除当前 transitionID,并让所有子节点退出 layout pending 状态。内部通过asdisplaynode_iscancelled_block_t在测量前后反复检查 transitionID,一旦被新过渡取代就立即中止旧过渡的工作。

官方示例工程:ASDKLayoutTransition

官方在仓库中提供了专门演示该 API 的示例工程 examples/ASDKLayoutTransition。其核心代码在 ViewController.m:

  • TransitionNode开启automaticallyManagesSubnodes = YES,并把defaultLayoutTransitionDuration设为1.0,演示如何调参默认过渡动画;
  • 点击按钮切换enabled状态并调用transitionLayoutWithAnimation:shouldMeasureAsync:measurementCompletion:(源码第 79-81 行);
  • layoutSpecThatFits:根据enabled在textNodeOne与textNodeTwo之间二选一放入水平栈;
  • 文件顶部用#define USE_CUSTOM_LAYOUT_TRANSITION 0开关自定义动画实现:开启后重写animateLayoutTransition:,通过removedSubnodes、insertedSubnodes、subnodesForKey:ASTransitionContextToLayoutKey与initialFrameForNode:/finalFrameForNode:实现两个文本节点左右滑入滑出、按钮跟随finalFrameForNode:移动、父节点随布局尺寸变化自动 resized 的完整动画,并在 completion 中调用completeTransition:。

这是把本文所有概念落到真实可运行代码的最佳参考,建议配合文档阅读。

要点速查

  • 使用前必须先开启Automatic Subnode Management(automaticallyManagesSubnodes = YES),且禁用 ASM 后不得再手动addSubnode:。
  • transitionLayoutWithAnimation:负责"当前约束尺寸下重新布局";transitionLayoutWithSizeRange:animated:负责"以新约束尺寸重新布局",两者都必须在主线程调用。
  • 自定义动画重写animateLayoutTransition:;新节点在回调前已插入,旧节点在completeTransition:后才被移除。
  • 务必在动画完成后调用[context completeTransition:finished],否则新布局不会成为calculatedLayout。
  • 传NO时仍会走过渡回调,可通过[context isAnimated]区分,并建议调用super提供默认非动画落地行为。
  • 过渡期间的一切子节点增删都由 ASM + 布局差异计算自动完成,无需手动管理层级。
  • 移动开发
  • UI组件

【免费下载链接】Texture

Smooth asynchronous user interfaces for iOS apps.

项目地址:https://gitcode.com/gh_mirrors/te/Texture
点击查看免费下载

相关推荐

上一篇:用免费的开源 yuzu 模拟器在 PC 上玩 Switch 游戏,30 分钟就能完成首次启动
下一篇:让 AI 的置信度可信赖:SemIf 如何用按工作负载温度校准把概率 ECE 降低 67%

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询