IOS Microapps Architecture — Part 3

Artem Kvasnetskyi
21 min readOct 6, 2023

--

This is the third part of a series of articles on Microapps architecture. In this part, we will continue the development of our application, finish our first Feature module, and create our first Microapp.

If you missed the previous parts, it’s best to start there:

  • First part
    Will give you an overview of what modular architecture is, why you need it, and how to implement it in iOS.
  • Second part
    Will focus on what Microapps Architecture is and how to implement it with Swift Package Manager. Also, this is the part where we started developing our app, so this part is a must-read.

Part four is now available to read!

The next part will be the final part. In it, we will create a Home Feature using UIKit, create Foundation layer modules, and connect it all to our main application.

Demo application

You can see an example of an already completed application on my GitHub (I’d be grateful for your subscription).

In this series of articles, we’ll create it from scratch, but if it’s easier for you to understand the code, you can do that.

The app contains a total of four screens:

  • Auth Select Screen (Part 2)
  • Sign Up Screen (This part)
  • Sign In Screen (This part)
  • Home Screen (Part 4)

In this part, we will create the Sign Up and Sign In screens, finish the Authentication feature, and create our first Microapp.

With this app, registered users can view a list of characters from the Rick and Morty universe.

All modules use the MVVM architecture implemented with Combine. The module related to authorization is written using SwiftUI, the main screen uses UIKit to demonstrate the variability of Microapps architecture.

In addition, the application uses some libraries. In this series of articles we will not create real business logic, so most of the libraries will not be used in the articles, but if you are interested in real business logic, you can see it in the demo application.

Well, let’s continue our application by creating Sign In and Sign Up screens.

Sign In & Sign Up Screens

Since these screens contain business logic, we need to think about how we will use it.

Let’s start with the fact that when a user is authorized, we will receive a token and a refresh token. This is the standard auth flow for the REST API. Also, since this is communication with the server, we may receive errors that we need to display to the user. Let’s start by creating the data entities.

Utility Modules — Reusable Entities

Since these entities will be used in different modules, we will put them in a separate module.

Create a new module with the following path:
Sources/UtilityModules/Entities

In it, add:
1. Errors/AppError.swift
2. Errors/NetworkError.swift
3. UserAuthData.swift

Don’t forget to modify your Package.swift.

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),
// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]),
// MARK: - Utility
.library(name: "Navigation", targets: ["Navigation"]),
.library(name: "Entities", targets: ["Entities"]) // <---
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities") // <---
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),
// MARK: - Utility
.target(name: "Navigation", path: "Sources/UtilityModules/Navigation"),
.target(name: "Entities", path: "Sources/UtilityModules/Entities"), // <---
// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target(
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

Let’s start with AppError. This will be the interface for all possible errors in our application.

import Foundation

public protocol AppError: Error {
var localizedDescription: String { get }
}

Since in our case all we have to do is to display errors to the user, our interface will contain a localisedDescription, which we will display in the future.

Next is NetworkError, which will be a simple implementation of our AppError.

import Foundation

public struct NetworkError: AppError {
public var localizedDescription: String

public init(_ description: String) {
localizedDescription = description
}
}

The last step is to create the UserAuthData.

import Foundation

public protocol UserAuthData {
var idToken: String { get }
var refreshToken: String { get }
}

This will be the interface for the token data I mentioned above.

In this way we have created entities related to the business logic that we will use in the feature module. Now let’s think about UI. When we make a request, we will need to show the loader. Also, we need a TextField in which we will enter data. Let’s create these elements.

UI Modules — New UI Elements

Let’s start with LoadingView. Create a new file at the following path:
Sources/Views/LoadingView.swift

Since the Views module already exists, we don’t need to make any changes to the Package.swift file.

As I mentioned earlier, our application will contain both UIKit and SwiftUI views. Let’s write LoadingView using UIKit.

import UIKit
import SwiftUI
import Combine
import Resources // 1

final public class LoadingView: UIView {
// MARK: - Public Properties
@Published public var isLoading: Bool = false // 2

// MARK: - Private Properties
private let indicator = UIActivityIndicatorView(style: .large)
private var subscription: AnyCancellable?

// MARK: - Init
public override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}

public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

