2

I am trying to create a rotating cube with SCNBox in SwiftUI, where you can tap each side of the box and a different popup screen / View appears with text etc. I have the rotating SCNBox cube, but how do I make it tappable and interactive, and how do I make it redirect to another view?

This is how I am currently trying to insert the Box Scene into a SwiftUI View, which would then go into the view hierarchy of an exiting app.

import SwiftUI
import SceneKit

class BoxNode: SCNNode {

    override init() {
        super.init()
        self.geometry = SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0.0)
        
        self.geometry?.firstMaterial?.shininess = 50
        
        let action = SCNAction.rotate(by: 360 * CGFloat(Double.pi / 180), around: SCNVector3(x:0, y:1, z:0), duration: 8)
        
        let repeatAction = SCNAction.repeatForever(action)
        
        self.runAction(repeatAction)
        
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

struct BoxScene: View {
        var scene = SCNScene(named: "MyScene")
        
        var cameraNode: SCNNode? {
                scene?.rootNode.childNode(withName: "camera", recursively: false)
        }
    
        var boxNode: SCNNode? {
                scene?.rootNode.addChildNode(BoxNode())
        }
        
        var body: some View {
                SceneView(
                        scene: scene,
                        pointOfView: cameraNode,
                        options: []
                )
        }
}

However, this code doesn't compile - it is saying "Type of expression is ambiguous without more context in the below line:

var boxNode: SCNNode? {
                scene?.rootNode.addChildNode(BoxNode())
        }
MA9
  • 55
  • 5
  • How are you displaying the SCNBox in your SwiftUI view? You should show that code. – Andrew Dec 31 '21 at 20:04
  • Currently I am trying to figure out how to do so. I'd like to integrate the SCNBox into an existing app with tabs. The goal is for this box to go into one of the Tabs (currently using TabView), and once you tap one of the sides of the box, you get (a) a popup window, and/or (b) get redirected to another tab in the app. The problem is integrating the box into the existing app (currently they are separate Xcode projects. Thanks in advance! – MA9 Dec 31 '21 at 20:19
  • You’re going to need to show more code than you have done. At the moment you aren’t giving enough code to help you. – Andrew Dec 31 '21 at 20:22
  • Just edited my original question to add the code you requested above... It is not compiling though. – MA9 Dec 31 '21 at 20:41
  • You probably need to subclass SKScene and override touchesBegan to capture the touch events. You could pass closures to your subclass so you can act on your touch events. – Andrew Dec 31 '21 at 21:10
  • Thank you so much! Will try this, but I've never done this before and not sure how to do so - do you have an example handy and/or a relevant thread? Also - any idea why this code is not compiling? – MA9 Dec 31 '21 at 21:16
  • In order to have 6 different side's on which you can tap and do different actions, I consider you to create a main Node (without geometry) as the center of the cube, and then add 6 childnodes, which are SCNPlane geometry - Squares - and order them around the center node, like a cube. This will simplify identifying the touches. - For the touches: consider to use a Tap-Gesture recognizer instead of the touches began and end functions. You could then also add a Pan-Gesture recognizer, if you want i.Ex. rotate the cube, but not trigger the actions during rotation. (there are many ways to do) – ZAY Jan 01 '22 at 11:11
  • @ZAY Thank you so much - this is excellent and sounds like exactly what I want. Have you seen any sample code that can get me started? Also, do you have a suggestion on how to integrate this Scene with the existing Views of the App? – MA9 Jan 02 '22 at 18:42
  • Andrew's answer solution seems pretty good. Try to make an adaptation of it. (Keep in mind, there ary many ways to achive, what you are trying) – ZAY Jan 03 '22 at 08:24

1 Answers1

3

Here is an example that may be helpful using a SCNBox.

BoxNode is a custom SCNNode that uses an SCNBox for its geometry. Each side is set to a different colour so that it is easy to see, you can use UIImages if you have something more elaborate that you want to show. As we are using SceneKit, UIKit, and SwiftUI make sure you import them where needed.

class BoxNode: SCNNode {
    
