SwiftUI Case Study: Data modelling of Brotherhood Alchemist

Dave Poirier
ITNEXT
Published in
13 min readApr 22, 2022

--

SwiftUI is relatively new and many of us in the iOS development community are still adapting to this declarative language. There are many simple examples of how to manage “State” in SwiftUI but few actual fully-working app examples.

In this article, I will be covering the lessons I learned while rewriting from scratch Brotherhood Alchemist using SwiftUI & Combine; how I learned not to model the data, and how it impacted performance in this relatively simple application.

Dark Brotherhood Hand Symbol (used as app icon)

The basic requirements

Brotherhood Alchemist is an iPhone/iPad application in which the user selects which ingredients and effects are desired and the application lists all available matching alchemy recipes. It’s a companion app to Skyrim (computer game).

In order for this to work, each ingredient has up to four effects; like regenerate health, damage stamina, etc. Version 1.2 of Brotherhood Alchemist comes bundled with 110 unique ingredients combining 4 of the 55 unique effects.

For each effect or ingredient, the user is able to specify if the recipes shown in the result screen “must have”, “may have” or “can’t have” the ingredients or the effect.

For example, a user may not have any “Nirnroot” in their current inventory so they may set that ingredient as “Can’t have”. They may have a surplus of Taproot or just looking for recipes that include Taproot so they set it to “must have”. Other ingredients which are allowed but not absolutely necessary are set as “may have”. Similarly a user may be looking for healing potions only, so they set “Regenerate Health” as “must have” and set “Damage Health” as “Can’t have”.

When all ingredients and effects are set to “May have”, the app generates over 25,000 recipes. A valid recipe is created by matching two or more ingredients which share common effects. Some combinations create valid potions with up to 5 effects.

The UI design

Brotherhood Alchemist on iPad in landscape

On iPad, the design is relatively simple. We split the screen into three columns: Left contains the ingredients, Right contains the effects, Center contains the matching recipes.

To get this design going, we have a ContentView defining a HStack with a custom SwiftUI view for each column.

Each of the views is composed of a VStack at its root that contains the headers and a ScrollView which itself contains a LazyVStack and a ForEach loop.

The challenge

We have to model the data and manage the state so that the ingredients can be toggled without affecting the list of effects (not redrawing the list of effects). Similarly we want to be able to toggle the effects without causing the Ingredients list to be redrawn. The Recipes list should be refreshed whenever ingredients or effects are toggled.

While SwiftUI has some built-in state comparison logic to avoid refreshing, we will review techniques that can be used to avoid unnecessary view refreshes and different ways that state can be managed and shared across the app.

Finally we will cover one of the ways that I identified to reduce the view updates to their bare minimum.

Sources for the examples

Part 1 — Getting started

To get started, we will create our Ingredient and Effect basic types. For now we can ignore the association between ingredients and effects.

If we follow some “basic” SwiftUI tutorials we may be tempted to start with @State variables in the ContentView. Let’s start with that and see where it leads us.

We will also limit ourselves to just the IngredientsList and the ContentView for now to see how they interact.

iPad preview of test app for Part 1 to 3

Basic data types

DataTypes.swift for Part 1

ContentView

ContentView.swift for Part 1

Ingredient detail view

Ingredients list

IngredientsList.swift for Part 1

Observed behaviour

When tapping on an ingredient, the Button defined in the IngredientsList gets triggered and the toggle(ingredient:) function updates the array of selected ingredients. This in turn causes the IngredientsList and one of the IngredientInfo views to be reprocessed.

Xcode console logs for Part 1

Ideally, we would want only the IngredientInfo to be reprocessed, but since the “selected” parameter is received from the IngredientsList that isn’t (yet) possible.

LESSON LEARNED: SwiftUI is smart enough to be able to know if a child view needs to be processed or not, by comparing the previously sent parameters against the updated parameters. Only if the parameters differs will the child view be reprocessed.

Part 2 — Binding “selected” up to IngredientInfo

One solution I wanted to explore was if it was possible to just “forward” the @Binding used in IngredientsList to the IngredientInfo and let the IngredientInfo perform the toggling and update itself.

