2

Problem

It seems that SpriteView doesn't pause the SKScene with a state property passed to the SpriteView(scene:isPaused:) initializer.

Sample Project

I created a sample Xcode 13 project on GitHub running on an iOS 15 simulator with a ContentView with a @State var paused property that is sent to a child SpriteView(scene: scene, isPaused: paused). The state property is changed by a "Paused:" button. The property changes and the text on the button is updated, but the scene is never paused by the SpriteView.

It looks like the SpriteView is not picking up the updates and is not pausing the underlying SKView and SKScene inside it.

SpriteView-not-pausing

All the relevant code is in ContentView.swift:

import SwiftUI
import SpriteKit

class GameScene: SKScene, ObservableObject {
    @Published var updates = 0
    private let label = SKLabelNode(text: "Updates in SKScene:\n0")
    
    override func didMove(to view: SKView) {
        addChild(label)
        label.numberOfLines = 2
    }
    
    override func update(_ currentTime: TimeInterval) {
        updates += 1
        label.text = "Updates in SKScene:\n\(updates)"
    }
}

struct ContentView: View {
    @State private var paused = false
    
    @StateObject private var scene: GameScene = {
        let scene = GameScene()
        scene.size = CGSize(width: 300, height: 400)
        scene.anchorPoint = CGPoint(x: 0.5, y: 0.5)
        scene.scaleMode = .fill
        return scene
    }()
    
    var body: some View {
        if #available(iOS 15.0, *) {
            print(Self._printChanges())
        }
        return ZStack {
            SpriteView(scene: scene, isPaused: paused).ignoresSafeArea()
            VStack {
                Text("Updates from SKScene: \(scene.updates)").padding().foregroundColor(.white)
                Button("Paused: \(paused)" as String) {
                    paused.toggle()
                }.padding()
                Spacer()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Restart Problem

I also created a second GitHub project that is similar to the first one but it also has a "Restart" button that shows the same problem.

The "Restart" button should recreate the SKScene when pressed. The SKScene gets recreated inside the SceneStore and the ContentView's Text view (with a new time assigned to the name property of the scene), but the SpriteView doesn't change the scene.

It seems that the SpriteView keeps the initial scene in memory, and doesn't let it go to replace it with the new scene. This can be seen in the Console, by hitting the Restart button twice and looking for something like "-- Scene 7:49:44 PM deinit --".

The updates from the @Published var updates = 0 property inside the scene also stop (at the top of the screen), because the new scene that gets created is not added into the view, so the SKScene.didMove(to view:) method is never called.

SpriteView-not-updating

The relevant code for this one is in ContentView.swift:

import SwiftUI
import SpriteKit

class GameScene: SKScene, ObservableObject {
    @Published var updates = 0
    private let label = SKLabelNode(text: "Updates in SKScene:\n0")
    
    override func didMove(to view: SKView) {
        addChild(label)
        label.numberOfLines = 4
        label.position = CGPoint(x: 0, y: -100)
    }
    
    override func update(_ currentTime: TimeInterval) {
        updates += 1
        label.text = "Updates in SKScene:\n\(updates)\nScene created at:\n\(name!)"
    }
    
    deinit {
        print("-- Scene \(name!) deinit --")
    }
}

class SceneStore : ObservableObject {
    @Published var currentScene: GameScene
    
    init() {
        currentScene = SceneStore.createScene()
    }
    
    func restartLevel() {
        currentScene = SceneStore.createScene()
    }
    
    // MARK: - Class Functions
    
    static private func createScene() -> GameScene {
        let scene = GameScene()
        scene.size = CGSize(width: 300, height: 400)
        scene.anchorPoint = CGPoint(x: 0.5, y: 0.5)
        scene.scaleMode = .fill
        scene.name = Date().formatted(date: .omitted, time: .standard)
        return scene
    }
}

struct ContentView: View {
    @EnvironmentObject private var sceneStore: SceneStore
    @EnvironmentObject private var scene: GameScene
    @State private var paused = false
    
    var body: some View {
        if #available(iOS 15.0, *) {
            print(Self._printChanges())
        }
        return ZStack {
            SpriteView(scene: scene, isPaused: paused).ignoresSafeArea()
            VStack {
                Text("Updates from SKScene: \(scene.updates)").padding().foregroundColor(.white)
                Text("Scene created at: \(scene.name!)" as String).foregroundColor(.white)
                Button("Restart") {
                    sceneStore.restartLevel()
                }.padding()
                Button("Paused: \(paused)" as String) {
                    paused.toggle()
                }
                Spacer()
            }
        }
    }
}

Question / Workaround?

Am I missing something? Or is this a bug? If so, is there any workaround?

Calin
  • 2,110
  • 1
  • 21
  • 36
  • Also posted on Apple Dev Forums: https://developer.apple.com/forums/thread/692527 – Calin Oct 18 '21 at 02:39
  • What you are trying to do is installing a `UIButton` with `SKScene` in UIKit. Once you switch to a game scene, its not the `View` guy that runs the show. – El Tomato Oct 18 '21 at 04:31
  • Not sure what you mean. I am just trying to use the SwiftUI API on a SpriteView, that should behave like any other SwiftUI view. – Calin Oct 18 '21 at 04:46
  • Oops... "What you are trying to do is installing..." -> "What you are trying to do is like installing..." Delegate the dirty job to the guy running the show. – El Tomato Oct 18 '21 at 04:54

2 Answers2

3

You are correct that isPaused, when passed to SpriteView, does not seem to affect the paused state.

To get around that, I used:

.onChange(of: paused) { newValue in
  sceneStore.currentScene.isPaused = newValue
}

Your second issue is one-part SpriteView problem and one part ObservableObject problem.

One important bit to know is that nested ObservableObjects do not propagate their state unless you manually call objectWillChange.send(). So, I've used Combine to track the @Published updates variable on the child object and call objectWillChange whenever there's an update.

Also, when there's a new scene, setupCombine is called again, which not only propagates the updates, but also alerts the View that the scene has changed.

Finally, because the scene doesn't get reloaded otherwise, I'm using an id that changes when a new GameScene is made -- this forces SwiftUI to create a new SpirteView.

import SwiftUI
import Combine

@main
struct SpriteView_not_updatingApp: App {
    @StateObject private var sceneStore = SceneStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(sceneStore)
        }
    }
}