// MARK: - Private Methods
private extension LoadingView {
func commonInit() {
backgroundColor = Colors.neutral10.color.withAlphaComponent(
C.backgroundAlpha
)

indicator.color = Colors.neutral100.color
indicator.translatesAutoresizingMaskIntoConstraints = false

addSubview(indicator)
indicator.centerXAnchor
.constraint(equalTo: centerXAnchor)
.isActive = true

indicator.centerYAnchor
.constraint(equalTo: centerYAnchor)
.isActive = true

binding()
}

func binding() {
subscription = $isLoading // 2
.sink { [weak indicator] isLoading in
isLoading ? indicator?.startAnimating() : indicator?.stopAnimating()
}
}
}

struct Loader: UIViewRepresentable { // 3
@Binding public var isLoading: Bool

public func makeUIView(context: Context) -> LoadingView {
LoadingView()
}

public func updateUIView(_ uiView: LoadingView, context: Context) {
uiView.isLoading = isLoading
}
}

// MARK: - LoadingView + SwiftUI
public extension LoadingView { // 3
@ViewBuilder
static func swiftUIView(isLoading: Binding<Bool>) -> some View {
Loader(isLoading: isLoading)
}
}

// MARK: - Static Properties
private struct C {
static let backgroundAlpha = 0.5
}
  1. We are importing the Resources module as we will be using colors from it.
  2. We create a Published state to change the state of the UIActivityIndicatorView and create a subscription to it.
  3. We create a UIViewRepresentable for our loader view to use in SwiftUI.

The next step is to create a TextFiled. In the same folder create AppTextField.swift.

import SwiftUI
import Resources

public struct AppTextField: View {
// MARK: - Private Properties
@Binding var text: String

private let placeholder: String
private let textSize: CGFloat

// MARK: - Init
public init(
placeholder: String,
textSize: CGFloat = 14,
text: Binding<String>
) {
self.placeholder = placeholder
self.textSize = textSize
self._text = text
}

// MARK: - Body
public var body: some View {
TextField(
String(),
text: $text,
prompt: Text(placeholder)
.font(
FontFamily.Montserrat.medium.swiftUIFont(fixedSize: textSize)
)
.foregroundColor(Colors.neutral100.swiftUIColor)
)
.font(
FontFamily.Montserrat.medium.swiftUIFont(fixedSize: textSize)
)
.foregroundColor(Colors.neutral100.swiftUIColor)
.tint(Colors.neutral100.swiftUIColor)
.padding()
.background {
RoundedRectangle(cornerRadius: C.cornerRadius)
.fill(
Colors.neutral60.swiftUIColor
)
}
}
}

// MARK: - Static Properties
private struct C {
static let cornerRadius = 10.0
}

It’s quite simple, so I won’t explain this code.

Now we have entities by which we can determine whether to show the next screen or an error to the user, depending on the server’s response. Also, we have all the UI elements that we’re going to use.

It would be nice to make it so that we don’t need to duplicate the logic of loading and showing errors on each screen. Let’s make interfaces that we can reuse for each View and UIViewController.

Utility Modules — Base Classes/Interfaces

Create a file with the following path:
Sources/UtilityModules/Base/ViewRenderingState.swift

Also, add the module to Package.swift. Our module will be dependent on Entities, as it will use the error type, and on Views, as it will need a loader.

let package = Package(
name: "Modules",
defaultLocalization: "en",
platforms: [.iOS(.v16)],
products: [
// MARK: - Feature
.library(name: "Authentication", targets: ["Authentication"]),

// MARK: - UI
.library(name: "Resources", targets: ["Resources"]),
.library(name: "Views", targets: ["Views"]),

// MARK: - Utility
.library(name: "Navigation", targets: ["Navigation"]),
.library(name: "Entities", targets: ["Entities"]),
.library(name: "Base", targets: ["Base"]) // <---
],
targets: [
// MARK: - Feature
.target(
name: "Authentication",
dependencies: [
// UI
.target(name: "Resources"),
.target(name: "Views"),
// Utility
.target(name: "Navigation"),
.target(name: "Entities"),
.target(name: "Base") // <---
],
path: "Sources/FeatureModules/Authentication",
resources: [.process("Resources/Process")]
),

// MARK: - Utility
.target(name: "Navigation", path: "Sources/UtilityModules/Navigation"),
.target(name: "Entities", path: "Sources/UtilityModules/Entities"),
.target( // <---
name: "Base",
dependencies: [
.target(name: "Entities"),
.target(name: "Views")
],
path: "Sources/UtilityModules/Base"
),

// MARK: - UI
.target(
name: "Resources",
path: "Sources/UIModules/Resources",
resources: [.process("Process")]
),
.target(
name: "Views",
dependencies: [
.target(name: "Resources")
],
path: "Sources/UIModules/Views"
)
]
)

