iosswiftswiftuisprite-kitspriteview

Pause a SpriteKit scene from SwiftUI using SpriteView(isPaused:), without reinitializing it every time?


I am trying to use the isPaused: argument in the SpriteView initializer to pause the SKScene when a state property changes in SwiftUI.

But when I use the state variable as an argument to isPaused: in the initializer like:

SpriteView(scene: scene, isPaused: showingLevelChooser)

instead of only:

SpriteView(scene: scene)

the scene is recreated every time the variable changes, which is not what I want. I only want to pause the game.

I am confused how is the isPaused: argument supposed to work. There isn't much information in the SpriteView documentation.

As far as I understand this happens because SwiftUI recreates the views that are dependent on the state. But if this is the case, how can you pause the SpriteKit scene from SwiftUI, without reinitializing it every time?

I created a sample Xcode 13 project here: https://github.com/clns/SpriteView-isPaused

The SKScene is displaying the "Time Elapsed" on the screen, in seconds. Every time the SwiftUI sheet is presented and the state variable showingLevelChooser changes, the timer starts from 0 (zero), because the scene is reinitialized.

Visual_Demonstration

All relevant code is in ContentView.swift.

class GameScene: SKScene {
    private let label = SKLabelNode(text: "Time Elapsed:\n0")
    private var lastUpdateTime : TimeInterval = 0
    
    override func didMove(to view: SKView) {
        addChild(label)
    }
    
    override func update(_ currentTime: TimeInterval) {
        if (self.lastUpdateTime == 0) {
            self.lastUpdateTime = currentTime
        }
        
        let seconds = Int(currentTime - lastUpdateTime)
        label.text = "Time Elapsed:\n\(seconds)"
        label.numberOfLines = 2
    }
}

struct ContentView: View {
    @State private var showingLevelChooser = false
    
    var scene: SKScene {
        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 {
        ZStack {
            SpriteView(scene: scene, isPaused: showingLevelChooser)
                .ignoresSafeArea()
            VStack {
                Button("Level Chooser") {
                    showingLevelChooser.toggle()
                }
                Spacer()
            }
        }
        .sheet(isPresented: $showingLevelChooser) {
            VStack {
                Button("Cancel") {
                    showingLevelChooser.toggle()
                }
                Text("Level Chooser")
            }
        }
    }
}


Solution

  • There are two issues to take care of here. The first is that you want to have a reference to your SKScene that will stick around. Making it a computed property of the View will not work (as you're experiencing) because the View is transitive and every time it gets reloaded, you'll get a new SKScene.

    There are multiple acceptable solutions to this, but I chose to encapsulate the SKScene in an ObservableObject that you can hold a reference to via StateObject. You could even experiment with making the SKScene an ObservableObject itself (not shown here).

    Secondly, your logic for determine/showing the "pause" on screen was going to be a little tough to accurately see, because it always just displayed the elapsed time -- there was no logic involved to not count the time during the pause. I replaced it with a simple counter that shows the number of updates. That way, you can clearly tell that the scene has indeed been paused (and doesn't update).

    
    class SceneStore : ObservableObject {
        var scene = GameScene()
        
        init() {
            scene.size = CGSize(width: 300, height: 400)
            scene.anchorPoint = CGPoint(x: 0.5, y: 0.5)
            scene.scaleMode = .fill
        }
    }
    
    class GameScene: SKScene {
        private let label = SKLabelNode(text: "Updates: 0")
        private var updates = 0
        
        override func didMove(to view: SKView) {
            addChild(label)
        }
        
        override func update(_ currentTime: TimeInterval) {
            updates += 1
            label.text = "Updates: \(updates)"
            label.numberOfLines = 2
        }
    }
    
    struct ContentView: View {
        @State private var showingLevelChooser = false
        @StateObject private var sceneStore = SceneStore()
        
        var body: some View {
            ZStack {
                SpriteView(scene: sceneStore.scene, isPaused: showingLevelChooser)
                    .ignoresSafeArea()
                VStack {
                    Button("Level Chooser") {
                        showingLevelChooser.toggle()
                    }
                    Spacer()
                }
            }
            .sheet(isPresented: $showingLevelChooser) {
                VStack {
                    Button("Cancel") {
                        showingLevelChooser.toggle()
                    }
                    Text("Level Chooser")
                }
            }
        }
    }