Hero转场过程分析

1,703 阅读6分钟

一款非常火的过场动划开源库,github 2W+ Strar。

Hero是一个iOS界面切换库。它代替了UIKit本身的转场动画接口,使制作自定义的转场动画(View Controller Transition)非常简单!

Hero很像Keynote的“神奇移动”过渡(Magic Move)。在界面切换时,Hero会把开始界面的视图与结束界面的视图配对,假如他能找到一对儿有着一样的heroID的视图的话,Hero便会自动为此视图创建动画,从它一开始的状态移动到结束时的状态。

不仅如此,Hero还可以为没有配对的视图制作动画。每一个视图都可以轻易的用heroModifiers来告诉Hero你想为这个视图所创造的动画。交互式动画(interactive transition)也是支持的哟。

1.Hero调用

FromVC

redView.backgroundColor = UIColor.red
redView.heroID = "redView"

blueView.backgroundColor = UIColor.cyan
blueView.heroID = "blueView"
blueView.hero.modifiers = [.fade]

ToVC

redView.backgroundColor = UIColor.red
redView.heroID = "redView"

blueView.backgroundColor = UIColor.cyan
blueView.heroID = "blueView"
blueView.heroModifiers = [.fade]

collection.hero.modifiers = [.cascade(delta: 0.02, direction: .topToBottom, delayMatchedViews: true), .translate(y: 0), .useGlobalCoordinateSpace, .scale(0.5)]

ezgif-2-5488417351.gif

2.转场代理实现类 HeroTransition


var interactiveTransitioning: UIViewControllerInteractiveTransitioning? {
    return forceNotInteractive ? nil : self
}

UINavigationControllerDelegate

optional func navigationController(_ navigationController: UINavigationControlleranimationControllerFor 
                                   operation:UINavigationController.Operationfrom fromVC: UIViewControllerto toVC: UIViewController) -> 
                                   UIViewControllerAnimatedTransitioning? {
    guard !isTransitioning else { return nil }
    self.state = .notified
    self.isPresenting = operation == .push
    self.fromViewController = fromViewController ?? fromVC
    self.toViewController = toViewController ?? toVC
    self.inNavigationController = true
    return self
}

optional func navigationController(_ navigationController: UINavigationControllerinteractionControllerFor 
                                   animationController:
                                   UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
    return interactiveTransitioning
}

UIViewControllerTransitioningDelegate

optional func animationController(forPresented presented: UIViewControllerpresenting: UIViewController,                   
                                  source: UIViewController) -> 
                                  UIViewControllerAnimatedTransitioning? {
    guard !isTransitioning else { return nil }
    self.state = .notified
    self.isPresenting = true
    self.fromViewController = fromViewController ?? presenting
    self.toViewController = toViewController ?? presented
    return self
  }

optional func interactionControllerForPresentation(using animator: 
              UIViewControllerAnimatedTransitioning) ->
              UIViewControllerInteractiveTransitioning? {
    return interactiveTransitioning
  }
UITabBarControllerDelegate

optional func tabBarController(_ tabBarController: UITabBarControlleranimationControllerForTransitionFrom 
                               fromVC: UIViewControllerto toVC: UIViewController) -> 
                               UIViewControllerAnimatedTransitioning? {
    guard !isTransitioning else { return nil }
    self.state = .notified
    let fromVCIndex = tabBarController.children.firstIndex(of: fromVC)!
    let toVCIndex = tabBarController.children.firstIndex(of: toVC)!
    self.isPresenting = toVCIndex > fromVCIndex
    self.fromViewController = fromViewController ?? fromVC
    self.toViewController = toViewController ?? toVC
    self.inTabBarController = true
    return self
}

optional func tabBarController(_ tabBarController: UITabBarControllerinteractionControllerFor animationController: 
                               UIViewControllerAnimatedTransitioning) -> 
                               UIViewControllerInteractiveTransitioning? {
    return interactiveTransitioning
}

UIViewControllerAnimatedTransitioning:动画控制器协议,可以在遵守该协议的类中进行转场动画的设计,如果返回的对象为nil,则保持系统动画,不会使用自定义动画。

