Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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:

basic swift app with SceneKit scene

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?

like image 340
dirk Avatar asked Oct 04 '19 12:10

dirk


2 Answers

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?

like image 194
dirk Avatar answered Nov 15 '22 03:11

dirk


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.

like image 36
Procrastin8 Avatar answered Nov 15 '22 04:11

Procrastin8