Let's say we need to make a small tweak to how the screen works. The screen changes every second because there are many processes going on at the same time. As a rule, in order to settle all screen states, it is necessary to refer to variables, each of which has its own life. Keeping them in mind is either very difficult or completely impossible. To find the source of the problem, you will have to understand the variables and states of the screen, and even make sure that our fix does not break something elsewhere. Let's say we spent a lot of time and still made the necessary edits. Was it possible to solve this problem easier and faster? Let's figure it out.
MVI
This pattern was first described by JavaScript developer Andre Stalz. The general principles can be found at the link
Intent : waits for events from the user and processes them
Model : waits for handled events to change the state
View : waits for state changes and shows them
Custom element : a subsection of the View, which is itself a UI element. Can be implemented as MVI or as a web component. Optional in View.
On the face of a reactive approach. Each module (function) expects an event, and after receiving and processing it, it passes this event to the next module. It turns out a unidirectional flow. The single state of the View resides in the Model, and this solves the problem of many hard-to-track states.
How can this be applied in a mobile application?
Martin Fowler and Rice David wrote in their book "Patterns of Enterprise Applications" that patterns are patterns for solving problems, and instead of copying one to one, it is better to adapt them to current realities. The mobile application has its own limitations and features that must be taken into account. View receives an event from the user, and then it can be proxied to the Intent. The scheme is slightly modified, but the principle of the pattern remains the same.
Implementation
There will be a lot of code below.
The final code can be viewed under the spoiler below.
MVI implementation
View
Model
Intent
Router
import SwiftUI
struct RootView: View {
// Or @StateObject for iOS 14
@ObservedObject private var intent: RootIntent
var body: some View {
ZStack {
imageView()
.onTapGesture(perform: intent.onTapImage)
errorView()
loadView()
}
.overlay(RootRouter(screen: intent.model.routerSubject))
.onAppear(perform: intent.onAppear)
}
static func build() -> some View {
let model = RootModel()
let intent = RootIntent(model: model)
let view = RootView(intent: intent)
return view
}
}
// MARK: - Private - Views
private extension RootView {
private func imageView() -> some View {
Group { () -> AnyView in
if let image = intent.model.image {
return Image(uiImage: image)
.resizable()
.toAnyView()
} else {
return Color.gray.toAnyView()
}
}
.cornerRadius(6)
.shadow(radius: 2)
.frame(width: 100, height: 100)
}
private func loadView() -> some View {
guard intent.model.isLoading else {
return EmptyView().toAnyView()
}
return ZStack {
Color.white
Text("Loading")
}.toAnyView()
}
private func errorView() -> some View {
guard intent.model.error != nil else {
return EmptyView().toAnyView()
}
return ZStack {
Color.white
Text("Fail")
}.toAnyView()
}
}
Model
import SwiftUI
import Combine
protocol RootModeling {
var image: UIImage? { get }
var isLoading: Bool { get }
var error: Error? { get }
var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}
class RootModel: ObservableObject, RootModeling {
enum StateType {
case loading, show(image: UIImage), failLoad(error: Error)
}
@Published private(set) var image: UIImage?
@Published private(set) var isLoading: Bool = true
@Published private(set) var error: Error?
let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()
func update(state: StateType) {
switch state {
case .loading:
isLoading = true
error = nil
image = nil
case .show(let image):
self.image = image
isLoading = false
case .failLoad(let error):
self.error = error
isLoading = false
}
}
}
Intent
import SwiftUI
import Combine
class RootIntent: ObservableObject {
let model: RootModeling
private var rootModel: RootModel! { model as? RootModel }
private var cancellable: Set<AnyCancellable> = []
init(model: RootModeling) {
self.model = model
cancellable.insert(rootModel.objectWillChange.sink { self.objectWillChange.send() })
}
}
// MARK: - API
extension RootIntent {
func onAppear() {
rootModel?.update(state: .loading)
let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")
let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in
guard let data = data, let image = UIImage(data: data) else {
DispatchQueue.main.async {
self?.rootModel?.update(state: .failLoad(error: error ?? NSError()))
self?.rootModel?.routerSubject.send(.alert(title: "Error",
message: "It was not possible to upload a image"))
}
return
}
DispatchQueue.main.async {
self?.rootModel?.update(state: .show(image: image))
}
}
task.resume()
}
func onTapImage() {
guard let image = rootModel?.image else {
rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
return
}
rootModel?.routerSubject.send(.descriptionImage(image: image))
}
}
Router
import SwiftUI
import Combine
struct RootRouter: View {
enum ScreenType {
case alert(title: String, message: String)
case descriptionImage(image: UIImage)
}
let screen: PassthroughSubject<ScreenType, Never>
@State private var screenType: ScreenType? = nil
@State private var isFullImageVisible = false
@State private var isAlertVisible = false
var body: some View {
Group {
alertView()
descriptionImageView()
}.onReceive(screen, perform: { type in
self.screenType = type
switch type {
case .alert:
self.isAlertVisible = true
case .descriptionImage:
self.isFullImageVisible = true
}
})
}
}
private extension RootRouter {
private func alertView() -> some View {
guard let type = screenType, case .alert(let title, let message) = type else {
return EmptyView().toAnyView()
}
return Spacer().alert(isPresented: $isAlertVisible, content: {
Alert(title: Text(title), message: Text(message))
}).toAnyView()
}
private func descriptionImageView() -> some View {
guard let type = screenType, case .descriptionImage(let image) = type else {
return EmptyView().toAnyView()
}
return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
self.screenType = nil
}, content: {
DescriptionImageView.build(image: image, action: { _ in
// code
})
}).toAnyView()
}
}
Now let's get down to examining each module separately.
Before proceeding with the implementation, we need an extension for the View, which will simplify the writing of the code and make it more readable.
extension View {
func toAnyView() -> AnyView {
AnyView(self)
}
}
View
View - accepts events from the user, passes them to the Intent and waits for a state change from the Model
import SwiftUI
struct RootView: View {
// 1
@ObservedObject private var intent: RootIntent
var body: some View {
ZStack {
// 4
imageView()
errorView()
loadView()
}
// 3
.onAppear(perform: intent.onAppear)
}
// 2
static func build() -> some View {
let intent = RootIntent()
let view = RootView(intent: intent)
return view
}
private func imageView() -> some View {
Group { () -> AnyView in
// 5
if let image = intent.model.image {
return Image(uiImage: image)
.resizable()
.toAnyView()
} else {
return Color.gray.toAnyView()
}
}
.cornerRadius(6)
.shadow(radius: 2)
.frame(width: 100, height: 100)
}
private func loadView() -> some View {
// 5
guard intent.model.isLoading else {
return EmptyView().toAnyView()
}
return ZStack {
Color.white
Text("Loading")
}.toAnyView()
}
private func errorView() -> some View {
// 5
guard intent.model.error != nil else {
return EmptyView().toAnyView()
}
return ZStack {
Color.white
Text("Fail")
}.toAnyView()
}
}
- All events that the View receives are passed to the Intent. Intent keeps a link to the actual state of the View in itself, since it is he who changes the states. The @ObservedObject wrapper is needed in order to transfer to the View all changes that occur in the Model (more details below)
- Simplifies the creation of a View, thus it is easier to accept data from another screen (example RootView.build () or HomeView.build (articul: 42) )
- Sends the life cycle event of the View to the Intent
- Functions that create custom elements
- The user can see different screen states, it all depends on what data is in Model. If the boolean value of the intent.model.isLoading attribute is true , the user sees the loading, if false, then he sees the loaded content or an error. Depending on the state, the user will see different Custom elements.
Model
Model - keeps the actual state of the screen
import SwiftUI
// 1
protocol RootModeling {
var image: UIImage? { get }
var isLoading: Bool { get }
var error: Error? { get }
}
class RootModel: ObservableObject, RootModeling {
// 2
@Published var image: UIImage?
@Published var isLoading: Bool = true
@Published var error: Error?
}
- The protocol is needed in order to show the View only what is needed to display the UI
- @Published is needed for reactive data transfer in the View
Intent
Inent - waits for events from View for further actions. Works with business logic and databases, makes requests to the server, etc.
import SwiftUI
import Combine
class RootIntent: ObservableObject {
// 1
let model: RootModeling
// 2
private var rootModel: RootModel! { model as? RootModel }
// 3
private var cancellable: Set<AnyCancellable> = []
init() {
self.model = RootModel()
// 3
let modelCancellable = rootModel.objectWillChange.sink { self.objectWillChange.send() }
cancellable.insert(modelCancellable)
}
}
// MARK: - API
extension RootIntent {
// 4
func onAppear() {
rootModel.isLoading = true
rootModel.error = nil
let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")
let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in
guard let data = data, let image = UIImage(data: data) else {
DispatchQueue.main.async {
// 5
self?.rootModel.error = error ?? NSError()
self?.rootModel.isLoading = false
}
return
}
DispatchQueue.main.async {
// 5
self?.model.image = image
self?.model.isLoading = false
}
}
task.resume()
}
}
- The intent contains a link to the Model, and when necessary, changes the data for the Model. RootModelIng is a protocol that shows the attributes of the Model and prevents them from being changed
- In order to change the attributes in the Intent, we convert the RootModelProperties to RootModel
- The Intent is constantly waiting for the Model's attributes to change and passes them to the View. AnyCancellable allows you not to keep in memory a reference to wait for changes from Model. In this simple way, the View gets the most current state.
- This function receives an event from the user and downloads a picture
- This is how we change the state of the screen
This approach (changing states in turn) has a drawback: if the Model has a lot of attributes, then when changing attributes, you can forget to change something.
One possible solution
protocol RootModeling {
var image: UIImage? { get }
var isLoading: Bool { get }
var error: Error? { get }
}
class RootModel: ObservableObject, RootModeling {
enum StateType {
case loading, show(image: UIImage), failLoad(error: Error)
}
@Published private(set) var image: UIImage?
@Published private(set) var isLoading: Bool = true
@Published private(set) var error: Error?
func update(state: StateType) {
switch state {
case .loading:
isLoading = true
error = nil
image = nil
case .show(let image):
self.image = image
isLoading = false
case .failLoad(let error):
self.error = error
isLoading = false
}
}
}
// MARK: - API
extension RootIntent {
func onAppear() {
rootModel?.update(state: .loading)
...
I believe that this is not the only solution and that you can solve the problem in other ways.
There is one more drawback - the Intent class can grow a lot with a lot of business logic. This problem is solved by splitting business logic into services.
What about navigation? MVI + R
If you manage to do everything in View, then most likely there will be no problems. But if the logic gets more complicated, a number of difficulties arise. As it turned out, making a Router with data transfer to the next screen and return data back to the View that called this screen is not so easy. Data transfer can be done via @EnvironmentObject, but then all Views below the hierarchy will have access to this data, which is not good. We refuse this idea. Since the screen states change through the Model, we refer to the Router through this entity.
protocol RootModeling {
var image: UIImage? { get }
var isLoading: Bool { get }
var error: Error? { get }
// 1
var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}
class RootModel: ObservableObject, RootModeling {
// 1
let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()
- Point of entry. Through this attribute we will refer to Router
In order not to clog the main View, everything related to transitions to other screens is taken out in a separate View
struct RootView: View {
@ObservedObject private var intent: RootIntent
var body: some View {
ZStack {
imageView()
// 2
.onTapGesture(perform: intent.onTapImage)
errorView()
loadView()
}
// 1
.overlay(RootRouter(screen: intent.model.routerSubject))
.onAppear(perform: intent.onAppear)
}
}
- A separate View that contains all the logic and Custom elements related to navigation
- Sends the life cycle event of the View to the Intent
Intent collects all the necessary data for the transition
// MARK: - API
extension RootIntent {
func onTapImage() {
guard let image = rootModel?.image else {
// 1
rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
return
}
// 2
model.routerSubject.send(.descriptionImage(image: image))
}
}
- If for some reason there is no picture, then it transfers all the necessary data to the Model to show the error
- Sends the necessary data to the Model to open a screen with a detailed description of the picture
import SwiftUI
import Combine
struct RootRouter: View {
// 1
enum ScreenType {
case alert(title: String, message: String)
case descriptionImage(image: UIImage)
}
// 2
let screen: PassthroughSubject<ScreenType, Never>
// 3
@State private var screenType: ScreenType? = nil
// 4
@State private var isFullImageVisible = false
@State private var isAlertVisible = false
var body: some View {
Group {
alertView()
descriptionImageView()
}
// 2
.onReceive(screen, perform: { type in
self.screenType = type
switch type {
case .alert:
self.isAlertVisible = true
case .descriptionImage:
self.isFullImageVisible = true
}
}).overlay(screens())
}
private func alertView() -> some View {
// 3
guard let type = screenType, case .alert(let title, let message) = type else {
return EmptyView().toAnyView()
}
// 4
return Spacer().alert(isPresented: $isAlertVisible, content: {
Alert(title: Text(title), message: Text(message))
}).toAnyView()
}
private func descriptionImageView() -> some View {
// 3
guard let type = screenType, case .descriptionImage(let image) = type else {
return EmptyView().toAnyView()
}
// 4
return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
self.screenType = nil
}, content: {
DescriptionImageView.build(image: image)
}).toAnyView()
}
}
- Enum with required data for screens
- Events will be sent through this attribute. By events, we will understand which screen should be shown
- This attribute is needed to store data for opening the screen.
- Change from false to true and the required screen opens
Conclusion
SwiftUI, like MVI, is built around reactivity, so they fit together well. There are difficulties with navigation and large Intent with complex logic, but everything can be solved. MVI allows you to implement complex screens and, with minimal effort, very dynamically change the state of the screen. This implementation, of course, is not the only correct one, there are always alternatives. However, the pattern fits nicely with Apple's new UI approach. One class for all screen states makes it much easier to work with the screen.
The code from the article , as well as Templates for Xcode, can be viewed on GitHub.