On our journey from UIKit to SwiftUI

Maksim Artemov
SwissBorg Engineering
8 min readJan 10, 2024

Our iOS team has recently begun an exciting journey of transitioning our app code base from UIKit to SwiftUI, a framework that allows us to design apps in a declarative way. This changeover was a great opportunity to re-evaluate our current architectural approach, MVVM+Coordinator, and see how it would work together with SwiftUI.

The MVVM+Coordinator architecture was our choice from the very beginning and we decided to go with it because of its easy integration with RxSwift, the reactive programming framework that we chose for managing communication between views and view models via inputs and outputs. Up until now, we were happy with this architectural choice as it met our development requirements for our iOS app. However, as we explored SwiftUI we realised that we needed to rethink the architecture of our application to take full advantage of the framework’s features to successfully unlock its potential in our application.

In this article, we will share our journey of migration to SwiftUI and critically evaluate the fit of our current MVVM+Coordinator architecture. We will review the strengths and limitations of this architecture in the context of SwiftUI, describe the issues we encountered, and the decisions we eventually made. Our goal is to provide valuable insights and recommendations for developers considering a similar choice of architecture.

Motivation for Transition

As it happens with new frameworks, SwiftUI was no exception, the first versions are not always stable and usually have flaws that do not allow us to confidently use them in production. Despite our initial curiosity, after getting to know and experimenting with this framework, we decided to postpone SwiftUI integration until it is mature and stable enough.

A year after SwiftUI was released, we decided to take another look at it and try experimenting with a simple feature. We were still hesitant to take on something complex and large. We were also still supporting iOS 11 and were gradually moving toward iOS 13. So that fact didn’t allow us to advance a lot with the SwiftUI. Therefore our first candidate for using SwiftUI became the iOS Widgets. By trying SwiftUI on a simpler feature first, we were able to see how it worked in real life and were quite happy with the result. However, it still felt like the framework needed more time before we could use it for the more critical parts of our application.

Over time, SwiftUI has improved and evolved, and this has occasionally sparked discussions within our iOS team about whether it’s mature enough to be used in production. We were finally on the way to drop supporting iOS 13 and moving up to iOS 14. This gave us enough confidence to look more seriously into the SwiftUI. Eventually, all of this led us to the idea to think about changing the entire architecture of our mobile app. We carefully analysed the implications of a full transition to SwiftUI. While the growing popularity and maturity of SwiftUI opened the door for major changes, the challenge was to integrate it smoothly and seamlessly into our existing code base so that both the current MVVM+Coordinator architecture and the new one could work together. This decision led us to the conclusion that we needed to explore different architectures that would work smoothly with SwiftUI and choose the most appropriate one for our application.

Currently, we are already using SwiftUI in the development of our application. We are working on creating a separate library that contains components in SwiftUI. We are using UIHostingController to integrate these components with the UIKit world. While our application is still mostly UIKit-based (around 98% of the codebase) we are progressively moving to a more SwiftUI-centric approach.

Evaluation of Architectural Approaches for SwiftUI

As our iOS app evolved, the SwiftUI framework evolved as well, which made us think about adopting it for our app. Initially, we considered keeping our current MVVM architecture, given that it was familiar and met our needs all along. However, because SwiftUI uses a declarative paradigm, which is different from the imperative paradigm used in MVVM, we realised that moving to a different architecture that is more compatible with SwiftUI would be more advantageous going forward.

In the MVVM architecture, the connection between the view and the view model is established by binding data through an I/O approach. This allows the view to remain independent of the internal workings of the view model, and vice versa. However, in SwiftUI, the view itself takes on many of the responsibilities traditionally performed by the view model. This fact made us think that using the current MVVM architecture simply adds an unnecessary level of complexity without any significant benefits.

We also noted that React and Flutter, frameworks that follow a declarative UI paradigm, do not use MVVM architectures. This further supported our decision to explore alternative architectures. All of this led us to consider the Redux/StateMachine approach, in which state management is centralized and components, including views, are informed of state changes through actions. This approach aligned with our vision of integration with SwiftUI. After some time of searching and evaluating possible solutions, the most suitable library within this paradigm led us to The Composable Architecture (TCA) from Point-Free.

TCA, inspired by Redux, caught our eye because it offers a balance between simplicity and power, and it fits our vision of a manageable and efficient architecture. When we explored alternative MVVM architectures, we were particularly attracted to TCA’s unique capabilities and its alignment with our development goals. Our familiarity with the Point-Free approach and examples of their successful solutions further fueled our interest in using TCA. TCA offers several advantages over MVVM, including seamless integration with SwiftUI, simplified state management, predictable data flow, modular design, and, as importantly, improved testability.

Below we discuss in more detail the specific benefits and results that validated our choice of the Composable Architecture over the traditional MVVM approach.

Implementing The Composable Architecture (TCA) in Our App

In transitioning to The Composable Architecture (TCA), we identified key types that modelled our app’s domain:

State:

  • A type that encapsulates the data required for feature logic and UI rendering.
  • In a slight variation from the Redux/StateMachine approach, we have decided to go with a struct-based State, which provides flexibility to modify the state in the Reducer.
struct State: Equatable {
var isLoading: Bool = false
var account: [AccountType]?
}

Action:

  • A type that encompasses all possible actions within the feature, including user actions, notifications, and event sources.