class GameScene: SKScene, ObservableObject {
    @Published var updates = 0
    var id = UUID()
    private let label = SKLabelNode(text: "Updates in SKScene:\n0")
    
    override func didMove(to view: SKView) {
        addChild(label)
        label.numberOfLines = 4
        label.position = CGPoint(x: 0, y: -100)
    }
    
    override func update(_ currentTime: TimeInterval) {
        updates += 1
        label.text = "Updates in SKScene:\n\(updates)\nScene created at:\n\(name!)"
    }
    
    deinit {
        print("-- Scene \(name!) deinit --")
    }
}

class SceneStore : ObservableObject {
    var currentScene: GameScene
    
    var cancellable : AnyCancellable?
    
    init() {
        currentScene = SceneStore.createScene()
        setupCombine()
    }
    
    func restartLevel() {
        currentScene = SceneStore.createScene()
        setupCombine()
    }
    
    func setupCombine() {
        cancellable = currentScene.$updates.sink { _ in
            self.objectWillChange.send()
        }
    }
    
    // MARK: - Class Functions
    
    static private func createScene() -> GameScene {
        let scene = GameScene()
        scene.size = CGSize(width: 300, height: 400)
        scene.anchorPoint = CGPoint(x: 0.5, y: 0.5)
        scene.scaleMode = .fill
        scene.name = Date().formatted(date: .omitted, time: .standard)
        return scene
    }
}

struct ContentView: View {
    @EnvironmentObject private var sceneStore: SceneStore
    @State private var paused = false
    
    var body: some View {
        if #available(iOS 15.0, *) {
            print(Self._printChanges())
        }
        return ZStack {
            SpriteView(scene: sceneStore.currentScene, isPaused: paused)
                .id(sceneStore.currentScene.id) //<-- Here
                .ignoresSafeArea()
                .onChange(of: paused) { newValue in
                    sceneStore.currentScene.isPaused = newValue //<-- Here
                }
            VStack {
                Text("Updates from SKScene: \(sceneStore.currentScene.updates)").padding().foregroundColor(.white)
                Text("Scene created at: \(sceneStore.currentScene.name!)" as String).foregroundColor(.white)
                Button("Restart") {
                    sceneStore.restartLevel()
                }
                .padding()
                Button("Paused: \(paused)" as String) {
                    paused.toggle()
                }
                Spacer()
            }
        }
    }
}
jnpdx
  • 45,847
  • 6
  • 64
  • 94
  • Thank you for taking the time. The `.id()` modifier did the trick! The pause issue was more to illustrate the problem in a simpler way, as you can pause it like you described. The real problem was not being able to change the scene (eg. when level changes). I knew nested ObservableObjects do not propagate their state, that's why I am sending the `scene` as an ObservedObject in the environment, along with the `sceneStore`: `ContentView().environmentObject(sceneStore).environmentObject(sceneStore.currentScene)`. This seems to work without `objectWillChange`. Thanks again! – Calin Oct 18 '21 at 05:20
  • Interesting. I’m surprised that the environment object accurately reflects the change when the reset happens. I wouldn’t have predicted that. – jnpdx Oct 18 '21 at 05:26
  • I am surprised too but it seems to be working. The only thing that didn't change, while using just the environment to pass the two nested ObservableObjects as separate objects, was the `SpriteView`, which seems to be working now with the id. – Calin Oct 18 '21 at 05:33
  • It is important to note that, although sending the nested `ObservableObject` scene into the environment as `.environmentObject(sceneStore.currentScene)` works fine in iOS 15, it does not work in iOS 14. You need to use the `objectWillChange` for that and only send `.environmentObject(sceneStore)` into the env. – Calin Oct 18 '21 at 18:21
0

You can check Pausing a sprite kit scene

Basically you have to pause the scene every time you click the pause button

    @State private var paused = false {
        didSet {
            self.scene.isPaused = paused
        }
    }

That will pause your update

AchmadJP
  • 893
  • 8
  • 17
  • The problem is SpriteView doesn’t seem to always track the updates in the state. The pause problem can be fixed with a hack, but it will still be a problem, with restart for example. Let’s say you need to change the level, and load a different scene. It won’t work because SpriteView won’t unload and replace the initial scene with the new one. – Calin Oct 18 '21 at 03:18