We still need the ContentView to hold the @State variable since it needs to be able to share that state with the Recipes list later on. Let’s try it and see what happens.

Ingredient detail view

IngredientInfo.swift for Part 2

Notable changes:

  • Line 5: We defined our @Binding.
  • Line 9: Now has the Button from the IngredientsList
  • Line 13: We updated how we detect if the ingredient is selected
  • Line 19–25: Added the toggle(ingredient:) function

Ingredients list

IngredientsList.swift for Part 2

Notable changes:

  • The Button and the code to toggle the selection are no longer present
  • Line 16: We forward the @Binding to the child view. We could have used Binding(projectedValue: …) or $selected here, achieving the same behaviour. If anyone has details to the most desirable syntax and why feel free to comment!

Observed behaviour

When tapping on one of the ingredient, the Button, now defined in the IngredientInfo, gets triggered and toggle(ingredient:) updates the selected ingredients list.

The IngredientList is no longer processed on every update, instead every IngredientInfo view gets refreshed. With many more ingredients and effects yet to be added, this is undesirable. The console clearly shows three IngredientInfo being updated whenever we toggle any of them.

Xcode console logs for Part 2

Note only the ingredients currently on the screen would be updated, thanks to the LazyVStack, even once we have the 110 ingredients. But ideally we would want a single IngredientInfo view to be updated.

LESSON LEARNED: Forwarding a @Binding to every view will cause every of those views to be processed when the data of that binding changes, if that view actually extract any data from that binding.

BONUS LESSON LEARNED: Receiving a @Binding and simply forwarding it along will not cause a view to be processed when the data changes.

Part 3 — Using a closure in IngredientInfo

Since the @Binding caused every IngredientInfo view to be processed on any change to a selected ingredient, maybe we can provide a closure to the IngredientInfo and let it update its own state.

Ingredient detail view

IngredientInfo.swift for Part 3

Notable changes:

  • Line 5: Receive the action to perform when the button is pressed
  • Line 6: Define our @State variable to keep track of the selection status for the UI updates
  • Line 11: Update our @State with the result from the toggle action
  • Line 14: Use the @State variable for updating the UI

Ingredients list

IngredientsList.swift for Part 3

Notable changes:

  • Line 16: Provide to the IngredientInfo the action to perform when the button is pressed
  • Lines 23–31: Implement the code to toggle the selection
  • The selection status is no longer forwarded to the IngredientInfo

Observed behaviour

When tapping on an ingredient, the Button in the IngredientInfo executes the closure it received from the IngredientsList. This in turns update the selected ingredients then returns the updated selection status. With the updated status the code in the Button’s action closure is now able to update its state.

Looking at the logs, we may believe we have a working solution since only the IngredientInfo gets updated. So in part 4 below we will add more ingredients to solidify our solution.

Xcode console logs for Part 3

LESSON LEARNED: We can provide a closure to a child view instead of providing a @Binding and in some cases that may be sufficient to prevent all views from refreshing.

BONUS LESSON LEARNED: Even if a view receives a @Binding and modify its content value, if the view does not use a value in its view builder the view will not be reprocessed when the value changes.

Part 4 — More ingredients

Building from our successful experience in Part 3, let’s see if we can add more ingredients and maintain the expected behaviour.

iPad preview of test app for Part 4

Content view with more ingredients

ContentView.swift for Part 4

Notable changes:

  • Line 4: replaced the 3 ingredients with 100 dynamically generated ingredients

Observed behaviour

Surprisingly everything works! To be honest I was expecting the views to be deallocated when they were scrolled off-screen. However, once a view has been initiated in the LazyVStack, its state is maintained even if the view is no longer visible.

LESSON LEARNED: LazyVStack will only instantiate enough views to fill the screen, but will maintain the state of previously instantiated views even after they are no longer visible.

Part 5 — Filtering

Now that we solved our view refresh problem, let’s see if we can implement ingredients filtering so the user can quickly find the desired ingredient.

For this we will need to modify the IngredientsList view by adding a Textfield. The value entered in the textfield will be used to do a partial match on the ingredient names. To make our lives easier, we will return to our list of 3 ingredients we originally had in Part 1, 2 and 3.

