Skip to content

A simple, fast way to create awesome looking frame shift transitions

License

Notifications You must be signed in to change notification settings

wmcginty/Shifty

Repository files navigation

Shifty

CI Status Version License Platform

Purpose

This library is intended as a supplement to the existing UIViewController transitioning APIs. While Shifty will not replace the UIKit view controller transitioning delegates and animators, it greatly simplifies the implementation of frame shift transitions while giving you the power to customize many parts of the animation to create unique effects.

Key Concepts

  • TransitionDriving - A protocol representing any object that can respond to various callbacks from the transition animator throughout it's lifecycle.
  • Shift.Target - Encapsulates a target state for a shifting view, in both the source and the destination.
  • ShiftTransitioning - A protocol representing any object (usually a UIViewController) that can vend State objects to the animator.
  • ShiftAnimator - The animator object that manages the matching and coordinating of State objects between the source and destination.

Usage

TransitionDriving

The TransitionDriving protocol can be used to create a huge variety of transitions. It allows you to separate out view controller specific effects and animations from the UIViewControllerAnimatedTransitioning object itself. This allows to create more reusable animators without losing the custom nature of the animations.

In order to create a simple transition between two view controllers whose backgrounds are identical (say both blue). We might create a UIViewControllerAnimatedTransitioning object:

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

    let container = transitionContext.containerView
    guard let sourceController = transitionContext.viewController(forKey: .from), let destinationController = transitionContext.viewController(forKey: .to) else { return }
    guard let destinationView = transitionContext.view(forKey: .to) else { return }
    guard let source = sourceController as? TransitionDriving, let destination = destinationController as? TransitionDriving else { return }

    destination.prepareForTransition(from: source)
    source.prepareForTransition(to: destination, withDuration: transitionDuration(using: transitionContext)) { finished in

    container.addSubview(destinationView)
    destinationView.frame = transitionContext.finalFrame(for: destinationController)

    source.completeTransition(to: destination)
    destination.completeTransition(from: source)
    transitionContext.completeTransition(finished)
}

This animator follows a simple sequence of events. After ensuring that the UIViewControllerContextTransitioning is configured properly, it will instruct the source to perform any animations necessary to facilitate a transition to the destination while at the same time giving the destination a chance to prepare itself before it's visible in the window.

Once those animations have completed by the source, the animator will add the destinationView to the container and configure it in it's final frame.

Finally, now that the destination view is visible (and obscuring the source view) it will instruct the source to clean up after itself, the destination to perform any animations or work necessary to complete the transition and will call back to the UIViewControllerContextTransitioning object to indicate the end of the transition.

But in order to complete the effect that these two screens are continuous, all the content on the source must be cleared, and all the content on the destination must be cleared before the swap itself can happen. In order to accomplish this we might implement TransitionDriving on our source and destination view controllers:

extension ViewController: TransitionDriving {
    func completeTransition(from source: TransitionDriving?) {
        UIView.animate(withDuration: 0.3, delay: 0.0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.0, options: [], animations: {
            animatingViews.forEach { $0.transform = .identity }
        }, completion: nil)
    }

    func completeTransition(to destination: TransitionDriving?) {
        animatingViews.forEach { $0.transform = .identity }
    }

    func prepareForTransition(from source: TransitionDriving?) {
        animatingViews.forEach { $0.transform = CGAffineTransform(translationX: -self.view.bounds.width, y: 0) }
    }

    func prepareForTransition(to destination: TransitionDriving?, withDuration duration: TimeInterval, completion: @escaping (Bool) -> Void) {
        UIView.animate(withDuration: duration - delay, delay: delay, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.0, options: [], animations: {
            animatingViews.forEach { $0.transform = CGAffineTransform(translationX: -self.view.bounds.width, y: 0) }
        }, completion: completion)
    }
}

