
Most mobile applications contain more than a dozen screens, complex transitions, as well as parts of the application, divided by meaning and purpose. Consequently, there is a need to organize the correct navigation structure of the application, which will be flexible, convenient, expandable, provide comfortable access to various parts of the application, and will also take care of the system's resources.
In this article, we will design the in-app navigation to avoid the most common mistakes that lead to memory leaks, spoil the architecture, and break down the navigation structure.
Most applications have at least two parts: authentication (pre-login) and closed part (post-login). Some applications may have a more complex structure, multiple profiles with one login, conditional transitions after the launch of the application (deeplinks), etc.
To navigate the application in practice, mainly two approaches are used:
- One navigation stack for both view controllers (present) and navigation controllers (push), without the ability to go back. This approach leads to the fact that all previous ViewController'y remain in memory.
- The window.rootViewController switch is used. With this approach, all previous ViewControllers are destroyed in memory, but this does not look the best from the point of view of the UI. It also does not allow moving back and forth if necessary.
And now let's see how you can make an easily supported structure that allows you to easily switch between different parts of the application, without spaghetti code and with easy navigation.
Let's imagine that we are writing an application consisting of:
- Splash screen : this is the very first screen that you see, as soon as the application is launched, you can add, for example, an animation or make any primary API requests.
- Authentification part screens: login, registration, password reset, email confirmation, etc. The user's work session is usually saved, so there is no need to enter a login every time you start the application.
- Main application: main application business logic
All these parts of the application are isolated from each other and each exists in its navigation stack. Thus, we may need the following transitions:
- Splash screen -> Authentication screen , in case the current session of the active user is absent.
- Splash screen -> Main screen, in case the user has already entered the application before and there is an active session.
- Main screen -> Authentication screen , in case the user has logged out
Basic settingWhen the application starts, we need to initialize the
RootViewController , which will be loaded first. This can be done both by code and through Interface Builder. Create a new project in xCode and everything will be done by default: the
main.storyboard is already attached to
window.rootViewController .
But in order to focus on the main topic of the article, we will not use storyboards in our project. Therefore, delete the
main.storyboard , and also clear the “Main Interface” field in the Targets -> General -> Deployment info item:

