Apps: What do they all need?
Fundamentally, all apps have three types of requirements:
- Keep track of and mutate state.
- Respond to user actions or other types of events (say notifications, asynchronous callback, etc) by mutating state.
- Trigger functions using dependencies and do the above.
Broadly speaking most apps deal with two or more of the above requirements. SwiftUI offers a way to deal with these requirements in a consistent fashion and along with it, introduces a declerative API to build our UI.
The Case for SwiftUI
SwiftUI is a fantastic first party solution by to deal with these requirements without having to introduce any external dependencies and while allowing to build and iterate UI very quickly.
SwiftUI: What it is
SwiftUI does two things:
- Lets us build UI declaratively
- Lets us manage state and dependencies
To keep things within the scope of this post, let’s ignore the UI part and focus on how it let’s us manage state and dependencies. It introduces a few different property wrappers to aid state and dependency management, some of which are:
- @State
This lets us define mutable properties local to a view. Any change to these properties will cause the view to be re-rendered.
Example:
1struct ContentView: View {2 @State3 var backgroundColor: Color = .green45 var body: some View {6 Text("Hello, world!")7 .padding()8 .background(backgroundColor)9 }
In this example backgroundColor
is a state that the view manages and anytime it changes, the view gets re-rendered.
- @StateObject
This lets us create properties of reference type data to be held within our views, using this ensures that the property stays alive in that view until we need it for the lifecycle of the view.
Example:
Say you have a view model that you would like to use to drive a view:
1class ViewModel: ObservableObject {2 let title: String34 init(title: String) {5 self.title = title6 }7}
The view:
1struct ContentView: View {2 @State3 var backgroundColor: Color = .green45 @StateObject6 var viewModel = ViewModel(title: "Hello")78 var body: some View {9 Text(viewModel.title)10 .padding()11 .background(backgroundColor)12 }13}
In this case using @StateObject
ensures that the property (which is of a reference type) can be held onto through the view’s lifetime before it gets released.
- @Environment
This is a way to read information from the system - trait collection, color scheme, etc.
Example:
1@Environment(\.horizontalSizeClass) var horizontalSizeClass
- @EnvironmentObject
Similar to @StateObject
, this allows us to hold onto a property that conforms to ObservableObject
within the view, with one difference being — unlike the state object which expects the object to be provided by the view during initialisation, EnvironmentObject
expects the SwiftUI environment to provide it.
Example: Say we have a view model like:
1class Order: ObservableObject {2 @Published var items = [String]()3}
The View:
1struct ContentView: View {2 @EnvironmentObject var order: Order34 var body: some View {5 // view code6 }7}
The view now has a dependency to order
which would be provided by the environment using the .envionment()
modifier. This is a good way to handle dependencies in the app like so:
1ContentView()2 .environment(Order())
SwiftUI: What it isn’t
While all of this is great and you could build fully functioning apps with what SwiftUI provides out of the box, we would quickly notice a pattern where state is mutated in multiple points across the app. As the app grows with multiple screens and dependencies - we would have been mutating state in multiple places, handling events and dependencies within a soup of UI code.
Here’s a simple example:
1struct ContentView: View {2 @State var todos: [Todo] = []34 var body: some View {5 NavigationView {6 List {7 ForEach(Array(zip(todos.indices.sorted(), todos)), id: \.0) { index, todo in8 HStack {9 Button(action: {10 todos[index].isComplete.toggle()11 }) {12 Image(systemName: todo.isComplete ? "square.fill" : "square")13 }14 .buttonStyle(PlainButtonStyle())15 TextField("Untitled todo",16 text: $todos[index].body)17 .foregroundColor(todos[index].isComplete ? .gray : nil)18 }19 }20 }.navigationTitle("Todos")21 }22 }23}
Scattered State Mutation
In this example we see that the state is being mutated in a few different places and an action being performed, which inturn mutates state. Having this happen across many different views we would quickly be in position where our business logic is spread out over a lot of views and abstractions which would have state being mutated in many different places, both local state and global.
Bindings, while they’re fantastic in certain cases, like to present an alert for instance:
.alert(self.$alert) { }
While this is a great API, alert
gets set to nil
once the alert is dismissed which is a state mutation that happens under the hood, which we might need to know about.
Side Effects
Side Effects are things that have an effect on the state of the app during it’s lifetime. Think async API calls, notifications, etc. This has traditionally been handled with closures, SwiftUI also adopts a very similar approach of using them within closures, for example:
1func fetchImage(id: Int) {2 apiClient.fetch(imageID: id) { image in3 self.image = image4 }5}
In this instance, the effect or the async call fetch
is being fired off and we’re hoping to get a response back the way we expect, while this is how effects have always worked, the effect by itself cannot be controlled since it’s isolated with closures and we expect to receive a response at this point. Once it’s been fired off, we cannot cancel it either.
Sure we can ensure that it works as expected by writing some tests - but wouldn’t it be better if we had a data type to control the uncertainity of effects? I tend to like work with guaranteed outcome whenever possible, there’s definitely different types of effects that could have different degrees of unpredictability.
-
While this isn’t a criticism of SwiftUI itself, I feel like dealing with software where state gets mutated all over the place could be harder to maintain than with centralised state manipulation and being able to guarantee the behaviour of effects in the app using concrete types would definitely be nicer when it comes to working with asychronous effects.
Hmm, if only there was a way to do that.