- 移动开发
- UI组件
【免费下载链接】Texture
Smooth asynchronous user interfaces for iOS apps.
导读
在 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 会负责完成以下工作:
- 自动计算新旧布局之间的差异;
- 自动插入新出现的元素;
- 在过渡结束后自动移除不再需要的元素;
- 自动更新仍存在元素的位置。
同时,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,其流程大致如下:
- 前置校验:必须在主线程调用(
ASDisplayNodeAssertMainThread);若constrainedSize.max宽度或高度小于等于 0 则直接忽略;若节点正处于子节点的布局过渡中(ASHierarchyStateIncludesLayoutPending)则断言并返回。 - 失效计算布局:调用
invalidateCalculatedLayout——源码注释明确说明这是节点的"动画版 setNeedsLayout",避免后续layoutThatFits:返回过期布局。 - 分配过渡 ID:每次新过渡生成
transitionID,后续新过渡会基于该 ID 判定并取消旧过渡;同时将所有子节点置为ASHierarchyStateLayoutPending并记录pendingTransitionID。 - 测量阶段:以传入的 constrained size 执行完整布局创建(
calculateLayoutThatFits:)。shouldMeasureAsync为YES时保证在后台线程测量(ASPerformBlockOnBackgroundThread),否则在调用线程测量。整个测量带ASLayoutElementContext(携带 transitionID)上下文。 - 主线程提交:更新
_calculatedDisplayNodeLayout为新布局,构造ASLayoutTransition(持有 previousLayout 与 pendingLayout,见 ASLayoutTransition.h)与_ASTransitionContext(持有 animated 标记、layout delegate 与 completion delegate,见 _ASTransitionContext.h)。 - 回调测量完成:触发
_layoutTransitionMeasurementDidFinish,然后执行可选的measurementCompletion(官方注释:它总是在主线程、在animateLayoutTransition:之前被调用)。 - 立即应用插入与移动:调用
applySubnodeInsertionsAndMoves把新节点真正挂进层级,以便后续动画能作用于它们。 - 触发动画:调用
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.
相关推荐
react-native-reanimated 布局过渡(Layout Transitions)完全指南:从预置过渡到自定义动画
react native reanimated 布局过渡(Layout Transitions)完全指南:从预置过渡到自定义动画 布局动画(Layout Ani
移动开发前端ToolJet 移动端布局(Mobile Layout)完全指南:从 600px 断点到双布局画布
ToolJet 移动端布局(Mobile Layout)完全指南:从 600px 断点到双布局画布 Mobile layout 是 ToolJet 应用构建器中
低代码后端前端AI 应用MCP 服务Reanimated 布局动画实战:用 entering 进入动画与 layout 布局过渡打造丝滑列表(Layout Animations 教程)
Reanimated 布局动画实战:用 entering 进入动画与 layout 布局过渡打造丝滑列表(Layout Animations 教程) 本文基于
移动开发前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考