    override init() {
        let length: CGFloat = 5
        let box = SCNBox(width: length, height: length, length: length, chamferRadius: 0)
        
        box.materials = [UIColor.red, .yellow, .green, .blue, .purple, .brown].map {
            let material = SCNMaterial()
            material.diffuse.contents = $0
            material.isDoubleSided = true
            material.transparencyMode = .aOne
            return material
        }
        
        super.init()
        
        self.geometry = box
        self.name = "boxNode"
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

This is then used inside a custom SCNScene, which adds the BoxNode as a root node and sets up a rotation animation and pivots the cube.

class GameScene: SCNScene {
    
    override init() {
        super.init()
        
        let cubeNode = BoxNode()
        self.rootNode.addChildNode(cubeNode)
        
        let xAngle = SCNMatrix4MakeRotation(.pi / 3.8, 1, 0, 0)
        let zAngle = SCNMatrix4MakeRotation(-.pi / 4, 0, 0, 1)
        cubeNode.pivot = SCNMatrix4Mult(SCNMatrix4Mult(xAngle, zAngle), cubeNode.transform)
        
        // Rotate the cube
        let animation = CAKeyframeAnimation(keyPath: "rotation")
        animation.values = [SCNVector4(1, 1, 0.3, 0 * .pi),
                            SCNVector4(1, 1, 0.3, 1 * .pi),
                            SCNVector4(1, 1, 0.3, 2 * .pi)]
        animation.duration = 5
        animation.repeatCount = HUGE
        cubeNode.addAnimation(animation, forKey: "rotation")
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

The GameScene is then set inside a SCNView inside a UIViewController. The SCNView is pinned to the edges of the ViewController and we override touchesBegan so that we can interact with the SceneView. I also create a variable sideTapped as this will allow you to inject a callback so that you can tell which side has been tapped on your cube.

class GameSceneViewController: UIViewController {
    
    private let sceneView: SCNView = .init(frame: .zero)
    private let scene = GameScene()
    
    var sideTapped: ((Int) -> Void)?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        sceneView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(sceneView)
        
        NSLayoutConstraint.activate([
            sceneView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            sceneView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            sceneView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            sceneView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
        ])
        sceneView.scene = scene
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touchPoint = touches.first?.location(in: sceneView) else { return }
        guard let hitTestResult = sceneView.hitTest(touchPoint, options: nil).first else { return }
        guard hitTestResult.node is BoxNode else { return }
        
        sideTapped?(hitTestResult.geometryIndex)
    }
}

As we are using this inside SwiftUI we need a UIViewControllerRepresentable. Here we pass the sideTapped function to the GameSceneViewController.

struct GSViewControllerRepresentable: UIViewControllerRepresentable {
    
    let sideTapped: ((Int) -> Void)?
    
    func makeUIViewController(context: Context) -> GameSceneViewController {
        let vc = GameSceneViewController()
        vc.sideTapped = sideTapped
        return vc
    }
    
    func updateUIViewController(_ uiViewController: GameSceneViewController, context: Context) {
    
    }
}

Now we can add this to a SwiftUI view.

struct ContentView: View {

    var body: some View {
        GSViewControllerRepresentable { side in
            print(side)
            // Perform additional logic here
        }
        .frame(width: 200, height: 200)
    }
}

The end result should look something like this:

enter image description here

Tapping on a side of cube will print a number from 0 to 5 representing which side you tapped. Based on the materials used the numbers should correspond to the index of the material, so in this case:

  • 0 is red
  • 1 is yellow
  • 2 is green
  • 3 is blue
  • 4 is purple
  • 5 is brown

That should give you something that you should be able to switch your navigation off of.


I am no expert in SceneKit but I was playing with it at the weekend and had pretty much done what you were asking about. Hopefully this gives you an idea, there are many ways to achieve what you want to do, this is one possibility.

If you are wanting to swipe the cube to changes faces, rather than just having it rotate by itself, you might want to look at this answer.

Andrew
  • 26,706
  • 9
  • 85
  • 101
  • Thank you so much! This is exactly what I needed and it works fantastic. One question - do you know how to redirect a tapped side to an existing View in the app? I tried the below with no success: ` struct ContentView: View { var body: some View { GSViewControllerRepresentable { side in if side = 1 { FirstView() } else { AnotherView() } } .frame(width: 200, height: 200) } } ` – MA9 Jan 07 '22 at 06:10
  • That would never work as the callback where you get the side is not a ViewBuilder. You would need to update some state value in your ContentView that would then drive the change of view. The second code example in this link should help you https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-programmatic-navigation-in-swiftui though it uses Strings it should work for using Ints. The GSViewControllerRepresentable is basically a fancy button, that returns a value in its closure, you would set that value to the selection in the linked example. – Andrew Jan 07 '22 at 06:50
  • Now when I tap on one side, the View pops up at the bottom, and the Cube remains at the top of screen. Do you know how to adjust that to **redirect ** to another view completely? My current code looks like this: ` struct CubeView: View { @State private var selection: Int? = nil var body: some View { GSViewControllerRepresentable { side in selection = side } .frame(width: 250, height: 350) if selection == 1 { FirstView() } else { } } } ` – MA9 Jan 07 '22 at 18:43
  • The tutorial that I listed in the previous comment is what you need to do. – Andrew Jan 07 '22 at 20:21
  • Thank you - that is what I did. However, the View doesn't displace the Cube, but rather it appears below the Cube, and the Cube is still visible. Wondering how to remove the Cube from the View, but since I am not familiar with ViewControllerRepresentable behavior, I am not sure how to debug this. Thanks again! – MA9 Jan 07 '22 at 20:56
  • Cause if it is what you did where is the NavigationView and NavigationLink in your code? – Andrew Jan 07 '22 at 21:00
  • Andrew - thanks again for all the help, it worked. One other question. Do you know how to make the Cube respond differently to different touches on the same side (e.g. double tap, long hold etc.)? I've used onTapGesture(count: 2) before etc. but not sure how to integrate a gesture recognizer into the UIViewController. – MA9 Jan 13 '22 at 14:54
  • I don't unfortunately, but I would imagine that it would be something like this https://medium.com/@rbreve/tap-gesture-recognizer-for-object-in-scenekit-arkit-dc9815f8643b where you add your `UIGestureRecognizer` to your `sceneView` and then capture the events, you will notice that the code in `handleTap` is similar to what we have in the `touchesBegan` you'd probably want to update the `handleTap` code. And if you have multiple `UIGestureRecognizer`s then you will want to make sure you handle them appropriately https://stackoverflow.com/questions/41918256/handling-multiple-gesturerecognizers – Andrew Jan 13 '22 at 16:11
  • Thank you! Will try this. Last question for now - I am trying to create a “grid” of these tappable boxes, where when I tap one of the boxes in the grid, it redirects me to the individual box view as we have it now. Each of the boxes in the grid would correspond to a unique piece of data (e.g. item in to-do list). Do you have suggestions on how to create this grid? Should I create a grid of Box Nodes within the Scene, or a grid of Views? – MA9 Jan 13 '22 at 17:04
  • I honestly don't know. But I am sure you will figure it out. Good luck. – Andrew Jan 13 '22 at 18:17