UIViewControllerInteractiveTransitioning:交互控制协议,该对象定义了转场动画的交互行为。

这里它做的事很简单就是设定一些流程中的状态,然后将来源VC与目标VC做保存的动作,以便日后取用。

extension HeroTransition: UIViewControllerAnimatedTransitioning {
   public func animateTransition(using context: UIViewControllerContextTransitioning) {
    transitionContext = context
    fromViewController = fromViewController ?? context.viewController(forKey: .from)
    toViewController = toViewController ?? context.viewController(forKey: .to)
    transitionContainer = context.containerView
    start()
  }
  
  public func transitionDuration(using transitionContext: 
                                 UIViewControllerContextTransitioning?) -> 
                                 TimeInterval{
    return 0.375 // doesn't matter, real duration will be calculated later
  }

  public func animationEnded(_ transitionCompleted: Bool) {
    self.state = .possible
  }
}


extension HeroTransition: UIViewControllerInteractiveTransitioning {
  public var wantsInteractiveStart: Bool {
    return true
  }
  
  public func startInteractiveTransition(_ transitionContext: 
                                         UIViewControllerContextTransitioning) {
    animateTransition(using: transitionContext)
  }
}

3.Start

start首先是做了一些比较简单的准备工作:

3.1 layout目标VC.View,获取到目标UI

3.2 设定自身状态并发送通知给代理即将开始转场

3.3 截取起始屏幕UI贴在舞台上防止一些中间态被展示出来

3.4  Preprocessor和HeroAnimator


internal var processors: [HeroPreprocessor] = []

internal var animators: [HeroAnimator] = []

internal var plugins: [HeroPlugin] = []

HeroTransition 维护了成员为遵守HeroPreprocessor和HeroAnimator协议的两个数组,用于描述和控制动画的过程

HeroPreprocessor只有一个process方法,在流程中起到一个《设计稿》的作用,会给两个VC内每一个subView都设计他们在动画过程中的参数

HeroAnimator 暴露了交互接口,可用于控制动画的流程

HeroPlugin 是同时满足了HeroPreprocessor, HeroAnimator两个协议的类,用于提供给开发者进行自定义动画创作


public protocol HeroPreprocessor: class {

  var hero: HeroTransition! { get set }

  func process(fromViews: [UIView], toViews: [UIView])

}

public protocol HeroAnimator: class {

  var hero: HeroTransition! { get set }

  func canAnimate(view: UIView, appearing: Bool) -> Bool

  func animate(fromViews: [UIView], toViews: [UIView]) -> TimeInterval

  func clean()

\


  func seekTo(timePassed: TimeInterval)

  func resume(timePassed: TimeInterval, reverse: Bool) -> TimeInterval

  func apply(state: HeroTargetState, to view: UIView)

  func changeTarget(state: HeroTargetState, isDestination: Bool, to view: UIView)

}

open class HeroPlugin: NSObject, HeroPreprocessor, HeroAnimator

3.5 预设processor和Animator


plugins = HeroTransition.enabledPlugins.map({ return $0.init() })

processors = [
    IgnoreSubviewModifiersPreprocessor(),
    ConditionalPreprocessor(),
    DefaultAnimationPreprocessor(),
    MatchPreprocessor(),
    SourcePreprocessor(),
    CascadePreprocessor()
]

animators = [
    HeroDefaultAnimator<HeroCoreAnimationViewContext>()
]

// There is no covariant in Swift, so we need to add plugins one by one.

for plugin in plugins {
    processors.append(plugin)
    animators.append(plugin)
}

3.6 创建了一个新的View作为动画的表现层贴到了舞台上

3.7 HeroContext和HeroTargetState


public internal(set) var context: HeroContext!
context = HeroContext(container: container)
context.set(fromViews: fromView.flattenedViewHierarchy, toViews: toView.flattenedViewHierarchy)

HeroContext是Hero自身维护的一个上下文,里面保存了动画过程中需要用到的一切描述信息与映射关系,并且由构造方法可以看出,上下文持有了动画表现层


internal var heroIDToSourceView = [String: UIView]()
internal var heroIDToDestinationView = [String: UIView]()
internal var targetStates = [UIView: HeroTargetState]()

