SwiftUI Case Study: Data modelling of Brotherhood Alchemist
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.
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
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.
Basic data types
ContentView
Ingredient detail view
Ingredients list
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.
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
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
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.
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
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
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.
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.
Content view with more ingredients
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
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
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
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
Notable changes:
Line 10: we replaced the @State var with a “let” for the new Selected class.
Ingredient detail
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
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
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
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
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
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