Building a Custom Navigation Stack in UIKit
When I started building Perdiem, I quickly ran into the limitations of UINavigationController. The default push/pop animations felt generic, the navigation bar was difficult to customise, and integrating custom transitions was painful. So I built my own.
Why Replace UINavigationController?
UINavigationController is great for simple apps, but it imposes a lot of opinions:
- The navigation bar has a fixed layout and animation behaviour
- Custom transitions require conforming to
UIViewControllerAnimatedTransitioning— verbose and inflexible - You can't easily mix navigation styles (e.g., push for some screens, slide-up for others)
- Back gestures are tied to the navigation bar's back button
For Perdiem, I wanted complete control. Different screens needed different transition styles, and I wanted smooth, interruptible animations without fighting the framework.
The Architecture
The replacement is called StackControllerViewController. At its core, it's a container view controller that manages a stack of child view controllers:
class StackControllerViewController: UIViewController {
private var viewControllers: [UIViewController] = []
func push(_ viewController: UIViewController, animated: Bool) {
addChild(viewController)
view.addSubview(viewController.view)
viewControllers.append(viewController)
if animated {
animatePush(viewController)
} else {
viewController.didMove(toParent: self)
}
}
func pop(animated: Bool) {
guard let top = viewControllers.last else { return }
if animated {
animatePop(top)
} else {
removeChild(top)
}
}
}
Custom Transitions
Each transition is defined as a simple struct rather than a protocol-heavy animator:
struct StackTransition {
let duration: TimeInterval
let push: (UIView, UIView) -> Void
let pop: (UIView, UIView) -> Void
}
This makes it trivial to define new transition types — slide from right, slide up, fade, or anything custom. The container just calls the appropriate closure during push/pop.
Gesture Handling
For interactive dismissal, I use a UIPanGestureRecognizer attached to the container. The gesture drives an animation that's calculated frame-by-frame:
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: self.view)
let progress = translation.x / self.view.bounds.width
switch gesture.state {
case .changed:
self.updateInteractiveTransition(progress: progress)
case .ended:
let velocity = gesture.velocity(in: self.view)
if progress > 0.3 || velocity.x > 500 {
self.finishInteractiveTransition()
} else {
self.cancelInteractiveTransition()
}
default:
break
}
}
Slide-Up Modals
One pattern I use heavily is slide-up presentations for detail screens. These get their own container — SlideUpContainerViewController — which handles:
- Full-screen presentation that slides up from the bottom
- Interactive drag-to-dismiss with velocity detection
- Background screen scaling for depth effect
- Safe area handling so the content respects the notch/Dynamic Island
The key insight is setting contentIsFullScreen = true and using proper safe area insets in the content view's layout. Without this, touch events in the status bar area get consumed by the content view, breaking gesture recognition.
Lessons Learned
-
Container view controllers are underrated. Apple provides the API for child view controller management, but few developers use it beyond basic tab bars. It's incredibly powerful.
-
Manual layout pays off for animations. When you control every frame, interruptible animations become straightforward. Auto Layout constraints can fight you during transitions.
-
Test gesture edge cases early. Interactive transitions have a lot of states — started, changed, cancelled, ended — and each needs to be handled correctly, especially when the user changes direction mid-gesture.
Building a custom navigation stack was one of the best architectural decisions in Perdiem. It removed an entire category of UIKit workarounds and gave me the freedom to create transitions that feel uniquely polished.