I want to add a custom UIView with one UILabel into SCNNode (using SceneKit and ARKit).
I used SCNPlane with SCNMaterial containing created UIView's layer. Then I init new SCNNode and add it into scene. Firstly I tried UIView created in interface builder and this doesn't work. So I decided to create UIView programmaticaly.
class InfoPlaneNodeView: UIView {
var elevationLabel: UILabel
override init(frame: CGRect) {
elevationLabel = UILabel(frame: frame)
super.init(frame: frame)
backgroundColor = UIColor.white
layer.cornerRadius = 20
elevationLabel.translatesAutoresizingMaskIntoConstraints = false
elevationLabel.textColor = UIColor.black
elevationLabel.text = "333m"
addSubview(elevationLabel)
elevationLabel.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
elevationLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
}
}
class InfoViewPlaneNode: SCNNode {
init(planeWidth: CGFloat, planeHeight: CGFloat) {
let material = SCNMaterial()
let infoPlaneNodeView = InfoPlaneNodeView(frame: CGRect(x: 0, y: 0, width: 200, height: 100))
material.diffuse.contents = infoPlaneNodeView.layer
material.transparency = 0.7
material.lightingModel = .constant
material.isDoubleSided = true
let plane = SCNPlane(width: planeWidth, height: planeHeight)
plane.materials = [material]
super.init()
self.geometry = plane
self.position = SCNVector3(0, 3, 0)
}
}
When I run all the code, I see only white UIView without UILabel. I tried this code without SceneKit and everything works fine. Where is problem? Thank you
Once I faced this case. Since view is not part of any view controller, you have to draw it manually:
view.setNeedsDisplay()
view.displayIfNeeded()
Related
I have a UIView object inside a horizontal stackView.
I'm calling this function inside LayoutSubviews:
func roundCorners(corners: UIRectCorner, radius: CurveRadius) {
var r = CGFloat()
switch radius {
case .small:
r = self.frame.width / 30
case .medium:
r = self.frame.width / 25
}
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: r, height: r))
let mask = CAShapeLayer()
mask.path = path.cgPath
layer.mask = mask
}
but it just doesn't work. I guess it has something to do with a stackView. can anyone help me out?
extension UIView {
/// Rounds ``UIView`` corners.
/// - Parameters:
/// - maskedCorners: Corners to be rounded.
/// - cornerRadius: Value to be set as corner radius.
func roundCorners(maskedCorners: CACornerMask,
cornerRadius: CGFloat) {
layer.cornerRadius = cornerRadius
layer.maskedCorners = maskedCorners
layer.masksToBounds = true
}
}
I'm not sure if the UIStackView is the problem here, but there's another, more re-usable way to do this using a CACornerMask on the UIView's layer in an extension:
extension UIView {
func roundCorners() {
self.layer.cornerRadius = 10
self.layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMaxXMinYCorner]
}
}
When you create your view, just call the roundCorners(), like this:
private lazy var roundCorneredView: UIView = {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
view.backgroundColor = .red
view.roundCorners()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
Then add that view to your hierarchy in the usual way using addSubview.
CACornerMask docs # Apple
I'm currently playing with CALayers a bit. For demo purposes I'm creating a custom UIView that renders a fuel gauge. The view has two sub-layers:
one for the background
one for the hand
The layer that represents the hand is then simple rotated accordingly to point at the correct value. So far, so good. Now I want the view to resize its layers whenever the size of the view is changed. To achieve this, I created an override of the layoutSubviews method like this:
public override func layoutSubviews()
{
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds)
{
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
As the method is being called many times, I'm using previousBounds to make sure I only perform the update on the layers when the size has actually changed.
At first, I had just the following code in the updateLayers method to set the frames of the sub-layers:
backgroundLayer.frame = bounds.insetBy(dx: 5, dy: 5)
handLayer.frame = bounds.insetBy(dx: 5, dy: 5)
That worked fine - until the handLayer was rotated. In that case some weird things happen to its size. I suppose it is because the frame gets applied after the rotation and of course, the rotated layer doesn't actually fit the bounds and is thus resized to fit.
My current solution is to temporarily create a new CATransaction that suppresses animations, reverting the transformation back to identity, setting the frame and then re-applying the transformation like this:
CATransaction.begin()
CATransaction.setDisableActions(true)
let oldTransform = scaleLayer.transform
handLayer.transform = CATransform3DIdentity
handLayer.frame = bounds.insetBy(dx: 5, dy: 5)
handLayer.transform = oldTransform
CATransaction.commit()
I already tried omitting the CATransaction and instead applying the handLayer.affineTransform to the bounds I'm setting, but that didn't yield the expected results - maybe I did it wrong (side question: How to rotate a given CGRect around its center without doing all the maths myself)?
My question is simply: Is there a recommended was of setting the frame of a transformed layer or is the solution I found already "the" way to do it?
EDIT
Kevvv provided some sample code, which I've modified to demonstrate my problem:
class ViewController: UIViewController {
let customView = CustomView(frame: .init(origin: .init(x: 200, y: 200), size: .init(width: 200, height: 200)))
let backgroundLayer = CALayer()
let handLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(customView)
customView.backgroundColor = UIColor.blue
backgroundLayer.backgroundColor = UIColor.yellow.cgColor
backgroundLayer.frame = customView.bounds
let handPath = UIBezierPath()
handPath.move(to: backgroundLayer.position)
handPath.addLine(to: .init(x: 0, y: backgroundLayer.position.y))
handLayer.frame = customView.bounds
handLayer.path = handPath.cgPath
handLayer.strokeColor = UIColor.black.cgColor
handLayer.backgroundColor = UIColor.red.cgColor
customView.layer.addSublayer(backgroundLayer)
customView.layer.addSublayer(handLayer)
handLayer.transform = CATransform3DMakeRotation(5, 0, 0, 1)
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
customView.addGestureRecognizer(tap)
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
customView.frame = customView.frame.insetBy(dx:10, dy:10)
/*let animation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
let fromValue = self.handLayer.transform
let toValue = CGFloat.pi * 2
animation.duration = 2
animation.fromValue = fromValue
animation.toValue = toValue
animation.valueFunction = CAValueFunction(name: .rotateZ)
self.handLayer.add(animation, forKey: nil)*/
}
}
class CustomView: UIView {
var previousBounds: CGRect!
override func layoutSubviews() {
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds) {
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
func updateLayers(_ bounds: CGRect) {
guard let sublayers = self.layer.sublayers else { return }
for sublayer in sublayers {
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
}
}
}
If you add this to a playground, then run and tap the control, you'll see what I mean. Watch the red "square".
Do you mind explaining what the "weird things happening to the size" means? I tried to replicate it, but couldn't find the unexpected effects:
class ViewController: UIViewController {
let customView = CustomView(frame: .init(origin: .init(x: 200, y: 200), size: .init(width: 200, height: 200)))
let backgroundLayer = CALayer()
let handLayer = CAShapeLayer()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(customView)
backgroundLayer.backgroundColor = UIColor.yellow.cgColor
backgroundLayer.frame = customView.bounds
let handPath = UIBezierPath()
handPath.move(to: backgroundLayer.position)
handPath.addLine(to: .init(x: 0, y: backgroundLayer.position.y))
handLayer.frame = customView.bounds
handLayer.path = handPath.cgPath
handLayer.strokeColor = UIColor.black.cgColor
customView.layer.addSublayer(backgroundLayer)
customView.layer.addSublayer(handLayer)
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
customView.addGestureRecognizer(tap)
}
#objc func tapped(_ sender: UITapGestureRecognizer) {
let animation = CABasicAnimation(keyPath: #keyPath(CALayer.transform))
let fromValue = self.handLayer.transform
let toValue = CGFloat.pi * 2
animation.duration = 2
animation.fromValue = fromValue
animation.toValue = toValue
animation.valueFunction = CAValueFunction(name: .rotateZ)
self.handLayer.add(animation, forKey: nil)
}
}
class CustomView: UIView {
var previousBounds: CGRect!
override func layoutSubviews() {
super.layoutSubviews()
if previousBounds == nil || !previousBounds!.equalTo(self.bounds) {
previousBounds = self.bounds
self.updateLayers(self.bounds)
}
}
func updateLayers(_ bounds: CGRect) {
guard let sublayers = self.layer.sublayers else { return }
for sublayer in sublayers {
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
}
}
}
Edit
I think the issue is that the red box is resized with a frame. Since a frame is always upright even if it's rotated, if you were to do an inset from a frame, it'd look like this:
However, if you were to resize the red box with bounds:
sublayer.bounds = bounds.insetBy(dx: 5, dy: 5)
sublayer.position = self.convert(self.center, from: self.superview)
instead of:
sublayer.frame = bounds.insetBy(dx: 5, dy: 5)
You'll probably have to re-center the handPath and everything else in it accordingly as well.
I created an arbitrary view
let middleView = UIView(
frame: CGRect(x: 0.0,
y: view.frame.height/4,
width: view.frame.width,
height: view.frame.height/4))
middleView.backgroundColor = UIColor.blue
view.addSubview(middleView)
Then I created a circle using UIBezierPath; however when I set the position to middleView.center, the circle is far off to the bottom of the view. Can you set the position in the center of a subview?
let shapeLayer = CAShapeLayer()
let circlePath = UIBezierPath(
arcCenter: .zero,
radius: 100,
startAngle: CGFloat(0).toRadians(),
endAngle: CGFloat(360).toRadians(),
clockwise: true)
shapeLayer.path = circlePath.cgPath
shapeLayer.strokeColor = UIColor.purple.cgColor
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.position = middleView.center
middleView.layer.addSublayer(shapeLayer)
How do I center this circle in that view?
You have two problems.
First, you are setting shapeLayer.position = middleView.center. The center of a view is is the superview's geometry. In other words, middleView.center is relative to view, not to middleView. But then you're adding shapeLayer as a sublayer of middleView.layer, which means shapeLayer needs a position that is in middleView's geometry, not in view's geometry. You need to set shapeLayer.position to the center of middleView.bounds:
shapeLayer.position = CGPoint(x: middleView.bounds.midX, y: middleView.bounds.midY)
Second, you didn't say where you're doing all this. My guess is you're doing it in viewDidLoad. But that is too early. In viewDidLoad, the views loaded from the storyboard still have the frames they were given in the storyboard, and haven't been laid out for the current device's screen size yet. So it's a bad idea to look at frame (or bounds or center) in viewDidLoad if you don't do something to make sure that things will be laid out correctly during the layout phase. Usually you do this by setting the autoresizingMask or creating constraints. Example:
let middleView = UIView(
frame: CGRect(x: 0.0,
y: view.frame.height/4,
width: view.frame.width,
height: view.frame.height/4))
middleView.backgroundColor = UIColor.blue
middleView.autoresizingMask = [.flexibleWidth, .flexibleHeight, .flexibleTopMargin, .flexibleBottomMargin]
view.addSubview(middleView)
However, shapeLayer doesn't belong to a view, so it doesn't have an autoresizingMask and can't be constrained. You have to lay it out in code. You could do that, but it's better to just use a view to manage the shape layer. That way, you can use autoresizingMask or constraints to control the layout of the shape, and you can set it up in viewDidLoad.
let circleView = CircleView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
circleView.center = CGPoint(x: middleView.bounds.midX, y: middleView.bounds.midY)
circleView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin]
circleView.shapeLayer.strokeColor = UIColor.purple.cgColor
circleView.shapeLayer.fillColor = nil
middleView.addSubview(circleView)
...
class CircleView: UIView {
override class var layerClass: AnyClass { return CAShapeLayer.self }
var shapeLayer: CAShapeLayer { return layer as! CAShapeLayer }
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.path = UIBezierPath(ovalIn: bounds).cgPath
}
}
Result:
And after rotating to landscape:
I want to display a SKSpriteNode in front of a CAShapeLayer and this is the code that I am using :
import SpriteKit
import GameplayKit
import UIKit
class GameScene: SKScene {
let progressLayer = CAShapeLayer()
override func didMove(to view: SKView) {
let circle = UIView(frame: CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height))
view.addSubview(circle)
view.sendSubview(toBack: circle)
progressLayer.frame = view.bounds
progressLayer.path = progressPath.cgPath
progressLayer.fillColor = UIColor.clear.cgColor
progressLayer.strokeColor = UIColor.green.cgColor
progressLayer.lineWidth = 20.0
let animation2 = CABasicAnimation(keyPath: "strokeEnd")
animation2.fromValue = 0.0
animation2.toValue = 1.0
animation2.duration = 1
animation2.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
progressLayer.add(animation2, forKey: "drawLineAnimation")
circle.layer.addSublayer(progressLayer)
var popup = SKSpriteNode(imageNamed: "popupWorking.png")
popup.anchorPoint = CGPoint(x: 0.5, y: 0.5)
popup.position = CGPoint(x: self.size.width/2, y: self.size.height/2)
popup.size = CGSize(width: 400, height: 200)
popup.zPosition = 10
self.addChild(popup)
}
}
But when I run the code, the CAShapeLayer appears in front of the SKSpriteNode, how do I make the SKSpriteNode appear in front of the CAShapeLayer?
You'll need to change your view hierarchy at the view controller level. By default the view controller's view is an SKView. If you want to place a different UIView behind your SKView, you will need to adjust the view hierarchy.
Modify the view controller's view to be a UIView instead of an SKView. Then, add your shape layer's view as a subview to this main view. After that, add your SKView as a subview of the main view.
Your new view hierarchy should look like this:
View Controller
– view (UIView)
- circle view (UIView with your CAShapeLayer as a sublayer)
- spritekit view (SKView)
While it is possible to combine UIKit and SpriteKit in this way, it may be easier to stay within the SpriteKit world and recreate the circle animation using SKSpriteNodes instead of a CAShapeLayer.
Here is a playground that shows one way of doing it. I followed suggestion by #nathan .
Yellow line is drawn using SKShapeNode. The red shadow needs to be animated behind it and hence uses CAShapeLayer. (Note: I needed to mix the two as animating shadow path would be much more complicated in pure Sprite)
import SpriteKit
import PlaygroundSupport
private func invert(_ path: CGMutablePath) -> CGPath {
var rotation = CGAffineTransform(scaleX: 1.0, y: -1.0);
return path.copy(using: &rotation)!;
}
let bounds = CGRect(x: 0, y: 0, width: 400, height: 200)
let uiview = UIView(frame: bounds)
let pathview = UIView(frame: bounds)
let skview = SKView(frame: bounds)
PlaygroundPage.current.liveView = uiview
// Define the path
let path: CGMutablePath = CGMutablePath();
path.move(to: CGPoint(x:0, y:0))
path.addLine(to: CGPoint(x:200, y:100))
// Use CAShapeLayer to draw the red line
var pathLayer: CAShapeLayer = CAShapeLayer()
pathLayer.position = CGPoint(x: uiview.bounds.minX, y: uiview.bounds.minY + 200)
pathLayer.path = invert(path)
pathLayer.strokeColor = UIColor.red.cgColor
pathLayer.fillColor = nil
pathLayer.lineWidth = 10.0
pathLayer.lineJoin = kCALineJoinBevel
pathLayer.lineCap = kCALineCapRound
pathLayer.zPosition = 3;
let strokeAnimation = CABasicAnimation(keyPath: "strokeAnimation")
strokeAnimation.duration = 2;
strokeAnimation.fromValue = 0;
pathview.layer.addSublayer(pathLayer);
pathLayer.add(strokeAnimation, forKey: "strokePath");
uiview.addSubview(pathview)
//Use SKShapeNode to draw the yellow line
let pathShape: SKShapeNode = SKShapeNode(path: path);
pathShape.strokeColor = .white;
pathShape.lineWidth = 2.0;
pathShape.zPosition = 20;
// Create SK Scene
let scene = SKScene(size: CGSize(width: 400, height: 200))
scene.scaleMode = SKSceneScaleMode.aspectFill
scene.size = skview.bounds.size
scene.addChild(pathShape);
scene.backgroundColor = UIColor.clear;
skview.backgroundColor = UIColor.clear
skview.presentScene(scene);
uiview.insertSubview(skview, aboveSubview: pathview)
Hello I have a SCNScene with a sprite kit overlay. I also have a subview that contains just a UISlider. The Slider controls the value of one of the SKLabel nodes in the sprite kit overlay. However when I am moving the slider the label does not update. It only updates once I touch outside of the slider. So I am guessing that the SCNScene and the overlay don't update while my touches are in the subview and only update once I touch back to the main view. How can I fix this?
Thanks!
My project is in swift, but if you only know objective-c ill still gratefully take your answer and translate on my own.
SceneKit Code:
class MyMarbleScene: UIViewController {
var scene: SCNScene!
var sceneView: SCNView!
var hud : MyMarbleSpriteKit!
var cameraNode: SCNNode!
override func viewDidLoad() {
super.viewDidLoad()
// create a new scene
scene = SCNScene()
sceneView = SCNView()
sceneView.frame = self.view.frame
sceneView.autoresizingMask = UIViewAutoresizing.allZeros
sceneView.scene = scene
sceneView.autoenablesDefaultLighting = false
sceneView.allowsCameraControl = false
sceneView.backgroundColor = UIColor.blackColor()
self.view = sceneView
cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
scene.rootNode.addChildNode(cameraNode)
cameraNode.position = SCNVector3(x: 0, y: 20, z: 60)
hud = MyMarbleSpriteKit(size: self.view.frame.size, game: self)
sceneView.overlaySKScene = hud
let frame = CGRectMake(10.0, self.view.frame.height*0.5, 180.0, 10.0)
let slider = UISlider(frame: frame)
slider.addTarget(self, action: "sliderValueChanged:", forControlEvents: UIControlEvents.ValueChanged)
slider.backgroundColor = UIColor.clearColor()
slider.minimumValue = 1.0
slider.maximumValue = 10.0
slider.continuous = true
slider.value = 5
self.view?.addSubview(slider)
}
func sliderValueChanged(sender: UISlider) {
var currentValue = Int(sender.value)
println("\(currentValue)")
self.hud.updateLabel(currentValue)
}
}
SpriteKit code
class MyMarbleSpriteKit: SKScene {
var myScene: MyMarbleScene!
var SensLabelTwo = SKLabelNode()
override func didMoveToView(view: SKView) {
}
init(size: CGSize, game:MyMarbleScene) {
super.init(size: size)
myScene = game
let SensLabel = SKLabelNode()
SensLabel.text = "Marble Sensitivity:"
SensLabel.fontName = "AvenirNext-HeavyIta"
SensLabel.fontSize = 40
SensLabel.fontColor = UIColor.whiteColor()
SensLabel.verticalAlignmentMode = SKLabelVerticalAlignmentMode.Top
SensLabel.position = CGPoint(x: 170, y: size.height-20)
self.addChild(SensLabel)
SensLabelTwo.text = "5"
SensLabelTwo.fontName = "AvenirNext-HeavyIta"
SensLabelTwo.fontSize = 60
SensLabelTwo.fontColor = UIColor.whiteColor()
SensLabelTwo.verticalAlignmentMode = SKLabelVerticalAlignmentMode.Top
SensLabelTwo.position = CGPoint(x: 100, y: size.height-80)
self.addChild(SensLabelTwo)
}
func updateLabel(text: Int){
println("helllo")
SensLabelTwo.text = String(format:"%i", text)
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
the 2D scene is not automatically refreshed when it's modified.
You will have to wait for the 3D scene to be redisplayed first. This happens when 3D animations or actions are running or when playing is set to YES. Alternatively, you can manually request a refresh with -[SCNView setNeedsDisplay].