internal func set(fromViews: [UIView], toViews: [UIView]) {
    self.fromViews = fromViews
    self.toViews = toViews
    process(views: fromViews, idMap: &heroIDToSourceView)
    process(views: toViews, idMap: &heroIDToDestinationView)
}

internal func process(views: [UIView], idMap: inout [String: UIView]) {
    for view in views {
        view.layer.removeAllHeroAnimations()
        let targetState: HeroTargetState?
        if let modifiers = view.hero.modifiers {
            targetState = HeroTargetState(modifiers: modifiers)
        } else {
            targetState = nil
        }

        if targetState?.forceAnimate == true || container.convert(view.bounds, from: view).intersects(container.bounds) {
            if let heroID = view.hero.id {
                idMap[heroID] = view
            }
            targetStates[view] = targetState
        }
    }
  }

在set方法中,会将拥有id映射关系的View存入各自的字典中,并且为每一个View根据其动画形式(modifiers)创建一个可选的HeroTargetState,然后也存入状态保存字典

HeroTargetState

public struct HeroTargetState {
    public var beginState: [HeroModifier]?
    public var conditionalModifiers: [((HeroConditionalContext) -> Bool, [HeroModifier])]?
    
    public var position: CGPoint?
    public var size: CGSize?
    public var transform: CATransform3D?
    public var opacity: Float?
    public var cornerRadius: CGFloat?
    public var backgroundColor: CGColor?
    public var zPosition: CGFloat?
    
    public var contentsRect: CGRect?
    public var contentsScale: CGFloat?
    
    public var borderWidth: CGFloat?
    public var borderColor: CGColor?
    
    public var shadowColor: CGColor?
    public var shadowOpacity: Float?
    public var shadowOffset: CGSize?
    public var shadowRadius: CGFloat?
    public var shadowPath: CGPath?
    public var masksToBounds: Bool?
    public var displayShadow: Bool = true
    
    public var overlay: (color: CGColor, opacity: CGFloat)?
    
    public var spring: (CGFloat, CGFloat)?
    public var delay: TimeInterval = 0
    public var duration: TimeInterval?
    public var timingFunction: CAMediaTimingFunction?
    
    public var arc: CGFloat?
    public var source: String?
    public var cascade: (TimeInterval, CascadeDirection, Bool)?
    
    public var ignoreSubviewModifiers: Bool?
    public var coordinateSpace: HeroCoordinateSpace?
    public var useScaleBasedSizeChange: Bool?
    public var snapshotType: HeroSnapshotType?
    
    public var nonFade: Bool = false
    public var forceAnimate: Bool = false
    public var custom: [String: Any]?
}

HeroTargetState 是一个保存了每个视图起始和最终UI状态的结构体,但是在set方法中可以看到,HeroTargetState只是被初始化存入了字典中,其中的各项参数都没有赋值

3.8 对每一个View写入State,筛选可以进行动画的视图


for processor in processors {
    processor.process(fromViews: context.fromViews, toViews: context.toViews)
}

animatingFromViews = context.fromViews.filter { (view: UIView) -> Bool in
                                               for animator in animators {
                                                   if animator.canAnimate(view: view, appearing: false) {
                                                       return true
                                                   }
                                               }
                                               return false
                                              }
animatingToViews = context.toViews.filter { (view: UIView) -> Bool in
                                           for animator in animators {
                                               if animator.canAnimate(view: view, appearing: true) {
                                                   return true
                                               }
                                           }
                                           return false

}

Ex:CascadePreprocessor瀑布流式的动画则会给每一个视图设置一个delay

CascadePreprocessor

class CascadePreprocessor: BasePreprocessor {

  override func process(fromViews: [UIView], toViews: [UIView]) {

    process(views: fromViews)

    process(views: toViews)

  }


