Skip to content

Instantly share code, notes, and snippets.

@overlair
Last active October 29, 2023 19:33
Show Gist options
  • Save overlair/cd116c7f991c6065c0c0635a2e94dcd4 to your computer and use it in GitHub Desktop.
Save overlair/cd116c7f991c6065c0c0635a2e94dcd4 to your computer and use it in GitHub Desktop.
SwiftUI+Combine+SpriteKit
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