NEW! Master Swift design patterns with my latest book! >>

< Previous: Basics quick start: SKShapeNode   Next: Enemy or bomb: AVAudioPlayer >

Shaping up for action: CGPath and UIBezierPath

Like I already explained, we're going to keep an array of the user's swipe points so that we can draw a shape resembling their slicing. To make this work, we're going to need five new methods, one of which you've met already. They are: touchesBegan(), touchesMoved(), touchesEnded(), touchesCancelled() and redrawActiveSlice().

You already know how touchesBegan() works, and the other three "touches" methods all work the same way. There's a subtle difference between touchesEnded() and touchesCancelled(): the former is called when the user stops touching the screen, and the latter is called if the system has to interrupt the touch for some reason – e.g. if a low battery warning appears. We're going to make touchesCancelled() just call touchesEnded(), to avoid duplicating code.

First things first: add this new property to your class so that we can store swipe points:

var activeSlicePoints = [CGPoint]()

We're going to tackle the three easiest methods first: touchesMoved(), touchesEnded() and touchesCancelled(). All the touchesMoved() method needs to do is figure out where in the scene the user touched, add that location to the slice points array, then redraw the slice shape, so that's easy enough:

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let touch = touches.first else { return }

    let location = touch.location(in: self)

    activeSlicePoints.append(location)
    redrawActiveSlice()
}

When the user finishes touching the screen, touchesEnded() will be called. I'm going to make this method fade out the slice shapes over a quarter of a second. We could remove them immediately but that looks ugly, and leaving them sitting there for no reason would rather destroy the effect. So, fading it is – add this touchesEnded() method:

override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    activeSliceBG.run(SKAction.fadeOut(withDuration: 0.25))
    activeSliceFG.run(SKAction.fadeOut(withDuration: 0.25))
}

You haven't used the fadeOut(withDuration:) action before, but I think it's pretty obvious what it does!

The third easy function is touchesCancelled(), and it's easy because we're just going to forward it on to touchesEnded() like this:

override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
    touchesEnded(touches, with: event)
}

So far this is all easy stuff, but we're going to look at an interesting method now: touchesBegan(). This needs to do several things:

  1. Remove all existing points in the activeSlicePoints array, because we're starting fresh.
  2. Get the touch location and add it to the activeSlicePoints array.
  3. Call the (as yet unwritten) redrawActiveSlice() method to clear the slice shapes.
  4. Remove any actions that are currently attached to the slice shapes. This will be important if they are in the middle of a fadeOut(withDuration:) action.
  5. Set both slice shapes to have an alpha value of 1 so they are fully visible.

We can convert that to code with ease – in fact, I've put numbered comments in the code below so you can match them up to the points above:

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    super.touchesBegan(touches, with: event)

    // 1
    activeSlicePoints.removeAll(keepingCapacity: true)

    // 2
    if let touch = touches.first {
        let location = touch.location(in: self)
        activeSlicePoints.append(location)

        // 3
        redrawActiveSlice()

        // 4
        activeSliceBG.removeAllActions()
        activeSliceFG.removeAllActions()

        // 5
        activeSliceBG.alpha = 1
        activeSliceFG.alpha = 1
    }
}

So, there's some challenge there but not a whole lot. Where it gets interesting is the redrawActiveSlice() method, because this is going to use a new class called UIBezierPath that will be used to connect our swipe points together into a single line.

As with the previous method, let's take a look at what redrawActiveSlice() needs to do:

  1. If we have fewer than two points in our array, we don't have enough data to draw a line so it needs to clear the shapes and exit the method.
  2. If we have more than 12 slice points in our array, we need to remove the oldest ones until we have at most 12 – this stops the swipe shapes from becoming too long.
  3. It needs to start its line at the position of the first swipe point, then go through each of the others drawing lines to each point.
  4. Finally, it needs to update the slice shape paths so they get drawn using their designs – i.e., line width and color.

