SwiftUI with SceneKit: How to use button action from view to manipulate underlying scene - swift

I’m trying to implement a very basic SwiftUI app with an underlying SceneKit scene/view. The buttons in the SwiftUI view should manipulate the content of the scene and vice versa the content of the scene should determine if the buttons are active or inactive.
Yes, I’ve read and watched the Apple developer sessions 226: Data Flow through SwiftUI and 231: Integrating SwiftUI. And I worked my way through the Apple tutorials. But I’m little lost here. Any hints and directions are appreciated.
Here is the code:
I have a MainView, which uses a SceneKit SCNView with a HUDView on top:
import SwiftUI
struct MainView: View {
var body: some View {
ZStack {
SceneView()
HUDView()
}
}
}
The SceneView integrates a SCNView via the UIViewRepresentable protocol. The scene has two functions to add and remove the box from the scene:
import SwiftUI
import SceneKit
struct SceneView: UIViewRepresentable {
let scene = SCNScene()
func makeUIView(context: Context) -> SCNView {
// create a box
scene.rootNode.addChildNode(createBox())
// code for creating the camera and setting up lights is omitted for simplicity
// …
// retrieve the SCNView
let scnView = SCNView()
scnView.scene = scene
return scnView
}
func updateUIView(_ scnView: SCNView, context: Context) {
scnView.scene = scene
// allows the user to manipulate the camera
scnView.allowsCameraControl = true
// configure the view
scnView.backgroundColor = UIColor.gray
// show statistics such as fps and timing information
scnView.showsStatistics = true
scnView.debugOptions = .showWireframe
}
func createBox() -> SCNNode {
let boxGeometry = SCNBox(width: 20, height: 24, length: 40, chamferRadius: 0)
let box = SCNNode(geometry: boxGeometry)
box.name = "box"
return box
}
func removeBox() {
// check if box exists and remove it from the scene
guard let box = scene.rootNode.childNode(withName: "box", recursively: true) else { return }
box.removeFromParentNode()
}
func addBox() {
// check if box is already present, no need to add one
if scene.rootNode.childNode(withName: "box", recursively: true) != nil {
return
}
scene.rootNode.addChildNode(createBox())
}
}
The HUDView bundles the buttons with actions to add and remove the box from the underlying scene. If the box object is in the scene, the add button should be inactive, only the remove button should be active:
struct HUDView: View {
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .center, spacing: 2) {
Spacer()
ButtonView(action: {}, icon: "plus.square.fill", isActive: false)
ButtonView(action: {}, icon: "minus.square.fill")
ButtonView(action: {}) // just a dummy button
}
.background(Color.white.opacity(0.2))
Spacer()
}
}
}
The buttons are fairly simple as well, they take an action and optionally an icon as well as their initial active/inactive state:
struct ButtonView: View {
let action: () -> Void
var icon: String = "square"
#State var isActive: Bool = true
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.title)
.accentColor(isActive ? Color.white : Color.white.opacity(0.5))
}
.frame(width: 44, height: 44)
}
}
The resulting app is pretty simple:
The SceneKit scene is rendered correctly and I am able to manipulate the camera on screen.
But how do I connect the button actions to the corresponding scene functions? How do I let the scene inform the HUDView about it’s content (box is present or not), so it can set the active/inactive state of the buttons?
What is the best approach to this task? Should I implement a Coordinator in SceneView? Should I use a separate SceneViewController implemented via the UIViewControllerRepresentable protocol?