  func process(views: [UIView]) {
    for view in views {
      guard let (deltaTime, direction, delayMatchedViews) = context[view]?.cascade else { continue }
      var parentView = view
      if view is UITableView, let wrapperView = view.subviews.get(0) {
        parentView = wrapperView
      }
      let sortedSubviews = parentView.subviews.sorted(by: direction.comparator)
      let initialDelay = context[view]!.delay
      let finalDelay = TimeInterval(sortedSubviews.count) * deltaTime + initialDelay
      for (i, subview) in sortedSubviews.enumerated() {
        let delay = TimeInterval(i) * deltaTime + initialDelay
        func applyDelay(view: UIView) {
          if context.pairedView(for: view) == nil {
            context[view]?.delay = delay
          } else if delayMatchedViews, let paired = context.pairedView(for: view) {
            context[view]?.delay = finalDelay
            context[paired]?.delay = finalDelay
          }
          for subview in view.subviews {
            applyDelay(view: subview)
          }
        }
        applyDelay(view: subview)
      }
    }
  }
}

以上全部都是参数的准备工作,完成后进入则开始动画


if inNavigationController {
    // When animating within navigationController, we have to dispatch later into the main queue.
    // otherwise snapshots will be pure white. Possibly a bug with UIKit
    DispatchQueue.main.async {    
        self.animate()    
    }    
} else {
    animate()
}

4.Animate

4.1 将需要进行动画的View都存一张截图View来真正执行动画,目的是为了不修改原View的参数


if context.insertToViewFirst {
    for v in animatingToViews { _ = context.snapshotView(for: v) }
    for v in animatingFromViews { _ = context.snapshotView(for: v) }
} else {
    for v in animatingFromViews { _ = context.snapshotView(for: v) }
    for v in animatingToViews { _ = context.snapshotView(for: v) }
}

4.2 执行动画的同时获取到每一个小动画的时长,取最大值即为整个动画的时长

for animator in animators {
      let duration = animator.animate(fromViews: animatingFromViews.filter({ animator.canAnimate(view: $0, appearing: false) }),
                                      toViews: animatingToViews.filter({ animator.canAnimate(view: $0, appearing: true) }))
      if duration == .infinity {
        animatorWantsInteractive = true
      } else {
        totalDuration = max(totalDuration, duration)
      }
    }

  func animate(key: String, beginTime: TimeInterval, duration: TimeInterval, fromValue: Any?, toValue: Any?) -> TimeInterval {
    let anim = getAnimation(key: key, beginTime: beginTime, duration: duration, fromValue: fromValue, toValue: toValue)

    if let overlayKey = overlayKeyFor(key: key) {
      addAnimation(anim, for: overlayKey, to: getOverlayLayer())
    } else {
      switch key {
      case "cornerRadius", "contentsRect", "contentsScale":
        addAnimation(anim, for: key, to: snapshot.layer)
        if let contentLayer = contentLayer {
          // swiftlint:disable:next force_cast
          addAnimation(anim.copy() as! CAAnimation, for: key, to: contentLayer)
        }
        if let overlayLayer = overlayLayer {
          // swiftlint:disable:next force_cast
          addAnimation(anim.copy() as! CAAnimation, for: key, to: overlayLayer)
        }
      case "bounds.size":
        guard let fromSize = (fromValue as? NSValue)?.cgSizeValue, let toSize = (toValue as? NSValue)?.cgSizeValue else {
          addAnimation(anim, for: key, to: snapshot.layer)
          break
        }
        setSize(view: snapshot, newSize: fromSize)
        uiViewBasedAnimate(duration: anim.duration, delay: beginTime - currentTime) {
          self.setSize(view: self.snapshot, newSize: toSize)
        }
      default:
        addAnimation(anim, for: key, to: snapshot.layer)
      }
    }
    return anim.duration + anim.beginTime - beginTime
  }

5.HeroProgressRunner

HeroProgressRunner是一个状态与实际动画执行状态保持高度统一的监听类,用于在动画流程中监听各种事件,修改HeroTranslition的状态并且向代理发送动画生命周期通知,这里开启一个与动画时长相同的progressRunner(与4.2并行)

self.totalDuration = totalDuration
complete(after: totalDuration, finishing: true)

var progressRunner: HeroProgressRunner

func complete(after: TimeInterval, finishing: Bool) {
    guard [HeroTransitionState.animating, .starting, .notified].contains(state) else { return }
    if after <= 1.0 / 120 {
        complete(finished: finishing)
        return
    }
    let totalTime: TimeInterval
    if finishing {
        totalTime = after / max((1 - progress), 0.01)
    } else {
        totalTime = after / max(progress, 0.01)
    }
    progressRunner.start(timePassed: progress * totalTime, totalTime: totalTime, reverse: !finishing)
}