To make this work, you're going to need to know that an SKShapeNode object has a property called path which describes the shape we want to draw. When it's nil, there's nothing to draw; when it's set to a valid path, that gets drawn with the SKShapeNode's settings. SKShapeNode expects you to use a data type called CGPath, but we can easily create that from a UIBezierPath.

Drawing a path using UIBezierPath is a cinch: we'll use its move(to:) method to position the start of our lines, then loop through our activeSlicePoints array and call the path's addLine(to:) method for each point.

To stop the array storing more than 12 slice points, we're going to use a new loop type called a while loop. This loop will continue executing until its condition stops being true, so we'll just give the condition that activeSlicePoints has more than 12 items, then ask it to remove the first item until the condition fails.

I'm going to insert numbered comments into the code again to help you match up the goals with the code more easily:

func redrawActiveSlice() {
    // 1
    if activeSlicePoints.count < 2 {
        activeSliceBG.path = nil
        activeSliceFG.path = nil
        return
    }

    // 2
    while activeSlicePoints.count > 12 {
        activeSlicePoints.remove(at: 0)
    }

    // 3
    let path = UIBezierPath()
    path.move(to: activeSlicePoints[0])

    for i in 1 ..< activeSlicePoints.count {
        path.addLine(to: activeSlicePoints[i])
    }

    // 4
    activeSliceBG.path = path.cgPath
    activeSliceFG.path = path.cgPath
}

At this point, we have something you can run: press Cmd+R to run the game, then tap and swipe around on the screen to see the slice effect – I think you'll agree that SKShapeNode is pretty powerful!

As the player swipes, their slices light up the screen in a bright yellow curve.

Before we're done with the slice effect, we're going to add one more thing: a "swoosh" sound that plays as you swipe around. You've already seen the playSoundFileNamed() method of SKAction, but we're going to use it a little differently here.

You see, if we just played a swoosh every time the player moved, there would be 100 sounds playing at any given time – one for every small movement they made. Instead, we want only one swoosh to play at once, so we're going to set to true a property called isSwooshSoundActive, make the waitForCompletion of our SKAction true, then use a completion closure for runAction() so that isSwooshSoundActive is set to false.

So, when the player first swipes we set isSwooshSoundActive to be true, and only when the swoosh sound has finished playing do we set it back to false again. This will allow us to ensure only one swoosh sound is playing at a time.

First, give your class this new property:

var isSwooshSoundActive = false

Now we need to check whether that's false when touchesMoved() is called, and, if it is false, call a new method called playSwooshSound(). Add this to code just before the end of touchesMoved():

if !isSwooshSoundActive {
    playSwooshSound()
}

I've provided you with three different swoosh sounds, all of which are effectively the same just at varying pitches. The playSwooshSound() method needs to set isSwooshSoundActive to be true (so that no other swoosh sounds are played until we're ready), play one of the three sounds, then when the sound has finished set isSwooshSoundActive to be false again so that another swoosh sound can play.

By playing our sound with waitForCompletion set to true, SpriteKit automatically ensures the completion closure given to runAction() isn't called until the sound has finished, so this solution is perfect.

func playSwooshSound() {
    isSwooshSoundActive = true

    let randomNumber = RandomInt(min: 1, max: 3)
    let soundName = "swoosh\(randomNumber).caf"

    let swooshSound = SKAction.playSoundFileNamed(soundName, waitForCompletion: true)

    run(swooshSound) { [unowned self] in
        self.isSwooshSoundActive = false
    }
}

Love Hacking with Swift?

Get all 40 projects in PDF and HTML: buy the Hacking with Swift book! It contains over 1300 pages of hands-on Swift coding, and will really help boost your iOS career

< Previous: Basics quick start: SKShapeNode   Next: Enemy or bomb: AVAudioPlayer >
MASTER SWIFT NOW
Buy Practical iOS 12 Buy Pro Swift Buy Swift Design Patterns Buy Practical iOS 11 Buy Swift Coding Challenges Buy Server-Side Swift (Vapor Edition) Buy Server-Side Swift (Kitura Edition) Buy Hacking with macOS Buy Advanced iOS Volume One Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with Swift Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let me know!

Click here to visit the Hacking with Swift store >>