In this article we will talk about the SwiftUI framework in conjunction with Redux. This pair allows us to create applications quickly and easily. SwiftUI is used to create a declarative style user interface, unlike UIKit. Redux, in turn, serves to control the state of the application.
SwiftUI Interaction with Redux Architecture
State is a fundamental concept in SwiftUI and Redux. In our case, this is not only a buzzword, but also an entity that connects them and allows them to work very well together. In this article we will try to show that the thesis above is true, so let's get started!
Before we go deeper into writing code, first let's understand what Redux is and what it consists of.
Redux s an open source library for managing the state of an application. It is most often used in conjunction with React or Angular to develop the client side. It contains a number of tools to significantly simplify the transfer of storage data through the context. Its creators are Daniil Abramov and Andrew Clark.
For us, Redux is not just a library, it is already something more. We attribute it to the architectural decisions which the application is based on, primarily due to its unidirectional data stream.
Multidirectional or unidirectional flow
To explain what we mean by data flow, we will give the following example. An application that created using VIPER supports a multidirectional data flow between modules:
Redux, in turn, is a unidirectional data stream and is easiest to explain on the basis of its constituent components.
Let's talk in some more detail about each Redux component.
State is the only source of truth that contains all the necessary information for our application.
Action is the intention to change state. In our case, this is an enumeration that contains new information that we want to add or change in the current State.
Reducer is a function that takes Action and current State as parameters and returns a new State. This is the only way to create it. It is also worth noting that this feature should be clean.
Store is an object that contains State and provides all the necessary tools for updating it.
That would probably be enough for the first go at theory, let's now move on to practice.
Redux implementation
One of the easiest ways to get to know a tool is to start using it. Everyone knows that if you want to learn a programming language, you should write an application in it. So let's create a small application, for example a simple training diary. It will have only four options, - the first is to display a list of workouts, the second is to add a completed workout, the third is to delete and the fourth is to sort workouts. A pretty simple application, but at the same time it will allow us to get acquainted with Redux and SwiftUI.
Create a clean project in Xcode, give it a name WorkoutsDiary, and, most importantly, select SwiftUI for the User Interface.
After creating the project, create a Workout structure that will be responsible for the workout that we performed.
import Foundation
struct Workout: Identifiable {
let id: UUID = .init()
let name: String
let distance: String
let date: Date
let complexity: Complexity
}
As you can see, there is nothing complicated in this structure, the id field is required to comply with the Identifiable protocol, and the complexity field is just an enum with the following definition:
enum Complexity: Int {
case low
case medium
case high
}
Now we have everything we need to start implementing Redux. Let's start by creating a State.
struct AppState {
var workouts: [Workout]
var sortType: SortType?
}
State is a simple structure that contains two fields: workouts and sortType. The first is a list of workouts, and the second is an optional field that determines how the list is sorted.
SortType is an enumeration that is defined as follows:
enum SortType {
case distance
case complexity
}
For simplicity, we will sort by distance and difficulty in descending order. That means that the higher the complexity of our training, the higher it will be displayed in our list. It is worth noting that sortType is an optional type and it can be nil, which means that the list is not sorted at the moment.
We will continue the implementation of our components. Let's create an Action:
enum Action {
case addWorkout(_ workout: Workout)
case removeWorkout(at: IndexSet)
case sort(by: SortType)
}
As we can see, Action is an enumeration with three cases that give us an ability to manipulate our State.
- addWorkout (_ workout: Workout) simply adds a workout that is passed as a parameter.
- removeWorkout (at: IndexSet) removes the item at the specified index.
- sort (by: SortType) sorts the training list by the specified sort type.
Let's create one of the most complex components. It is Reducer:
func reducer(state: AppState, action: Action) -> AppState {
var state = state
switch action {
case .addWorkout(let workout):
state.workouts.append(workout)
case .removeWorkout(let indexSet):
state.workouts.remove(atOffsets: indexSet)
switch type {
case .distance:
state.workouts.sort { $0.distance > $1.distance }
state.sortType = .distance
case .complexity:
state.workouts.sort { $0.complexity.rawValue > $1.complexity.rawValue }
state.sortType = .complexity
}
}
return state
}
The function we wrote is quite simple and works as follows:
1. It copies the current State to work with it.
2. Based on Action, we update our copied State.
3. We return the updated State.
It should be pointed out that the function above is a pure function, and that is what we wanted to achieve! A function must meet two conditions in order to be considered “pure”:
- Each time, the function returns the same result when it is called with the same data set.
- There are no side effects.
The last missing Redux element is the Store, so let's implement it for our application.
final class Store: ObservableObject {
@Published private(set) var state: AppState
init(state: AppState = .init(workouts: [Workout]())) {
self.state = state
public func dispatch(action: Action) {
state = reducer(state: state, action: action)
}
}
In the implementations of the Store object, we use all the advantages of the ObservableObject protocol, which allows us to exclude the writing of a large amount of template code or the use of third-party frameworks. The State property is read-only and uses the wrapper of the @Published property, which means that whenever it is changed, SwiftUI will receive notifications. The init method takes an initial state as a parameter with a given default value in the form of an empty array of Workout elements. The dispatch function is the only way to update the state: it replaces the current state with the new one created by the reducer function, based on the Action, which is passed as a parameter.
After we implemented all the components of Redux, we can begin to create a user interface for our application.
Application implementation
The user interface of our application will be quite simple. And it will consist of two small screens. The first and the main one is a screen that will display a list of workouts. The second screen is an add workout screen. Also, each element will be displayed in a certain color, the color will reflect the complexity of the workout. The red cells indicate the highest difficulty of the workout, orange is responsible for the average difficulty and green shows the easiest workout.
We will implement the interface using a new framework from Apple called SwiftUI. SwiftUI comes to replace our familiar UIKit. SwiftUI is fundamentally different from UIKit, primarily in that it is a declarative approach to writing UI elements with code. In this article, we will not delve into all the intricacies of SwiftUI and we assume that you already have experience with SwiftUI. If you do not have knowledge of SwiftUI, we advise you to pay attention to the documentation from Apple, namely, look at their several complete tutorials with step-by-step addition and interactive display of the result on view. There are also links to example projects. These tutorials will let you dive quickly into the declarative world of SwiftUI.
It should be borne in mind that SwiftUI is not yet ready for production projects, it is too young and more than a year will pass before it can be used in this way. Also, do not forget that it only supports iOS 13.0+ versions. But it is also worth noting that SwiftUI will work on all Apple platforms, which is a big advantage over UIKit!
Let's start the implementation from the main screen of our application. Go to the file ContentView.swift and change the current code to the following one:
struct ContentView: View {
@EnvironmentObject var store: Store
@State private var isAddingMode: Bool = false
var body: some View {
NavigationView {
WorkoutListView()
.navigationBarTitle("Workouts diary", displayMode: .inline)
.navigationBarItems(
leading: AddButton(isAddingMode: self.$isAddingMode),
trailing: TrailingView()
)
}
.sheet(isPresented: $isAddingMode) {
AddWorkoutView(isAddingMode: self.$isAddingMode)
.environmentObject(self.store)
}
}
}
Content View is a standard view in SwiftUI. The most important part, from our point of view, is the line of code that contains the store variable. We will create @EnvironmentObject. This will allow us to use data from the Store wherever it is necessary, and in addition, it will automatically update our views if the data is changed. This is something like Singleton for our Store.
@EnvironmentObject var store: Store
The following line of code is important:
@State private var isAddingMode: Bool = false
@State is a wrapper that we can use to indicate the state of a View. SwiftUI will store it in a special internal memory outside the View structure. Only a linked View can access it. As soon as the value of the State property changes, SwiftUI rebuilds the View to account for state changes.
Then we will go to the SceneDelegate.swift file and add the code to the method:
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
let contentView = ContentView().environmentObject(Store())
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
In the same way, any @EnvironmentObject can be passed to any child representation in the entire application, and all this is possible thanks to the Environment. The variable isAddingMode is marked State and indicates whether the secondary view is displayed or not. The store variable is automatically inherited by WorkoutListView, and we do not need to pass it explicitly, but we need to do this for AddWorkoutView, because it is presented in the form of a sheet that is not a child of the ContentView.
Now create a WorkoutListView that will inherit from View. Create a new swift file called WorkoutListView.
struct WorkoutListView: View {
@EnvironmentObject var store: Store
var body: some View {
List {
ForEach(store.state.workouts) {
WorkoutView(workout: $0)
}
.onDelete {
self.store.dispatch(action: .removeWorkout(at: $0))
}
.listRowInsets(EdgeInsets())
}
}
View, which uses the container List element to display a list of workouts. The onDelete function is used to delete a workout and uses the removeWorkout action, which is performed using the dispatch function provided by the store. To display the workout in the list, WorkoutView is used.
Create another file WorkoutView.swift which will be responsible for displaying our item in the list.
struct WorkoutView: View {
let workout: Workout
private var backgroundColor: Color {
switch workout.complexity {
case .low:
return .green
case .medium:
return .orange
case .high:
return .red
}
}
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(workout.name)
Text("Distance:" + workout.distance + "km")
.font(.subheadline)
}
Spacer()
VStack(alignment: .leading) {
Text(simpleFormat(workout.date))
}
}
.padding()
.background(backgroundColor)
}
}
private extension WorkoutView {
func simpleFormat(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd MMM yyyy"
dateFormatter.locale = .init(identifier: "en_GB")
return dateFormatter.string(from: date)
}
}
This view takes the training object as a parameter and is configured based on its properties.
To add a new item to the list, you must change the isAddingMode parameter to true to display AddWorkoutView. This responsibility lies with AddButton.
struct AddButton: View {
@Binding var isAddingMode: Bool
var body: some View {
Button(action: { self.isAddingMode = true }) {
Image(systemName: "plus")
}
}
}
AddButton is also worth putting into a separate file.
This view is a simple button that has been extracted from the main ContentView for better structure and code separation.
Create a view to add a new workout. Create a new AddWorkoutView.swift file:
struct AddWorkoutView: View {
@EnvironmentObject private var store: Store
@State private var nameText: String = ""
@State private var distanceText: String = ""
@State private var complexityField: Complexity = .medium
@State private var dateField: Date = Date()
@Binding var isAddingMode: Bool
var body: some View {
return .red
}
}
var body: some View {
NavigationView {
Form {
TextField("Name", text: $nameText)
TextField("Distance", text: $distanceText)
Picker(selection: $complexityField, label: Text("Complexity")) {
Text("Low").tag(Complexity.low)
Text("Medium").tag(Complexity.medium)
Text("High").tag(Complexity.high)
}
DatePicker(selection: $dateField, displayedComponents: .date) {
Text("Date")
}
}
.navigationBarTitle("Workout Details", displayMode: .inline)
.navigationBarItems(
leading: Button(action: { self.isAddingMode = false }) {
Text("Cancel")
},
trailing: Button(action: {
let workout = Workout(
name: self.nameText,
distance: self.distanceText,
date: self.dateField,
complexity: self.complexityField
)
self.store.dispatch(action: .addWorkout(workout))
self.isAddingMode = false
}) {
Text("Save")
}
.disabled(nameText.isEmpty)
)
}
}
This is a fairly large controller which, like other controllers, contains the store variable. It also contains the variables nameText, distanceText, complexityField, and isAddingMode. The first three variables are necessary for linking TextField, Picker, DatePicker, which can be seen on this screen. The navigation bar has two elements. The first button is a button that closes the screen without adding a new workout, and the last one adds a new workout to the list, which is achieved by sending the addWorkout action. This action also closes the new workout screen.
Last but not least is TrailingView.
struct TrailingView: View {
@EnvironmentObject var store: Store
var body: some View {
HStack(alignment: .center, spacing: 30) {
Button(action: {
switch self.store.state.sortType {
case .distance:
self.store.dispatch(action: .sort(by: .distance))
default:
self.store.dispatch(action: .sort(by: .complexity))
}
}) {
Image(systemName: "arrow.up.arrow.down")
}
EditButton()
}
}
}
This view consists of two buttons that are responsible for sorting the workout list and for activating or deactivating the editing mode of our workout list. Sort actions are called using the dispatch function, which we can call through the store property.
Result
The application is ready and should work exactly as expected. Let's try to compile and run it.
Conclusion
Redux and SwiftUI work very well together. Code written using these tools is easy to understand and can be well organized. Another good aspect of this solution is its excellent code testability. However, this solution is not without drawbacks. One of them is a large amount of memory used by the application when the State of the application is very complex, and application performance may not be ideal in some specific scenarios, since all Views in SwiftUI are updated when creating a new State. These shortcomings can have a big impact on the quality of the application and user interaction, but if we remember them and prepare the state in a reasonable way, the negative impact can be easily minimized or even avoided.
We hope that you have liked this article and learned something new. See you soon. The subjects that we are going to speak about further will be even more interesting ;)