Implementing anchorPoint in SKNode subclass - swift

I have a custom class inheriting from SKNode and would like to implement SKSpriteNode's anchorPoint-like functionality on that class. Specifically, what I am trying to achieve is to center the custom node in the parent node automatically (anchorPoint at 0.5, 0.5) without having to offset it by half its width/height.
Any tips?
Edit: I ended up by first creating an anchorPoint variable and then overriding the position variable and set the super position modified by the anchorPoint.
var anchorPoint = CGPoint(x: 0.5, y: 0.5)
override var position: CGPoint {
set {
super.position = CGPoint(x: self.position.x - self.size.width * self.anchorPoint.x, y: self.position.y - self.size.height * self.anchorPoint.y)
}
get {
return super.position
}
}

You can over ride the setPosition function of the inherited class. In this function you would adjust for the offset-ed anchorPoint.
For example, if you sprite is 100x100. And you have set its anchor as (.5,.5), When someone sets the position, you should add or subtract the sprite dimensions from it and then set the position.
In this case, if user sets the sprite position as (0,0), you would add (100*.5,100*.5) = (50,50). And adding (0,0) to it would be (50,50).
This is the new position that you should set.

It might be easier to add a new "root" SKNode under your control's SKNode, add any child nodes to it, and then just move it when the anchorPoint property is changed. That's what I did.
var anchorPoint: CGPoint = CGPoint( x: 0.5, y: 0.5 )
{
didSet
{
let translateX = ( anchorPoint.x - 0.5 ) * controlSize.width
let translateY = ( anchorPoint.y - 0.5 ) * controlSize.height
rootNode!.position = CGPoint(x: translateX, y: translateY)
}
}

Related

didSet observer does not get called when initialising property

