SwiftUI: MVI How to Easily Implement Structure MVI🧐

Efficient and structured application development is a key to success in the world of software development. One approach that can help achieve this goal is the Model-View-Intent (MVI) pattern. In this article, we will discuss the concept of MVI and how we can implement it in application development using SwiftUI, Apple's powerful user interface framework.

1. What is Model-View-Intent (MVI)?

a. Model: The Model represents the data in the application. It contains the information that will be displayed by the View.

b. View: The View is the user interface component that displays data from the Model. It is the part that depicts what the user sees.

c. Intent: The Intent is an action or event that affects the Model. For example, when a user clicks a button, it could be an "Intent" to update the Model.

2. Benefits of MVI in SwiftUI:

Structured and Understandable: MVI separates the application logic into structured components, making it easier to understand and maintain. Testability: MVI code is easy to test due to clear boundaries between Model, View, and Intent. Avoiding Common Pitfalls: MVI can help avoid issues such as inconsistent states in the application.

None
source image quickbirdstudios

3. Implementing MVI in SwiftUI:

Model: Create a data structure that will be used in your application. This could be an object containing properties that will be displayed in your view. View: Create your view using SwiftUI components like Text, Button, and others to display data from the Model. Intent: Handle user actions like button clicks and update the Model accordingly with Intents.

None
Structure MVI

4. Code Example to Implementation view:

Show an example code that illustrates how MVI is implemented in SwiftUI. This may include the declaration of the Model, creating a view with View, and handling Intents :

  1. first create Model State(StateModel.swift):
import Foundation
struct LoginState {
    var shouldRememberMe : Bool = false
    var email : String = ""
    var password : String = ""
    var showSignUpPage : Bool = false
    var shouldNavigateToHome : Bool = false
    var isLoading : Bool = false
    var errorMessage : Bool = false
}

2. Model UI Event(UIEventModel.swift) for action button:

import Foundation
enum LoginUiEvent {
    case onClickLogin(email: String,password: String,shouldRememberMe: Bool)
}

3. ViewModel (ViewModel.swift) for Api view:

class ViewModel: ObservableObject{
    @Published var state: LoginState = LoginState()
    
    func onEvent(event: LoginUiEvent) {
        switch event{
        case .onClickLogin(let email , let password, let shouldRememberMe):
            state.email = email
            state.password = password
            state.shouldRememberMe = shouldRememberMe          
            login()
            
        }
    } func login(){
        state.shouldNavigateToHome = true
    }
}

4 . ContentView (ContentView.swift) :

import SwiftUI
struct ContentView: View {
      @ObservedObject var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            Button(action: {
                   loginViewModel.login(email: viewModel.state.emailString, password: viewModel.state.passwordString)              
                        }, label: {
                            Text("Button")
                                .font(.custom("Poppins-Medium", size: 17))
                                .foregroundColor(Color.white)
                        })
                    }
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: AppViewModel())
    }
}

5. Setup for API:

- URLSession for API

import Combine
import Foundation
import SystemConfiguration
class URLSessionAPIClient: APIClient {
    
    enum CustomError: Error {
        case noInternetConnection
    }
    
    static func getPostString(params: [String: Any]) -> String {
        var data = [String]()
        for (key, value) in params {
            data.append(key + "=\(value)")
        }
        return data.map { String($0) }.joined(separator: "&")
    }
    
    func request(_ endpoint: EndpointType) -> AnyPublisher {
        guard Reachability.isConnectedToNetwork() else {
                   return Fail(error: APIError.invalidResponse(errorMessage:"Tidak ada koneksi, harap periksa jaringan internet anda")).eraseToAnyPublisher()
               }
        
        let url = endpoint.baseURL.appendingPathComponent(endpoint.path)
        
        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        
        endpoint.headers?.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) }
        print("header end",  endpoint.headers ?? "")
        print("Request Headers: \(request.allHTTPHeaderFields ?? [:])")
        if let contentType = endpoint.headers?["Content-Type"], contentType == "application/json" {
            if let parameters = endpoint.parameters {
                do {
                    request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
                } catch {
                    return Fail(error: error).eraseToAnyPublisher()
                }
            }
        } else {
            if let parameters = endpoint.parameters {
                if endpoint.method == .get {
                    var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
                    urlComponents?.queryItems = parameters.map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) }
                    if let queryString = urlComponents?.query {
                        // Manually append the query string to the URL
                        request.url = URL(string: url.absoluteString + "?" + queryString)
                    }
                }
                if endpoint.method == .post {
                    let postString = URLSessionAPIClient.getPostString(params: parameters)
                    request.httpBody = postString.data(using: .utf8)
                }
            }
        }
        
        return URLSession.shared.dataTaskPublisher(for: request)
            .subscribe(on: DispatchQueue.global(qos: .background))
            .tryMap { data, response -> Data in
                print(response)
                guard let httpResponse = response as? HTTPURLResponse else {
                    throw APIError.invalidResponse(errorMessage: "Invalid response")
                }
                if (200...299).contains(httpResponse.statusCode) {
                    print("response data", response)
                    return data
                } else if let errorMessage = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]? {
                    // Attempt to parse error message from the response body
                    let errorMessageString = errorMessage?["message"] as? String ?? "Unknown error"
                    throw APIError.invalidResponse(errorMessage: errorMessageString)
                } else {
                    // Handle the case where parsing the error message fails or it's not present in the response body
                    throw APIError.invalidResponse(errorMessage: "Unhandled status code: \(httpResponse.statusCode)")
                }
            }
            .decode(type: T.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}
