Scene Editor Development: Updating a SwiftUI List When Adding an Item
This article talks about the first big problem I encountered when starting to develop a SpriteKit scene editor in SwiftUI. It’s a problem every SwiftUI developer has seen: a view doesn’t update when the data changes.
Initial Code
My initial idea for the scene editor interface was to have a split view with three columns.
- A scene graph to show the items in the scene
- A canvas to place items in the scene
- An inspector to view and edit details for an item
I started with the following SwiftUI view:
struct ContentView: View {
@Binding var document: Level
var body: some View {
NavigationView {
SceneGraph(document: $document)
SpriteView(scene: document.scene)
Inspector()
}
}
}
I used a SwiftUI sprite view for the canvas.
The document struct has a property that holds a SpriteKit scene.
struct Level: FileDocument {
var scene: GameScene
}
GameScene
is a subclass of SKScene
, SpriteKit’s scene class.
The scene graph looks like the following:
struct SceneGraph: View {
@Binding var document: Level
var body: some View {
List {
ForEach(document.scene.children, id: \.self) { item in
Text(item.name ?? "Node")
}
}
}
}
My Initial Goal
My initial goal was to add a sprite to the scene by clicking or tapping on the canvas. The sprite’s location is the location of the click or tap. Adding the sprite also adds an item to the scene graph.
Problem: The SwiftUI List Doesn’t Update when Adding an Item
I ran the app and tried adding sprites to the canvas. The sprites appeared on the canvas, but the scene graph was empty.
I stepped through the code in the debugger and saw that the new sprites were in the scene’s array of children. When I saved the document the sprites appeared in the scene graph.
Adding sprites works, but the scene graph doesn’t update. This is a common problem SwiftUI developers run into. The user interface doesn’t update when the data changes.
Fix Attempts that Didn’t Work
There are two common approaches to get the user interface to update when the data changes. First, if your data model uses structs, use @State
and @Binding
.
Second, if your data model uses classes, make the class conform to ObservableObject
. Add the @Published
property wrapper to class members that you want to trigger a UI update when the member’s value changes.
I tried the following things to get the scene graph to update when adding a sprite to the scene:
- Make
GameScene
conform toObservableObject
. - Add a
@Published
variable toGameScene
that holds an array of the scene’s items, haveGameScene
conform toObservableObject
, and have the scene graph show the array of scene items. - Make
Level
a class with a@Published
property for the scene and use@StateObject
instead of@Binding
in the scene graph. - Add a
@Published
variable that tracks whether the scene was edited and set it to true when placing the sprite. - Add a refresh ID to the
GameScene
class and change it when placing the sprite. - Add a refresh ID to the
Level
struct and change it when clicking on the canvas. - Add a property for the level to the
GameScene
class. - Give the scene graph a binding to a game scene instead of a level.
- Give the scene graph a
@StateObject
property for the scene.
None of these attempts updated the scene graph after adding a sprite to the canvas.
The Solution
I struggled to fix this problem for weeks. Finally I asked a question on Reddit about the problem and learned what the problem was. The problem has two parts.
First, SwiftUI’s data flow system does not automatically track changes in SpriteKit scenes. I have to manually trigger changes when the scene changes.
Second, The binding for the level in the scene graph won’t trigger an update unless I make the level show a new scene. The app is a scene editor so I am never going to change the scene in a level. Because I don’t change scenes in a level, any changes I make to the level will not trigger a UI update.
The solution to the problem has the following parts:
- Make
GameScene
conform toObservableObject
. - Send an
objectWillChange
message after placing the sprite. - Add a private property for the scene to the scene graph with the
@ObservedObject
property wrapper. - Add a private property for the level.
- Use the private property for the scene in the
ForEach
block.
Sending the objectWillChange
message required adding one line of code.
self.objectWillChange.send()
I had to add the following code to the SceneGraph
struct:
private var document: Level
@ObservedObject private var gameScene: GameScene
init(document: Level) {
self.document = document
self.gameScene = document.scene
}
The final thing I had to do was change the code of the ForEach
block for the list to the following:
ForEach(gameScene.children, id: \.self) { item in
Text(item.name ?? "Node")
}