6

I'm trying to create a simple transition animation between two view controllers, both of which have the same label. I simply want to animate the label from its position in the first view controller, to its position in the second (see below illustration).

View Controller Illustration

I have set up my view controllers to use a custom animation controller, where I have access to both view controllers and the label through an outlet.

In the animation block, I simply set the frame of the label on the first view controller to that of the label on the second view controller.

[UIView animateWithDuration:self.duration animations:^{
    fromViewController.label.frame = toViewController.titleLabel.frame;
} completion:^(BOOL finished) {
    [transitionContext completeTransition:finished];
}];

Instead of the intended effect of the label moving from the middle of the screen to the upper left corner, as soon as the animation begins the label is positioned in the bottom right corner and then animates to the middle.

I tried printing out the positions of the labels beforehand, which shows the same frame I see in the storyboard:

fromViewController.label.frame: {{115.5, 313}, {144, 41}}
toViewController.titleLabel.frame: {{16, 12}, {144, 41}}

I have no idea as to why I'm not getting the intended behavior, and what is happening in its place.

Any suggestions as to what I can change to make my animation run correctly and why I'm seeing this behavior would be greatly appreciated.

Aleksander
  • 2,735
  • 5
  • 34
  • 57
  • @matt Thank you for the suggestion, but that answer does not seem to answer my question. To reiterate, I'd like to use a custom view controller transition animation to animate a subview of one view controller to another position as defined by a subview of the second view controller. This should be a trivial task, but for some reason I cannot get it to work and I do not understand why. – Aleksander Oct 06 '17 at 00:18
  • @matt If I understand your answer to the other question correctly, you create a snapshot of the `UIView` to move, give it the position of the original `UIView`, and then animate it to it's destination. While I agree with the logic, I'd prefer not to create a snapshot. Instead I would like to move the original `UILabel` inside the first view controller to a position dictated by the corresponding labels position in the second view controller. – Aleksander Oct 06 '17 at 00:35
  • @matt To follow up, yes creating a snapshot would work. I would like to know why it doesn't work if I modify the label correctly, and what I could do to make it work. – Aleksander Oct 06 '17 at 00:40

2 Answers2

37

You mention the animation of the subviews but you don't talk about the overall animation, but I'd be inclined to use the container view for the animation, to avoid any potential confusion/problems if you're animating the subview and the main view simultaneously. But I'd be inclined to:

  1. Make snapshots of where the subviews in the "from" view and then hide the subviews;
  2. Make snapshots of where the subviews in the "to" view and then hide the subviews;
  3. Convert all of these frame values to the coordinate space of the container and add all of these snapshots to the container view;
  4. Start the "to" snapshots' alpha at zero (so they fade in);
  5. Animate the changing of the "to" snapshots to their final destination changing their alpha back to 1.
  6. Simultaneously animate the "from" snapshots to the location of the "to" view final destination and animate their alpha to zero (so they fade out, which combined with point 4, yields a sort of cross dissolve).
  7. When all done, remove the snapshots and unhide the subviews whose snapshots were animated.

The net effect is a sliding of the label from one location to another, and if the initial and final content were different, yielding a cross dissolve while they're getting moved.

For example:

enter image description here

By using the container view for the animation of the snapshots, it's independent of any animation you might be doing of the main view of the destination scene. In this case I'm sliding it in from the right, but you can do whatever you want.

Or, you can do this with multiple subviews:

enter image description here