Ingredients list

IngredientsList.swift for Part 5

Notable changes:

  • Line 6: A new @State variable containing the filter entered by the user
  • Lines 8–14: Computed property to easily retrieve the ingredients matching the filter
  • Line 20: Textfield added to capture the user’s filter
  • Line 23: Iteration over the filteredIngredients instead of the ingredients

Observed behaviour

Our list of ingredients works as expected. Each ingredient can maintain its state whether it is filtered out or visible.

The reason this works, is the state is maintained on a per-id basis. Since the id (the UUID) of our ingredients is unique and permanent for each ingredient, the LazyVStack/ForEach and SwiftUI are capable of keeping track of the state of each ingredient, redrawing the views as the filter gets updated.

LESSON LEARNED: Once a view is instantiated, the state of the view is saved on a per Id basis. If the view re-appears with the same Id at some point in the future the state of that view will be restored before the view builder is called.

Part 6 — Resetting

The Brotherhood Alchemist app offers a few reset options but for sake of our case study, let’s assume the Reset button will reset all effects back to their unselected state.

Since this is a button defined in the IngredientsList, let’s see if we can simply update the @Binding that we received, similar to how we are toggling the ingredients.

Ingredients list

IngredientsList.swift for Part 6

Notable changes:

  • Lines 19–27: Replace the simple Text containing only the “Ingredients” title with a HStack with the title and the reset button.
  • Lines 51–53: Function to reset the state of all ingredients to unselected

Observed behaviour

When the list comes up, if we toggle ingredients everything seems to work as expected. However when we press the Reset button this is when we notice that the ingredients are not visually reset.

This is because each IngredientInfo maintain its own @State and therefore even if the @Binding is modified, the visual indicator on the IngredientInfo view will not be reset.

We can confirm that the @Binding was properly modified by first toggling on an ingredient, hitting the reset button and then attempting to toggle the ingredient off. Since the ingredient was already deselected by tapping the reset when we tap the ingredient again to toggle it, within the @Binding it will go from unselected to selected and the IngredientInfo will again display it as selected.

Clearly confusing for the user and the app is no longer showing our desired state.

LESSON LEARNED: When a view maintain its own @State it becomes disconnected from other state changes happening in the app.

We are effectively attempting to maintain the same state from two different places at once. We could use a NotificationCenter notification or an @Environment variable to notify each views that the main state of the app has changed. However attempting to synchronize state in multiple places should be avoided if possible.

We already know that using a @Binding to each of the IngredientInfo will cause every IngredientInfo view to be processed when any ingredient is toggled (See Part 2).

We also know that if we manually pass the selected state to the IngredientInfo both the IngredientList and the affected IngredientInfo views will be reprocessed.

Maybe we can adjust our data model to keep track of the selected state.

Part 7 — ObservableObject

SwiftUI allows us to observe an object for changes. In theory this should allow an object instance to be forwarded around to all the views, then each view observe the state of the object. Depending on how we organize our data model this may be useful, but it will likely not work for our list of selected ingredients.

Still, let’s see what happens when we try to wrap the list of ingredients as an ObservableObject

Data types

DataTypes.swift for Part 7

Notable changes:

  • Lines 13–15: Added the Selected class declared as ObservableObject
  • Line 14: @Published variable to ensure the object will properly propagate the .objectWillChange() notification when the ingredients are updated

Content view

ContentView.swift for Part 7

Notable changes:

Line 10: we replaced the @State var with a “let” for the new Selected class.

Ingredient detail

IngredientInfo.swift for Part 7

Notable changes:

  • Line 5: Defined the @ObservedObject with the new Selected class
  • Lines 7–9: Quality of life computed properly to easily check if the ingredient is currently selected
  • Line 17: Using the computed property to update the UI
  • Lines 24–28: Updated the toggle function to update the Selected class object

Ingredients list

IngredientsList.swift for Part 7

Notable changes:

  • Line 5: We no longer receive a @Binding for the selected ingredients; replace by a simple “let” reference to our Selected class
  • Line 35: We pass the Selected object reference to the IngredientInfo view

Observed behaviour