I found a solution using #EnvironmentalObject but I am not completely sure, if this is the right approach. So comments on this are appreciated.
First, I moved the SCNScene into it’s own class and made it an OberservableObject:
class Scene: ObservableObject {
#Published var scene: SCNScene
init(_ scene: SCNScene = SCNScene()) {
self.scene = scene
self.scene = setup(scene: self.scene)
}
// code omitted which deals with setting the scene up and adding/removing the box
// method used to determine if the box node is present in the scene -> used later on
func boxIsPresent() -> Bool {
return scene.rootNode.childNode(withName: "box", recursively: true) != nil
}
}
I inject this Scene into the app as an .environmentalObject(), so it is available to all views:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Create the SwiftUI view that provides the window contents.
let sceneKit = Scene()
let mainView = MainView().environmentObject(sceneKit)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: mainView)
self.window = window
window.makeKeyAndVisible()
}
}
}
MainView is slightly altered to call SceneView (a UIViewRepresentable) with the separate Scene for the environment:
struct MainView: View {
#EnvironmentObject var scene: Scene
var body: some View {
ZStack {
SceneView(scene: self.scene.scene)
HUDView()
}
}
}
Then I make the Scene available to the HUDView as an #EnvironmentalObject, so I can reference the scene and its methods and call them from the Button action. Another effect is, I can query the Scene helper method to determine, if a Button should be active or not:
struct HUDView: View {
#EnvironmentObject var scene: Scene
#State private var canAddBox: Bool = false
#State private var canRemoveBox: Bool = true
var body: some View {
VStack {
HStack(alignment: .center, spacing: 0) {
Spacer ()
ButtonView(
action: {
self.scene.addBox()
if self.scene.boxIsPresent() {
self.canAddBox = false
self.canRemoveBox = true
}
},
icon: "plus.square.fill",
isActive: $canAddBox
)
ButtonView(
action: {
self.scene.removeBox()
if !self.scene.boxIsPresent() {
self.canRemoveBox = false
self.canAddBox = true
}
},
icon: "minus.square.fill",
isActive: $canRemoveBox
)
}
.background(Color.white.opacity(0.2))
Spacer()
}
}
}
Here is the ButtonView code, which used a #Bindingto set its active state (not sure about the correct order for this with the#State property inHUDView`):
struct ButtonView: View {
let action: () -> Void
var icon: String = "square"
#Binding var isActive: Bool
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.title)
.accentColor(self.isActive ? Color.white : Color.white.opacity(0.5))
}
.frame(width: 44, height: 44)
.disabled(self.isActive ? false: true)
}
}
Anyway, the code works now. Any thoughts on this?

I answered a very similar question here. The idea is that you create a PassthroughSubject that your parent view (the one that owns the buttons) will send events down and your UIViewRespresentable will subscribe to those events.

There are many ways to do this stuff and you've obviously solved it - congrats!
You might check out this post 58103566 on game design layout. I'm sure there will be experts that disagree which is fine, and I don't want to cause a debate, just thought it might save you a few steps in the future.
From multiple VC's, I manipulate game objects and buttons often, usually without muddying up the water too much.
Hope it helps.

Related

Create segues in RealityKit

I want to add a segue in my Reality Kit project between a simple swiftUI page and the AR camera ContentView. Here is my SegueView, the swiftui page that has a button which I want to segue to the AR view.
struct SegueView: View {
#State private var isActive: Bool = false
var body: some View {
NavigationLink(destination: ContentView(rootActive: $isActive), isActive: $isActive, label: {
Text("See in AR")
})
}
}
Here is the code from my ContentView, which only renders a box:
struct ContentView : View {
#Binding var rootActive: Bool
var body: some View {
return ARViewContainer().edgesIgnoringSafeArea(.all)
}
}
struct ARViewContainer: UIViewRepresentable {
func makeUIView(context: Context) -> ARView {
let arView = ARView(frame: .zero)
// Load the "Box" scene from the "Experience" Reality File
let anchor = AnchorEntity(plane: .horizontal)
let box = ModelEntity(mesh: MeshResource.generateBox(size: 0.3), materials: [SimpleMaterial(color: .blue, isMetallic: true)])
anchor.addChild(box)
// Add the box anchor to the scene
arView.scene.anchors.append(anchor)
return arView
}
func updateUIView(_ uiView: ARView, context: Context) {}
}
I followed this tutorial to create the segue, but it doesn't contain an AR view and I cannot find a tutorial that does. I also have to add the rootActive variable in the app delegate here:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView(rootActive: .constant(true))
// Use a UIHostingController as window root view controller.
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
return true
}
But since the tutorial doesn't have this step, I'm not entirely sure what to do. When I run the app, only the ContentView gets rendered. Does anyone know how I can create a segue where the SegueView is rendered first, and then when the navigation link "see in AR" is pressed, it goes to the ContentView?
Thanks!
In ContentView, put NavigationLink inside NavigationView.
import SwiftUI
import RealityKit
struct ContentView: View {
var body: some View {
NavigationView {
ZStack {
Color.black
VStack {
NavigationLink(destination: Reality().ignoresSafeArea()) {
Text("Go to ARView")
.foregroundColor(.cyan)
.font(.title3)
}
}
}.ignoresSafeArea()
}
}
}
ARView
struct Reality: UIViewRepresentable {
func makeUIView(context: Context) -> ARView {
let arView = ARView(frame: .zero)
let model = ModelEntity(mesh: .generateSphere(radius: 0.2))
let anchor = AnchorEntity(plane: .horizontal)
anchor.addChild(model)
arView.scene.anchors.append(anchor)
return arView
}
func updateUIView(_ uiView: ARView, context: Context) { }
}
P. S.
In project's Info tab add key Privacy - Camera Usage Description.

