SpriteKit – Anchors & Safe Area

I’m still on my SpriteKit journey and am ready to tackle a few more hurdles. Primarily, these include making some easy and reusable code to allow me to place one node relative to another. We will then expand on this solution to help us place one node relative to the screen’s edges. Finally, this solution will also show how to handle the “safe area” you’ll see on an iPhone X.

This project begins from my previous project demo, SpriteKit Recipe – Custom Scale Mode. Feel free to grab the completed project from that lesson if you’d like to follow along. Otherwise, you can always just check out the completed project at the end of this post.

Scene Setup

Open the GameScene.sks file so that we can add a couple of test nodes to work with:

  1. First I created a sprite node at the center of the screen:
    • Name: Container
    • Position: (0, 0)
    • Size: (200, 200)
    • Anchor Point: (0.5, 0.5)
    • Color: Red
  2. Next I created a child sprite node of the Container so I could test parent child anchoring:
    • Name: First Child
    • Position: (0, 0)
    • Size: (100, 100)
    • Anchor Point: (0.5, 0.5)
    • Color: Green
  3. Next I created another child sprite node of the Container so I could test sibling anchoring:
    • Name: Last Child
    • Position: (100, 100)
    • Size: (50, 50)
    • Anchor Point: (1, 1)
    • Color: Blue
  4. Next I created another sprite node outside of the Container so I could test anchoring even outside of a nodes hierarchy:
    • Name: Other
    • Position: (160, -50)
    • Size: (100, 100)
    • Anchor Point: (0.5, 0.5)
    • Color: Red
  5. Finally I created an empty node named to represent the “safe area” of the screen:
    • Name: Safe Area
    • Position: (0, 0)
    • Custom Class: SafeAreaNode

Game Scene

Now that I have created a few nodes to play with, open the GameScene.swift file so we can hook it all together. First, I will declare some fields to hold the various node references:

[csharp]
private var container: SKSpriteNode!
private var firstChild: SKSpriteNode!
private var secondChild: SKSpriteNode!
private var other: SKSpriteNode!
// private var safeArea: SafeAreaNode!
[/csharp]

Using the “didMove(to view:)” we will assign the node instances to the new fields:

[csharp]
override func didMove(to view: SKView) {
super.didMove(to: view)
container = childNode(withName: “Container”) as? SKSpriteNode
firstChild = container.children.first as? SKSpriteNode
secondChild = container.children.last as? SKSpriteNode
other = childNode(withName: “Other”) as? SKSpriteNode
// safeArea = childNode(withName: “Safe Area”) as? SafeAreaNode
}
[/csharp]

Next, let’s add a public method called “refresh” which we can call from the ViewController whenever it has finished the layout of its subviews. Note that the safe area of the screen’s view will also be known at this point.

[csharp]
func refresh() {

}
[/csharp]

We will populate the “refresh” method with a few demo test cases in a moment. Also note that we will implement the safe area node at a later point.

Game View Controller

The view controller receives several important events, such as when a layout of the screen’s subviews has completed. We are going to use the “viewDidLayoutSubviews” method as an opportunity to invoke our new “refresh” method on the GameScene. Add the following line to the end of that method, just after we have finished updating the scene’s size:

[csharp]
(view.scene as? GameScene)?.refresh()
[/csharp]

CGPoint Extensions

For convenience, I added a few methods that allow us to add and subtract CGPoint structs. I also declared several new static points with names reflecting common layout locations for user interface items. We will use these as “Anchor Points” during layout of our nodes relative to each other.

[csharp]
func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}

func -(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
return CGPoint(x: lhs.x – rhs.x, y: lhs.y – rhs.y)
}

