SwiftUI Interaction with Redux

image

Hello everyone. In this article we will talk about the SwiftUI framework in conjunction with Redux, this bundle allows us to quickly and easily create applications. SwiftUI is used to create a declarative style user interface, unlike UIKit . Redux, in turn, serves to control the state of the application.

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 I will try to show that the above thesis is true, so let's get started!

Before we dive into writing code, let's first understand what Redux is and what it consists of.

Redux is an open source library for managing the state of an application. Most often used in conjunction with React or Angular to develop the client side. Contains a number of tools to significantly simplify the transfer of storage data through the context. Creators: Daniil Abramov and Andrew Clark.

For me, Redux is not just a library, it is already something more, I attribute it to the architectural decisions on which the application is based. Primarily due to its unidirectional data stream.

Multidirectional or unidirectional flow


To explain what I mean by data flow, I will give the following example. An application created using VIPER supports a multidirectional data stream between modules:

image

Redux, in turn, is a unidirectional data stream and is easiest to explain based on its constituent components.

image

Let's talk a bit in 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.

ReducerIs 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.

I believe that the theory is enough, let's move on to practice.

Redux implementation


One of the easiest ways to get to know a tool is to start using it, as my programming teacher said, if you want to learn a programming language, write an application on it. So let's create a small application, let it be 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. Pretty simple application, but at the same time will allow us to get acquainted with Redux and SwiftUI.

Create a clean project in Xcode, give it the name WorkoutsDiary, and most importantly, select SwiftUI for the User Interface, as we will use SwiftUI to create our user interface.

After creating the project. Create a Workout structure that will be responsible for the workout we completed.

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 crazy about 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 that 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 is, 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 may 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 the 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, this 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)
    
    case .sort(let type):
        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. Copies the current State to work with it.
  2. Based on Action , we update our copied State .
  3. Return the updated State

It is worth noting that the above function 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 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 onAction , which is passed as a parameter.

Now that we have 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 main screen is a screen that will display a list of workouts. The second screen is the 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 is responsible for 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 I will not delve into all the intricacies of SwiftUI and I assume that you already have experience with SwiftUI. If you do not have knowledge of SwiftUI, I 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 quickly dive into the declarative world of SwiftUI.

It is also worth noting 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 change the current code to this.

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 my 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

It is also worth noting the following line of code:

@State private var isAddingMode: Bool = false

StateIs 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. Once the property valueState changes, SwiftUI rebuilds the View to account for state changes.

image

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. IsAddingMode variable is markedStateand 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 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 {
        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 add new workout screen.

image

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.

image

Result


The application is ready and should work exactly as expected. Let's try to compile and run it.

findings


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.

You can download or view the source code of the project.here .

I hope that you liked this article and you learned something new for yourself, see you soon. Further it will be even more interesting.

All Articles