Create an app "homepage" in SwiftUI

Create an app "homepage" in SwiftUI

During my time with ustwo, as part of the team which built the The Body Coach app, I worked on a really interesting feature called the Today screen. This is a screen in which we (the app creators) aim to engage with the user and help them get stuff done along their journey with The Body Coach.

The app has many aspects to it, a plan with meal recipes and workouts, plus a planning tool to help stick to the plan. There is a ceremony aspect here too, after 28 days on the plan you're encouraged to check-in, and move to a higher level. There are also Live workouts which could be live right now, or just scheduled up next. Plus other dynamic content such as meal recipe inspiration, maybe even inspirational content direct from Joe himself.

For the experience itself, we also wanted to make sure that we helped newcomers to the app, by giving them a nice welcome explaining how the plan works. Encourage new users to submit their starting stats, and then plan their workouts and meals.

Given all these lofty goals, the Today screen aimed to bring all of these elements together into a beautiful, engaging screen with dynamic content which helped you succeed at crushing your goals (as Joe would say).


Data Modelling

Before I started work on this screen, the designers put in a huge amount of effort to figure out how the screen would look and adapt to the user. They decided it would be:

  • a vertical scrolling screen made up of different components/widgets/modules. We eventually went with the term homepage modules, but in hindsight I would have preferred today components. Naming stuff is hard.
  • modules should fit together using the S-style top and bottom border used elsewhere in the app.
  • the item at the top would always look special - i.e. be a Hero version of the module.
  • The order of the components was specificly chosen. Items at the top are considered more important than those below.
  • Some modules should only show depending on the user. For example, the Welcome module should not appear after it's been opened the first time.

Given all of this, I figured out that we have a number of inputs to the screen, plus we need to fetch any content such as recipe inspiration, and user state.

  • Has the user completed their starting stats?
  • Is it the user's first plan cycle?
  • Is the user's plan check-in window open?
  • Has the user already seen the plan welcome module?
  • Are there live workouts now, or later today, or upcoming?
  • Has the user set-up their workout & meals plan for today?

We can represent all of these various "flags" as an OptionSet , something like this:

struct HomepageRawState: OptionSet, Equatable {
    let rawValue: Int

    static let planIntroduction = .init(rawValue: 1 << 0)
    static let addStartingStats = .init(rawValue: 1 << 1)
    static let livesNow = .init(rawValue: 1 << 2)
    static let livesToday = .init(rawValue: 1 << 3)
    static let livesUpcoming = .init(rawValue: 1 << 4)
    static let plannerComplete = .init(rawValue: 1 << 5)
    static let generalRecipeInspiration = .init(rawValue: 1 << 6)
    static let refuelRecipeInspiration = .init(rawValue: 1 << 7)
}

An OptionSet here is very useful, because it allows us condense all of the inputs required for the business logic into a single value from which we can derive everything we need to configure the screen.

But, it's not the best choice to model each specific module, instead we can use an enum. This is better because a list of specific values translates to what we see on the screen far better than a single value representing the whole screen.

enum HomepageModule: Hashable {
    
  enum Plan: Hashable {
    case introduction, details
  }

  enum Planner: Hashable {
    case empty, noMeals, fullyPlanned
  }

  enum Lives: Hashable {
    case now, today, upcoming
  }

  enum RecipeInspiration: Hashable {
    case general, refuel
  }

  enum Inspiration: Hashable {
    case recipe(RecipeInspiration)
  }

  case addStartingStats
  case lives(Lives)
  case plan(Plan)
  case planner(Planner)
  case inspiration(Inspiration)
}

I'm not going to share the whole logic of this, but essentially we can now write a computed property to evaluate the homepage's raw state and give us a list of modules. This is a pure function, so it's easy to write exhaustive unit tests to check that we've implemented the business logic correctly.

extenstion HomepageRawState {

  var modules: [HomepageModule] {
  
    // Create an empty array for the modules
    var modules: [HomepageModule] = []
    
    if firstUsage {
      modules.append(.plan(.introduction))
      modules.append(.addStartingStats)
      appendPlannerModule()
      appendLivesModule()
      appendRecipeInspiration()
    }
 
    // etc
  }
}

Combine all the inputs together