extension CGPoint {
static var upperLeft: CGPoint {
return CGPoint(x: 0, y: 1)
}

static var upperCenter: CGPoint {
return CGPoint(x: 0.5, y: 1)
}

static var upperRight: CGPoint {
return CGPoint(x: 1, y: 1)
}

static var middleLeft: CGPoint {
return CGPoint(x: 0, y: 0.5)
}

static var middleCenter: CGPoint {
return CGPoint(x: 0.5, y: 0.5)
}

static var middleRight: CGPoint {
return CGPoint(x: 1, y: 0.5)
}

static var lowerLeft: CGPoint {
return CGPoint(x: 0, y: 0)
}

static var lowerCenter: CGPoint {
return CGPoint(x: 0.5, y: 0)
}

static var lowerRight: CGPoint {
return CGPoint(x: 1, y: 0)
}
}
[/csharp]

SKNode Extensions

I created an extension of SKNode (note that it doesnt have to be a sprite) to hold our anchoring methods. These are the methods used to align one node against another.

[csharp]
extension SKNode {
func anchored(value: CGPoint, target: SKNode? = .none) -> CGPoint {
guard let target = target ?? parent else { return position }
let targetMin = convert(CGPoint(x: target.frame.minX, y: target.frame.minY), to: self)
let targetMax = convert(CGPoint(x: target.frame.maxX, y: target.frame.maxY), to: self)
let xPos = (targetMax.x – targetMin.x) * value.x + targetMin.x
let yPos = (targetMax.y – targetMin.y) * value.y + targetMin.y
return CGPoint(x: xPos, y: yPos)
}

func anchor(local: CGPoint, other: CGPoint, target: SKNode? = .none) -> CGPoint {
let targetPos = anchored(value: other, target: target)
let xPos = (frame.maxX – frame.minX) * local.x + frame.minX
let yPos = (frame.maxY – frame.minY) * local.y + frame.minY
let offset = CGPoint(x: targetPos.x – xPos, y: targetPos.y – yPos)
let result = offset + position
return result
}
}
[/csharp]

The first method, “anchored(value: target:)” will return a new position that could cause the node to be placed relative to another node. In other words, this could allow something like “position the current node at the upper right corner of the target node”. It doesn’t take into account the size of the node to be moved, nor does it take into account the anchor point of a sprite, it handles position only. The “value” parameter represents the proportional desired location of the target node – and the point would typically use values in the range of 0-1, just like our named anchor points. The “target” parameter is optional – if you don’t specify a target, then the method will assume you meant to anchor the node against its parent node. You can choose to specify any node, including sibling nodes or even nodes outside of the current node’s hierarchy. This is possible because the frame of the target is converted to the coordinate space of the node’s parent before the final position is calculated.

The second method, “anchor(local: other: target:)” is similar to the first, but it will also take into account the size and anchor point of a sprite. In other words, this could allow something like “position the lower left corner of the current node at the lower left corner of the parent node”. All of this would work even if the node was a sprite with a non-zero anchor point – in this case the actual anchor point is left in tact, which could be great if you wanted to place things easily, but retain a certain anchor point due to the way you wanted an item to rotate.

Anchoring Demo

Head back to the GameScene.swift file and find the “refresh” method. First, let’s try positioning the “First Child” node against its parent container. Add any of the following lines to the “refresh” method’s body (but only one at a time). I took screen grabs showing the results of each.

[csharp]
// #1
firstChild.position = firstChild.anchored(value: CGPoint.upperCenter)

// #2
firstChild.position = firstChild.anchored(value: CGPoint.middleLeft)

// #3
firstChild.position = firstChild.anchored(value: CGPoint.lowerRight)
[/csharp]

Note that in each example, the “center” of the green square appears at the indicated position of the container node. This is because the node which is being moved has its anchor point set to (0.5, 0.5).

Next, try positioning the “Second Child” against its sibling node. Add any of the following lines to the “refresh” method’s body (but only one at a time). I took screen grabs showing the results of each.

[csharp]
// #4
secondChild.position = secondChild.anchored(value: CGPoint.upperLeft, target: firstChild)

// #5
secondChild.position = secondChild.anchored(value: CGPoint.middleRight, target: firstChild)

// #6
secondChild.position = secondChild.anchored(value: CGPoint.lowerCenter, target: firstChild)
[/csharp]

