Development iOS

Application Architecture with SwiftUI

June 15, 2022
Application Architecture with SwiftUI

What is Soft­ware Archi­tec­ture? #

Soft­ware archi­tec­ture is one of those things that can feel over­bear­ing and boil­er-plate rid­den” when done wrong, espe­cial­ly at the begin­ning of a project. It is not until your project grows sig­nif­i­cant­ly that a well-thought out struc­ture bears fruit, and once your project grows to such a size it can be dif­fi­cult to adapt a bur­geon­ing code­base to a con­sis­tent pat­tern. It pays to think through a few things up front.

At the core of soft­ware archi­tec­ture are the questions 

Where do I put this? Where should this busi­ness log­ic go? Where should I store this data? How do I get that data from its loca­tion to a view?

We then must fol­low up with anoth­er question.

What does that mean for maintainability?

Soft­ware projects are nev­er real­ly done. Plat­forms change, users give feed­back, stake­hold­ers make new pri­or­i­ties and we are con­stant­ly mea­sur­ing user engage­ment and try­ing to improve the user expe­ri­ence. If we can set some con­sis­tent guide­lines about where to put the func­tion­al lev­els” of our app we can more eas­i­ly grow and adapt our projects over time.

Goals #

We think a good archi­tec­ture should improve our allowance to for­get things. We want to max­i­mize the inde­pen­dence of each part of our app, even down to mak­ing sure the View does not know where its data comes from. We think this helps with the main­tain­abil­i­ty of a project, if you have clear­ly defined lines between your Views, your data, and how that data is manip­u­lat­ed for dis­play future devel­op­ers should be able to pick up just the pieces they need for a giv­en task and not have to hold the entire app in their mind before mak­ing a change or adding a feature.

We also want our archi­tec­ture to help facil­i­tate the dis­cus­sion around the ques­tion where should I put this func­tion­al­i­ty?” Giv­ing archi­tec­tur­al guide­posts for where things go can speed up devel­op­ment in the longterm, pay­ing off the invest­ment in set­up we make at the start of a project. 

A few words on acronyms #

His­tor­i­cal­ly UIK­it based iOS apps have their roots in an MVC set­up (Mod­el — View — Con­troller). There much nuance and opin­ion about how much log­ic should go in a UIV­iew­Con­troller, whether to sub­class UIV­iew, how to com­pose views togeth­er, and how to get from screen to screen. Over time we have set­tled into a pat­tern for our UIK­it apps that uses some fla­vor of MVC or MVVM but uses a sep­a­rate Coor­di­na­tor con­cept to man­age get­ting from screen to screen.

Swif­tUI, the hot new declar­a­tive UI frame­work for iOS, push­es iOS archi­tec­ture toward an MVVM (Mod­el ‑View — View Mod­el) set­up and away from UIKit’s MVC roots. SwiftUI’s struct-based Views are restrict­ed in how much state they can rea­son­ably man­age on their own and can­not main­tain and manip­u­late their par­ent or child views in the same way we can with UIK­it. As we con­tin­ue to devel­op more and more using Swif­tUI, it is a good time to re-eval­u­ate how we struc­ture our iOS projects.

Struc­ture #

Let’s zoom in on MVVM for a moment. The View is our structs that imple­ment SwiftUI’s View pro­to­col. Large­ly our Mod­el lay­er is the same as it has always been, some tables in a data­base, pref­er­ences in UserDe­faults, etc. The View Mod­el sits between the View and Mod­el, act­ing as the con­nec­tive tis­sue that pre­pares your app’s data for dis­play. The Mod­el is the where and how your app stores and retrieves its data. The Model’s imple­men­ta­tion is not impor­tant for the pur­pose of the dis­cus­sion in this post. We will use some lan­guage around Core Data as an exam­ple, but none of the tech­niques dis­cussed hinge upon its use. Instead we will focus on the rela­tion­ship between View and View Mod­el spe­cif­ic to how we use them with SwiftUI.

V is for View #

The View, a screen, the application’s UI — at this lay­er we are only con­cerned with orga­niz­ing our infor­ma­tion for dis­play and respond­ing to the user’s inputs. Our views are not con­cerned with where the data came from or how changes the user makes are prop­a­gat­ed and saved, that respon­si­bil­i­ty falls to the View Model.

In gen­er­al a View should have exact­ly one View Mod­el that dri­ves it, and that View Mod­el should be the View’s only depen­den­cy. We also define a View’s require­ments via a pro­to­col, which we call a View­Con­tract. A View can be dri­ven by any type that imple­ments its con­tract, and we can have more than one fla­vor of View Mod­el dri­ve the same UI. As a result, a View will nev­er know the actu­al type of its View Mod­el, it is only con­cerned with the ele­ments of its contract.