Now let’s add some code to the ViewRenderingState.

import SwiftUI
import Combine
import Entities
import Views
import Resources

public struct ViewRenderingState { // 1
// MARK: - Private Properties
fileprivate var alert: AlertModel = .init() // 2
fileprivate var isLoading: Bool = false // 2
}

// MARK: - Public Methods
public extension ViewRenderingState {
mutating func render(_ state: Render) { // 3
switch state {
case .alert(let message):
isLoading = false
alert = .init(
isAlertPresented: true,
alertMessage: message
)

case .error(let error):
isLoading = false
alert = .init(
isAlertPresented: true,
alertMessage: error.localizedDescription
)

case .loading:
alert = .init()
isLoading = true

case .view:
alert = .init()
isLoading = false
}
}
}

// MARK: - Models
public extension ViewRenderingState {
enum Render { // 3
case view
case loading
case alert(message: String)
case error(_ error: AppError)
}

struct AlertModel {
var isAlertPresented: Bool = false
var alertMessage: String?
}
}
  1. Our ViewRenderingState is the state model for the View. We will use it to determine whether an alert is currently showing and with what message, as well as whether the LoadingView is currently showing.
  2. As you may have noticed, all the properties of this structure are private so we cannot change them directly. This is done so that the user of this structure doesn’t mess around by changing all states directly.
  3. To modify the rendering state, we have created a render method that takes in 4 different states:
    - view — the screen shows its content.
    - loading — the screen shows LoadingView.
    - alert — the screen shows an alert with some message.
    - error — the screen shows an alert with an error description.

In the same folder, create BaseViewModel.swift and add the following code.

import Foundation
import Combine

public protocol ViewModel: ObservableObject { // 1
var renderingState: ViewRenderingState { get set }
var renderingStatePublisher: AnyPublisher<ViewRenderingState, Never> { get }
}

open class BaseViewModel: ViewModel {
// MARK: - Public Properties
@Published public var renderingState: ViewRenderingState = .init() // 2
public lazy var renderingStatePublisher = $renderingState.eraseToAnyPublisher()

public var subscriptions = Set<AnyCancellable>() // 3

public init() {}
}
  1. We have created a ViewModel protocol in which we specify a renderingState that we can modify and a renderingStatePublisher that we can subscribe to changes to.
  2. We create renderingState as a Published property so that when it changes, the SwiftUI View is redrawn.
  3. Since our future ViewModels will use Combine, we put subscriptions in the base class.

The next step is to create the BaseViewController. Create it in the same folder, and add the following code.

import UIKit
import Combine

open class BaseViewController<VM: ViewModel>: UIViewController { // 1
// MARK: - Public Properties
private(set) public var viewModel: VM // 1
public var subscriptions = Set<AnyCancellable>() // 2

// MARK: - Internal Properties
var alert: UIAlertController? // 3

// MARK: - Init
public init(viewModel: VM) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}

public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Lifecycle
open override func viewDidLoad() {
subscribeToRenderingStates() // 4
}
}
  1. We create a BaseViewController for our future controllers and pass the ViewModel protocol as a generic type to our controller.
  2. Since our future UIViewControllers will use Combine, we put subscriptions in the base class.
  3. We keep a reference to the UIAlertController for the future. It will be clearer later why we need it.
  4. This method does not exist yet, so you will get an error here.

Up to this point, it might not have been clear to you why we’re doing all this. Blink if that’s the case :)

Let’s tie this together. Go to the ViewRenderingState file, and add the following code to the end of the file.