SceneKit change `.allowCameraControl` without refreshing

I'm making a test program that allows the user to switch .allowCameraControl on and off with a button
So I'm using observable objects that update the scene object whenever a variable changes. But every time the .allowCameraControl option is changed, the scene refreshes and objects goes back to its original orientation.
How do I make it so that the object stays in its current orientation even when the .allowCameraControl changes
Here's a minimum reproducible example
ContentView.swift:
import SwiftUI
import SceneKit
final class Lmao: ObservableObject {
#Published var yo: SceneView.Options = [.allowsCameraControl]
}
struct ContentView: View {
#EnvironmentObject var l: Lmao
var scene = SCNScene(named: "myScene.scn")
var body: some View {
VStack {
SceneView(scene: scene, options: l.yo)
.frame(width: UIScreen.main.bounds.width ,
height: UIScreen.main.bounds.height / 2)
Button("allow/disable camera controll") {
if l.yo == [] {
l.yo = [SceneView.Options.allowsCameraControl]
}
else {
l.yo = []
}
}
}
}
func updateLocation(_ location: CGPoint) {
print(location)
}
}
CameraTestApp.swift:
#main
struct CameraTestApp: App {
#StateObject private var ll = Lmao()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(ll)
}
}
}
myScene.scn: put some random stuff in a scene file
Common approach
In ContentView use #State property wrapper allowOrNot for toggling states:
import SwiftUI
import SceneKit
struct ContentView : View {
#State private var text: String = "CamControl is On"
#State private var allowOrNot: Bool = true
var body: some View {
ZStack {
VRViewContainer(allowOrNot: $allowOrNot)
.ignoresSafeArea()
VStack {
Text(text)
.onTapGesture {
allowOrNot.toggle()
if !allowOrNot { text = "CamControl is OFF" }
else { text = "CamControl is On" }
}
Spacer()
}
}
}
}
In VRViewContainer use #Binding property wrapper.
struct VRViewContainer: UIViewRepresentable {
#Binding var allowOrNot: Bool
func makeUIView(context: Context) -> SCNView {
let sceneView = SCNView(frame: .zero)
let scene = SCNScene(named: "art.scnassets/ship.scn")
sceneView.scene = scene
return sceneView
}
func updateUIView(_ uiView: SCNView, context: Context) {
uiView.allowsCameraControl = allowOrNot
}
}
Your approach
If you wanna control a camera's transform using your code, you have to retrieve all the sixteen values ​​from the transform matrix (position, orientation and scale) of the scene's default camera node (or from transform of any other SCNCamera node). In both cases, you'll need the sceneView instance.
sceneView.pointOfView?.transform // SCNMatrix4
In simplified SwiftUI's SceneView init you've got the parameter called pointOfView:
SceneView(scene: SCNScene?, pointOfView: SCNNode?, options: SceneView.Options)

pass / share class with SpriteView GameScene from a SwiftUi View