struct ItemListView<ViewModel: ItemListViewContract>: View {
  @ObservedObject var viewModel: ViewModel

  var body: some View {
    ScrollView {
      VStack {
        TextField("Search", binding: self.viewModel.searchTerm)
        ForEach(self.viewModel.listOfItems, id: \.self) { item in
          ItemDetailView(viewModel: item)
        }
        Button(action: viewModel.buttonClick) {
          Label("Refresh")   
        }
      }
    }
  }
}

Abstract­ing the View­Mod­el behind a View Con­tract Pro­to­col #

SwiftUI’s Pre­view sys­tem is a pow­er­ful tool for build­ing and main­tain­ing UI. Pre­views allow you to see what your View will look like under dif­fer­ent envi­ron­ment con­di­tions and with dif­fer­ent sets of data. This works just fine for sim­ple Views, but when your View is dri­ven by a View Mod­el that loads its data from an end­point we need to start think­ing about ways to make Pre­views use­ful with­out the need for set­ting up or mock­ing out all of our app’s services.

To get the most out of SwiftUI’s pre­view sys­tem we insert a pro­to­col (inter­face for any non-Swift folks out there) that describes the data require­ments for a View. For our pur­pos­es, we will call this a View­Con­tract. Check out Using View Mod­el Pro­to­cols to man­age com­plex Swif­tUI Views for an intro­duc­tion to this idea.

This View Con­tract pro­to­col defines exact­ly what is need­ed to dri­ve a piece of UI — its func­tions, vari­ables, states, etc. This sep­a­rates the require­ments of the UI from their under­ly­ing Mod­el-lay­er com­po­nents. We can then imple­ment that pro­to­col in mul­ti­ple con­crete view mod­el class­es and use each to dri­ve a par­tic­u­lar view in dif­fer­ent sce­nar­ios. The list­ing below is an exam­ple of what a pro­to­col might look like for a screen that dis­plays a list of items. 

protocol ItemListViewContract: ObservableObject {
  associatedtype ItemDetailViewModelType: ItemDetailViewContract

  var searchTerm: String { get set }
  var listOfItems: [ItemDetailViewModelType] { get }

  func buttonClick()
}

We extend ObservableObject so that Swif­tUI can use the @ObservedObject or @StateObject prop­er­ty wrap­pers. The prop­er­ties we define in the Con­tract should have fair­ly sim­ple types (String, Int, etc.) or have their types hid­den behind asso­ci­at­ed type with pro­to­col require­ments (e.g. ItemViewModelType). The pur­pose is to hide the actu­al source of this infor­ma­tion from the UI so we can’t go about return­ing Core­Da­ta objects direct­ly here.

This is also where you would define a hier­ar­chy of View Mod­els, for exam­ple if a list view needs to pro­vide an array of View Mod­els for a detail screen. In the list­ing above, our con­tract requires an array of ItemViewModelType which can be any type that imple­ments the con­tract for the item detail view (ItemContentViewContract).

The View Con­tract pro­to­col approach allows us to define mul­ti­ple vari­a­tions on our View Mod­els with­out sub­class­ing and with­out tying our Views to any spe­cif­ic data source. We reap div­i­dends from this set­up when it comes to mak­ing the most of Swif­tUI Pre­views. As we will see in the next sec­tion, we can define a con­crete View Mod­el that uses all hard cod­ed data for Swif­tUI Pre­views, or reads from a JSON file, whilst also let­ting our actu­al View Mod­els talk to resources that are not avail­able or dif­fi­cult to use in Pre­views (like Core­Da­ta, an API, UserDe­faults, etc.).

struct ItemListView_Previews: PreviewProvider {
  static var previews: some View {
    NavigationView {
      ItemListView(viewModel: PreviewItemListViewModel())
    }
    NavigationView {
      ItemListView(viewModel: PreviewItemListViewModel())
    }
    .colorScheme(.dark)
  }
}

Cre­at­ing Con­crete View Mod­els #

Now that we have our require­ments defined for our View we can work on imple­ment­ing a pair of View Mod­els that we can use to dri­ve it — one for pre­views and anoth­er for our app’s actu­al data.

Pre­view View Mod­el Class #

