It seems that SpriteView
doesn't pause the SKScene
with a state property passed to the SpriteView(scene:isPaused:)
initializer.
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.
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()
}
}
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.
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()
}
}
}
}
Am I missing something? Or is this a bug? If so, is there any workaround?
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 ObservableObject
s 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()
}
}
}
}