I am playing around with SpriteView, which works great. However I cannot figure out how to share a class with Spritekit GameScene,that is instantiated in one my views. Sharing my class with other views works like a charm. But how can I access my gameCenterManager class from spritekit's GameScene, how do I pass the class ? I don't have any clue how to do this. I considered making a global accessible class, but its not what I want.
The goal is to be able to send and receive data from inside GameScene to update players location and etc. But the matchmaking happens inside a SwiftUI View, which is also where the class is created.
The gameCenterManger class is created like so;
struct ContentView: View {
#StateObject var gameCenterManager = GameCenterManager()
var body: some View {
...
etc
the class is set up like so :
class GameCenterManager: NSObject, ObservableObject {
//lots of stuff going on
...
}
And is shared to the GameSceneView view that will create the SpriteView, like so :
Button("Show Sheet") {
self.showingSheet.toggle()
}
.buttonStyle(RoundedRectangleButtonStyle())
.buttonStyle(ShadowButtonStyle())
.fullScreenCover(isPresented: $showingSheet, content: { GameSceneView(gameCenterManager:self.gameCenterManager)})
Finally, inside my GameSceneView, the GameScene for SpriteView is configured.
struct GameSceneView: View {
var gameCenterManager: GameCenterManager
#Environment(\.presentationMode) var presentationMode
var scene: SKScene {
let scene = GameScene()
scene.size = CGSize(width: UIScreen.main.bounds.size.width , height: UIScreen.main.bounds.size.height)
scene.scaleMode = .fill
return scene
}
var body: some View {
Button("Dismiss") {
self.presentationMode.wrappedValue.dismiss()
}
Button("send data") {
gameCenterManager.increment()
}
SpriteView(scene: scene )
.frame(width: UIScreen.main.bounds.size.width, height: UIScreen.main.bounds.size.height - 200 )
.ignoresSafeArea()
.overlay(ImageOverlay(), alignment: .bottomTrailing)
}
}
Perhaps others are looking to do the same? Here below is the answer in code snippets
GameCenterManager is the class that I want to access across all views and SKScenes
In mainview:
#main
struct gameTestApp: App {
#StateObject var gameCenterManager = GameCenterManager()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(gameCenterManager)
}
}
}
In Contentview view add :
#EnvironmentObject var gameCenterManager:GameCenterManager
And where I transition to GameSceneView inside Contentview, which will load my SKScene
Button("Show Sheet") {
self.showingSheet.toggle()
}
.buttonStyle(RoundedRectangleButtonStyle())
.buttonStyle(ShadowButtonStyle())
.fullScreenCover(isPresented: $showingSheet, content: { GameSceneView()})
Then in GameSceneView add:
#EnvironmentObject var gameCenterManager:GameCenterManager
and where we load SKScene:
var scene: SKScene {
let scene = GameScene()
scene.size = CGSize(width: UIScreen.main.bounds.size.width , height: UIScreen.main.bounds.size.height)
scene.scaleMode = .fill
scene.gameCenterManager = gameCenterManager
return scene
}
Then finally in GameScene itself add:
var gameCenterManager: GameCenterManager?

Press SwiftUI button and go to the next screen (next view) when server callback

I stuck at the pretty simple step when I want to press on the button show loading indicator and if server returns success response then show new view
It's pretty straightforward in UIKit, but with SwiftUI I stuck to do this.
I need to know how to init/add activity indicator I found some cool examples here. Can I just store it as a let variable in my view sruct?
Then by pressing button Unhide/Animate indicator
Make a server request via my rest api service
Wait some time and show new view on success callback or error message.
Nothing super hard, but I stuck here is a button which is a part of my NavigationView. Please help me push to new screen.
Button(action: {
// show indicator or animate
// call rest api service
// wait for callback and show next view or error alert
})
I found some link but not sure how to use it right.
Not sure I need PresentationButton or NavigationLink at all as I already have a simple Button and want to kind of push new view controller.
Very similar question to this one but I have not find it useful as I don't know how to use step by step how to "Create hidden NavigationLink and bind to that state"
EDITED:
I also found this video answer looks like I figure out how to do navigation. But still need to figure out how to show activity indicator when button pressed.
To show anything you need at some point in SwiftUI, simply use a #State variable.
You can use as many of these Bool as needed. You can toggle a new view, animation...
Example
#State var showNextView = false
#State var showLoadingAnimation = false
Button(action: {
self.showLoadingAnimation.toggle()
self.makeApiCall()
}) {
Text("Show next view on api call success")
}
// Method that handle your api call
func makeApiCall() {
// Your api call
if success {
showLoadingAnimation = false
showNextView = true
}
}
As for the animation, I would suggest the use the Lottie framework. You can find some really cool animations:
https://github.com/airbnb/lottie-ios
You can find many animations here:
https://lottiefiles.com
And you can create a class to implement your Lottie animation via a JSON file that you dropped in your project:
import SwiftUI
import Lottie
struct LottieRepresentable: UIViewRepresentable {
let named: String // name of your lottie file
let loop: Bool
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
let animationView = AnimationView()
let animation = Animation.named(named)
animationView.animation = animation
animationView.contentMode = .scaleAspectFit
if loop { animationView.loopMode = .loop }
animationView.play()
animationView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.widthAnchor.constraint(equalTo: view.widthAnchor),
animationView.heightAnchor.constraint(equalTo: view.heightAnchor)
])
return view
}
func updateUIView(_ uiView: UIView, context: Context) { }
}
Create a SwiftUI file to use your lottie animation in your code:
// MARK: - Show LottieRespresentable as view
struct LottieView: View {
let named: String
let loop: Bool
let size: CGFloat
var body: some View {
VStack {
LottieRepresentable(named: named, loop: loop)
.frame(width: size, height: size)
}
}
}
So the final code would look like this with a NavigationLink, and you will have your loader starting at the beginning of your api call, and ending when api call succeeds:
import SwiftUI
//MARK: - Content view
struct ContentView: View {
#State var showMessageView = false
#State var loopAnimation = false
var body: some View {
NavigationView {
ZStack {
NavigationLink(destination: MessageView(),
isActive: $showMessageView) {
Text("")
VStack {
Button(action: {
self.loopAnimation.toggle()
self.makeApiCall()
}) {
if self.loopAnimation {
Text("")
}
else {
Text("Submit")
}
}
}
if self.loopAnimation {
LottieView(named: "Your lottie json file name",
loop: self.loopAnimation,
size: 50)
}
}
.navigationBarTitle("Content View")
}
}
}
func makeApiCall() {
// your api call
if success {
loopAnimation = false
showMessageView = true
}
}
}