// MARK: - View + ViewRenderingState
public extension View {
func subscribeToRenderingStates<VM: ViewModel>(
_ viewModel: ObservedObject<VM>.Wrapper
) -> some View {
self
.overlay {
if viewModel.renderingState.isLoading.wrappedValue {
LoadingView.swiftUIView(isLoading: .constant(true))
}
}
.alert(
viewModel.renderingState.alert.alertMessage.wrappedValue ?? .init(),
isPresented: viewModel.renderingState.alert.isAlertPresented
) {
Button(ReusableLocalization.ok) {
viewModel.renderingState.alert.isAlertPresented.wrappedValue = false
viewModel.renderingState.alert.alertMessage.wrappedValue = nil
}
}
}
}

// MARK: - BaseViewController + ViewRenderingState
extension BaseViewController {
func subscribeToRenderingStates() {
viewModel.renderingStatePublisher
.sink { [weak self] state in
if state.isLoading {
self?.showLoadingView()
} else {
self?.hideLoadingView()
}

if state.alert.isAlertPresented, let message = state.alert.alertMessage {
self?.showAlert(message: message)
} else {
self?.alert?.dismiss(animated: true)
}
}
.store(in: &subscriptions)
}

private func showLoadingView() {
let loadingView = LoadingView(frame: UIScreen.main.bounds)
loadingView.isLoading = true

view?.addSubview(loadingView)
}

private func hideLoadingView() {
view.subviews
.first(where: { $0 is LoadingView })?
.removeFromSuperview()
}

private func showAlert(
title: String = ReusableLocalization.error,
message: String
) {
let alertController = UIAlertController(
title: title,
message: message,
preferredStyle: .alert
)

let okAction = UIAlertAction(
title: ReusableLocalization.ok,
style: .default
) { [weak viewModel] _ in
viewModel?.renderingState.alert = .init()
}

alertController.addAction(okAction)
present(alertController, animated: true, completion: nil)
self.alert = alertController
}
}

In this code, we have added an extension for View and BaseViewController in which we have added an implementation of the subscribeToRenderingStates method. In this method, we subscribe to all possible renderingState events from our ViewModel. This way we can avoid duplication of code related to rendering states.

This may not be the best code for this, but in our case, it fits. If you don’t understand what’s going on here, you can just copy this implementation.

Well, now we’re all set for our feature. How will the business logic of our feature work if we haven’t written it? Theoretically, we can simply create services in a separate module and use them in MVVM models of our screens. This would be simple, but at the same time, it would hardwire our business logic to the module.

I prefer to use the Dependency Injection pattern. It will allow:

  • Easily reuse our models with different business logic.
  • Easily create test doubles for future unit tests.

That’s why we don’t need real business logic at the moment. If you are not familiar with this pattern, familiarise yourself with it first, then we will continue.

Feature Level Dependency Injection

Well, before creating our View-ViewModel-Model, let’s prepare our dependencies. Create file at the following path:
Sources/FeatureModules/Authentication/Presentation/SignIn/SignInDependencies.swift

This file will store all the business logic we need for the Sign-in screen. Add the following code to the file.

import Foundation
import Combine
import Entities

public struct SignInDependencies {
// MARK: - Internal Properties
var loginAction: (_ email: String, _ password: String) -> AnyPublisher<UserAuthData, NetworkError> // 1
var saveAction: (UserAuthData) -> Void // 2

// MARK: - Init
public init(
loginAction: @escaping (String, String) -> AnyPublisher<UserAuthData, NetworkError>, // 1
saveAction: @escaping (UserAuthData) -> Void // 2
) {
self.loginAction = loginAction
self.saveAction = saveAction
}
}

// MARK: - Placeholder
extension SignInDependencies {
static let placeholder = SignInDependencies( // 3
loginAction: { _ ,_ in
return Just(Dummy())
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
},
saveAction: { _ in }
)

fileprivate struct Dummy: UserAuthData {
var idToken = String()
var refreshToken = String()
}
}
  1. We pass each method as a separate closure. loginAction will be responsible for user login. This method can return UserAuthData, in case of a successful response from the server, or NetworkError, in case of an error.
  2. saveAction will write our tokens from the server response to some storage. Since storage can’t give us errors, we don’t return anything from it.
  3. placeholder is created for our future preview.

With the business logic in place, we can create a model that will call the methods we need. Create SignInModel.swift in the same folder and add the following code there.

import Foundation
import Combine
import Entities

protocol SignInModel { // 1
func login(email: String, password: String) -> AnyPublisher<Never, NetworkError>
}