In each example, the “upper right” of the blue square appears at the indicated position of the sibling node. This is because the node which is being moved has its anchor point set to (1, 1).

Finally, lets try moving the “Second Child” against a node outside of its hierarchy. Add any of the following lines to the “refresh” method’s body (but only one at a time). I took screen grabs showing the results of each.

[csharp]
// #7
secondChild.position = secondChild.anchored(value: CGPoint.upperRight, target: other)

// #8
secondChild.position = secondChild.anchored(value: CGPoint.middleCenter, target: other)

// #9
secondChild.position = secondChild.anchored(value: CGPoint.lowerRight, target: other)
[/csharp]

All of the samples up to this point used the first anchoring method. Now, lets use the second version, which lets us specify an “anchor” on both the moving node and the target node. I have provided an example against a parent, sibling, and outsider. Add any of the following lines to the “refresh” method’s body (but only one at a time). I took screen grabs showing the results of each.

[csharp]
// #10
firstChild.position = firstChild.anchor(local: CGPoint.lowerLeft, other: CGPoint.lowerLeft)

// #11
firstChild.position = firstChild.anchor(local: CGPoint.lowerRight, other: CGPoint.lowerLeft, target: secondChild)

// #12
firstChild.position = firstChild.anchor(local: CGPoint.lowerRight, other: CGPoint.upperLeft, target: other)
[/csharp]

Safe Area

Hopefully by now you realize how easy it can be to layout any node against any other node. However, the “screen edge” isn’t a node. How can you make a node anchor to the upper right corner of a screen? Or, perhaps more challenging, how to you make it anchor to the safe area, so that it is right in the corner of an iPhone 6, but moved further down on an iPhone X to accomodate the curved edge? My answer is to create a new type of node called a “SafeAreaNode” which represents the safe area of a screen. Because it is a subclass of an SKNode, all of the previous anchoring methods will work with it.

[csharp]
class SafeAreaNode: SKNode {
override var frame: CGRect {
get {
return _frame
}
}
private var _frame: CGRect = CGRect.zero

func refresh() {
guard let scene = scene, let view = scene.view else { return }
let scaleFactor = min(scene.size.width, scene.size.height) / min(view.bounds.width, view.bounds.height)
let x = view.safeAreaInsets.left * scaleFactor
let y = view.safeAreaInsets.bottom * scaleFactor
let width = (view.bounds.size.width – view.safeAreaInsets.right – view.safeAreaInsets.left) * scaleFactor
let height = (view.bounds.size.height – view.safeAreaInsets.bottom – view.safeAreaInsets.top) * scaleFactor
let offsetX = scene.size.width * scene.anchorPoint.x
let offsetY = scene.size.height * scene.anchorPoint.y
_frame = CGRect(x: x – offsetX, y: y – offsetY, width: width, height: height)
}
}
[/csharp]

The anchoring methods all operate on an SKNode’s “frame” property which is normally read-only. I worked around this issue by creating a private backing field called “_frame” which is returned from the “frame” getter. I will need to call the “refresh” method of this node in order to get it to update its frame based on the scene’s view’s safe area.

Safe Area Demo

Now we can show some examples of anchoring to the screen edge. Head back to the GameScene.swift file and uncomment the declaration of the “safeArea” field, and also uncomment the last line of the “didMove(to view:)” method where we assign its reference. Finally, let’s make the last example anchor the “First Child” against the upper right corner of the screen. Let’s also give it a little bit of padding. The code should look like this:

[csharp]
func refresh() {
safeArea.refresh()
firstChild.position = firstChild.anchor(local: CGPoint.upperRight, other: CGPoint.upperRight, target: safeArea) – CGPoint(x: 20, y: 20)
}
[/csharp]

Now run the demo on a device that doesn’t require a safe area, such as the iPhone 6. Then try the demo on an iPhone X, try in each orientation to get a couple of different safe area requirements.

Summary

