I never have enough time to work on the games that I make. So when I am working on them, I want to stay focused on the game screen, and spend as little time as possible writing the other boring (but still necessary!) screens for the App. Apple’s SwiftUI
framework is very easy to use, and if you don’t much care about making things pixel-perfect, it can sure save a lot of time.
I am not yet convinced that SwiftUI
is all that great at navigation, however. I feel like I only just got used to coordinators in UIKit
(admittedly many years ago now), and maybe I’m still in the honeymoon stage with them or something.
So for my latest game, Blither, and somewhat by extension this open source Hexagon Grid Generator that I released, I’m using SwiftUI
for all the straightforward UI, SpriteKit
for the “interesting” views (game and grid), and UIKit
mostly just for its UIViewController
s.
In Blither, I have a single UINavigationViewController
, and a class named AppCoordinator
that manages the navigation stack. It looks like this:
public final class AppCoordinator {
public enum Route: Int {
case about
case appIcon
case back
case game
case mainMenu
case newGameConfig
case options
case rules
case stats
}
public let navigationController: UINavigationController = {
let controller = UINavigationController()
return controller
}()
public func navigate(to route: Route) {
if let presented = navigationController.presentedViewController {
presented.dismiss(animated: true)
}
switch route {
case .about:
let viewController = AboutViewController()
navigationController.pushViewController(viewController, animated: true)
case .appIcon:
let viewController = AppIconSceneViewController()
navigationController.pushViewController(viewController, animated: true)
case .back:
navigationController.popViewController(animated: true)
case .game:
if let topVC = navigationController.topViewController, topVC is GameViewController {
// already showing the GameViewController, so pop first
navigationController.popViewController(animated: true)
}
let gameViewController = GameViewController(coordinator: self)
navigationController.pushViewController(gameViewController, animated: true)
case .mainMenu:
if let topVC = navigationController.topViewController, topVC is MenuViewController {
return // already showing the MenuViewController
}
let menuViewController = MenuViewController(coordinator: self)
navigationController.pushViewController(menuViewController, animated: true)
case .newGameConfig:
let configVC = PlayerConfigurationViewController(coordinator: self)
navigationController.present(configVC, animated: true)
case .options:
let viewController = OptionsViewController(coordinator: self)
navigationController.pushViewController(viewController, animated: true)
case .rules:
let rulesViewController = RulesViewController(coordinator: self)
navigationController.pushViewController(rulesViewController, animated: true)
case .stats:
let viewController = StatsViewController(coordinator: self)
navigationController.pushViewController(viewController, animated: true)
}
}
}
That may look like a lot, but if you look closely, almost every switch case is the same. The AppCoordinator
instance is created in the app delegate’s standard didFinishLaunching
function:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
/// The root coordinator for the app.
var appCoordinator: AppCoordinator?
/// The main window
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
let appCoordinator = AppCoordinator()
self.appCoordinator = appCoordinator
window.rootViewController = appCoordinator.navigationController
window.makeKeyAndVisible()
self.window = window
Blither.setup()
appCoordinator.navigate(to: .mainMenu)
return true
}
}
All of those View Controllers getting created in the AppCoordinator
are UIHostingController
subclasses, and look almost exactly like this one for the main menu:
public final class MenuViewController: UIHostingController<MenuView> {
private weak var coordinator: AppCoordinator?
init(coordinator: AppCoordinator? = nil) {
self.coordinator = coordinator
let menuView = MenuView(coordinator: coordinator)
super.init(rootView: menuView)
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
If you’ve worked with SwiftUI
and UIKit
together before, you’ll recognize the UIHostingController
, but even if you haven’t, all you really need to know is that MenuView is a standard SwiftUI
View
, meaning it’s dramatically fewer lines of code to add something like a button. And because it also holds a reference to the AppCoordinator
, the definition for a button can look like this one:
Button("Read the Rules", action: {
coordinator?.navigate(to: .rules)
})
When that’s tapped, the coordinator pushes the RulesViewController
onto the stack.
If I wanted, I could get rid of the coordinator reference, and do something like this:
Button("Read the Rules", action: {
(UIApplication.shared.delegate as? AppDelegate)?
.appCoordinator?.navigate(to: .rules)
})
…but I don’t like how that looks quite as much, and it also closes the door on dependency injection.
Hopefully you can appreciate how simple this all is! I’ve been pretty happy with how easy I found it to create new screens using this framework. Let me know in the comments or over on mastadon if you have done something similar, as I’d love to hear about similar approaches.