Per­haps it is eas­i­est to start by defin­ing a View Mod­el we can use for pre­views. We find it help­ful to do this upfront, in tan­dem with build­ing our actu­al UI. We can use hard-cod­ed data or pass in val­ues to cre­ate dif­fer­ent scenarios.

final class PreviewItemListViewModel: ItemListViewContract {
  var searchTerm: String = "Preview"
  var listOfItems: [PreviewItemDetailViewModel] = []

  func buttonClick() {}

  init() {}
}

The list­ing above hard codes the data that we will show in pre­views and pro­vides a sim­ple imple­men­ta­tion of the actions required by our View Con­tract. Addi­tion­al­ly, we need to cre­ate anoth­er class, PreviewItemContentViewModel, that will imple­ment the con­tract for our detail view (ItemContentViewContract).

Now that we have our UI ele­ments fig­ured out we can write anoth­er View Mod­el imple­men­ta­tion that pulls from our app’s actu­al data.

Core­Da­ta Backed View­Mod­el Class #

Core­Da­ta is by no means the only place you can store your app’s data. It is worth reit­er­at­ing that this approach is inten­tion­al­ly agnos­tic of where your data comes from; the UI can only see what is defined in its contract.

This is where the data actu­al­ly is accessed. It defines every­thing estab­lished in the pro­to­col it imple­ments. Here we also pro­vide an imple­men­ta­tion of our buttonClick func­tion that reloads the data.

final class ItemListViewModel: ItemListViewContract {
  @Published var searchTerm: String = "CoreData 😎"
  @Published var listOfItems: [ItemDetailViewModel] = []

  private let context: NSManagedObjectContext

  init(context: NSManagedObjectContext) {
    self.context = context
  }
  
  private func fetch() {
    // Load the list of items from CoreData and convert
    // them into our detail view models
    self.listOfItems = self.context
      .fetch(MyCoreDataModel.searchRequest(term: self.searchTerm))
      .map(ItemDetailViewModel.init)
  }

  func buttonClick() {
    self.fetch()
  }
}

If Core­Da­ta is not your back­ing store of choice, hope­ful­ly you can see how you might cre­ate a View Mod­el class that imple­ments our View Con­tract but gets its data from SQLite, Realm, an API, or sim­i­lar. The end result is a hier­ar­chy that looks some­thing like the image below.

Archi­tec­ture ben­e­fits #

We want the imple­men­ta­tion of our View Mod­els to be entire­ly invis­i­ble to our Views. This strong iso­la­tion between View and Mod­el allows us to devel­op in par­al­lel bet­ter. When start­ing work on a screen we can define the screen’s require­ments based on designs and the inter­ac­tions the user needs to take and cod­i­fy it into a View Con­tract. Mul­ti­ple devel­op­ers can then work on imple­ment­ing the each side of the con­tract independently.

This approach can also ease main­te­nance of both the UI and an app’s busi­ness log­ic. As long as the view receives the data in the cor­rect for­mat, accord­ing to its con­tract, the source of it can change and the page will func­tion the same. Addi­tion­al­ly, we can redesign or rewrite a screen and know that it will not affect the under­ly­ing busi­ness log­ic con­tained in our Mod­els and View Mod­els. We also can rely on the com­pil­er when we need to change the View Con­tract to make sure noth­ing slips through the cracks.

This is all in addi­tion to the real­i­ties of devel­op­ment where often the UI design is estab­lished before all of the exter­nal resources and infra­struc­ture are ready to use. By estab­lish­ing this abstrac­tion between our Views and the rest of our app, we can eas­i­ly write a View Mod­el imple­men­ta­tion that works for demo­ing the UI and lat­er fol­low up with a View Mod­el that pro­vides real data. We have con­fi­dence to do this with min­i­mal rework because of our require­ments defined in the View Contract.

It is worth not­ing that not all Views war­rant the cre­at­ing of a View Mod­el and View Con­tract. Views can be sim­ple, for exam­ple just tak­ing a few strings and dis­play­ing them in a stack. These types of Views are use­ful to keep your UI ele­ments con­sis­tent. Once your View needs to respond to changes, manip­u­late state, or per­form oth­er inte­gra­tions with sys­tem APIs it might be time to reach for a View Model.

Shar­ing func­tion­al­i­ty across screens #

Above we stat­ed that in gen­er­al a View should only have one depen­den­cy, its View Mod­el, but often times in larg­er appli­ca­tions we need to manip­u­late appli­ca­tion state in the same way across dif­fer­ent screens and in dif­fer­ent view mod­els. For this we intro­duce a new lay­er into the mix between our View Mod­els and our data­base, the Use Case .