final class SignInModelImpl {
// MARK: - Private Properties
private let dependencies: SignInDependencies

// MARK: - Init
init(dependencies: SignInDependencies) { // 2
self.dependencies = dependencies
}
}

// MARK: - SignInModel
extension SignInModelImpl: SignInModel {
func login(email: String, password: String) -> AnyPublisher<Never, NetworkError> { // 3
dependencies.loginAction(email, password)
.receive(on: DispatchQueue.main)
.map { [weak self] (data) -> Void in
self?.dependencies.saveAction(data)
}
.ignoreOutput()
.eraseToAnyPublisher()
}
}
  1. Our SignInModel protocol will contain only one method — login. We may receive an error or a successful completion in response. We will not pass tokens to ViewModel, as it is not important for it.
  2. We pass our SignInDependencies to the model initializer.
  3. In the implementation of the login method, we attempt to sign in user, and if successful, we save the token to the storage using the saveAction method.

Now let’s move on to ViewModel. Create SignInViewModel.swift in the same folder. Our ViewModel will look like this.

import Foundation
import Combine
import Base

protocol SignInViewModel: ViewModel { // 1
var email: String { get set }
var password: String { get set }
var isSignInActive: Bool { get }

func signInTapped()
}

final class SignInViewModelImpl: BaseViewModel, SignInViewModel { // 3
// MARK: - Internal Properties
@Published var email = String()
@Published var password = String()
@Published var isSignInActive = false

// MARK: - Private Properties
private let model: SignInModel
private let signInPassed: () -> Void

// MARK: - Init
init( // 2
_ model: SignInModel,
signInPassed: @escaping @autoclosure () -> Void
) {
self.model = model
self.signInPassed = signInPassed

super.init()

binding()
}
}

// MARK: - Internal Methods
extension SignInViewModelImpl {
func signInTapped() {
renderingState.render(.loading) // 3

model.login(email: email, password: password) // 4
.sink { [weak self] completion in
guard case let .failure(error) = completion else {
self?.renderingState.render(.view) // 3
self?.signInPassed()
return
}

self?.renderingState.render(.error(error)) // 3

} receiveValue: { _ in }
.store(in: &subscriptions)
}
}

// MARK: - Private Methods
private extension SignInViewModelImpl {
func binding() {
$email.combineLatest($password) // 5
.sink { [weak self] email, password in
let isEmailValid = email.count > 5
let isPasswordValid = password.count > 5

self?.isSignInActive = isEmailValid && isPasswordValid
}
.store(in: &subscriptions)
}
}

// MARK: - Preview
extension SignInViewModelImpl { // 6
static let placeholder = SignInViewModelImpl(
SignInModelImpl(dependencies: .placeholder),
signInPassed: ()
)
}
  1. We create a ViewModel protocol that contains the email and password to be filled in from the text field. It also contains the isSignInActive button state and the signInTapped method.
  2. In init, we pass the model that contains the business logic work and the signInPassed closure that we will call when our login is successful. We pass it instead of navigation because after login we somehow need to switch to another feature, which we don’t have yet.
  3. We inherit our ViewModel from BaseViewModel, in order to be able to conveniently switch View states between loading, view, and error.
  4. On clicking the future sign in button, we call the login method from our model, and depending on the response, change the state of our View or call the block that should trigger the next feature for us.
  5. We subscribe to our field changes, and validate them. If both email and password contain more than 5 characters, we make the sign in button active.
  6. We are creating a placeholder for our future preview.

Now all we have left is View. Create SignInView.swift in the same folder.

import SwiftUI
import Resources
import Views
import Base

struct SignInView<VM: SignInViewModel>: View {
// MARK: - Internal Properties
@StateObject var viewModel: VM

// MARK: - Body
var body: some View {
VStack(spacing: C.spacing) {
textFields()

Button(Localization.Sign.In.title) {
viewModel.signInTapped()
}
.buttonStyle(AppButtonStyle())
.disabled(!viewModel.isSignInActive)

Spacer()
}
.navigationTitle(Localization.Sign.In.title)
.padding([.horizontal, .vertical])
.subscribeToRenderingStates($viewModel) // <---
}
}

