iOS App As a Microservice. Using SwiftUI in Modular App

The modular architecture is excellent. But how to implement it effectively with SwiftUI? From its core, SwiftUI is state-driven, and it can be tricky to modularize an app and define exact responsibility borders.

Alex Dremov
iOS App As a Microservice. Using SwiftUI in Modular App

Photo by Kelly Sikkema / Unsplash

In this post, I will describe features of SwiftUI that work well in modular design and those that are better to avoid.

πŸ’₯
This is the third and the last post in the series on a modular architecture.Check out the previous issues to boost your understanding of critical concepts!
iOS App As a Microservice. Build Robust App Architecture
What will you choose: MVVM, MVC, VIPER? Those all are local and problem-specific architectures. But how to structure your app on a larger scale to make it scalable and well-organized?
iOS App As a Microservice. Modularize Your App With Tuist
This is the second article in a series on modular app architecture. In this post, I will cover implementation details using Tuist

What's The Problem

Why SwiftUI use in modular design is different, and why do I need a whole new post for it? As I already mentioned, SwiftUI is state-driven and trying to avoid that leads to ineffective and messy solutions.

For example

Let's suggest that you have settings and homepage modules. Users can log out on the settings screen and your app needs to handle this case correctly. The first intent is to pass a closure to the settings module that will be called on the logout button press. Sounds reasonable, right?

Ok, but how does it connect with SwiftUI? Notice that handling action does not necessarily mean that there will be a change in state. There is a logical change, though. But how can SwiftUI know about that?

πŸ’‘
State-driven means that views are a function of the state. So, the only way to update the view is to change its state.

Data Flow

Apple released a nice presentation on WWDC19 about the role of data in SwiftUI. The presentation covers cases where @Binding, @EnvironmentObject, etc. are the most applicable.

Apple WWDC19 β€” Swift Data Flow
Apple WWDC19 β€” Swift Data Flow

But also the crucial point is made β€” the view is not the result of a sequence of events, but rather a representation of data or state. It's also essential where this data comes from. There should be a single source of truth.

Data Flow Through SwiftUI - WWDC19 - Videos - Apple Developer
SwiftUI was built from the ground up to let you write beautiful and correct user interfaces free of inconsistencies. Learn how to connect...

Keeping this in mind, let's move on to the first tip that will solve the issue proposed in the "problem" section of this article.

Use Data Flows and Not Callbacks

The problem with handling the logout action is in the word handle itself. There is no explicit change in state and it's unknown who's responsible for changing the state if it is even defined.

So, if SwiftUI is state-driven, let's define the source of truth for this state. It must be a variable that stores the current logged-in / logged-out state. Depending on the state's complexity, it can be a bool, enum, or struct.

Singleton or global state? No.

πŸ’₯
As described in previous posts, dependencies should be explicit.In this case, the logged-in / logged-out variable should be passed as a dependency to the settings module and to the homepage module.

But we need to listen for changes in this variable and update views respectively. Also, it's bad if every module can change this variable. There should be restrictions on which module can modify state and which can only read.

SwiftUI + Combine. It's a Match

You may already know that SwiftUI automatically listens for ObservableObject changes and updates views when something is changed. So, we can create such a class:

class LogInState: ObservableObject {
    @Published var isLoggedIn: Bool
    
    init(isLoggedIn: Bool) {
        self.isLoggedIn = isLoggedIn
    }
    
    func loggedOut() {
        isLoggedIn = false
    }
    
    func loggedIn() {
        isLoggedIn = true
    }
}

It later can be injected into a SwiftUI view as simple as that

struct MyView: View {
	@ObservedObject var logInState: LogInState

	var body: some View {
    	Text(logInState.isLoggedIn ? "Yes" : "No")
    }
}

...
let logInState = LogInState(isLoggedIn: true)
HomePageModule(logInState: logInState)
...
SettingsModule(logInState: logInState)

Don't you think that creating such a distinct class for every state is bad? It may be fine for complex data types, but definitely not for a single boolean value.

Also, notice that both HomePageModule and SettingsModule can change the state. What if you have many more modules that depend on logInState? They all could change it!

πŸ’₯
If every part of your app can hypothetically change the shared state, then if a bug arises, you start playing an amazing game"Who the hell changed this value?"
Subscribe and don't miss posts!

Better Combine Use

Ok, we've solved the problem with callbacks. Though we still have a problem with the boilerplate code needed to define a new ObservableObject, and a problem with state modification privileges.

We can solve those by creating a custom ObservableObject!

πŸ’‘
You also can use third-party reactive frameworks, but I will cover implementation using Combine as it seamlessly integrates with SwiftUI

To use SwiftUI's automatic listening to updates, we need to conform to ObservableObject. Here's a generic class to make any type observable. It also utilizes @propertyWrapper and @dynamicMemberLookup features.

import Foundation
import Combine

@dynamicMemberLookup
@propertyWrapper
public class ObservableProperty<Output>: ObservableObject {
    @Published private var storedValue: Output
    
    public var wrappedValue: Output {
        get {
            storedValue
        }
        set {
            storedValue = newValue
        }
    }
    
    public init(wrappedValue initialValue: Output) {
        self.storedValue = initialValue
    }
    
    public subscript<Result>(dynamicMember keyPath: WritableKeyPath<Output, Result>) -> Result {
        get {
            storedValue[keyPath: keyPath]
        }
        set {
            storedValue[keyPath: keyPath] = newValue
        }
    }
    