Given the following class,
class Spaceship {
var position: CGPoint! {
didSet {
node.position = position
}
}
var node: SKSpriteNode!
init(frame: CGRect) {
node = SKSpriteNode(
color: UIColor.red,
size: CGSize(
width: frame.size.width / 5,
height: frame.size.width / 5
)
)
self.position = CGPoint(x: frame.midX, y: frame.midY)
}
}
it looks as though the didSet observer of the position property does not get called.
Since the didSet observer should set the SpriteKit node's position when the position property gets modified, I thought that instead of using the same line of code contained within the didSet block, I could trigger the latter instead, but it doesn't seem to work; when the scene gets created (in the main GameScene, which simply creates a Spaceship objects and adds the spaceship.node to the scene's node children), the node's position seems to be 0; 0.
Any clue on why does this happen? Thank you.
Update: although the defer statement works, the assignment does not allow to make use of it.
Rather than using didSet to update your node, directly forward queries to it:
class Spaceship {
let node: SKSpriteNode
init(frame: CGRect) {
self.node = SKSpriteNode(
color: UIColor.red,
size: CGSize(
width: frame.size.width / 5,
height: frame.size.width / 5
)
)
self.position = CGPoint(x: frame.midX, y: frame.midY)
}
var position: CGPoint {
get { return node.position }
set { node.position = newValue }
}
}
The willSet and didSet observers of superclass properties are called when a property is set in a subclass initializer, after the superclass initializer has been called. They are not called while a class is setting its own properties, before the superclass initializer has been called.
One possible solution could be to set the position property outside of the Spaceship's initialiser, or also set directly the node's position.
I personally would just subclass Spaceship as an SKSpriteNode. Spaceship does not seem to be a complex object that involves composition over inheritance.
class Spaceship : SKSpriteNode {
convenience init(frame: CGRect) {
self.init(
color: UIColor.red,
size: CGSize(
width: frame.size.width / 5,
height: frame.size.width / 5
)
)
self.position = CGPoint(x: frame.midX, y: frame.midY)
}
}
Now I am not sure why you are passing in frame like this.... I am assuming frame is your scene? If this is the case, you are going to come across issues when anchor point is not (0,0).

How to update NSView layer position and anchor point for transform simultaneously with NSView frame for mouse events

I am currently trying to implement some custom NSViews that will be animate by certain events in a macOS app. The controls are along the lines of slider you would find in an AudioUnit Plugin.
The question of animation has been answered in this question.
What I am uncertain of is how to alter a view's CALayer position and anchor point for the rotation and updating its frame for mouse events.
As a basic example, I wish to draw a square by creating an NSView and setting it's background colour. I then want to animate the rotation of the view, a la the previous link, on a mouseDown event.
With following setup, the view is moved to the centre of window, its anchor point is altered so that the view rotates around it and not around the origin of the window.contentView!. However, moving the view.layer.position and setting the view.layer!.anchorPoint = CGPoint(x: 0.5,y: 0.5) does not also move the frame that will detect events.
#IBOutlet weak var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification)
{
let view = customView(frame: NSRect(x: 0, y: 0, width: 50, height: 50))
window.contentView!.wantsLayer = true
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.blue.cgColor
window.contentView?.addSubview(view)
let containerFrame = window.contentView!.frame
let center = CGPoint(x: containerFrame.midX, y: containerFrame.midY)
view.layer?.position = center
view.layer?.anchorPoint = CGPoint(x: 0.5,y: 0.5)
}
The customView in this case is simply an NSView with the rotation animation from the previous link executed on mouseDown
override func mouseDown(with event: NSEvent)
{
let timeToRotate = 1
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation")
rotateAnimation.fromValue = 0.0
rotateAnimation.toValue = angle
rotateAnimation.duration = timeToRotate
rotateAnimation.repeatCount = .infinity
self.layer?.add(rotateAnimation, forKey: nil)
}
Moving the frame by setting view.frame = view.layer.frame results in the view rotating around one of its corners. So, instead I have altered the view.setFrameOrigin()
#IBOutlet weak var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification)
{
let view = customView(frame: NSRect(x: 0, y: 0, width: 50, height: 50))
window.contentView!.wantsLayer = true
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.blue.cgColor
window.contentView?.addSubview(view)
let containerFrame = window.contentView!.frame
let center = CGPoint(x: containerFrame.midX, y: containerFrame.midY)
let offsetFrameOrigin = CGPoint(x: center.x - view.bounds.width/2,
y: center.y - view.bounds.height/2)
view.setFrameOrigin(offsetFrameOrigin)
view.layer?.position = center
view.layer?.anchorPoint = CGPoint(x: 0.5,y: 0.5)
}
Setting the view.frame origin to an offset of the centre achieves what I want, but I cannot help but feel this is a little 'hacky' and that I may be approaching this the wrong way. Especially since any further change to view.layer or view.frame will result in either the animation being incorrect or events being detected outside what is drawn.
How can I alter NSView.layer so that it rotates around its centre at the same time as setting the NSView.frame so that mouse events are detected in the correct area?
Also, is altering NSView.layer.anchorPoint a correct way to set it up for rotation around its centre?
I think I'm in much the same position as you. Fortunately, I stumbled across a gist that extends NSView with a setAnchorPoint function that does the job for me (keeping the layer's anchor in sync with the main frame).
There's a fork for that gist for Swift 4. Here's the code itself:
extension NSView
{
func setAnchorPoint (anchorPoint:CGPoint)
{
if let layer = self.layer
{
var newPoint = CGPoint(x: self.bounds.size.width * anchorPoint.x, y: self.bounds.size.height * anchorPoint.y)
var oldPoint = CGPoint(x: self.bounds.size.width * layer.anchorPoint.x, y: self.bounds.size.height * layer.anchorPoint.y)
newPoint = newPoint.applying(layer.affineTransform())
oldPoint = oldPoint.applying(layer.affineTransform())
var position = layer.position
position.x -= oldPoint.x
position.x += newPoint.x
position.y -= oldPoint.y
position.y += newPoint.y
layer.position = position
layer.anchorPoint = anchorPoint
}
}
}
You'd then use it like this on the NSView directly (i.e. not its layer):
func applicationDidFinishLaunching(_ aNotification: Notification)
{
// ... //
let view = customView(frame: NSRect(x: 0, y: 0, width: 50, height: 50))
view.wantsLayer = true
view.setAnchorPoint(anchorPoint: CGPoint(x: 0.5, y: 0.5))
// ... //
}

Animate view to hide/scale into its bottom right corner