The reset functionality finally works as intended. The state of the ingredients can both be toggled by the IngredientInfo and reset by the IngredientsList. However since the Selected class publish a .objectWillChange() notification anytime the selected ingredients changes, and every IngredientInfo view use @ObservedObject, we have the refresh problem where every IngredientInfo view gets updated whenever any ingredient is toggled.

LESSON LEARNED: Using @ObservedObject may cause unnecessary view refreshes if our view is only interested in a particular element of the observed object.

Part 8 — @Published without ObservableObject

SwiftUI views can define onReceive observers for @Published properties which allows the view to define some custom code that may or not update the @State of the view.

Data types

Notable changes:

  • Line 13: Our class no longer requires to conform to ObservableObject

Ingredient detail

Notable changes:

  • Line 5: Removed the @ObservedObject modifier
  • Line 7: Replaced the computed property with a @State variable for UI updates
  • Lines 19–24: Added onReceive observer to detect when a change to the ingredients selected should generate a @State change.

Observed behaviour

Everything works like a charm. The IngredientInfo views are able to toggle the ingredient selection; The IngredientList is able to properly reset the selections; and only the affected views are updated.

LESSON LEARNED: Excessive refreshes from ObservedObject can be avoided by defining custom observers and implementing your own logic to decide when and how the change will affect the @State of your view

BONUS LESSON LEARNED: You can monitor a @Published property without requiring the parent class to conform to ObservableObject

Part 9 — Stateful Ingredients

Since we can now use @Published to expose only a property of a class, let’s see if we can model our data to expose a .selected property to our ingredient.

Data types

DataTypes.swift for Part 9

Notable changes:

  • Line 3: Changed struct to a class
  • Line 5: Added @Published variable to hold the selected status
  • Lines 8–10: Defined the initializer to set the name property

Content view

ContentView.swift for Part 9

Notable changes:

  • Removed the “let selected” since state is now stored as part of the Ingredient object
  • No longer forwarding ‘selected’ to the IngredientsList

Ingredient detail

IngredientInfo.swift for Part 9

Notable changes:

  • Removed the isSelected @State variable
  • Line 5: Added @State variable to indicate to SwiftUI when it is time to update the view
  • Line 9: Access the @State variable from the view builder, otherwise it would be ignored by SwiftUI and the view would never update
  • Line 14: The UI now updates based on the ingredient property directly
  • Line 18: .onReceive updated to observe the @Published property on the ingredient and simply changes the value of the @State variable whenever the state changes
  • Line 24: Simplified the toggling logic to simply flipping the boolean

Ingredients list

IngredientsList.swift for Part 9

Notable changes:

  • No longer need the ‘let selected’ as the Ingredient object hold the state
  • No longer need to forward the selected ingredients to IngredientInfo
  • Line 41: Updated the reset function to reset the state on the Ingredient objects

Observed behaviour

Once again everything is working as expected. The views only refresh when they need to. Ingredient state is easily accessible anywhere we have a reference to an ingredient, and can easily be toggled or reset from anywhere.

LESSON LEARNED: Carefully planning how your interface will need to interact with your data will allow you to update your data model to be more easily accessible and observable.

BONUS LESSON LEARNED: SwiftUI is often better used with class rather than struct since it allows observing @Published variable individually if needed. Whereas @Binding a struct or may trigger unwanted view refreshes.

Conclusions

While we didn’t get to re-implement the entire logic of the Brotherhood Alchemist app in this article, we did learn quite a few valuable lessons that will hopefully help you create data models more adapted to SwiftUI.

Careful use of ObservableObject is a must, and @Binding should be avoided as it may often be a sign of poor data modelling.

Using class instead of structs may, in many cases, allow you to reduce the number of view refreshes by defining @Published properties.

Finally, if a view fails to refresh because you are no longer using @State variables it’s relatively easy to introduce some kind of @State variable that you can use as an update trigger for SwiftUI.

I hope you enjoyed this article, and may your coding days be happy!

Curious to try Brotherhood Alchemist yourself? Check it out on the Apple App Store at https://apps.apple.com/app/brotherhood-alchemist/id1292251831

--

--

Senior iOS Developer | Mobile Security And Reliability Specialist