SwiftUI Craftsmanship: View Contracts
Defining the (Inter)face Value of a View By Asking What It's For
Flexibility is one of my favorite things about SwiftUI. Making a change to your UI can be as simple as changing a value, a quick copy/paste, an additional ViewModifier. If you want a Picker to change its style, simply type the one you want.
What I can’t stand is when that flexibility is lost. As someone who reviews many pull request, I often raise these questions when I come across a new view/component:
What if I want to use this somewhere else?
What if I want to change this part of the UI?
What if I’m not using this with any reactive data?
(Conversely) What if I’m using this with reactive data?
These questions usually stem from simply observing the first few lines of the View, itself. Namely, a View’s properties. They often dictate the flexibility of a View because they lock in the data that our UI ultimately serves. But if we narrow the scope of how we consume that data, we narrow the scope of flexibility of our View.
If we better design and think through the contract (or interface) of a View, we can unlock its hidden potential.
Note: This topic can get extremely complicated if we were to introduce ViewBuilder, generics, and options like that. I look forward to tackling some of in the future, but right now there’s plenty of chew on at (inter)face value (see what I did there?).
What is This View For?
The best way to start explaining View contracts is with the very first question anyone should be asking when creating a new view:
What is this view for?
It’s so simple and basic, yet profound. The problem is we don’t pack on more to that question than we should. What I mean is that we answer with what the view should look like, but we don’t go deeper and add the who, when, where, and how.
Use Case: Groceries App
Let’s say I’m making a grocery list app. Being excited by this gazillion dollar app idea, I immediately start building out the main feature: the grocery list. I make a List that takes in a collection of GroceryItems called selectedGroceries (that I’ve conveniently setup in a ShoppingCart Observable). The name, category, and price of each item is then displayed in a RowView, which takes in a GroceryItem.
struct GroceryItem {
let name: String
let category: String
let price: Double
}
struct RowItem: View {
let item: GroceryItem
var body: some View {
HStack {
VStack {
Text(item.name)
Text(item.category)
}
Spacer()
Text(String(item.price))
}
}
}
Now to create the next feature: the store stock list that lists whats available to add. I make a List that takes in a collection of StockItems called storeStock (that I’ve conveniently setup in a StoreStock Observable). I want a uniform look and feel when users scroll through lists, so i go to my perfectly designed RowView and… oh, it wants a GroceryItem when all I have is a StockItem….
struct StockItem {
let name: String
let category: String
let price: Double
let inStock: Bool
}
Note: I’m making a point with this, don’t get too hung up on this “architecture” beyond RowView.
The conflict here could be resolved by:
Making a copy/pasta clone of RowItem that take StockItem, instead. But if I want to maintain that “uniform look and feel”, then any items I make to one item will have to manually be made in the other as well.
Converting StockItem to GroceryItem… every… single… time.
Creating a protocol that requires name, category, and price to be provided and have both GroceryItem and StockItem conform to it, then edit RowItem to take in a generic that conforms to this new protocol.
Throwing my hands up, kicking my macbook across the room, and giving up on my gazillion dollar app idea.
Or, I could simply change the contract of RowItem to take in the specific data points it’s responsible for displaying:
struct RowItem: View {
let name: String
let category: String
let price: Double
var body: some View {
HStack {
VStack {
Text(name)
Text(category)
}
Spacer()
Text(String(price))
}
}
}
This is not only cleaner, but now RowItem can service any other type of object, as long as it has data it can pass into name, category, and price.
Answering the Question
So, for our Grocery List App, when we went to make our RowItem, how should we have answered the question “What is this View for?”.
If we answered “It’s for displaying GroceryItem”, then it turns out that answer was too short-sighted.
If, instead, we had answered “It’s for uniformly displaying the name, category, and price of items across the app”, then we could have prevented an issue before we even had one.
Soapbox Moment
Starting the Craftsmanship series with this topic and this question was intentional. When I announced this series, I compared crafting SwiftUI to woodworking. To continue with this comparison, a good carpenter knows the overall project they are building. But as they approach each and every piece of that project, they have to ask “what is this piece for?” and in relation to the entire project. Otherwise, you’ll end up with a crooked couch because the legs are all different or a lid to a chest that doesn’t match the dimensions of the body, leaving all the contents exposed.
Even something as simple as the row to a list deserves that time be given to the question: “What is this View for?”
In the grocery example, we were able to resolve the issue with a simple refactor. But refactoring is not always so simple and always costs the same: time. True, we won’t always be able to think super far ahead to all our apps needs (e.g. an agile approach would imagine and add features incrementally). But it doesn’t change the fact a View could be better planned up front should it ever need to be revisited in the future.
How and When to Use Typed Contracts
Now, you may be asking “Captain, does this mean all my Views need to have contracts built with basic data types?”. The answer is no, that’s definitely not the point. There is a time and a place for custom types to be used in a contract. The safest and usual starting place is at the entry point of a feature.
Back to the Use Case
Our Grocery App has the two lists: Grocery List and Stock List. We’ve already achieved a uniformed layout and design for items. Now its about getting those items into a List. As mentioned, we have a ShoppingCart object that holds the list of GroceryItems, and the StoreStock object that holds the list of StockItems.
If we went to make a list that took only basic data types, it would need to take a Collection of (name, category, price). But that’s silly, especially when we already have Collections, just of custom types. But beyond that, I want to be able to listen to any changes from either of the Observable types (e.g. an item is now out of stock). Lastly, both lists have entirely different actions attached to them: ShoppingCart allows users to add and subtract items to its list whereas StoreStock doesn’t, but allows items to be sent over to ShoppingCart to be added to its list.
Consider ShoppingCartView:
@Observable class ShoppingCart {
var items: [GroceryItem] = []
}
struct ShoppingCartView: View {
@State var shoppingCart = ShoppingCart()
var body: some View {
List(shoppingCart.items) { item in
RowItem(name: item.name,
category: item.category,
price: item.price)
.swipeActions {
Button(role: .destructive) { shoppingCart.items.remove(item) } label: {
Label("Delete", systemImage: "trash")
}
Button { shoppingCart.items.append(item) } label: {
Label("Flag", systemImage: "flag")
}
.tint(.green)
}
}
}
}
See what we just did there? We took the question “What is this View for?” and arrived at the answer for two new Views.
A Feature’s View Hierarchy and the Balance of Power
There were a few factors that went into deciding that we would need a ShoppingCartView and StoreStockView:
We had context-specific requirements to fulfill
We reached the top-level of a feature hierarchy
The latter is important. Think of a new SwiftUI project in Xcode, where we have our App → WindowGroup → ContentView. Let’s say we’re replacing the call to ContentView for ShoppingCartView. This is the entry point for our feature, the top-level of this features hierarchy.
Even if we instantiated ShoppingCart at the App level and passed it into ShoppingCartView as a parameter (don’t do that btw), ShoppingCartView is where we can break up ShoppingCart’s properties and set them to the relevant sub-views/sub-hierarchies.
If I had to name this layer, I would call it a FeatureView: the top-most view in a feature hierarchy where the necessary data for a feature become known and handled. When I’m asking “What is this View for?”, I’m usually answering “I’m entering a new feature with the necessary data I need to work with, and it is from the View that I’m building out the feature requirements”.
Started from the Bottom, Now We’re Here
So far we’ve learned the question we should be asking when crafting Views and we’ve applied them to both extremes of a View hierarchy (common subviews found at the bottom and FeatureViews found at the top).
This next part is tricky, because it’s any and every View that may exist in between.
There could be a subview that holds its own logic (lets say a subview holds a task to pass an actual GroceryItem object into some async function that requires it as a parameter). In asking what the View is for, this would help us determine that the type should be part of the contract.
Each View needs to have the question asked. But as you go deeper and deeper through the hierarchy, it should become easier and easier to determine that more descriptive and focused contracts should suffice.
But What About…
Ok, let me try and anticipate a few retorts here.
… Environment?
To be honest, while I get the value of Environment, I rarely like to use it. Placing items into the Environment may appear to make things simpler, but they do come with added responsibility. It becomes more difficult to track and maintain objects. Also, you risk assuming an object will be there, but won’t know its not until you crash.
It also opens the door to poor assumptions. If we’re passing a custom type through the environment and then using it in many of the subviews of a hierarchy, why not just pass it down? As we pass it down, do we really need the entire context of that type? Are we sure that these subviews are best served locked in to this data type… or are we making the same mistake we made with RowItem above and risk creating duplicative/redundant code?
You can use Environment, for sure (and perhaps you need to in some instances). But whether or not you allow an item that could be found there influence every subview in your hierarchy is up to you.
… Swift Data
The assumption with a Swift Data retort is that it’s encouraged that an object be passed into the environment. And that’s ok.
But, again, this doesn’t mean you HAVE to use it exclusively from the Environment throughout the hierarchy. You can very well access it from a FeatureView and break it up from there.
… This Is Truly a Silo’d Feature/Hierarchy/Codebase
If reuse is of absolutely no importance or even relevance because there is no adjacent code, no chance of this being used as a package or anything of the sort… then yeah, ok, go ahead and disregard this article.
BUT, just because you can doesn’t mean you should. And when trying to hone your craft, it’s best to practice it whenever you can, building up that discipline, and creating that muscle memory so that become faster, sharper, and more confident when you go to answer that sweet, sweet, beautiful question:
What is this View for?