SwiftUI Pro Tips 4–6

Ryan Jennings
Ancestry Product & Technology
4 min readApr 26, 2022

This article is part of a series on SwiftUI pro tips. To read the first article, click here.

Pro tip #4: Iterate Binding Array

(Some backstory on this tip. At Ancestry we hold hackathons a couple times a year where developers form teams and work on fun projects. We’ve also recently launched a tool to allow Ancestry users to build stories in the mobile apps. For a recent hackathon I developed a way for users to add stickers to the “slides” that make up their stories.)

As of Swift 5.5, you can now iterate through an array of bindings and extract each individual binding to use as you will. I did this when creating the UGC Stickers demo. Here’s how it’s done:

@Binding var stickers: [UGCStickerData]ForEach($stickers, id: \.id) { $data in
UGCSticker($data)
.frame(width: 100, height: 100)
}

I took this approach because each slide was responsible for maintaining the array of stickers that were added to the slide. But the logic for moving, scaling and rotating each sticker was part of theUGCSticker struct. So I would instantiate UGCStickerData on the slide, add it to the stickers array, and then pass the sticker data to the sticker struct for it to manipulate however it wanted. Since it was a shared binding, whenever the sticker was modified (moved, rotated, scaled), the data was both updated within the sticker itself (to maintain state) and modified in the array stored on the slide. This made it easy to save the sticker data along with the other slide data when necessary. Previous to Swift 5.5, you had to use custom bindings like this:

ForEach(stickers.indices, id:\.self ) { index in
UGCSticker(Binding(
get: { return stickers[index] },
set: { (newValue) in return self.stickers[index] = newValue}
))
}

Much easier now.

Pro Tip #5: EnvironmentObject

As you develop larger, more complex, interfaces in SwiftUI, there will be common properties that you will want different structs/views to reference. You could pass them from one struct to another through each struct’s initializer, but there’s a much easier way to make common variables accessible. First, define a class as an ObservableObject:

class StoryBuilderState: ObservableObject {   
@Published var currentSlideIndex = 0
@Published var isEditing = false
@Published var hasEdited = false
}

Then, in your main struct (the view that is shown first, also sometimes called the ancestor view), define a variable that will hold the ObservableObject:

@ObservedObject var state: StoryBuilderState

And add that variable as an environment object to the body of the main view struct:

public var body: some View {
ZStack {
SomeInnerView()
}
.environmentObject(state)
}

Now, without explicitly passing the variables, you can reference any of the observable object’s variables from within other structs by declaring an environment object within that struct:

struct SomeInnerView: View {
@EnvironmentObject var state: StoryBuilderState
...

The @Published variables from the ObservableObject will act as bindings, and so SomeInnerView will instantly know when isEditing is updated, for example. Used correctly, environment objects will declutter your code from the unnecessary back and forth passing of variables.

For more on environment objects: https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-environmentobject-to-share-data-between-views

Pro Tip #6: contentShape()

You can make anything tappable by adding an .onTapGesture to the element. For example:

Text("Some text")
.onTapGesture {
print("Do something")
}

When you add a tap gesture to text, the gesture is applied to the entire text, meaning you can tap on any of the words that comprise the text and the action associated with the gesture will be fired. But let’s imagine you’ve created a List. A list acts much like a UIKit UITableView and displays one row after another vertically. Let’s say each of these rows had some text and you wanted to allow the user to select a row by tapping anywhere on the row.

List {
ForEach(0..<10) { i in
Text("This is row \(i)")
.onTapGesture {
print("Do something")
}
}
}

Now the text essentially makes up the entire row (programmatically). If you were to apply the tap gesture to the Text (like above), you may be surprised to find out that the tap gesture only works if you tap directly on the text. Tapping anywhere else on the row, an area that does not display text, does nothing. Why is this? Because the gesture is only added to the parts of the container that display something. The empty parts of the row, that are not filled with words, will remain action-less. How can we change the tappable area? One way would be to wrap the Text in an HStack and then apply the tap gesture to the HStack:

HStack {
Text(type)
Spacer() // fill out empty space to make row full width
}
.onTapGesture {
print("Do something")
}

But this still doesn’t work because the tap gesture is still only applied to the parts of the container that display something. A spacer displays nothing, it just fills space. To change the “shape” of the tappable area, you need to use contentShape. By adding a content shape to the HStack, the tappable area will not just comprise what’s displayed by the HStack, but will instead encompass the entire bounds of the HStack. And yes, you still need the spacer because without it the HStack would only be as large as the text it displays, not the full width of the row. Here’s the full code:

List {
ForEach(0..<10) { i in
HStack {
Text("This is row \(i)")
Spacer() // fill out empty space to make row full width
}
.contentShape(Rectangle())
.onTapGesture {
print("Do something")
}
}
}

For more information on contentShape: https://www.hackingwithswift.com/quick-start/swiftui/how-to-control-the-tappable-area-of-a-view-using-contentshape

If you’re interested in joining Ancestry, we’re hiring! Feel free to check out our careers page for more info.

--

--