I'm trying to get a view to shrink down to nothing in the bottom right corner when its cancel button is pressed. Currently when i use:
UIView.animate(withDuration: 0.7, delay: 0, options: .beginFromCurrentState, animations: {
self.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
})
the view shrinks into its center.
I understand that I should be setting the anchor point of the layer so it shrinks to that point but when I do this it moves the view center to that point then shrinks:
UIView.animate(withDuration: 0.7, delay: 0, options: .beginFromCurrentState, animations: {
self.layer.anchorPoint = CGPoint(x: 0.0, y: 0.0)
self.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
})
Any ideas?
Edit: More info would be that im using cartography to set the view's constraints to the viewController.view edges, I'm wondering if this has something to do with it?
Frame of UIView or CALayer are actually derived properties which are calculated from layers position and anchorpoint. So, when you change either one of these, the layer frame also changes and hence you see the effect of view movement.
When you change the anchor point of the CALayer, you could also immediately set the position of the layer to compromise for the change. You can offset the view position with amount of anchor point changes relative to views bounds.
Here is a simple extension on UIView, which you could use to change the layers anchor point without actually changing the position.
extension UIView {
func set(anchorPoint: CGPoint) {
let originalPosition = CGPoint(x: frame.midX, y: frame.midY)
let width = bounds.width
let height = bounds.height
let newXPosition = originalPosition.x + (anchorPoint.x - 0.5) * width
let newYPosition = originalPosition.y + (anchorPoint.y - 0.5) * height
layer.anchorPoint = anchorPoint
layer.position = CGPoint(x: newXPosition,
y: newYPosition)
}
}
The code above changes position of CALayer as soon as you make change to the anchorPoint. You could as well do this without having to change the position.
Whenever you set the anchorPoint, simply set back previous frame,
let originalFrame = view.frame
view.layer.anchorPoint = CGPoint(x: 0, y: 0)
view.frame = originalFrame
This will prevent your view from moving and stay at the same position, despite of the change in anchorPoint.
Also, for your particular case, setting anchorPoint to layer triggers intrinsic animation. So, if you want to make it like shrink to lower or upper edge, perform anchorPoint change before animation like so,
UIView.performWithoutAnimation {
self.set(anchorPoint: CGPoint(x: 0, y: 0))
}
UIView.animate(withDuration: 0.7, delay: 0, options: .beginFromCurrentState, animations: {
self.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
})

animate along certain path of a layer in swift

I am pretty new to iOS, I am facing a small problem about the CAKeyframeAnimation, I want to animate a view to a certain path of another view layer. it is already successfully animating. However, the position of the animation is not what i expected.
As you can see in my code. I create a UIView(myView) with round bounds. I want another view(square) to follow the orbit of myView's bounds. I already set the myView's centre to the middle of the screen. then I try to get the CGPath of myView and set it to CAKeyframeAnimation's path. However, the square is rotate somewhere else, not on the bounds of myView. Could anyone help me? thanks
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
let square = UIView()
square.frame = CGRect(x: 0, y: 0, width: 20, height: 20)
square.backgroundColor = UIColor.redColor()
self.view.addSubview(square)
let bounds = CGRect(x: 0, y: 0, width: 200, height: 200)
let myView = UIView(frame: bounds)
myView.center = self.view.center
myView.layer.cornerRadius = bounds.width/2
myView.layer.borderWidth = 10
myView.layer.borderColor = UIColor.brownColor().CGColor
self.view.addSubview(myView)
var orbit = CAKeyframeAnimation(keyPath: "position")
orbit.path = CGPathCreateWithEllipseInRect(myView.layer.bounds, nil)
orbit.rotationMode = kCAAnimationRotateAuto
orbit.repeatCount = Float.infinity
orbit.duration = 5.0
square.layer.addAnimation(orbit, forKey: "orbit")
}
The frame "describes the view’s location and size in its superview’s coordinate system," whereas bounds "describes the view’s location and size in its own coordinate system."
So if you're building the path from bounds, then square would have to be a subview of myView. If you constructed the path using the frame of myView, then square could be a subview of view.
So, I find another solution, instead of create the CGPath based on some view already existed, I used
let myPath = UIBezierPath(arcCenter: myView.center, radius: bounds.width/2, startAngle: CGFloat(-(CGFloat(M_PI_2))), endAngle: CGFloat((2.0 * M_PI - M_PI_2)), clockwise: true).CGPath
in this way, I can specify the centre point of the UIBezierPath.

Rotate CGPath without changing its position