React on drag over view

I have a view that I want to change whenever long press or drag occurs over it. It can start outside of the view, but also inside.
struct ContentView: View {
#State var active: Bool = false
var body: some View {
Rectangle()
.fill(self.active ? Color.red : Color.secondary)
}
}
An example of this behaviour is the iPhone keyboard: when you long press on a key it pops up (active = true). When you move outside it, it pops down (active = false) but the next key is then active.
I have tried using LongPressGesture but cannot figure out how to make it behave as I want.
I made example for you in playgrounds to display how to use a LongPressGestureRecognizer.
You add the gesture recognizer to the view you want long pressed, with target being the parent controller handling the gesture recognition (ContentView in your case) and action being what happens with a long press.
In my implementation, a long press to the view bodyView changes its color from clear to red. This happens inside the didSet of the showBody property, which calls showBodyToggled(). I'm checking the state of the gesture, because the gesture recognizer will send messages for each state (I'm only performing the action if the state is .began).
Let me know if you have any questions:
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
class MyViewController : UIViewController {
var bodyView: UIView!
var showBody = false {
didSet {
showBodyToggled()
}
}
override func viewDidLoad() {
super.viewDidLoad()
configureSubviews()
configureLongPressRecognizer()
}
func configureSubviews() {
bodyView = UIView()
bodyView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bodyView)
NSLayoutConstraint.activate([
bodyView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
bodyView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
bodyView.heightAnchor.constraint(equalToConstant: 200),
bodyView.widthAnchor.constraint(equalToConstant: 300)
])
}
func configureLongPressRecognizer() {
bodyView.addGestureRecognizer(
UILongPressGestureRecognizer(
target: self,
action: #selector(longPressed(_:))
)
)
}
#objc func longPressed(_ sender: UILongPressGestureRecognizer) {
// the way I'm doing it: only acts when the long press first happens.
// delete this state check if you'd prefer the default long press implementation
switch sender.state {
case .began:
showBody = !showBody
default:
break
}
}
func showBodyToggled() {
UIView.animate(withDuration: 0.4) { [weak self] in
guard let self = self else { return }
self.bodyView.backgroundColor = self.showBody ? .red : .clear
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
EDIT:
Here's an example in SwiftUI where a long press of 0.5 seconds toggles the color of a circle between red and black
struct ContentView: View {
#State var active = false
var longPress: some Gesture {
LongPressGesture()
.onEnded { _ in
withAnimation(.easeIn(duration: 0.4)) {
self.active = !self.active
}
}
}
var body: some View {
Circle()
.fill(self.active ? Color.red : Color.black)
.frame(width: 100, height: 100, alignment: .center)
.gesture(longPress)
}
}