A new architecture for SwiftUI applications

8 minute(s) read

SwiftUI is a really good UI framework to build easily new interfaces for all Apple platforms. As it is considered as the future of UI on these platforms, let’s try to find a good architecture.

Introduction

Apple built UIKit with MVC in mind. We can use other patterns such as MVVM, VIPER, Redux,… but with all of that we still need to rely on UIViewController, and other patterns such as Delegation patterns. Because of that, architecture patterns feel like not fully usable. (With MVVM, ViewController tends to be part of the View, but its purpose is to provide logic and business rules to screens, not display things, UIView are here for that).

With SwiftUI as the new way to build apps on their platforms, is there another architecture pattern heavily used in it? How should we architecture our SwiftUI app?

As I started a new project while building this architecture, I tried to understand the essence behind this framework, how to stick to it, to avoid boilerplate, and the growing complexity. This way I intended to build a scalable architecture, and evolve it as I need to answer new problems. In terms of development team size, this architecture is tested with one developer alone at the beginning, then 3 concurrent contributors.

Observation

Here is some determining observation about SwiftUI:

We pass needed piece of data into the initializer of the Views. It acts like dependency of the view.

  • It is great because our views indeed need these data to display properly, so ensure to have it at creation is really cool.
  • On the other hand, we pass parts of the Model to the Views, that is not great it terms of separation of layers.

All Views are first citizen views.

  • It means that there is no difference between a SwiftUI native View and one View of the app. It behaves the same way, initializes the same way, and can be composed as well. There is no difference between a Cell in a List, a massive all-screen view and basic Text.
  • All Views can embed other views really easily if needed, and handle their own logic object at their level.

Views hold a reference to Logic Unit, no the opposite.

  • Instead of having a logic unit such as a ViewController that own a view, and modify it if necessary, we are in the opposite way. A view that holds the logic unit it needs. This pattern brings closer SwiftUI to MVVM, and helps to build it in an easier way.

I tried to think deeper about what really is an application. It is just a display, that shows some data, and react to it. In other apps, I think we have lost this notion, due to complex business rules and some constraints based on Server issues. I chose for this application to keep the simplicity if possible, and build models based on what the app needs instead of what a Web Service sends me. We’ll need to convert from the web, and for the web, but that’s just conversion, not a constraint in all our model layer.

Based on these observations:

  • We’ll have Views that hold Model if needed, not an artificial layer of abstraction to separate things. After all, a Text holds a String, not a TextModel that contains a String.
  • We’ll have logical units that are owned by the Views, but only when needed.
  • We’ll build small reusable views and compose them as needed.
  • Models are created for the app needs, not based on constraints outside the app scope. If the Web services model change, we just need to change our Codable conformance to have the information we need without having to re-write something else in the app.

Introducing Managers

After these reflections, we still need to figure out how to keep a state of our app in memory, how to handle lists data, and impact a change in some deeper views. It made sense to me to have ModelTypeManager: an object that manages all models of a type. This object will have to work with services, serve the models to views, and keep everything clean about the state of these models. It should only be injected in Views that need to interact with the models of these type, or needs to create new models and store it. We will not add it to a view in a list if it only needs one model. All these behaviors fits in a ObservedObject that can be embedded as EnvironmentObject in the views.

Recap

  • Views own models that it needs.
  • When it needs to use models from an app memory storage, it uses a Manager.
  • If it needs some logic, we can use some Logic units dedicated to that

Example

Let’s dive into a simple example to illustrate all this. First, a simple Model with a Social Media post:

struct Post {
   let title: String
   let content: String
   let author: String
}

The first thing we want to do is to display this post, so we create a SwiftUI View to do this.

struct PostView: View {
    let post: Post

    var body: some View {
        VStack {
            HStack {
                Text(post.title)
                Spacer()
                Text(post.author)
            }
            Divider()
            Text(post.content)
        }
    }
}

This view only displays a post, and doesn’t do any form of logic, so we don’t need a Logic object.

Now, we’ll need to show the list of all posts we have in the app, and redirect to this view. We’ll need a manager that manages all posts of the app:

final class PostManager: ObservableObject {
    @Published private(set) var posts: [Post]

    let postService: () -> [Post] // we just use a function to fetch the posts for now

    init(postService: @escaping () -> [Post]) {
        self.postService = postService
        self.posts = postService()
    }
}

And now the view that uses it:


extension Post: Identifiable {
    // the pair author and title could be a first id for identify each posts as one author won't write multiple posts with the same title
    var id: String { title + author }
}

struct PostList: View {
    @EnvironmentObject var postManager: PostManager
    
    var body: some View {
        List(postManager.posts) { post in
            NavigationLink(destination: PostView(post: post)) {
                Text(post.title)
            }
        }
    }
}

Alright, Now, we need to allow a user to create a post, so let’s begin with the Manager. It should accept newly created Posts:

final class PostManager: ObservableObject {
    /* ... */

   func add(_ post: Post) {
      posts.append(post)
   }
}

Then, let’s continue with the view:

struct NewPostView: View {
    @EnvironmentObject var postManager: PostManager
    @State private var title = ""
    @State private var author = ""
    @State private var content = ""
    
    var body: some View {
        VStack {
            TextField("Title", text: $title)
            TextField("Author", text: $author)
            TextEditor(text: $content)
        }
        .toolbar {
            ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) {
                Button(action: dismiss) { Text("Cancel") }
            }

            ToolbarItem(placement: ToolbarItemPlacement.confirmationAction) {
                Button(action: createPost) { Text("Create") }
                    .disabled(!areAllFieldsFilled)
            }
        }
    }

    var areAllFieldsFilled: Bool {
        return !title.isEmpty && !author.isEmpty && !content.isEmpty
    }

    private func dismiss() { }

    func createPost() {
        guard areAllFieldsFilled else { return }

        let newPost = Post(title: title, content: content, author: author)
        postManager.add(newPost)
        dismiss()
    }
}

Oops! It seems to have logic here, let’s extract it to a Logic object:

extension NewPostView {
    final class Logic: ObservableObject {
        @Published var title = ""
        @Published var author = ""
        @Published var content = ""

        var dismissSubject = PassthroughSubject<Void, Never>()

        var areAllFieldsFilled: Bool { !title.isEmpty && !author.isEmpty && !content.isEmpty }

        private unowned var postManager: PostManager

        init(postManager: PostManager) {
            self.postManager = postManager
        }

        func createPost() {
            guard areAllFieldsFilled else { return }

            let newPost = Post(title: title, content: content, author: author)
            postManager.add(newPost)
            dismissSubject.send(())
        }
    }
}

And then use it:

struct NewPostView: View {
    @EnvironmentObject var postManager: PostManager
    @ObservedObject private var logic: Logic

    init() {
        self.logic = Logic(postManager: postManager) // Won't compile because environmentObject not available at init time
    }
 
    var body: some View {
        VStack {
            TextField("Title", text: $logic.title)
            TextField("Author", text: $logic.author)
            TextEditor(text: $logic.content)
        }
        .toolbar {
            ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) {
                Button(action: dismiss) { Text("Cancel") }
            }

            ToolbarItem(placement: ToolbarItemPlacement.confirmationAction) {
                Button(action: logic.createPost) { Text("Create") }
                    .disabled(!logic.areAllFieldsFilled)
            }
        }
    }

    func dismiss() { }
}

We have a little problem here due to the need of the manager in the logic object, and we are unable to pass it easily. To bypass this problem, I used a private internal view:

struct NewPostView: View {
    @EnvironmentObject var postManager: PostManager
    
    var body: some View {
        InternalView(logic: Logic(postManager: postManager))
    }
}

extension NewPostView {
    fileprivate struct InternalView: View {
        @ObservedObject var logic: Logic

        var body: some View {
            VStack {
                TextField("Title", text: $logic.title)
                TextField("Author", text: $logic.author)
                TextEditor(text: $logic.content)
            }
            .onReceive(logic.dismissSubject) { self.dismiss() }
            .toolbar {
                    ToolbarItem(placement: ToolbarItemPlacement.cancellationAction) {
                        Button(action: dismiss) { Text("Cancel") }
                    }

                    ToolbarItem(placement: ToolbarItemPlacement.confirmationAction) {
                        Button(action: logic.createPost) { Text("Create") }
                            .disabled(!logic.areAllFieldsFilled)
                    }
              }
        }

        private func dismiss() { }
    }
}

Alright, let’s display this view from the list. Here we want a modal display so we’ll use a sheet:

struct PostList: View {
    @EnvironmentObject var postManager: PostManager
    @State private var newPostIsPresented = false

    var body: some View {
        NavigationView {
            List(postManager.posts) { post in
                NavigationLink(destination: PostView(post: post)) {
                    Text(post.title)
                }
            }
            .toolbar {
                ToolbarItem {
                    Button(action: { newPostIsPresented = true }, label: {
                        Image(systemName: "plus")
                    })
                }
            }
            .sheet(isPresented: $newPostIsPresented) {
                NewPostView()
            }
        }
    }
}

We need to handle dismiss properly now in the NewPostView:

extension NewPostView {
    fileprivate struct InternalView: View {
        // ...
        @Environment(\.presentationMode) var presentationMode

        /// ...

        private func dismiss() {
            presentationMode.wrappedValue.dismiss()
        }
    }
}

Alright, we saw all the principles of this architecture, and why it is built this way. You can see the architecture diagram of this sample project :

Architecture Diagram

You can find the code from this article on Github You can also see this architecture applied to a real project with Running Order. Don’t hesitate to contact me if you have any question or suggestion.

Thank you for reading !


Written by

Clément Nonn

iOS Software Engineer