In this post, I will describe features of SwiftUI that work well in modular design and those that are better to avoid.
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?
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.
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.
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.
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!
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!
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
}
}
}
}
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!
Alternatively, you can use other open-source solutions. For example, I recently found a similar framework:
As always, let me know what you think in the comments!