The Body Coach app was written in SwiftUI for iOS 13, and much of the architecture dates from early 2020. No doubt, if targetting iOS 15 we might well have some different abstractions and architecture for our Views. Regardless, our SwiftUI Views used @EnvironmentObject to access Services which each exposed a Store, which in-turn provided a @Published property. Therefore, given a view for the Today view, we can import the various services which provide the inputs discussed above:

struct HomepageView {
  @EnvironmentObject private var userService: UserService
  @EnvironmentObject private var cycleService: CycleService
  @EnvironmentObject private var livesService: LiveWorkoutService
  @EnvironmentObject private var plannerService: PlannerService
  @EnvironmentObject private var recipeInspiration: RecipeInspirationStore

}

We will also need access to some other inputs such as the calendar, and user defaults.

struct HomepageView {
  // etc
  @Environment(\.calendar) private var calendar
  @Environment(\.referenceDate) private var referenceDate // included so we refresh on calendar day change
  @Environment(\.snapshotReferenceDate) private var snapshotReferenceDate
  @Environment(\.homepageUserDefaults) private var userDefaults
  
  private let controller: HomepageController
}

All of these various inputs can now be combined together into a single publisher of a single HomepageRawState. The details of this is a bit beyond the scope of this post, but essentially, given an array of publishers, [AnyPublisher<HomepageRawState, Never>] we can reduce the array into a single publisher using CombineLatest and form a union of each pair of outputs - which if you remember is an OptionSet. e.g.

extension Collection where Element: OptionSet {
  var reduced: Element {
    reduce([]) { $0.union($1) }
  }
}

typealias HomepageRawValuePublisher = AnyPublisher<HomepageRawValue, Never>

extension Collection where Element == HomepageRawValuePublisher {
  var reduced: HomepageRawValuePublisher {
    guard let first = publishers.first else {
      return Just([]).eraseToAnyPublisher()
    }

    return publishers.reduce(first) { p1, p2 in
      Publishers.CombineLatest(p1, p2)
        .map { [$0, $1].reduced }
        .eraseToAnyPublisher()
    }
  }
}

We can even wrap this up into a result builder, so that from the call site, it's much easier to see what is happening.

@resultBuilder
struct HomepageRawStatePublishersBuilder {
  static func buildBlock(_ publishers: HomepageRawStatePublisher...) -> HomepageRawStatePublisher {
    publishers.reduced
  }
}

This technique allows us to convert our group of various inputs into a single publisher of [HomepageModule].

struct HomepageView {

  // etc
  
  private var modulesPublisher: AnyPublisher<[HomepageModule], Never> {
    controller.evaluate {
    
      Publishers.CombineLatest(
        userService.store.$value,
        cycleService.store.$value
      )
      .addStartingStatsHomepageModule(
        usingCalendar: calendar, 
        date: now
      )
      
      cycleService.store.$value
        .planHomepageModule(
          usingUserDefaults: userDefaults, 
          calendar: calendar, 
          date: now
        )
        
      livesService.store.$value
        .liveWorkouts(
          relativeTo: now
        )
        .livesHomepageModule(
          usingCalendar: calendar
        )
        
      plannerService.store.$values
        .events
        .plannerHomepageModule(
          usingCalendar: calendar, 
          now: now
        )
        
      recipeInspiration.$value
        .recipeInspirationHomepageModule()
    }
    .eraseToAnyPublisher()
  }

Which means we're finally ready to perform updates when we receive new outputs from this publisher...

The Content View

Up until now, we've been focused on evaluating the various inputs along with the business logic provided by the designers to generate a list of modules. But these are nothing more than cases of an enum. Eventually we need to turn this into a list of SwiftUI views, which we can assume will then go into a ScrollView. So lets say that we'll want something along these lines, a VStack inside a ScrollView...

struct HomepageView {

  // etc
  
  @State private var modules: [HomepageModule]
  
  func update(modules: [HomepageModule]) {
    withAnimation { self.modules = modules }
  }
  
  var body: some View {
    ScrollView {
      VStack(spacing: 0) {
        headerView
        contentView
          .onReceive(modulesPublisher, perform: update(modules:))
      }
    }   
  }
  