// MARK: - Private Methods
private extension SignInView {
@ViewBuilder
func textFields() -> some View {
VStack {
AppTextField(
placeholder: Localization.Credentials.Email.title,
text: $viewModel.email
)

AppTextField(
placeholder: Localization.Credentials.Password.title,
text: $viewModel.password
)
}
}
}

// MARK: - Static Properties
private struct C {
static let spacing = 40.0
}

// MARK: - Preview Provider
#Preview {
SignInView(viewModel: SignInViewModelImpl.placeholder)
}

This View does not contain anything interesting except subscribeToRenderingStates. This is a method we implemented with you in the Base module to automatically change the state between view, loading, and error.

Let’s do the same for Sign Up. Simply create a SignUp folder in Presentation, and create files in it:

  • SignUpDependencies.swift
import Foundation
import Combine
import Entities

public struct SignUpDependencies {
// MARK: - Internal Properties
var registration: (_ email: String, _ password: String) -> AnyPublisher<UserAuthData, NetworkError>
var saveAction: (UserAuthData) -> Void

// MARK: - Init
public init(
registration: @escaping (String, String) -> AnyPublisher<UserAuthData, NetworkError>,
saveAction: @escaping (UserAuthData) -> Void
) {
self.registration = registration
self.saveAction = saveAction
}
}

// MARK: - Placeholder
extension SignUpDependencies {
static let placeholder = SignUpDependencies(
registration: { _ ,_ in
return Just(Dummy())
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
},
saveAction: { _ in }
)

fileprivate struct Dummy: UserAuthData {
var idToken = String()
var refreshToken = String()
}
}
  • SignUpModel.swift
import Foundation
import Combine
import Entities

protocol SignUpModel {
func signUp(email: String, password: String) -> AnyPublisher<Never, NetworkError>
}

final class SignUpModelImpl {
// MARK: - Private Properties
private let dependencies: SignUpDependencies

// MARK: - Init
init(dependencies: SignUpDependencies) {
self.dependencies = dependencies
}
}

// MARK: - SignUpModel
extension SignUpModelImpl: SignUpModel {
func signUp(email: String, password: String) -> AnyPublisher<Never, NetworkError> {
dependencies.registration(email, password)
.receive(on: DispatchQueue.main)
.map { [weak self] (data) -> Void in
self?.dependencies.saveAction(data)
}
.ignoreOutput()
.eraseToAnyPublisher()
}
}
  • SignUpViewModel.swift
import Foundation
import Combine
import Base

protocol SignUpViewModel: ViewModel {
var email: String { get set }
var password: String { get set }
var confirmPassword: String { get set }
var isSignUpActive: Bool { get }

func signUpTapped()
}

final class SignUpViewModelImpl: BaseViewModel, SignUpViewModel {
// MARK: - Internal Properties
@Published var email = String()
@Published var password = String()
@Published var confirmPassword = String()
@Published var isSignUpActive = false

// MARK: - Private Properties
private let model: SignUpModel
private let signUpPassed: () -> Void

// MARK: - Init
init(
_ model: SignUpModel,
signUpPassed: @escaping @autoclosure () -> Void
) {
self.model = model
self.signUpPassed = signUpPassed

super.init()

binding()
}
}

// MARK: - Internal Methods
extension SignUpViewModelImpl {
func signUpTapped() {
renderingState.render(.loading)

model.signUp(email: email, password: password)
.sink { [weak self] completion in
guard case let .failure(error) = completion else {
self?.renderingState.render(.view)
self?.signUpPassed()
return
}

self?.renderingState.render(.error(error))

} receiveValue: { _ in }
.store(in: &subscriptions)
}
}

// MARK: - Private Methods
private extension SignUpViewModelImpl {
func binding() {
$email
.combineLatest($password, $confirmPassword)
.sink { [weak self] email, password, confirmPassword in
let isEmailValid = email.count > 5
let isPasswordValid = password.count > 5 && password == confirmPassword

self?.isSignUpActive = isEmailValid && isPasswordValid
}
.store(in: &subscriptions)
}
}

// MARK: - Preview
extension SignUpViewModelImpl {
static let placeholder = SignUpViewModelImpl(
SignUpModelImpl(dependencies: .placeholder),
signUpPassed: ()
)
}
  • SignUpView.swift
import SwiftUI
import Resources
import Views
import Base

