Public repo: For those of you who want to check out the code right away here is the repo: https://github.com/Lascorbe/SwiftUI-MVP-Coordinator.
Note: This is a blog series of 3 parts. This is the first part, and here is part 2 and part 3.
I didn't want to do much on SwiftUI until the next version is announced due to my previous experience with Swift, I mean, it'll likely introduce a lot of breaking changes. I still remember the pain of migrating between Swift versions (Swift 3 to 4 anyone?), and I didn't want to live that again.
But all changed, quarantine arrived and I wanted to give a hand building a new app for a friend. I thought well, it could be nice to try SwiftUI making a real app. I liked what I saw about it until now, but after using it... what a great tool it is! Something that took you 2 days to do with UIKit now you can do it in 2 hours, it just boosts development so much. It's what we were waiting for while we were looking at things like hot reloading of our frontend colleagues, or React Native, or Flutter... Plus a declarative layout, nothing left to ask for.
But in SwiftUI not everything is a land of unicorns, specially when you discover that not only navigation is a bit tied to the views, but there're some broken things between iOS 13 minor versions and other platforms.
I can't do much about how broken an Apple framework/tool is (above reporting radars feedbacks), but I can explore how navigation can be decoupled from Views, or at least I can try. Also, looks like there's some interest.
So in this post I'm going to show you how to use SwiftUI with Coordinators, and using the MVP design pattern.
I choose MVP and Coordinators, because I've worked with both, and because Coordinators became the defacto design pattern to route our navigation in a UIKit app (thanks Soroush! ๐). I don't know if those 2 are the best design patterns to use with SwiftUI, maybe not, maybe something like redux would fit better, I don't know. But it doesn't hurt to try.
I'm not going to explain how coordinators work, there're several blog posts explaining it far better and from smarter people than me, which I recommend you to check out if you haven't.
One more thing before we get started. I created this project thinking in a big app, with testing in mind. That's why you'll see an interface for most of the types (aka protocols), so everything can be mocked and tested.
In this part, part 1, we're going to learn how to set up an entire screen with the MVP pattern, we'll create a base Coordinator
protocol, and implement our first 2 coordinators. We'll see how to wrap our view in a NavigationView, and how we can implement NavigationLink so it doesn't depend on anything else in the view.
In the next section, we're going to see how to set up our 1st screen with MVP (Model-View-Presenter). Create a new project/playground and let's make our first view.
With our project created, let's define the 1st view of our app:
struct MasterView: View {
var body: some View {
Text("We will rock the stage at NSSpain again")
}
}
I really want to try to void words like "just", "easy", "simple", "complex"... but this is literally just one label in the middle of the screen.
Now the model, just storing a date (I'll call them ViewModels because we're at the UI layer):
struct MasterViewModel {
let date: Date
}
Now the presenter:
protocol MasterPresenting: ObservableObject { // Notice conformance to ObservableObject
var viewModel: MasterViewModel { get }
}
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel = MasterViewModel(date: Date())
}
Yes, we're doing protocols all the way. This is going to be a small app, but let's treat it as if it was a big one. We declare the protocol of the presenter, so we can inject it in our view, which will make things like testing easier.
Using the power of Combine, we declared the protocol conformance to ObservableObject
. Now we can observe the @Published
properties from the view.
Let's change our view to adopt this presenter protocol:
struct MasterView: View {
@ObservedObject var presenter: MasterPresenting // check out @ObservedObject
var body: some View {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
To bind this view to the presenter we need the @ObservedObject
property wrapper. dateFormatter
is just a global DateFormatter
defined somewhere else. Now our view is "listening" for whenever our viewModel
changes!
But since MasterPresenting
is conforming to ObservableObject
, it is a generic protocol. So we have to tell that to the view:
struct MasterView<T: MasterPresenting>: View { // hi there T!
@ObservedObject var presenter: T
{...}
}
We still don't want MasterView
to know what MasterPresenting
we are injecting, so let's keep using the MasterPresenting
protocol, this time as a generic one (<T: MasterPresenting>
).
We've completed the MVP part of our first screen. In the next section we're going to define our base Coordinator type and define our first 2 coordinators.
This is what we've done so far:
struct MasterViewModel {
let date: Date
}
protocol MasterPresenting: ObservableObject {
var viewModel: MasterViewModel { get }
}
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel = MasterViewModel(date: Date())
}
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
With our MVP in place, now we are going to create protocols and implementations for coordinators. So how does our base Coordinator look like?
protocol Coordinator {
func start()
}
Let's start with something like this. And we extend the protocol to define a coordinate function:
extension Coordinator {
func coordinate(to coordinator: Coordinator) {
coordinator.parent = self
coordinator.start()
}
}
Wait, how are you storing parent in a protocol extension? Where is it defined? Bear with me for a moment, we'll get there.
Next, we can try to implement MasterView
's coordinator:
protocol MasterCoordinator: Coordinator {} // empty for now
final class RootMasterCoordinator: MasterCoordinator {
func start() {
??
}
}
Hmmm, what can we do here? Let's go to the beginning of the app, the SceneDelegate
, and see what we need.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let coordinator = AppCoordinator(window: window) // <-- look here
coordinator.start() // <-- and here
self.window = window
}
}
The important part is the 2 lines of the coordinator, the initialization, where we inject the window, and coordinator.start()
. Now let's define our AppCoordinator
, which is going to be the starting point of the app navigation:
final class AppCoordinator: Coordinator {
private weak var window: UIWindow?
init(window: UIWindow) {
self.window = window
}
func start() {
let coordinator = RootMasterCoordinator(window: window)
coordinate(to: coordinator)
}
}
We're injecting the UIWindow
through the init
, and then passing it to MasterView
's coordinator.
Next, we have to handle the presentation on the window. Let's go back to our RootMasterCoordinator
and set up the start()
function:
final class RootMasterCoordinator: MasterCoordinator {
private weak var window: UIWindow?
init(window: UIWindow?) {
self.window = window
}
func start() {
let view = MasterFactory.make(with: self)
let hosting = UIHostingController(rootView: view)
window?.rootViewController = hosting
window?.makeKeyAndVisible()
}
}
Here we just take the window and present the rootViewController
(UIHostingController
is what you need to bring SwiftUI Views to UIKit). The AppCoordinator
and the RootMasterCoordinator
are the only 2 coordinators where we need UIKit, maybe in June we get a new UISceneDelegate
/UIApplicationDelegate
API?
Cool! Coordinators are working via AppCoordinator
and RootMasterCoordinator
.
There's an interesting line in RootMasterCoordinator
, what's behind that MasterFactory
? As you can guess, a factory:
enum MasterFactory {
static func make(with coordinator: MasterCoordinator) -> some View {
let presenter = MasterPresenter(coordinator: coordinator)
let view = MasterView(presenter: presenter)
return view
}
}
I'm using an enum
so it cannot be initialized, but a struct
with an unavailable init also works. Here we get an interesting bit, notice we're returning some View
, so we can get the SwiftUI view in RootMasterCoordinator
.
Also, looks like we're injecting our coordinator into the presenter now, in MasterPresenter(coordinator: coordinator)
, so let's implement that too:
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel: MasterViewModel
private let coordinator: MasterCoordinator
init(coordinator: MasterCoordinator) {
self.coordinator = coordinator
self.viewModel = MasterViewModel(date: Date())
// You may want to bind your ViewModel to a service/DB here, maybe using Combine/RxSwift
}
}
As you can see, we inject the coordinator through the init as we did with the window in the coordinator. Then you may want to bind your model/entity here. I tried using onAppear
on MasterView
and report it back to the presenter as a way to bind the ViewModel, but it works as a viewDidAppear
not like a viewDidLoad
.
We've learned how to link everything together, the MVP with the coordinators, now we have the base structure of the UI. In the next section we'll see how we can navigate to a view.
This is what we've done so far:
// MARK: Coordinator
protocol Coordinator {
func start()
}
extension Coordinator {
func coordinate(to coordinator: Coordinator) {
coordinator.start()
}
}
// MARK: AppCoordinator
final class AppCoordinator: Coordinator {
private weak var window: UIWindow?
init(window: UIWindow) {
self.window = window
}
func start() {
let coordinator = RootMasterCoordinator(window: window)
coordinate(to: coordinator)
}
}
// MARK: MasterCoordinator
protocol MasterCoordinator: Coordinator {}
final class RootMasterCoordinator: MasterCoordinator {
private weak var window: UIWindow?
init(window: UIWindow?) {
self.window = window
}
func start() {
let view = MasterFactory.make(with: self)
let hosting = UIHostingController(rootView: view)
window?.rootViewController = hosting
window?.makeKeyAndVisible()
}
}
// MARK: Factory
enum MasterFactory {
static func make(with coordinator: MasterCoordinator) -> some View {
let presenter = MasterPresenter(coordinator: coordinator)
let view = MasterView(presenter: presenter)
return view
}
}
// MARK: MVP
struct MasterViewModel {
let date: Date
}
protocol MasterPresenting: ObservableObject {
var viewModel: MasterViewModel { get }
}
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel: MasterViewModel
private let coordinator: MasterCoordinator
init(coordinator: MasterCoordinator) {
self.coordinator = coordinator
self.viewModel = MasterViewModel(date: Date())
}
}
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
Now that we have our MVP and Coordinators in place, let's go to MasterView
and see how we can navigate to another view with NavigationLink
:
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
NavigationView {
NavigationLink(destination: EmptyView()) {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
}
}
What's going on here? We're telling MasterView
that its content is wrapped in a NavigationView
, kind of a UINavigationController
. Then with NavigationLink
, we create a push
action to EmptyView()
which is going to be triggered when Text
is pressed.
But we don't want either MasterView
to know that is being presented in a NavigationView
, or that we're presenting EmptyView()
, or that it must use NavigationLink
to present it.
So first, we're going to move NavigationView
out. Where should it go? Yup, the coordinator:
final class RootMasterCoordinator: MasterCoordinator {
private weak var window: UIWindow?
init(window: UIWindow?) {
self.window = window
}
func start() {
let view = MasterFactory.make(with: self)
let navigation = NavigationView { view } // Hi! I'm new
let hosting = UIHostingController(rootView: navigation)
window?.rootViewController = hosting
window?.makeKeyAndVisible()
}
}
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
// NavigationView is no longer here
NavigationLink(destination: EmptyView()) {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
}
Better. Next, we're going to move NavigationLink
out too. It should be a function we can call on the presenter, something like:
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
var body: some View {
presenter.presentSuperAmazingView {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
}
}
}
But there're 2 problems here. One, it's hard to understand what presentSuperAmazingView
does, does it make Text
a button? will it push the view? Second, we're working with NavigationLink
, but what happens if we want to present a modal?
The way to present a modal is with a view modifier called .sheet
. That's right, to push a view we have a View struct, NavigationView
, and to present a modal we have a modifier, .sheet
. If there's something I truly want to avoid is the lack of consistency. Maybe I'm too dumb to understand why it's been done like this, but I'm my humble opinion they both should work the same way (and I don't care if it's with structs or modifiers, or both, but use the same thing). So please, please ๐๐ป, if you're an Apple Engineer working in SwiftUI reading this, for the sake of consistency, expose them both the same way, thank you.
Anyway, how can we avoid this nicely? The best way I found is using a .background
view inside a Button's content. It's better to see it in code, so now our MasterView
looks like this:
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
@State private var isPresented = false
var body: some View {
Button(action: {
self.isPresented = true
}) {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
.background(
// this is the cool part
NavigationLink(destination: EmptyView(), isActive: $isPresented) {
EmptyView()
}
)
}
}
}
Wooooow, ok, it looks weird at first, but it'll serve us perfectly and more importantly, it works! Essentially, we're hiding the NavigationLink
in the Button Text's background, and the way it works is through isActive
. Whenever the button is pressed, it'll switch isPresented
to true
which will trigger the NavigationLink
.
Cool! We learned how to wrap our view in a NavigationView
and how we can implement NavigationLink
so it doesn't depend of anything else in the view, that way we can also easly change it to present a modal for example.
This is what we've done so far:
// MARK: Coordinator
protocol Coordinator {
func start()
}
extension Coordinator {
func coordinate(to coordinator: Coordinator) {
coordinator.start()
}
}
// MARK: AppCoordinator
final class AppCoordinator: Coordinator {
private weak var window: UIWindow?
init(window: UIWindow) {
self.window = window
}
func start() {
let coordinator = RootMasterCoordinator(window: window)
coordinate(to: coordinator)
}
}
// MARK: MasterCoordinator
protocol MasterCoordinator: Coordinator {}
final class RootMasterCoordinator: MasterCoordinator {
private weak var window: UIWindow?
init(window: UIWindow?) {
self.window = window
}
func start() {
let view = MasterFactory.make(with: self)
let navigation = NavigationView { view }
let hosting = UIHostingController(rootView: navigation)
window?.rootViewController = hosting
window?.makeKeyAndVisible()
}
}
// MARK: Factory
enum MasterFactory {
static func make(with coordinator: MasterCoordinator) -> some View {
let presenter = MasterPresenter(coordinator: coordinator)
let view = MasterView(presenter: presenter)
return view
}
}
// MARK: MVP
struct MasterViewModel {
let date: Date
}
protocol MasterPresenting: ObservableObject {
var viewModel: MasterViewModel { get }
}
final class MasterPresenter: MasterPresenting {
@Published private(set) var viewModel: MasterViewModel
private let coordinator: MasterCoordinator
init(coordinator: MasterCoordinator) {
self.coordinator = coordinator
self.viewModel = MasterViewModel(date: Date())
}
}
struct MasterView<T: MasterPresenting>: View {
@ObservedObject var presenter: T
@State private var isPresented = false
var body: some View {
Button(action: {
self.isPresented = true
}) {
Text("\(presenter.viewModel.date, formatter: dateFormatter)")
.background(
NavigationLink(destination: EmptyView(), isActive: $isPresented) {
EmptyView()
}
)
}
}
}
We've learned how to set up an entire screen with the MVP pattern, we created a base Coordinator protocol, and implemented our first 2 coordinators. We saw how to wrap our view in a NavigationView
, and how to implement NavigationLink
so it doesn't depend on anything else in the view.
That's it! We've completed part 1 of this series. In the next post we're going to see how to extract that NavigationLink
from MasterView
and put it in a new coordinator. We'll have to modify our current base Coordinator
protocol, and we're going to see how to easily change from a navigation push to a modal presentation without touching the view, are you going to miss it? Then head over to part 2!
Go now to the next part of the series (part 2)!: https://lascorbe.com/posts/2020-04-28-MVPCoordinators-SwiftUI-part2
Thank you for reading, I hope you liked it.
Luis.