  private var contentView: some View {
    ContentView(modules) {
      // ??? but how can this work..
    }
  }
}

Let's further assume that our ContentView receives a @ViewBuilder of some kind. This is desired because we can add the various views for each homepage module declaratively. Additionally it will allow us to develop the Today screen modules incrementally. However, there is a problem, because it will require a mechanism for iterating through the modules and then show the appropriate view for that module. In other words, the order of the modules is dynamic, and some might not even be included. If we were to just compose a VStack we would always see all of the views, in the order we added them, whether they were required or not.

What we require is some kind of tagging system, so that we can tag any SwiftUI view as being a specific homepage module. This likely needs another result builder too. First, let's figure out how we want the call-site to look in our view.

struct HomepageView {

  // etc
  
  private var contentView: some View {
    ContentView(modules) {
      
      WelcomeToPlanModuleView()
        .homepage(.plan(.introduction))

      RecipeInspirationModuleView()
        .homepage(.inspiration(.recipe(.general)), .inspiration(.recipe(.refuel)))

      // add the rest of our module views here
      // in no particular order
    }
  }
}

This shows a view modifier which allows us to associate a view with a (variadic) list of HomepageModule values into a HomepageItem - note that this isn't a view, but is a box type which composes the view.  

struct HomepageItem {
  let view: AnyHomepageModuleView
  let modules: [HomepageModule]
}

extension View {

  func homepage(_ modules: HomepageModule...) -> HomepageItem {
    HomepageItem(
      view: AnyHomepageModuleView(self),
      modules: modules
    )
  }
}

In turn, the initialiser of ContentView needs to accept a result builder which receives a list of these items.

protocol HomepageItemBuildable {
  var items: [HomepageItem] { get }
}

extension HomepageView {

  struct ContentView {

    let modules: [HomepageModule]
    let items: [HomepageModule: AnyHomepageModuleView]

    init(_ modules: [HomepageModule], @HomepageBuilder content: () -> HomepageItemBuildable) {
      self.modules = modules
      self.items = content().items.reduce(into: [:]) { (acc, item) in
        for module in item.modules {
          acc[module] = item.view
        }
      }
    }
  }
}

This is a bit subtle, and there may well be a better way to achieve this now. But let's step through what is happening here. We pass in the modules (as discussed above) as the first argument, along with a @HomepageBuilder which is a result builder that vends a type which composes an array of HomepageItems. We can get to the builder next, but for now, in the view's initialiser, we can evaluate the result builder, access its array of items, which we reduce into a dictionary of SwiftUI views keyed by a HomepageModule.  

Later on in this view's body property, we can iterate through our modules, and then access any view associate with it. This is the key part of how we can process the declarative builder of views for all kinds of modules, to show in the order that we want.

💡
We could have achieved the same thing by iterating through our list of modules inside the VStack, and then writing a big switch statement. However, there are some other details which can be hidden using this approach. Our goal here is to make the HomepageView super readable - we don't want to clutter this list, but instead leave each Homepage module's view prominent with a minimal amount of configuration. Under the hood, these views are actually composed inside a wrapper view AnyHomepageModuleView which injects environment values, and applies the appropriate styling. That is all code which doesn't need much changing, in contrast to the on-going maintenance task of updating the existing modules or adding new ones.

The @HomepageBuilder itself is relatively straightforward, we just want to incrementally gather a list of items into a single value composing the list.

extension HomepageItem: HomepageItemBuildable {
  var items: [HomepageItem] { [self] }
}

struct HomepageItems: HomepageItemBuildable {
  let items: [HomepageItem]
}

@resultBuilder
struct HomepageBuilder {
  static func buildBlock(_ items: HomepageItemBuildable...) -> HomepageItemBuildable {
    HomepageItems(items: items.flatMap { $0.items })
  }

  static func buildIf(_ item: HomepageItemBuildable?) -> HomepageItemBuildable {
    item ?? HomepageItems(items: [])
  }
}

Finally, the body of the ContentView is just a matter of iterating through our list of modules, and checking for a view.

extension HomepageView.ContentView: View {
  var body: some View {
    VStack(spacing: 0) {
      ForEach(modules, id: \.self) { module in
        if let view = items[module] {
          view
            .homepageModule(isHero: module == modules.first)
            .homepageModuleStyle(module.style(isHero: module == modules.first))
        }
      }

      if modules.count > 1 {
        footerView
      }
    }
  }