    public subscript<Result>(dynamicMember keyPath: KeyPath<Output, Result>) -> Result {
        storedValue[keyPath: keyPath]
    }
}

It can be used as simply as that

struct MyView: View {
    @ObservedObject
    @ObservableProperty
    var logInState: Bool
    
    init(logInState: ObservableProperty<Bool>) {
        self._logInState = .init(initialValue: logInState)
    }
    
    var body: some View {
        VStack {
            Text(logInState ? "Yes" : "No")
            Button("toggle") {
                logInState = !logInState
            }
        }
    }
}
πŸ’₯
However, ObservableProperty works with value types only. Passing reference types will not trigger updates

Restrict Modules To Read-Only Variables

In the example above, MyView can modify the value. But how we can restrict it to read-only mode? We can create a similar class that will prohibit modification

@dynamicMemberLookup
@propertyWrapper
public class ObservableValue<Output>: ObservableObject {
    @Published private var storedValue: Output
    public var wrappedValue: Output {
        storedValue
    }

    public var value: Output {
        storedValue
    }

    public init(wrappedValue initialValue: Output) {
        fatalError("ObservableValue cannot be initialized with value. Use constant()")
    }

    init<Pub: Publisher<Output, Never>>(initialValue: Output, publisher: Pub) {
        storedValue = initialValue
        publisher.assign(to: &$storedValue)
    }

    public subscript<Result>(dynamicMember keyPath: WritableKeyPath<Output, Result>) -> Result {
        get {
            storedValue[keyPath: keyPath]
        }
        set {
            storedValue[keyPath: keyPath] = newValue
        }
    }

    public subscript<Result>(dynamicMember keyPath: KeyPath<Output, Result>) -> Result {
        storedValue[keyPath: keyPath]
    }

    public static func constant(initialValue: Output) -> ObservableValue<Output> {
        .init(
            initialValue: initialValue,
            publisher: Empty()
        )
    }

    public var publisher: Published<Output>.Publisher {
        $storedValue
    }
}

Then, we can add projectedValue to ObservableProperty to create ObservableValue from it.

public class ObservableProperty<Output>: ObservableObject {
	...
    public var publisher: AnyPublisher<Output, Never> {
        $storedValue.eraseToAnyPublisher()
    }
    
    public var projectedValue: ObservableValue<Output> {
        ObservableValue<Output>(
            initialValue: storedValue,
            publisher: publisher
        )
    }
	...
}

Great!

Now we can create an observable source of truth, and pass it to modules, restricting some of them to read-only mode. Check out the example:

struct ReadOnlyModule: View {
    @ObservedObject
    @ObservableValue
    var logInState: Bool
    
    init(logInState: ObservableValue<Bool>) {
        self._logInState = .init(wrappedValue: logInState)
    }
    
    var body: some View {
        Text(logInState ? "Yes" : "No")
    }
}

struct ModifyModule: View {
    @ObservableProperty
    var logInState: Bool
    
    init(logInState: ObservableProperty<Bool>) {
        self._logInState = logInState
    }
    
    var body: some View {
        Button("toggle") {
            logInState = !logInState
        }
    }
}

struct MyView: View {
    @ObservableProperty
    var logInState: Bool
    
    init(logInState: ObservableProperty<Bool>) {
        self._logInState = logInState
    }
    
    var body: some View {
        VStack {
        	// projected read-only value (ObservableValue)
            ReadOnlyModule(logInState: $logInState)
            
            // ObservableProperty reference
            ModifyModule(logInState: _logInState)
        }
    }
}

So, the callbacks problem is solved and we can move on to the next idea.

Do Not Use EnvironmentObjects

Yes, I'm this definite about it. Environment objects in their core are global variables that create implicit dependencies. Also, they are easily overlooked and can produce unexpected crashes when not set.

Apart from that, you can't set two environment objects of the same type and it results in messy decisions and code modifications.

And the third reason is that they simply don't work with dependency inversion. You cannot hide the environment object behind the protocol as only ObservableObject can be passed as an environment object.

Go For Programmatic Navigation

SwiftUI is trying to introduce ways for implementing programmatic navigation, but it is not ready yet. Though, it's essential for modular architecture because of loose coupling.

There are frameworks that can be used to achieve that. I have a post on this topic. Check it out!

SwiftUI Navigation Is a Mess. Here’s What You Can Do
Managing navigation in pure SwiftUI is hard and leads to messy solutions. In this post, I will show you how you can manage views effectively

Alternatively, you can use other open-source solutions. For example, I recently found a similar framework:

GitHub - johnpatrickmorgan/FlowStacks: FlowStacks allows you to hoist SwiftUI navigation and presentation state into a Coordinator
FlowStacks allows you to hoist SwiftUI navigation and presentation state into a Coordinator - GitHub - johnpatrickmorgan/FlowStacks: FlowStacks allows you to hoist SwiftUI navigation and presentati...

As always, let me know what you think in the comments!

References

Data Flow Through SwiftUI - WWDC19 - Videos - Apple Developer
SwiftUI was built from the ground up to let you write beautiful and correct user interfaces free of inconsistencies. Learn how to connect...
Apple Developer Documentation

Share

Subscribe to Alex Dremov

Get the email newsletter and receive valuable tips to bump up your professional skills