SwiftUI and The Wonderful Charm of Assembler

HashNode / Article 001 - Assembler 1/2

SwiftUI and The 
Wonderful Charm of Assembler

Introduction

Working with SwiftUI involves a detailed approach to how views are constructed and, more specifically, how the necessary classes that their ViewModels require are created, assembled, and injected. In this context, the concept of Assembler plays a crucial role. This construction technique is applied not only to the creation of views but also to the development of network layers and a wide range of modular software components, thereby facilitating code management and modularity.

The View

The view is the final output produced by our assembler. Regarding the view itself, aiming to adhere to the MVVM architecture, the only change we will implement is the injection of the ViewModel dependency as a parameter. To achieve this, we will design a view initializer, which will require an instance of the ViewModel. This means that it will no longer be possible to access the view directly; the only way to do so will be through its assembler.

struct MainView: View {
    // observed instance of an observable view model
    @ObservedObject var viewModel: MainViewModel

    // view initializer
    init(viewModel: MainViewModel) {
        self.viewModel = viewModel
    }

    var body: some View {
        ZStack {
            Color.chalkWhite01.ignoresSafeArea(.all)
            VStack {
                Text("Main View")
                    .font(.system(size: UIScreen.main.bounds.width * 0.10, weight: .medium))
                    .foregroundStyle(Color.matteBlack01)

                Spacer()
            }
        }
    }
}

The ViewModel

The ViewModel must be defined as a class. To ensure that the ViewModel instance, injected into the view, can include published variables and, consequently, be observable by the view, it is essential that it implements the ObservableObject protocol.

The view will access all the necessary services for its operation (such as biometric validation, network operations, access to local storage on the device, among others) through its ViewModel. These services should be provided to the ViewModel using an approach similar to the one used for injecting the ViewModel into the view: through a parameterization mechanism. Thus, the ViewModel will receive a set of service instances through a package of parameters.

class MainViewModel: ObservableObject {
    @Published var myParameter01: Bool
    @Published var myParameter02: Int
    // new published vars here ...

    init(params: Params) {
        self.myParameter01 = params.myParameter01
        self.myParameter02 = params.myParameter02
    }
}

// an extension is used to create the structure intended for parameter handling
extension MainViewModel {
    struct Params {
        let myParameter01: Bool
        let myParameter02: Int
        // new parameters here ...

        /*
         The parameter structure's initializer takes the parameters and assigns
         them as properties of the structure itself. It's crucial that the
         parameters are exhaustively declared beforehand, and ideally, each
         should have a default value.
         */
        init(myParameter01: Bool = false, myParameter02: Int = 0) {
            self.myParameter01 = myParameter01
            self.myParameter02 = myParameter02
        }
    }
}

The Assembler

The Assembler is the final piece of the puzzle, where the magic truly happens. Essentially, it is a structure featuring a single static method named create(), responsible for assembling all components and returning an opaque type, which fundamentally is a view.

struct MainViewAssembler {
    // we disable the default constructor so it cannot be invoked from the structure
    private init() {}

    /*
     we define a static method that can be invoked without needing instantiation and
     is capable of returning a view.
     */
    static func create() -> some View {

        // we create a parameter package specific to the viewmodel
        let viewModelParams: MainViewModel.Params = .init (
            myParameter01: true,
            myParameter02: 10
        )
        // we create a view model and parameterize it with the package we just created.
        let viewModel = MainViewModel(params: viewModelParams)

        /*
         we create an instance of the view parameterized with the instance of the
         view model we just created
         */
        let view = MainView(viewModel: viewModel)

        // finally, we return the fully assembled view
        return view
    }
}

Now, whether from the preview structure or any other part of the application, the view is exclusively invoked through the create() method of its assembler. This method is capable of handling and encapsulating any level of complexity that the view may require, thus removing this complexity from the view's interface itself (since assembling classes is not the responsibility of the view) and standardizing all calls. Thanks to this architectural approach, all views are invoked in a uniform manner.

We will soon analyze how to create a biometric validation layer to be injected as a dependency in this same project.

The Xcode project written in SwiftUI corresponding to this technical article is available on GitHub.