  private var footerView: some View {
    // etc
  }
}

These additional view modifiers set environment values which the module views can use to configure styling, which will be module specific.

A Homepage Module View

Up until now, we've figured out how to create a dynamic list of views based on the user's overall state. But what does each homepage module need to do in order to fit into the overall screen design? Clearly this is going to be specific to your own app's design. However, I will share the approach I used to extract the majority of the shared design elements into reusable view components. In particular, with the designers we identified that were three basic module designs, and so I named them: Hero, Guardian & Champion. Each of these got their own "container" view which would help us compose unique content into a suitable homepage module. This allows all the modules to share design logic, dimensions etc.

If we look at the most basic module, Add Starting Stats as an example, it looks a bit like this:

There are a number of design aspects to consider here...

  • The background has a top and bottom colour.
  • There is a shape which tiles horizontally denoting top and bottom
  • The S-curve respects the top and bottom colour
  • The card at the top (the hero card) also informs the style of the header where it shows the date.
  • This module has an image + a "card" which is the rounded box. Cards themselves also have a style which is derived from the module style.
  • Everything works in portrait & landscape, on iPhone & iPad, in light & dark mode.

The takeaway here, is that we can define a common HomepageModuleStyle type e.g.

enum HomepageModuleBackgroundStyle: Equatable {
  case wave(Color)
  case curve(Color, CGFloat)
  case hite(Color)
  case color(Color)
}

struct HomepageHeroImageStyle: Equatable {
  let backgroundColor: Color
}

struct HomepageModuleStyle: Equatable {
  let foregroundColor: Color
  let backgroundColor: Color
  var backgroundStyle: HomepageModuleBackgroundStyle?
  var cardBackgroundStyle: CardBackgroundStyle?
  var heroImageStyle: HomepageHeroImageStyle?
  var heroTopPadding: CGFloat?
}

And then each module can provide it's own value for this style. For example, for the add starting stats module, we have:

struct AddInitialStatsModuleView {
  static let style = HomepageModuleStyle(
    foregroundColor: .cobalt_500_white_0,
    backgroundColor: .blush_100_600,
    backgroundStyle: .wave(.blush_200_700),
    cardBackgroundStyle: .init(
      foregroundColor: .blush_100_600,
      backgroundColor: .blush_50_550
    )
  )
}

And this style definition drives the appearance of this module, via a reuable container view. Notice that the background style is a "blush" wave.

What's also quite nice about this approach, is that the style is statically derived by a computed property on the HomepageModule enum type.

extension HomepageModule {

  func style(isHero: Bool) -> HomepageModuleStyle {
    switch self {
    case .addStartingStats:
      return AddInitialStatsModuleView.style
    case .plan(.introduction):
      return WelcomeToPlanModuleView.style
      
      // etc  
    }
  }
}

The root view HomepageView also owns the @State property of the list of modules, so it is able to access the style for the first item, which can be applied to the header view.

For the view itself, the Add Starting Stats module is the most simplistic, with just an image and a Card View, but it helps to show the structure of all modules.

extension AddInitialStatsModuleView: View {

    var body: some View {
        HomepageGuardianModuleView {
            Image(decorative: "joe-check-in-stats")
                .frame(width: 272, height: 272)
        } content: {
            AddInitialStatsCardView()
        }
    }
}

And the card view itself, is little more than a VStack with the appropriate background applied.

Heros, Guardians & Champions

By making SwiftUI container views, by which I mean a view which is initialized with one or more other generic SwiftUI views, we can handle layout complexity in a single place. To do this, we just need to use some @ViewBuilders.

struct HomepageGuardianModuleView<Guardian: View, Content: View> {
  
  @Environment(\.homepageModuleStyle) private var style
  
  private var guardian: () -> Guardian
  private var content: () -> Content

  init(
    @ViewBuilder guardian: @escaping () -> Guardian, 
    @ViewBuilder content: @escaping () -> Content
  ) {
    self.guardian = guardian
    self.content = content
  }

  init(_ imageName: String, @ViewBuilder content: @escaping () -> Content) where Guardian == Image {
    self.init(guardian: { Image(imageName) }, content: content)
  }
}

Now, in the body of this view, we can construct our guardian() and content() views with the appropriate backgrounds, spacings, anchors  and paddings as defined by the style.

This same process was done for three different module designs, and it enabled me to rapidly create new content modules assured that they would have the same behaviour.


I will end it here, because (in addition to being a long post!) most of the remaining details, such as how the card views work, the backgrounds, the animating sun etc are all specific to The Body Coach app's content and design.