enum Action: Equatable {
case onAppear
case didFetchResults(_ results: [AccountType])
case selectAccount(_ account: AccountType)
}

Reducer:

  • A function that details how to transition from the current state to the next state happens given an action.
  • Additionally responsible for returning any effects (e.g., API requests) using the Effect type.
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
return .run { send in
await send(
.didFetchResults(
// logic to all API and send back its results
)
)
}

case let .didFetchResults(.success(results)):
state.accounts = results
state.isLoading = false
return .none

case .selectAccount:
return .none
}
}

And now these types are being combined into a struct:

struct AccountFeature: Reducer {
struct State: Equatable {
var isLoading: Bool = false
var account: [AccountType]?
}

enum Action: Equatable {
case onAppear
case didFetchResults(_ results: [AccountType])
case selectAccount(_ account: AccountType)
}

var body: some ReducerOf<Self> {
Reduce { state, action in
// the rest of the code
}
}
}

Store:

  • Establishes the runtime driving the feature.
  • Manages the execution of the reducer and effects, and facilitates observation of state changes for UI updates.
let store = Store(initialState: State()) {
AccountFeature()
}

Navigation Integration:

In order to integrate TCA with the existing Coordinator template, we had to take a thoughtful approach, particularly with regard to navigation. As a result, we implemented a solution called NavigationReducer, a specialized component that converts actions into routes. These routes were then injected into the screen and smoothly handled by the Coordinator.

enum Route: Equatable {
case didSelectAccount(_ account: AccountType)
}

NavigationReducer { _, action -> Route? in
switch action {
case let .selectAccount(account):
return .didSelectAccount(account)

default:
return nil
}
}

Then we instantiate the SwiftUI view with the Store in it:

struct AccountView: View {
let store: StoreOf<AccountFeature>
@ObservedObject var viewStore: ViewStoreOf<AccountFeature>

init(router: @escaping (AccountFeature.Route) -> Void) {
store = Store(initialState: AccountFeature.State()) {
AccountFeature()
.dependency(\.navigationClient, .init(router))
}
viewStore = ViewStore(store, observe: { $0 })
}
}

And finally, let's jump into the Coordinator

let view = AccountView { [weak self] route in
switch route {
case let .didSelectAccount(account):
/// continue the navigation to the next screen
}
}
let hostingController = UIHostingController(rootView: view)
navigationController.present(hostingController, animated: true)

This thoughtful approach to integration not only allowed us to preserve the existing Coordinator pattern, but also laid the groundwork for further scaling TCA into our codebase, allowing us to chain together screens built using the new architecture.

Unit Testing Strategies with The Composable Architecture (TCA):

TCA makes it easy to write unit tests using mockups for API calls. We would like to touch on this point a bit more in detail. The library provides a simple mechanism for exchanging between a live API client and a test mockup. And it gives us the ability to easily switch between the two environments, making testing more efficient and reliable.

Let's start by defining an API client for our service:

struct APIClient {
var accounts: @Sendable(_ query: String) async throws -> [AccountType]
}

Then we will wrap the client in DependencyValues and create DependencyKey for Live and Test Values:

extension DependencyValues {
var apiClient: APIClient {
get { self[APIClient.self] }
set { self[APIClient.self] = newValue }
}
}

extension APIClient: DependencyKey {
static let liveValue = Self(
accounts: { query in
try await /* live implementation */
}
)

static let testValue = Self(
accounts: /* test value implementation */
)
}

Now with this approach, we can easily swap between live and test values. Take a look at the example below. We instantiate a TestStore object and provide the test value as a dependency.

// given
let testStore = TestStore(initialState: .init()) {
AccountFeature()
} withDependencies: {
$0.apiClient.accounts = // set your mock data
}

// when
await store.send(.onAppear) {

// then
$0.isLoading = true
}

// then
await store.receive(.didFetchResults(.success(.mock))) {
$0.isLoading = false
$0.accounts = .mock
}

The simplicity of this approach, as well as the well-organised structure of TCA, made it incredibly suitable for test-driven development (TDD). We would also like to mention the detailed description of a failed test error message which definitely improves the overall testing experience and allows us to easily identify and fix issues while writing unit tests.

Results, Future Outlook, and Conclusion

During this journey, we were able to explore different app architectures in depth, which broadened our knowledge and provided very valuable insights not only about SwiftUI, but also about iOS development in general. But of course, we also got to know the SwiftUI framework's ins and outs, which made us much more comfortable to start using SwiftUI in bigger features of our app, and being confident about the end result.

TCA has demonstrated great compatibility with our existing MVVM+Coordinator architecture. Its smooth integration into our codebase simplified our development process, minimizing the need for significant refactoring and its robust testing approach gave us the feeling that we are on the right track. And of course, the fact that the framework follows the principles of clean architecture (SOLID) made us even more confident in choosing this approach.

As our SwiftUI adventure is just getting started, we know that the bigger our app gets, the more challenges and opportunities we’ll face and we’re ready for it. We’re going to tackle those by carefully examining how Composable Architecture works on a larger scale. We’ll keep an eye on its performance and scalability, and use that feedback to keep refining our architecture. That way, it’ll always be a solid foundation for our app’s future growth.

Keep an eye out for more updates as we dig deeper into the ins and outs of SwiftUI and Composable Architecture. We’re on a mission to keep building a smooth and scalable app for our users, so stay tuned for the ride.

--

--