struct SignUpView<VM: SignUpViewModel>: View {
// MARK: - Internal Properties
@StateObject var viewModel: VM

// MARK: - Body
var body: some View {
VStack(spacing: C.spacing) {
textFields()

Button(Localization.Sign.Up.title) {
viewModel.signUpTapped()
}
.buttonStyle(AppButtonStyle())
.disabled(!viewModel.isSignUpActive)

Spacer()
}
.navigationTitle(Localization.Sign.Up.title)
.padding([.horizontal, .vertical])
.subscribeToRenderingStates($viewModel)
}
}

// MARK: - Private Methods
private extension SignUpView {
@ViewBuilder
func textFields() -> some View {
VStack {
AppTextField(
placeholder: Localization.Credentials.Email.title,
text: $viewModel.email
)

AppTextField(
placeholder: Localization.Credentials.Password.title,
text: $viewModel.password
)

AppTextField(
placeholder: Localization.Credentials.Password.Confirm.title,
text: $viewModel.confirmPassword
)
}
}
}

// MARK: - Static Properties
private struct C {
static let spacing = 40.0
}

// MARK: - Preview Provider
#Preview {
SignUpView(viewModel: SignUpViewModelImpl.placeholder)
}

I will not explain this code as it is almost identical to the Sign In screen. If you don’t understand something, you can go to the Sign In explanation.

So we created two screens with MVVM architecture and Dependency Injection. All we have to do is assemble the screens from different elements and connect them to our navigation. To do this, let’s go to
Authentication/Core/AuthModuleFactory.

As you may already remember, this entity is responsible for assembling and creating screens. The first thing to think about is dependencies. Let’s add them here.

import Foundation
import SwiftUI
import Navigation

public final class AuthModuleFactory { // 1
// MARK: - Dependencies
public struct Dependencies { // 2
let signInDependencies: SignInDependencies
let signUpDependencies: SignUpDependencies

public init(
_ signIn: SignInDependencies,
_ signUp: SignUpDependencies
) {
signInDependencies = signIn
signUpDependencies = signUp
}
}

// MARK: - Private Properties
private let dependencies: Dependencies // 3
private let navigation: NavigationStore<Route>

// MARK: - Init
init(_ dependencies: Dependencies, _ navigation: NavigationStore<Route>) {
self.navigation = navigation
self.dependencies = dependencies
}
}

// ...
  1. We made the AuthModuleFactory public to make Dependencies public.
  2. We create a Dependencies structure that will store the dependencies of each screen of our feature. This way, no matter how many screens your feature contains, they will all be described in one place. Note that this struct has a public init. This is so that dependencies can be created from outside this module.
  3. We keep all dependencies in the AuthModuleFactory, in order to pass them to the screen in the future.

Now let’s create methods for our screens.

import Foundation
import SwiftUI
import Navigation

public final class AuthModuleFactory {
// MARK: - Dependencies
public struct Dependencies {
let signInDependencies: SignInDependencies
let signUpDependencies: SignUpDependencies

public init(
_ signIn: SignInDependencies,
_ signUp: SignUpDependencies
) {
signInDependencies = signIn
signUpDependencies = signUp
}
}

// MARK: - Private Properties
private let dependencies: Dependencies
private let navigation: NavigationStore<Route>

// MARK: - Init
init(_ dependencies: Dependencies, _ navigation: NavigationStore<Route>) {
self.navigation = navigation
self.dependencies = dependencies
}
}

// MARK: - Internal Methods
extension AuthModuleFactory {
@ViewBuilder
func getAuthSelect() -> some View {
AuthSelectView(
viewModel: AuthSelectViewModelImpl(
navigation: self.navigation
)
)
}

@ViewBuilder
func getSignIn( // <---
signInPassed: @escaping @autoclosure () -> Void
) -> some View {
SignInView(
viewModel: SignInViewModelImpl(
SignInModelImpl(
dependencies: self.dependencies.signInDependencies
),
signInPassed: signInPassed()
)
)
}

@ViewBuilder
func getSignUp( // <---
signUpPassed: @escaping @autoclosure () -> Void
) -> some View {
SignUpView(
viewModel: SignUpViewModelImpl(
SignUpModelImpl(
dependencies: self.dependencies.signUpDependencies
),
signUpPassed: signUpPassed()
)
)
}
}