Use Cas­es may be famil­iar to our Android friends, but for the unfa­mil­iar they are state­less objects that manip­u­late your appli­ca­tion state accord­ing to busi­ness require­ments — essen­tial­ly a cod­i­fied, reusable slice of busi­ness log­ic. This means that you can pass around a shared Use Case, or cre­ate mul­ti­ple instances and share them to each View Mod­el as need­ed, using what­ev­er depen­den­cy injec­tion mech­a­nism you prefer.

Below is an exam­ple of a Use Case you may write to pull togeth­er an API and some local­ly stored data to pro­vide a con­sis­tent way to access a user’s login state in your app. Addi­tion­al­ly we show two View­Mod­els that might make use of this shared functionality.

/// A Use Case that encapsulates the Login state of the application, allowing the
/// View Models a consistent mechanism to alter and respond to change in the state
final public class LoginStateUseCase: NSObject {
  var loginState: AnyPublisher<LoginState, Never>
  
  func login(credentials: Credentials) {
    // ... Do login
  }
  
  func logout() {
    // ... Do logout
  }
}

// Create a shared instance so all consumers can get the same state. This can
// also be done through more nuanced DI setups
extension LoginStateUseCase {
  public static let shared = ExampleUseCase()
}

/// View Model for the Login Screen that manipulates the login state by attempting
/// to login with the user's entered credentials
final class LoginScreenViewModel: LoginScreenViewContract {
  
  @Published var usernameField: String = ""
  @Published var passwordField: String = ""
  
  let useCase: LoginStateUseCase
  init(login: LoginStateUseCase = .shared) {
    self.useCase = login
  }
  
  func login() {
    self.useCase.login(
      with: Credentials(
        username: self.usernameField,
        password: self.passwordField
      )
    )
    .sink(...)
  }
}

/// View Model for the Root Screen that serves as the app's top level router, 
/// showing the main content or the login screen depending on the login state
final class RootScreenViewModel: RootScreenViewContract {
  @Published var isLoggedIn: Bool = false

  let useCase: LoginStateUseCase
  init(login: LoginStateUseCase = .shared) {
    self.useCase = login
    
    self.useCase.loginState
      .map { state in
        return state == .loggedIn
      }
      .assign(to: &$isLoggedIn)
  }
}

Use Cas­es can also depend on oth­er Use Cas­es allow­ing you to com­pose your app’s busi­ness log­ic from mul­ti­ple, inde­pen­dent parts. This is a pow­er­ful way to com­pose func­tion­al­i­ty togeth­er rather than build­ing one giant ser­vice that gets passed about through your entire app. The dia­gram below shows where in the hier­ar­chy a Use Case fits.

Mov­ing for­ward #

There is a ben­e­fit to think­ing through the ques­tions of where things go and how to main­tain a soft­ware project regard­less of whether the spe­cif­ic approach described in this post works for you. Some apps will war­rant addi­tion­al lay­ers and abstrac­tions like Repos­i­to­ries and oth­er Ser­vice types. Some will not ben­e­fit from the addi­tion of Use Cas­es. It all depends on a project’s com­plex­i­ty and what the main­te­nance life­time will be. The approach above works for us as a sol­id mid­dle ground that pro­vides enough struc­ture to ease some deci­sion mak­ing and main­te­nance whilst not being so heavy hand­ed as to become bur­den­some and a bar­ri­er to devel­op­ment. Any archi­tec­ture can work for you, just putting in a lit­tle thought and plan­ning up front can go a long way.

Addi­tion­al resources #

The App Archi­tec­ture book from objc​.io is a great intro­duc­tion to mul­ti­ple dif­fer­ent archi­tec­ture pat­terns. Check it out if you are inter­est­ed in learn­ing more.

Jeff Kloosterman
Jeff Kloosterman
Head of Client Services
Sarah Hendriksen
Sarah Hendriksen
Software Developer

Looking for more like this?

Sign up for our monthly newsletter to receive helpful articles, case studies, and stories from our team.

Cross tab navigation in Jetpack Compose
Android Development

Cross tab navigation in Jetpack Compose

October 4, 2022

Learn how to use Android's Jetpack Compose to navigate from a screen inside of one NavigationBarItem to another.

Read more
The benefits of open source technology for businesses
Business Development

The benefits of open source technology for businesses

March 29, 2024

Read more
5 takeaways from the Do iOS conference that will benefit our clients
iOS

5 takeaways from the Do iOS conference that will benefit our clients

November 30, 2023

Read more
View more articles