I've implemented a Navigation controller to incorporate an rotating-disc type of layout (imagine each VC laid out in a circle, that rotates as a whole, into view sequentially. The navigation controller is configured to use a custom transition class, as below :-
import UIKit class RotaryTransition: NSObject, UIViewControllerAnimatedTransitioning { let isPresenting :Bool let duration :TimeInterval = 0.5 let animationDuration: TimeInterval = 0.7 let delay: TimeInterval = 0 let damping: CGFloat = 1.4 let spring: CGFloat = 6.0 init(isPresenting: Bool) { self.isPresenting = isPresenting super.init() } func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { //Get references to the view hierarchy let fromViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)! let toViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)! let sourceRect: CGRect = transitionContext.initialFrame(for: fromViewController) let containerView: UIView = transitionContext.containerView if self.isPresenting { // Push //1. Settings for the fromVC ............................ // fromViewController.view.frame = sourceRect fromViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3); fromViewController.view.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3); //2. Setup toVC view........................... containerView.insertSubview(toViewController.view, belowSubview:fromViewController.view) toViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3); toViewController.view.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3); toViewController.view.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180)); //3. Perform the animation............................... UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: { fromViewController.view.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180)); toViewController.view.transform = CGAffineTransform(rotationAngle: 0); }, completion: { (animated: Bool) -> () in transitionContext.completeTransition(true) }) } else { // Pop //1. Settings for the fromVC ............................ fromViewController.view.frame = sourceRect fromViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3); fromViewController.view.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3); //2. Setup toVC view........................... // toViewController.view.frame = transitionContext.finalFrame(for: toViewController) toViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3); toViewController.view.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3); toViewController.view.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180)); containerView.insertSubview(toViewController.view, belowSubview:fromViewController.view) //3. Perform the animation............................... UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: { fromViewController.view.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180)); toViewController.view.transform = CGAffineTransform(rotationAngle: 0); }, completion: { //When the animation is completed call completeTransition (animated: Bool) -> () in transitionContext.completeTransition(true) }) } } func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return duration; } }
A representation of how the views move is show in the illustration below... The two red areas are the problems, as explained later.
The presenting (push) translation works fine - 2 moves to 1 and 3 moves to 2. However, the dismissing (pop) translation does not, whereby the dismissing VC moves out of view seemingly correctly (2 moving to 3), but the presenting (previous) VC arrives either in the wrong place, or with an incorrectly sized frame...
With the class as-is, the translation results in 2 moving to 3 (correctly) but 1 then moves to 4, the view is correctly sized, yet seems offset, by a seemingly arbitrary distance, from the intended location. I have since tried a variety of different solutions.
In the pop section I tried adding the following line (commented in the code) :-
toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
...but the VC now ends up being shrunk (1 moves to 5). I hope someone can see the likely stupid error I'm making. I tried simply duplicating the push section to the pop section (and reversing everything), but it just doesn't work!
FYI... Those needing to know how to hookup the transition to a UINavigationController - Add the UINavigationControllerDelegate to your nav controller, along with the following function...
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { let transition: SwingTransition = SwingTransition.init(isPresenting: ( operation == .push ? true : false )) return transition; }
The diagram below shows how all views would share the same originating point (for the translation). The objective is to give the illusion of a revolver barrel moving each VC into view. The top centre view represents the viewing window, showing the third view in the stack. Apologies for the poor visuals...
The problem is that one of the properties in the restored view controller's view isn't getting reset properly. I'd suggest resetting it when the animation is done (you probably don't want to keep the non-standard transform
and anchorPoint
in case you do other animations later that presume the view is not transformed). So, in the completion
block of the animation, reset the position
, anchorPoint
and transform
of the views.
class RotaryTransition: NSObject, UIViewControllerAnimatedTransitioning {
let isPresenting: Bool
let duration: TimeInterval = 0.5
let delay: TimeInterval = 0
let damping: CGFloat = 1.4
let spring: CGFloat = 6
init(isPresenting: Bool) {
self.isPresenting = isPresenting
super.init()
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let from = transitionContext.viewController(forKey: .from)!
let to = transitionContext.viewController(forKey: .to)!
let frame = transitionContext.initialFrame(for: from)
let height = frame.size.height
let width = frame.size.width
let angle: CGFloat = 15.0 * .pi / 180.0
let rotationCenterOffset: CGFloat = width / 2 / tan(angle / 2) / height + 1 // use fixed value, e.g. 3, if you want, or use this to ensure that the corners of the two views just touch, but don't overlap
let rightTransform = CATransform3DMakeRotation(angle, 0, 0, 1)
let leftTransform = CATransform3DMakeRotation(-angle, 0, 0, 1)
transitionContext.containerView.insertSubview(to.view, aboveSubview: from.view)
// save the anchor and position
let anchorPoint = from.view.layer.anchorPoint
let position = from.view.layer.position
// prepare `to` layer for rotation
to.view.layer.anchorPoint = CGPoint(x: 0.5, y: rotationCenterOffset)
to.view.layer.position = CGPoint(x: width / 2, y: height * rotationCenterOffset)
to.view.layer.transform = self.isPresenting ? rightTransform : leftTransform
//to.view.layer.opacity = 0
// prepare `from` layer for rotation
from.view.layer.anchorPoint = CGPoint(x: 0.5, y: rotationCenterOffset)
from.view.layer.position = CGPoint(x: width / 2, y: height * rotationCenterOffset)
// rotate
UIView.animate(withDuration: duration, delay: delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, animations: {
from.view.layer.transform = self.isPresenting ? leftTransform : rightTransform
to.view.layer.transform = CATransform3DIdentity
//to.view.layer.opacity = 1
//from.view.layer.opacity = 0
}, completion: { finished in
// restore the layers to their default configuration
for view in [to.view, from.view] {
view?.layer.transform = CATransform3DIdentity
view?.layer.anchorPoint = anchorPoint
view?.layer.position = position
//view?.layer.opacity = 1
}
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
}
I did a few other sundry changes, while I was here:
completion
closure of animate
method to finished
rather than animated
to more accurately reflect what it's real purpose is ... you could use _
, too;completeTransition
based upon whether the animation was canceled or not (because if you ever make this interactive/cancelable, you don't want to always use true
);.pi
rather than M_PI
; opacity
, but I generally do that to give the effect a touch more polish and to ensure that if you tweak angles so the views overlap, you don't get any weird artifacts of the other view just as the animation starts or just as its finishing; I've actually calculated the parameters so there's no overlapping, regardless of screen dimensions, so that wasn't necessary and I commented out the opacity
lines, but you might consider using them, depending upon the desired effect.Previously I showed how to simplify the process a bit, but the resulting effect wasn't exactly what you were looking for, but see the previous rendition of this answer if you're interested.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With