public class Reachability {
    class func isConnectedToNetwork() -> Bool {
        var zeroAddress = sockaddr_in()
        zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress))
        zeroAddress.sin_family = sa_family_t(AF_INET)
        guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, {
            $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { zeroSockAddress in
                SCNetworkReachabilityCreateWithAddress(nil, zeroSockAddress)
            }
        }) else {
            return false
        }
        var flags: SCNetworkReachabilityFlags = []
        if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) {
            return false
        }
        let isReachable = flags.contains(.reachable)
        let needsConnection = flags.contains(.connectionRequired)
        return isReachable && !needsConnection
    }
}

- AuthRepositoryProtocol

import Combine
protocol AuthRepositoryProtocol {
    func login(email: String, password: String) -> AnyPublisher
}

- UserEndpoint

import Combine
enum UserEndpoint: APIEndpoint {
    case login(email: String, password: String)
   var baseURL: URL {
        return URL(string: AsikaWebsite.baseUrl)!
    }
    var path: String {
       switch self {
        case .login:
            return "v1/auth"
}
}

   var method: HTTPMethod {
        switch self {
        case .login:
            return .post
}
}
  var headers: [String: String]? {
         switch self {
         case .login:
             return ["Content-Type": "application/json"]
}
}
   var parameters: [String: Any]? {
        switch self {
        case .login(let email, let password):
            return ["email": email, "password": password]
}
}

}

- AuthRepositoryActual

import Combine
class AuthRepositoryActual: AuthRepositoryProtocol {
  func login(email: String, password: String) -> AnyPublisher {
        return apiClient.request(.login(email: email, password: password))
    }

explain this Code Example:

1.Model State: AppState is a data structure representing the application's state. AppIntent is the type of actions that users can perform. reduce is a function that updates the application's state based on an intent.

2. View: ContentView is the application's view that displays text and a button. When the "Update Title" button is pressed, it calls the handle(intent:) method in the ViewModel to send an intent.

3. ViewModel: AppViewModel is the ViewModel that manages the application's state and connects the View and Model. When an intent is received, the ViewModel calls the reduce function to update the application's state.

4. Setup url URLSessionAPI: The provided Swift code defines a generic API client (URLSessionAPIClient) using Combine and URLSession for handling HTTP requests. It includes functionality for constructing requests, checking internet connectivity with the Reachability class, and decoding responses. The client conforms to the APIClient protocol, and it's designed to work with types that adhere to the APIEndpoint protocol. The code also includes error handling and logging for better debugging.

  • AuthRepositoryProtocol is a Swift protocol that declares the structure for handling user authentication. It includes a method login(email:password:) which, when implemented, should return a publisher emitting a ResponseLogin or an error.
  • The AuthRepositoryActual class conforms to AuthRepositoryProtocol and implements the login method. It uses an apiClient to make an authentication request by invoking the request function with the specified .login endpoint.
  • The UserEndpoint enum conforms to the APIEndpoint protocol, representing various API endpoints related to user actions. In this case, it defines a case for user login. The enum specifies the base URL, path, HTTP method, headers, and parameters required for the login endpoint. The path is set to "v1/auth", the method is set to .post, and the headers include "Content-Type: application/json". The parameters are the user's email and password.

I'm sorry because my code example only explains it I hope you understand. This is a more comprehensive example of the MVI approach in SwiftUI. You can customize this code to fit your application's needs and test the testing according to your business logic.

Thanks for reading! Stay tuned for more SwiftUI articles by Muhammad Naufal Adli which will be coming soon and don't forget to clap. this artikel make Hand-crafted & Made with❤️.

Stackademic 🎓

Thank you for reading until the end. Before you go: