Last active
October 29, 2023 19:33
-
-
Save overlair/cd116c7f991c6065c0c0635a2e94dcd4 to your computer and use it in GitHub Desktop.
SwiftUI+Combine+SpriteKit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SpriteKit | |
import SwiftUI | |
import Combine | |
/* | |
SpriteKit -> SwiftUI data message | |
*/ | |
enum StateUpdate { | |
case color(UIColor) | |
} | |
/* | |
UIKit/SwiftUI -> SpriteKit gesture message | |
*/ | |
enum ControlUpdate { | |
case tap | |
} | |
/* | |
Wrapping SwiftUI View | |
- holds UI state | |
- declares state/gesture streams (PassthroughSubjects) | |
- uses GeometryReader to pass screen size (for full-size canvas) | |
- overlays gestures (UIKit-based UIViewRepresentable) | |
- overlays UI (SwiftUI Views from @State) | |
- receives state updates and sets @State, which will reactively re-render UI (onReceive) | |
*/ | |
struct ExampleView: View { | |
@State var isPaused = false | |
@State var color: Color? = nil | |
var states = PassthroughSubject<StateUpdate, Never>() | |
var messages = PassthroughSubject<ControlUpdate, Never>() | |
var body: some View { | |
GeometryReader { geo in | |
SpriteKitView(isPaused: $isPaused, | |
size: geo.size, | |
states: states, | |
messages: messages) | |
.overlay(gestures) | |
.overlay(alignment: .topTrailing) { | |
if let color = color { | |
color | |
.frame(width: 40, height: 40) | |
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) | |
.shadow(radius: 3, x: 1, y: 2) | |
.padding() | |
} | |
} | |
} | |
.onReceive(states, perform: handle) | |
} | |
@ViewBuilder var gestures: some View { | |
ExampleGestureRepresentable(messages: messages) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
} | |
// state update callbacks | |
func handle(_ state: StateUpdate) { | |
switch state { | |
case .color(let color): | |
self.color = Color(color) | |
} | |
} | |
} | |
/* | |
SpriteKit SwiftUI Wrapper | |
- needs a frame (size), and streams (states, messages) | |
- can also pass in other SpriteKit-related state (isPaused) | |
*/ | |
struct SpriteKitView: View { | |
@Binding var isPaused: Bool | |
let size: CGSize | |
let states: PassthroughSubject<StateUpdate, Never> | |
let messages: PassthroughSubject<ControlUpdate, Never> | |
var scene: SKScene{ | |
let scene = ExampleScene(size: size, | |
states: states, | |
messages: messages) | |
scene.size = size | |
scene.scaleMode = .fill | |
return scene | |
} | |
var body: some View { | |
SpriteView(scene: scene, | |
isPaused: isPaused) | |
.frame(width: size.width, height: size.height) | |
} | |
} | |
/* | |
UIKit Wrapper | |
- uses Coordinator to handle UITapGesture callback | |
- can add Gestures (UIGestureRecognizer), | |
Touches (subclass UIView and implement touch methods), | |
and Interactions (like UIDropInteraction) | |
*/ | |
struct ExampleGestureRepresentable: UIViewRepresentable { | |
let messages: PassthroughSubject<ControlUpdate, Never> | |
func makeUIView(context: Context) -> some UIView { | |
let v = UIView(frame: .zero) | |
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.tap)) | |
v.addGestureRecognizer(tap) | |
return v | |
} | |
class Coordinator: NSObject, UIGestureRecognizerDelegate { | |
let messages: PassthroughSubject<ControlUpdate, Never> | |
init(messages: PassthroughSubject<ControlUpdate, Never>) { | |
self.messages = messages | |
} | |
@objc func tap(gesture: UITapGestureRecognizer) { | |
messages.send(.tap) | |
} | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(messages: messages) | |
} | |
func updateUIView(_ uiView: UIViewType, context: Context) {} | |
} | |
/* | |
SpriteKit Scene | |
- holds streams | |
- setups after loading to receive "messages" (.sink, .store) | |
- reacts to control message (by setting backgroundColor) | |
- sends data from SpriteKit (overlayColor) back to SwiftUI | |
*/ | |
class ExampleScene: SKScene { | |
let states: PassthroughSubject<StateUpdate, Never> | |
let messages: PassthroughSubject<ControlUpdate, Never> | |
init(size: CGSize, | |
states: PassthroughSubject<StateUpdate, Never>, | |
messages: PassthroughSubject<ControlUpdate, Never>) { | |
self.states = states | |
self.messages = messages | |
super.init(size: size) | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
override func sceneDidLoad() { | |
super.sceneDidLoad() | |
setup() | |
} | |
var cancellables = Set<AnyCancellable>() | |
private func setup() { | |
messages | |
.sink(receiveValue: handle) | |
.store(in: &cancellables) | |
} | |
private func handle(_ message: ControlUpdate) { | |
switch message { | |
case .tap: | |
let colors: [UIColor] = [.red, .blue, .green, .orange, .yellow] | |
let backgroundColor = colors.randomElement() ?? .black | |
let overlayColor = colors.randomElement() ?? .black | |
scene?.backgroundColor = backgroundColor | |
states.send(.color(overlayColor)) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment