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:

I used a SwiftUI sprite view for the canvas.

The document struct has a property that holds a SpriteKit scene.

GameScene is a subclass of SKScene, SpriteKit’s scene class.

The scene graph looks like the following:

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 to ObservableObject.
  • Add a @Published variable to GameScene that holds an array of the scene‚Äôs items, have GameScene conform to ObservableObject, 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 GameSceneclass.
  • 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 to ObservableObject.
  • 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.

I had to add the following code to the SceneGraph struct:

The final thing I had to do was change the code of the ForEach block for the list to the following: