I have an extension for performing transitions between view controllers. It will perform an animation with the views of each VC and then replace the current vc with the destination vc using this method:
private func replace(with: UIViewController, completion: (() -> Void)?) {
if let baseWindow = self.view.window, baseWindow.rootViewController == self {
baseWindow.rootViewController = with
}
}
Everything works fine except in the following scenario:
Using animation: .none works, so it must be something to do with my transition code, which I will share below:
extension UIViewController {
enum HorizontalDirection { case left, right }
enum TransitionAnimation {
case slide(_ direction: HorizontalDirection)
case pageIn(_ direction: HorizontalDirection)
case pageOut(_ direction: HorizontalDirection)
case zoomOut
case zoomIn
case fade
case none
}
private func replace(with: UIViewController, completion: (() -> Void)?) {
if let baseWindow = self.view.window, baseWindow.rootViewController == self {
baseWindow.rootViewController = with
}
}
// Note that these transitions will not work inside a macOS modal
func transition(to: UIViewController, animation: TransitionAnimation, completion: (() -> Void)? = nil) {
// initialSpringVelocity (default 0) determines how quickly the view moves during the first part of the animation, before the spring starts to slow it down.
// A higher value for initialSpringVelocity means that the view will move more quickly at the beginning of the animation, while a lower value means that it will start more slowly. The velocity value is measured in points per second.
// The usingSpringWithDamping (default 0.5) parameter determines how quickly the view slows down during each oscillation. A smaller value for usingSpringWithDamping creates a bouncier effect with more oscillations, while a larger value creates a more damped effect with fewer oscillations.
switch animation {
case .none: replace(with: to, completion: completion)
case .fade:
to.view.alpha = 0
self.view.insertSubview(to.view, aboveSubview: self.view)
UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseOut], animations: {
to.view.alpha = 1
}, completion: { [self] _ in replace(with: to, completion: completion) })
case .slide(.left):
let width = self.view.frame.size.width
self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
to.view.transform = CGAffineTransform(translationX: width, y: 0)
UIView.animate(withDuration: 0.7, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.2, options: [.curveEaseInOut], animations: { [self] in
self.view.transform = CGAffineTransform(translationX: -width, y: 0)
self.view.subviews.forEach({ $0.alpha = 0 })
to.view.transform = .identity
}, completion: { [self] _ in replace(with: to, completion: completion) })
case .slide(.right):
let width = self.view.frame.size.width
self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
to.view.transform = CGAffineTransform(translationX: -width, y: 0)
UIView.animate(withDuration: 0.7, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.2, options: [.curveEaseInOut], animations: { [self] in
self.view.transform = CGAffineTransform(translationX: width, y: 0)
self.view.subviews.forEach({ $0.alpha = 0 })
to.view.transform = .identity
}, completion: { [self] _ in replace(with: to, completion: completion) })
case .pageIn(.left):
let width = self.view.frame.size.width
self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
to.view.transform = CGAffineTransform(translationX: width, y: 0)
UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
self.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
self.view.alpha = 0
to.view.transform = .identity
}, completion: { [self] _ in replace(with: to, completion: completion) })
case .pageIn(.right):
let width = self.view.frame.size.width
self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
to.view.transform = CGAffineTransform(translationX: -width, y: 0)
UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
self.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
self.view.alpha = 0
to.view.transform = .identity
self.view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
}, completion: { [self] _ in replace(with: to, completion: completion) })
case .pageOut(.left):
let width = self.view.frame.size.width
self.view.superview?.insertSubview(to.view, belowSubview: self.view)
to.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
to.view.alpha = 0
UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
self.view.transform = CGAffineTransform(translationX: width, y: 0)
to.view.transform = .identity
to.view.alpha = 1
self.view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
}, completion: { [self] _ in replace(with: to, completion: completion) })
case .pageOut(.right):
let width = self.view.frame.size.width
self.view.superview?.insertSubview(to.view, belowSubview: self.view)
to.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
to.view.alpha = 0
UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
self.view.transform = CGAffineTransform(translationX: -width, y: 0)
to.view.transform = .identity
to.view.alpha = 1
self.view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
}, completion: { [self] _ in replace(with: to, completion: completion) })
case .zoomOut:
self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
to.view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
to.view.alpha = 0
// This backgroundView is added behind the 2 views transitioning because in certain scenarios, there are pages still visible behind them and the zoom animation reveals them.
let backgroundView = UIView(frame: self.view.frame)
backgroundView.backgroundColor = self.view.backgroundColor
self.view.superview?.insertSubview(backgroundView, belowSubview: self.view)
UIView.animate(withDuration: 0.2, delay: 0.0, options: [.curveEaseInOut], animations: { to.view.alpha = 1 }, completion: nil)
UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
self.view.transform = CGAffineTransform(scaleX: 0.7, y: 0.7)
to.view.transform = .identity
self.view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
}, completion: { [self] _ in replace(with: to, completion: completion) })
case .zoomIn:
self.view.superview?.insertSubview(to.view, aboveSubview: self.view)
to.view.transform = CGAffineTransform(scaleX: 0.7, y: 0.7)
to.view.alpha = 0
// This backgroundView is added behind the 2 views transitioning because in certain scenarios, there are pages still visible behind them and the zoom animation reveals them.
let backgroundView = UIView(frame: self.view.frame)
backgroundView.backgroundColor = self.view.backgroundColor
self.view.superview?.insertSubview(backgroundView, belowSubview: self.view)
UIView.animate(withDuration: 0.15, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in view.alpha = 0 }, completion: nil)
UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseInOut], animations: { [self] in
view.transform = CGAffineTransform(scaleX: 1.3, y: 1.3)
to.view.transform = .identity
to.view.alpha = 1
view.layoutIfNeeded() // This is here to prevent the safe area from being impacted by the scaling animation
}, completion: { [self] _ in replace(with: to, completion: completion) })
}
}
}
I have also created a demo Xcode project demonstrating the issue if anyone is this helps: https://drive.google.com/file/d/1aX3sQCCcp56wqRS5kH0wG8VzPpE5XIny/view?usp=share_link
Any hints about what causes this issue and how to fix it will be greatly appreciated.
You're manipulating view hierarchy in ways that cause problems - as we see from the results.
Notice that running your test project as-is, we get this in the debug console:
[Window] Manually adding the rootViewController's view to the view hierarchy
is no longer supported. Please allow UIWindow to add the rootViewController's view
to the view hierarchy itself.
Unbalanced calls to begin/end appearance transitions for
<UINavigationController: 0x7f7f2002de00>.
Unbalanced calls to begin/end appearance transitions for
<Test_project.Container: 0x7f7f1780ca10>.
What your code is doing is pulling the navigation controller's view out of the view hierarchy, then trying to add that controller.
You can try this...
IF the "to" controller is a UINavigationController
,
.viewControllers.first
controllerafter adding and showing the "to" view,
UINavigationController
.first
VCreplace()
with the New nav controllerWith only quick testing, this appears to solve the issue:
case .fade:
var origNavController: UINavigationController!
var origFirstVC: UIViewController!
if let toVC = to as? UINavigationController,
let fVC = toVC.viewControllers.first {
origNavController = toVC
origFirstVC = fVC
}
to.view.alpha = 0
// we can add it -- no need to "insert above"
//self.view.insertSubview(to.view, aboveSubview: self.view)
self.view.addSubview(to.view)
UIView.animate(withDuration: 0.3, delay: 0.0, options: [.curveEaseOut], animations: {
to.view.alpha = 1
}, completion: { [self] _ in
// if the "to" controller is a UINavigationController
if origNavController != nil, origFirstVC != nil {
// instantiate a NEW UINavigationController
let newNC = UINavigationController(rootViewController: origFirstVC)
// match the original
newNC.isNavigationBarHidden = origNavController.isNavigationBarHidden
// now, replace
self.replace(with: newNC, completion: completion)
} else {
self.replace(with: to, completion: completion)
}
})
Notes:
.fade
case...It may get you headed in the right direction.