How do you “easily” position one node to another node? Does it work the same way for parents, siblings, and nodes outside of the immediate hierarchy? How do you constrain UI elements, like labels or buttons, to the edge of the screen? Finally, how do you make sure all of this works with the new “safe area” introduced in iPhone X? These are all questions which we solved in this lesson – see it in the completed project here. Hopefully this will make the layout of your scene elements notably easier in any of your own projects!

Become a Patron!

9 thoughts on “SpriteKit – Anchors & Safe Area

  1. Please never stop what you’re doing. You make learning fun and affordable for people like me who can’t afford textbooks or to go to university.

    Please know that you’re impacting the lives of those around you even if we never speak up.

    Thank you.

    1. Great question, I haven’t tried working with the SKConstraints yet, but I skimmed over the documentation. So far it looks to me like my anchors and these constraints serve different purposes, but that they could definitely be used in combination. For example, my code will help determine placement coordinates even for dynamically sized nodes (such as my safe area node), and because it returns the calculated position you can choose whether to use it immediately or to use it for animation key frames. You could potentially use the calculated position with an SKConstraint as the “point” that you want to maintain a certain distance to in case the other node moves.

      In my case, I pretty much only want to apply the anchoring to UI when the screen loads, or possibly on an orientation change, or for animation of UI based on game state etc. Generally, I only want to do these calculations based on events. In contrast, I would imagine that constraints get calculated every frame. If that is the case, then using anchors for my UI would be more efficient.

  2. Hi, hope you can still help in 2020!

    I came across your code and I’m trying to implement it into my card game, I have a MenuScene that is called from GameViewController and once in the MenuScene you can click new game and it takes you to my GameScene.
    Your code is working fine in the MenuScene but I can’t get it to work in my GameScene, I did try adding reference to the GameScene like below, but it didn’t help:

    override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    guard let view = self.view as? SKView else { return }
    let resize = view.frame.size.asepctFill( CGSize(width: 480, height: 480) )
    view.scene?.size = resize
    (view.scene as? MenuScene)?.refresh()
    (view.scene as? GameScene)?.refresh()
    }

    any idea’s how I can get it to work on any other scene?

    1. I’ll try my best – I wondered if anyone was using SpriteKit 🙂

      It might help if you were a little clearer about what the actual problem is. It could be that you are making an assumption that viewDidLayoutSubviews is called after changing from the menu scene to the game scene and it isn’t? You could try adding the resize code to wherever you actually create the new scene, assuming that resizing is the issue?

  3. Hi, I just experimented to see if viewDidLayoutSubviews is called after changing from the menu scene to the game scene but it didn’t work.

    A clearer question is, how to get this working on multiple scenes? I can only get it to work on the scene that is called from GameViewController

    1. “viewDidLayoutSubviews” is called by a ViewController on its View. These are UIKit elements and are called at times relevant to setting up the screen itself. That layout will only need to be performed at special events such as when first creating the screen, or perhaps by changing orientations from portrait to landscape etc.

      After the view has already been created, you should be more interested in SpriteKit methods. For example, you can use the view to display a new scene using “presentScene”. At the time you invoke that method you should have a reference to the scene you want to display, so you should be able to configure its scale mode, or look through its hierarchy to find nodes and use them accordingly.

      In addition, a subclass of SKScene can override the implementation of “didMove(to view: SKView)” and use that as an opportunity for setup, such as calling refresh on your safe area node and anchoring your other sprites.

  4. I managed to get it working by adding the line when presenting the new scene:

    if touchnode == playLabel {

    if view != nil {

    let gameScene = GameScene(size: UIScreen.main.bounds.size)
    gameScene.scaleMode = .aspectFill

    // transistion to GameScene
    let transition:SKTransition = SKTransition.fade(withDuration: 1)

    // move to GameScene file
    self.view?.presentScene(gameScene, transition: transition)
    (gameScene.scene as? GameScene)?.refresh()
    }
    }

Leave a Reply

Your email address will not be published. Required fields are marked *