(Personally, if this were the case, where practically everything was sliding around, I'd lose the sliding animation of the main view because it's now becoming distracting, but it gives you the basic idea. Also, in my dismiss animation, I swapped around which view is being to another, which you'd never do, but I just wanted to illustrate the flexibility and the fading.)

To render the above, I used the following in Swift 4:

protocol CustomTransitionOriginator {
    var fromAnimatedSubviews: [UIView] { get }
}

protocol CustomTransitionDestination {
    var toAnimatedSubviews: [UIView] { get }
}

class Animator: NSObject, UIViewControllerAnimatedTransitioning {
    enum TransitionType {
        case present
        case dismiss
    }

    let type: TransitionType

    init(type: TransitionType) {
        self.type = type
        super.init()
    }

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 1.0
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let fromVC = transitionContext.viewController(forKey: .from) as! CustomTransitionOriginator  & UIViewController
        let toVC   = transitionContext.viewController(forKey: .to)   as! CustomTransitionDestination & UIViewController

        let container = transitionContext.containerView

        // add the "to" view to the hierarchy

        toVC.view.frame = fromVC.view.frame
        if type == .present {
            container.addSubview(toVC.view)
        } else {
            container.insertSubview(toVC.view, belowSubview: fromVC.view)
        }
        toVC.view.layoutIfNeeded()

        // create snapshots of label being animated

        let fromSnapshots = fromVC.fromAnimatedSubviews.map { subview -> UIView in
            // create snapshot

            let snapshot = subview.snapshotView(afterScreenUpdates: false)!

            // we're putting it in container, so convert original frame into container's coordinate space

            snapshot.frame = container.convert(subview.frame, from: subview.superview)

            return snapshot
        }

        let toSnapshots = toVC.toAnimatedSubviews.map { subview -> UIView in
            // create snapshot

            let snapshot = subview.snapshotView(afterScreenUpdates: true)!// UIImageView(image: subview.snapshot())

            // we're putting it in container, so convert original frame into container's coordinate space

            snapshot.frame = container.convert(subview.frame, from: subview.superview)

            return snapshot
        }

        // save the "to" and "from" frames

        let frames = zip(fromSnapshots, toSnapshots).map { ($0.frame, $1.frame) }

        // move the "to" snapshots to where where the "from" views were, but hide them for now

        zip(toSnapshots, frames).forEach { snapshot, frame in
            snapshot.frame = frame.0
            snapshot.alpha = 0
            container.addSubview(snapshot)
        }

        // add "from" snapshots, too, but hide the subviews that we just snapshotted
        // associated labels so we only see animated snapshots; we'll unhide these
        // original views when the animation is done.

        fromSnapshots.forEach { container.addSubview($0) }
        fromVC.fromAnimatedSubviews.forEach { $0.alpha = 0 }
        toVC.toAnimatedSubviews.forEach { $0.alpha = 0 }

        // I'm going to push the the main view from the right and dim the "from" view a bit,
        // but you'll obviously do whatever you want for the main view, if anything

        if type == .present {
            toVC.view.transform = .init(translationX: toVC.view.frame.width, y: 0)
        } else {
            toVC.view.alpha = 0.5
        }

        // do the animation

        UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
            // animate the snapshots of the label

            zip(toSnapshots, frames).forEach { snapshot, frame in
                snapshot.frame = frame.1
                snapshot.alpha = 1
            }

            zip(fromSnapshots, frames).forEach { snapshot, frame in
                snapshot.frame = frame.1
                snapshot.alpha = 0
            }

            // I'm now animating the "to" view into place, but you'd do whatever you want here

            if self.type == .present {
                toVC.view.transform = .identity
                fromVC.view.alpha = 0.5
            } else {
                fromVC.view.transform = .init(translationX: fromVC.view.frame.width, y: 0)
                toVC.view.alpha = 1
            }
        }, completion: { _ in
            // get rid of snapshots and re-show the original labels

            fromSnapshots.forEach { $0.removeFromSuperview() }
            toSnapshots.forEach   { $0.removeFromSuperview() }
            fromVC.fromAnimatedSubviews.forEach { $0.alpha = 1 }
            toVC.toAnimatedSubviews.forEach { $0.alpha = 1 }

            // clean up "to" and "from" views as necessary, in my case, just restore "from" view's alpha

            fromVC.view.alpha = 1
            fromVC.view.transform = .identity

            // complete the transition

            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}

// My `UIViewControllerTransitioningDelegate` will specify this presentation 
// controller, which will clean out the "from" view from the hierarchy when
// the animation is done.

class PresentationController: UIPresentationController {
    override var shouldRemovePresentersView: Bool { return true }
}

Then, to allow all of the above to work, if I'm transitioning from ViewController to SecondViewController, I'd specify what subviews I'm moving from and which ones I'm moving to:

extension ViewController: CustomTransitionOriginator {
    var fromAnimatedSubviews: [UIView] { return [label] }
}

extension SecondViewController: CustomTransitionDestination {
    var toAnimatedSubviews: [UIView] { return [label] }
}

And to support the dismiss, I'd add the converse protocol conformance:

extension ViewController: CustomTransitionDestination {
    var toAnimatedSubviews: [UIView] { return [label] }
}

extension SecondViewController: CustomTransitionOriginator {
    var fromAnimatedSubviews: [UIView] { return [label] }
}

Now, I don't want you to get lost in all of this code, so I'd suggest focusing on the high-level design (those first seven points I enumerated at the top). But hopefully this is enough for you to follow the basic idea.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Thank you so much for the elaborate explanation Rob! I follow your points, I guess I was just lost in the idea that I could manipulate the subviews directly as opposed to snapshotting them and manipulating them on some level "higher" than the two views (the container). I realize now that I have to use the container view, not only to animate in the view controllers views themselves, but also any subviews on them that I'd like to animate. – Aleksander Oct 06 '17 at 19:14
  • To be fair, there is a simpler solution that doesn't require all of this custom animator stuff where you do just animate the subview alongside the main animation and bypass all of this container stuff, but it imposes lots of limitations (the main animation can't involve moving of the main views, etc.). – Rob Oct 06 '17 at 19:17
  • While I believe I'll stick with this approach (seems to be the "correct" way), I'd love to learn more about the other. Any suggested resources for learning more/ search terms that would yield the appropriate results? – Aleksander Oct 06 '17 at 19:19
  • You can grab the view controller's [`transitionCoordinator`](https://developer.apple.com/documentation/uikit/uiviewcontroller/1619294-transitioncoordinator) and then [`animate(alongsideTransition:completion:)`](https://developer.apple.com/documentation/uikit/uiviewcontrollertransitioncoordinator/1619300-animate). That gives you an animation that is synchronized with the transition animation (including scrubbing an interactive animation gesture; etc.). – Rob Oct 06 '17 at 19:22
  • 1
    This deserve it's own tutorial – The1993 Aug 21 '19 at 07:02
2

The problem lies in dealing with coordinate systems. Consider these numbers:

fromViewController.label.frame: {{115.5, 313}, {144, 41}}
toViewController.titleLabel.frame: {{16, 12}, {144, 41}}

Those pairs of numbers are unrelated:

  • The frame of the label is in the bounds coordinates of its superview, probably fromViewController.view.

  • The frame of the titleLabel is in the bounds coordinates of its superview, probably toViewController.view.

Moreover, in most custom view transitions, the two view controller's views are in motion throughout the process. This makes it very difficult to say where the intermediate view should be at any moment in terms of either of them.

Thus you need to express the motion of this view in some common coordinate system, higher than either of those. That's why, in my answer here I use a snapshot view that's loose in the higher context view.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Fair enough. Please update your answer to include a note about the usage of snapshots to overcome this (and potentially a link to your other answer, to help others who may come across this question), and I can mark as accepted! – Aleksander Oct 06 '17 at 01:06
  • @matt can you make example in github? You answer every question related to this question but we can not figure out. – John Mar 14 '18 at 17:09