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:

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

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

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
}

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.

func refresh() {

}

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:

(view.scene as? GameScene)?.refresh()

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.

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)
    }
}

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.

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
    }
}

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.

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

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

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

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.

// #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)

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.

// #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)

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.

// #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)

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.

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)
    }
}

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:

func refresh() {
    safeArea.refresh()
    firstChild.position = firstChild.anchor(local: CGPoint.upperRight, other: CGPoint.upperRight, target: safeArea) - CGPoint(x: 20, y: 20)
}

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!

2 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.

Leave a Reply

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