We created two methods for our screens, where we build them with the right dependencies and a closure signaling the end of a given feature.

Well, the last step is to add these screens to the navigation. Let’s open our AuthRoot and modify it a bit.

import SwiftUI
import Navigation

public struct AuthRoot: View {
// MARK: - Private Properties
@StateObject private var navigation: NavigationStore<Route>

private let factory: AuthModuleFactory
private let signInPassed: () -> Void
private let signUpPassed: () -> Void

// MARK: - Init
public init( // 1
dependencies: AuthModuleFactory.Dependencies,
signInPassed: @escaping @autoclosure () -> Void,
signUpPassed: @escaping @autoclosure () -> Void
) {
let navigation = NavigationStore<Route>()
_navigation = StateObject(wrappedValue: navigation)

factory = AuthModuleFactory(
dependencies,
navigation
)

self.signInPassed = signInPassed
self.signUpPassed = signUpPassed
}

// MARK: - Body
public var body: some View {
NavigationStack(path: $navigation.route) {
factory.getAuthSelect()
.navigationDestination(for: Route.self) { route in
switch route { // 2
case .signIn:
factory.getSignIn(
signInPassed: signInPassed()
)

case .signUp:
factory.getSignUp(
signUpPassed: signUpPassed()
)
}
}
}
}
}

// MARK: - Preview Provider
#Preview {
AuthRoot(
dependencies: .init(.placeholder, .placeholder),
signInPassed: (),
signUpPassed: ()
)
}
  1. Since we’re going to pass dependencies externally, we pass them to init, just like we pass closure to end feature.
  2. We add new screens to navigationDestination, thus adding them to our navigation.

Congratulations! We have a Feature module ready to go.

But don’t you think there’s something missing from the fullness of the module? If so, you’d be right. One of the advantages of Microapps should be easy testing. In theory, you should be able to publish each individual feature on TestFlight, where the QA team can test it separately from other screens. Luckily, it’s not hard to do.

Authentication Feature Microapp

To be able to publish and test individual Features, we need separate Xcode Projects for each of the features. These applications can be called Microapp.

Inside your Swift Package, create a new folder. Name it Microapps. This will contain Microapps for each of our Features.

Now let’s create a new project inside this folder. When creating, remember to add this project to the package.

Now we need to add a dependency on the package. Close your Package, open a new project, and add the dependency to the local Package.

You can now remove ContentView and Preview Content, and then update your AuthFeatureMicroappApp.swift as follows:

import SwiftUI
import Authentication // 1
import Entities // 1
import Combine

@main
struct AuthFeatureMicroappApp: App {
var body: some Scene {
WindowGroup {
AuthRoot( // 2
dependencies: .init( // 3
.init(
loginAction: { email, password in
debugPrint("Login: \(email), \(password)")

return Just(DummyUserData())
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
},
saveAction: { _ in
debugPrint("Save user data")
}
),
.init( // 3
registration: { email, password in
debugPrint("Sign Up: \(email), \(password)")

return Just(DummyUserData())
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
},
saveAction: { _ in
debugPrint("Save user data")
}
)
),
signInPassed: (), // 4
signUpPassed: () // 4
)
}
}
}

private struct DummyUserData: UserAuthData {
let idToken: String = ""
let refreshToken: String = ""
}
  1. As you can see, we can import modules from our Swift Package into the project.
  2. Since AuthRoot is our starting point in the feature we initialize it as root view.
  3. At initialization, we pass dependencies for each screen. In our case they don’t do anything practical, they just pass DummyUserData for login and registration, and log the information to the console.
  4. We leave signInPassed and signUpPassed empty, as this Microapp only applies to this feature.

Now you can run your feature on the simulator, and even submit it to TestFlight. Isn’t that great? This concludes our Authentication module.

Conclusion

In this part, we saw how we can create base classes, interfaces, and entities in separate modules, we created two new screens using Dependency Injection, and our first Microapp with which we can send our feature to TestFlight separate from the overall application.

In the next part, we’ll start creating the home screen using UIKit, create the Foundation modules we need with a fake implementation, and connect it all to our application.

I’d love for you to subscribe to me here and on my GitHub. Thank you!

References

Microapps architecture in Swift. Dependency Injection.

--

--