I want to rotate a CGPath, I'm using the following code:
CGAffineTransform transform = CGAffineTransformMakeRotation(angleInRadians);
CGPathRef rotatedPath = CGPathCreateCopyByTransformingPath(myPath, &transform);
This code works fine but it changes path's position! I want the path remain at the same position as it was before rotating.
A path doesn't have “a position”. A path is a set of points (defined by line and curve segments). Every point has its own position.
Perhaps you want to rotate the path around a particular point, instead of around the origin. The trick is to create a composite transform that combines three individual transforms:
Translate the origin to the rotation point.
Rotate.
Invert the translation from step 1.
For example, here's a function that takes a path and returns a new path that is the original path rotated around the center of its bounding box:
static CGPathRef createPathRotatedAroundBoundingBoxCenter(CGPathRef path, CGFloat radians) {
CGRect bounds = CGPathGetBoundingBox(path); // might want to use CGPathGetPathBoundingBox
CGPoint center = CGPointMake(CGRectGetMidX(bounds), CGRectGetMidY(bounds));
CGAffineTransform transform = CGAffineTransformIdentity;
transform = CGAffineTransformTranslate(transform, center.x, center.y);
transform = CGAffineTransformRotate(transform, radians);
transform = CGAffineTransformTranslate(transform, -center.x, -center.y);
return CGPathCreateCopyByTransformingPath(path, &transform);
}
Note that this function returns a new path with a +1 retain count that you are responsible for releasing when you're done with it. For example, if you're trying to rotate the path of a shape layer:
- (IBAction)rotateButtonWasTapped:(id)sender {
CGPathRef path = createPathRotatedAroundBoundingBoxCenter(shapeLayer_.path, M_PI / 8);
shapeLayer_.path = path;
CGPathRelease(path);
}
UPDATE
Here's a demonstration using a Swift playground. We'll start with a helper function that displays a path and marks the origin with a crosshair:
import UIKit
import XCPlayground
func showPath(label: String, path: UIBezierPath) {
let graph = UIBezierPath()
let r = 40
graph.moveToPoint(CGPoint(x:0,y:r))
graph.addLineToPoint(CGPoint(x:0,y:-r))
graph.moveToPoint(CGPoint(x:-r,y:0))
graph.addLineToPoint(CGPoint(x:r,y:0))
graph.appendPath(path)
XCPCaptureValue(label, graph)
}
Next, here's our test path:
var path = UIBezierPath()
path.moveToPoint(CGPoint(x:1000,y:1000))
path.addLineToPoint(CGPoint(x:1000,y:1200))
path.addLineToPoint(CGPoint(x:1100,y:1200))
showPath("original", path)
(Remember, the crosshair is the origin and is not part of the path we're transforming.)
We get the center and transform the path so it's centered at the origin:
let bounds = CGPathGetBoundingBox(path.CGPath)
let center = CGPoint(x:CGRectGetMidX(bounds), y:CGRectGetMidY(bounds))
let toOrigin = CGAffineTransformMakeTranslation(-center.x, -center.y)
path.applyTransform(toOrigin)
showPath("translated center to origin", path)
Then we rotate it. All rotations happen around the origin:
let rotation = CGAffineTransformMakeRotation(CGFloat(M_PI / 3.0))
path.applyTransform(rotation)
showPath("rotated", path)
Finally, we translate it back, exactly inverting the original translation:
let fromOrigin = CGAffineTransformMakeTranslation(center.x, center.y)
path.applyTransform(fromOrigin)
showPath("translated back to original center", path)
Note that we must invert the original translation. We don't translate by the center of its new bounding box. Recall that (in this example), the original center is at (1050,1100). But after we've translated it to the origin and rotated it, the new bounding box's center is at (-25,0). Translating the path by (-25,0) will not put it anywhere close to its original position!
A Swift 5 version:
func rotate(path: UIBezierPath, degree: CGFloat) {
let bounds: CGRect = path.cgPath.boundingBox
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radians = degree / 180.0 * .pi
var transform: CGAffineTransform = .identity
transform = transform.translatedBy(x: center.x, y: center.y)
transform = transform.rotated(by: radians)
transform = transform.translatedBy(x: -center.x, y: -center.y)
path.apply(transform)
}
A Swift 3 method to rotate a rectangle in place around its center:
func createRotatedCGRect(rect: CGRect, radians: CGFloat) -> CGPath {
let center = CGPoint(x: rect.midX, y: rect.midY)
let path = UIBezierPath(rect: rect)
path.apply(CGAffineTransform(translationX: center.x, y: center.y).inverted())
path.apply(CGAffineTransform(rotationAngle: radians))
path.apply(CGAffineTransform(translationX: center.x, y: center.y))
return path.cgPath
}
All of rob mayoff's code for applying this to a path and its bounding box would still apply, so I didn't see cause to repeat them.
In case you want to rotate and/or scale an UIBezierPath, or scale a CAShapeLayer while keep the center of the UIBezierPath. Here the function for Swift 4/5. It is based on answer from rob mayoff.
extension UIBezierPath {
/// perform scale on UIBezierPath and keep the position in the path's center
func scale(scale:CGFloat) {
let bounds = self.cgPath.boundingBox
let center = CGPoint(x:bounds.midX,y:bounds.midY)
var transform = CGAffineTransform.identity.translatedBy(x: center.x, y: center.y)
transform = transform.scaledBy(x: scale, y: scale)
transform = transform.translatedBy(x: -center.x, y: -center.y);
self.apply(transform)
}
/// perform rotate on UIBezierPath and keep the center unchanged
func rotate(radians:CGFloat) {
let bounds = self.cgPath.boundingBox
let center = CGPoint(x:bounds.midX,y:bounds.midY)
var transform = CGAffineTransform.identity.translatedBy(x: center.x, y: center.y)
transform = transform.rotated(by: radians)
transform = transform.translatedBy(x: -center.x, y: -center.y);
self.apply(transform)
}
}