在start方法中开启了一个displayLink,以屏幕刷新率的频率去调用displayUpdate(_ :))方法

  func start(timePassed: TimeInterval, totalTime: TimeInterval, reverse: Bool) {
    stop()
    self.timePassed = timePassed
    self.isReversed = reverse
    self.duration = totalTime
    displayLink = CADisplayLink(target: self, selector: #selector(displayUpdate(_:)))
    displayLink!.add(to: .main, forMode: RunLoop.Mode.common)
  }
  @objc func displayUpdate(_ link: CADisplayLink) {
    timePassed += isReversed ? -link.duration : link.duration
    if isReversed, timePassed <= 1.0 / 120 {
      delegate?.complete(finished: false)
      stop()
      return
    }

    if !isReversed, timePassed > duration - 1.0 / 120 {
      delegate?.complete(finished: true)
      stop()
      return

    }
    delegate?.updateProgress(progress: timePassed / duration)
  }


extension HeroTransition: HeroProgressRunnerDelegate {
  func updateProgress(progress: Double) {
    self.progress = progress
  }
}

displayUpdate方法通知到代理(HeroTransition)更新进度


  public internal(set) var progress: Double = 0 {
    didSet {
      if state == .animating {
        if let progressUpdateObservers = progressUpdateObservers {
          for observer in progressUpdateObservers {
            observer.heroDidUpdateProgress(progress: progress)
          }
        }

        let timePassed = progress * totalDuration
        if interactive {
          for animator in animators {
            animator.seekTo(timePassed: timePassed)
          }
        } else {
          for plugin in plugins where plugin.requirePerFrameCallback {
            plugin.seekTo(timePassed: timePassed)
          }
        }
        transitionContext?.updateInteractiveTransition(CGFloat(progress))
      }
      delegate?.heroTransition(self, didUpdate: progress)
    }
  }

HeroTransition则根据进度下发至每一个需要刷新进度的实例,包括开发者外部写入的plugin,系统的转场上下文,以及转场动画代理

6. Complete

6.1 修改状态

6.2 根据动画形式缓存部分视图的透明度、截图视图等变量以便pop、dismiss操作可以重复利用


context.storeViewAlpha(rootView: fromView)

fromViewController?.hero.storedSnapshot = container

6.3 置空其余所有中间流程中产生的临时变量,中间视图全部从父视图移除


func stop() {
    displayLink?.isPaused = true
    displayLink?.remove(from: RunLoop.main, forMode: RunLoop.Mode.common)
    displayLink = nil
 }

6.4 将目标VC的截图贴到舞台上

6.5 将表现层从舞台移除

6.6 手动移除可能引起内存泄露的持有关系


public func clean() {
    for vc in viewContexts.values {
      vc.clean()
    }
    viewContexts.removeAll()
  }

6.7 向代理发送完成或取消转场的通知

7. 数据驱动

根据上面的全流程可以看出,Hero需要获取到目标VC的初始化UI才可以进行动画转场,但是在实际应用的过程中,如果目标VC的主要UI框架为UITableView/UICollectionView等以数据驱动UI的控件,其数据来源于后端API,viewDidLoad无法获取到API返回之后的UI样式,则Hero也无法为控件提供转场动画,这个时候有两种方案来实现平滑的动画转场效果

ezgif-2-26ca8ec793.gif

7.1 骨架屏

使用骨架屏控件(SkeletonView)来为UITableView预设Cell的数量,使得目标VC可以在viewDidLoad时就加载出UITableView的样式,这样就可以做相关的转场动画了

ezgif-2-5ed5702661.gif

7.2 Loading动画

原理跟骨架屏是一样的,将FromVC的相关视图与ToVC的Loading动画视图做关联,转场完成后获取到数据再隐藏Loading动画也可以达到平滑得转场效果,但是这种模式在Pop或者Dismiss的之前需要将loadingView的关联移除,原理是一样的,动图就偷懒不贴了