Now let's change the
didFinishLaunchingWithOptions method in
AppDelegate to make it look like this:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { window = UIWindow(frame: UIScreen.main.bounds) window?.rootViewController = RootViewController() window?.makeKeyAndVisible() return true }
Now the application will launch
RootViewController first . Rename the base
ViewController to
RootViewController :
class RootViewController: UIViewController { }
This will be the main controller responsible for all transitions between different sections of the application. Therefore, we will need a link to it every time we want to make a transition. To do this, add an extension to
AppDelegate :
extension AppDelegate { static var shared: AppDelegate { return UIApplication.shared.delegate as! AppDelegate } var rootViewController: RootViewController { return window!.rootViewController as! RootViewController } }
The forced extraction of the option in this case is justified, because the RootViewController does not change, and if this happens by chance, then the application crash is a normal situation.
So, now we have a link to
RootViewController from anywhere in the application:
let rootViewController = AppDelegate.shared.rootViewController
Now let's create some more controllers we need:
SplashViewController, LoginViewController, and
MainViewController .
Splash Screen is the first screen that the user sees after launching the application. At this time, all the necessary API requests are usually made, the user's session activity is checked, etc. To display the ongoing background actions use the
UIActivityIndicatorView :
class SplashViewController: UIViewController { private let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .whiteLarge) override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor.white view.addSubview(activityIndicator) activityIndicator.frame = view.bounds activityIndicator.backgroundColor = UIColor(white: 0, alpha: 0.4) makeServiceCall() } private func makeServiceCall() { } }
To simulate API requests, add the
DispatchQueue.main.asyncAfter method with a delay of 3 seconds:
private func makeServiceCall() { activityIndicator.startAnimating() DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(3)) { self.activityIndicator.stopAnimating() } }
We believe that these requests also set the user's session. In our application, we use
UserDefaults for this:
private func makeServiceCall() { activityIndicator.startAnimating() DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(3)) { self.activityIndicator.stopAnimating() if UserDefaults.standard.bool(forKey: “LOGGED_IN”) {
You will definitely not use UserDefaults to save the session state of the user in the release version of the program. We use local settings in our project to simplify understanding and not to go beyond the main topic of the article.Create
LoginViewController . It will be used to authenticate the user if the current session of the user is inactive. You can add your custom UI to the controller, but I will add here only the screen title and login button in the Navigation Bar.
class LoginViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor.white title = "Login Screen" let loginButton = UIBarButtonItem(title: "Log In", style: .plain, target: self, action: #selector(login)) navigationItem.setLeftBarButton(loginButton, animated: true) } @objc private func login() {
And finally, let's create the main controller of the
MainViewController application:
class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor.lightGray // to visually distinguish the protected part title = “Main Screen” let logoutButton = UIBarButtonItem(title: “Log Out”, style: .plain, target: self, action: #selector(logout)) navigationItem.setLeftBarButton(logoutButton, animated: true) } @objc private func logout() { // clear the user session (example only, not for the production) UserDefaults.standard.set(false, forKey: “LOGGED_IN”) // navigate to the Main Screen } }
Root navigationNow back to
RootViewController .
As we said earlier, the
RootViewController is the only object that is responsible for transitions between various independent controller stacks. In order to be aware of the current state of the application, we will create a variable in which we will store the current
ViewController :
class RootViewController: UIViewController { private var current: UIViewController }
Add a class initializer and create the first
ViewController that we want to load when the application starts. In our case, it will be
SplashViewController :
class RootViewController: UIViewController { private var current: UIViewController init() { self.current = SplashViewController() super.init(nibName: nil, bundle: nil) } }
In
viewDidLoad, add the current
viewController to the
RootViewController :
class RootViewController: UIViewController { ... override func viewDidLoad() { super.viewDidLoad() addChildViewController(current)
As soon as we add
childViewController (1), we adjust its size by setting
current.view.frame to
view.bounds (2).
If we skip this line,
viewController will still be placed correctly in most cases, but problems may arise if the
frame size changes.
Add a new subview (3) and call the didMove method (toParentViewController :). This completes the add controller operation. As soon as the
RootViewController loads ,
SplashViewController will immediately appear after that.
Now you can add several methods for navigation in the application. We will display the
LoginViewController without any animation, the
MainViewController will use a smooth dimming animation, and switching screens when the user is logged out will have a slide effect.
class RootViewController: UIViewController { ... func showLoginScreen() { let new = UINavigationController(rootViewController: LoginViewController()) // 1 addChildViewController(new) // 2 new.view.frame = view.bounds // 3 view.addSubview(new.view) // 4 new.didMove(toParentViewController: self) // 5 current.willMove(toParentViewController: nil) // 6 current.view.removeFromSuperview()] // 7 current.removeFromParentViewController() // 8 current = new // 9 }
Create
LoginViewController (1), add it as a child controller (2), set the frame (3). Add a
LoginController view as a subview (4) and call the didMove method (5). Next, prepare the current controller for removal by the willMove (6) method. Finally, remove the current view from superview (7), and remove the current controller from the
RootViewController (8). Do not forget to update the value of the current controller (9).
Now let's create the
switchToMainScreen method:
func switchToMainScreen() { let mainViewController = MainViewController() let mainScreen = UINavigationController(rootViewController: mainViewController) ... }
To animate the transition, you need another method:
private func animateFadeTransition(to new: UIViewController, completion: (() -> Void)? = nil) { current.willMove(toParentViewController: nil) addChildViewController(new) transition(from: current, to: new, duration: 0.3, options: [.transitionCrossDissolve, .curveEaseOut], animations: { }) { completed in self.current.removeFromParentViewController() new.didMove(toParentViewController: self) self.current = new completion?()
This method is very similar to
showLoginScreen , but all the last steps are done after the animation is complete. In order to notify the caller of the end of the transition, we at the very end call a closure (1).
Now the final version of the
switchToMainScreen method will look like this:
func switchToMainScreen() { let mainViewController = MainViewController() let mainScreen = UINavigationController(rootViewController: mainViewController) animateFadeTransition(to: mainScreen) }
And finally, let's create the last method that will be responsible for the transition from
MainViewController to
LoginViewController :
func switchToLogout() { let loginViewController = LoginViewController() let logoutScreen = UINavigationController(rootViewController: loginViewController) animateDismissTransition(to: logoutScreen) }
The
AnimateDismissTransition method provides a slide animation:
private func animateDismissTransition(to new: UIViewController, completion: (() -> Void)? = nil) { new.view.frame = CGRect(x: -view.bounds.width, y: 0, width: view.bounds.width, height: view.bounds.height) current.willMove(toParentViewController: nil) addChildViewController(new) transition(from: current, to: new, duration: 0.3, options: [], animations: { new.view.frame = self.view.bounds }) { completed in self.current.removeFromParentViewController() new.didMove(toParentViewController: self) self.current = new completion?() } }
These are just two examples of animation, using the same approach you can create any complex animations you need.
To complete the setup, add method calls with animations from
SplashViewController, LoginViewController, and
MainViewController :
class SplashViewController: UIViewController { ... private func makeServiceCall() { if UserDefaults.standard.bool(forKey: “LOGGED_IN”) { // navigate to protected page AppDelegate.shared.rootViewController.switchToMainScreen() } else { // navigate to login screen AppDelegate.shared.rootViewController.switchToLogout() } } } class LoginViewController: UIViewController { ... @objc private func login() { ... AppDelegate.shared.rootViewController.switchToMainScreen() } } class MainViewController: UIViewController { ... @objc private func logout() { ... AppDelegate.shared.rootViewController.switchToLogout() } }
Compile, run the application and test its work in two versions:
- when the user already has an active current session (logged in)
- when there is no active session and authentication is required
In this and in another case, you should see the transition to the desired screen immediately after downloading
SplashScreen .

As a result, we have created a small test model of the application, with navigation through its main modules. In case you need to expand the capabilities of the application, add additional modules and transitions between them, you can always quickly and conveniently expand and scale this navigation system.