This will have the effect of animating all the intended views off the leading edge of the screen when acting as the source, and to animate back in from the leading edge when acting as the destination. Implementing this on both the source and destination of the UIViewControllerAnimatedTransitioning object will create the illusion that it is one continuous screen.

ShiftTransitioning

Sometimes in transitions like these, there is content that is consistent between two screens - if not in the exact same size or position. It would be ideal in these situations to not animate the content off screen only to animate it back on. Instead we can use the ShiftTransitioning protocol to move it to it's new position. First, we must tell our source and destination which views are eligible to move:

extension ViewController: ShiftTransitioning {
    /* This empty conformance is enough to inform the animator to search through this controller's subviews for eligible shiftables. */

    //The default value of this variable is true, setting to false will short-circuit the search. */
    var isShiftingEnabled: Bool { return true }

    //The default value of this variable is an empty array, but allows you to short-circuit search in more complicated view hierarchies.
    var shiftExclusions: [UIView] { return [] }
}

extension ViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        /* ...other work... */

        yellowView.shiftID = "yellow"
        orangeView.shiftID = "orange"
    }
}

In this example, we have a yellow and orange view which are consistent between screens. Because their identifiers (which can be AnyHashable) are equal, the animator will match them up into a pair. It will move the UIView attached to each Target from it's state in the source, to it's state in the destination. This will create the illusion that the content is moving from one place to another (similar to the magic move effect in Keynote).

The full list of UIView and CALayer properties that comprise Target are so are automatically animatable are:

  • bounds
  • center
  • transform and layer.transform3d
  • layer.cornerRadius

In order to complete the effect and use this new shifting ability, we need to do a little bit more work in our animator:

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {

    let container = transitionContext.containerView
    guard let sourceController = transitionContext.viewController(forKey: .from), let destinationController = transitionContext.viewController(forKey: .to) else { return }
    guard let destinationView = transitionContext.view(forKey: .to) else { return }
    guard let source = sourceController as? TransitionDriving, let destination = destinationController as? TransitionDriving else { return }
    guard let shiftSource = sourceController as? ShiftTransitioning, let shiftDestination = destinationController as? ShiftTransitioning else { return }

    shiftAnimator = ShiftAnimator(source: shiftSource, destination: shiftDestination)

    destination.prepareForTransition(from: source)
    source.prepareForTransition(to: destination, withDuration: transitionDuration(using: transitionContext)) { finished in

        container.addSubview(destinationView)
        destinationView.frame = transitionContext.finalFrame(for: destinationController)
        destinationView.layoutIfNeeded()

        source.completeTransition(to: destination)
        destination.completeTransition(from: source)
        self.shiftAnimator?.animate(with: 0.3, inContainer: container) { position in
            transitionContext.completeTransition(position == .end)
        }
    }
}

This animator method is nearly identical to the previous, with the addition of the ShiftAnimator. This object is created with a specific source and destination. At some point during the transition it will be instructed to animate the matches it finds between the source and destination states. This animation can be done as part of the transition (ending it when the frame shifts complete) or separately (the transition will end as soon as the shifts begin).

In addition, providing a custom ShiftCoordinator object will allow you to provide a custom UITimingCurveProvider object and a different relative start time and end time for each shift. Many more complicated examples are available to run in the example project.

Example

To run the example project, clone the repo, and run pod install from the Example directory first.

Requirements

Requires iOS 12.0

Installation

Add this to your project using Swift Package Manager. In Xcode, that is simply: File > Swift Packages > Add Package Dependency... and you're done. Alternative installations options are shown below for legacy projects.

CocoaPods

If you are already using CocoaPods, just add 'Shifty' to your Podfile, then run pod install.

Carthage

If you are already using Carthage, just add to your Cartfile:

github "wmcginty/Shifty" ~> 3.0.0

Then run carthage bootstrap to build the framework and drag the built Shifty.framework into your Xcode project.

Contributing

See the CONTRIBUTING document. Thank you, contributors!