跳转至

3.Digital Crown

Written by Scott Grosch

In the starter materials for this chapter, please open the NumberScroller project. Build and run the project. You’ll see a lonely number:

img

Scrolling the Digital Crown makes it easy for your users to edit a number’s value, such as the one shown.

If you’re debugging with a physical device, rotate the crown. If you’re debugging on the simulator, scrolling your mouse simulates turning the digital crown. Give it a spin, and watch the number change.

Binding a Value

Did nothing happen? That shouldn’t come as much of a surprise since you didn’t tell watchOS what to do when you rotated the crown.

To let the Digital Crown interact with a SwiftUI control, you use digitalCrownRotation. It takes a ridiculous number of parameters in its full form, but you’ll start with just one.

Open ContentView. Modify the Text field as follows:

Text("\(number, specifier: "%.1f")")
  .focusable()
  .digitalCrownRotation($number)

By default, a Text field won’t accept focus as it’s not an interactive element. When using the digitalCrownRotation modifier, typically you’ll also immediately precede the call with .focusable(), so watchOS lets you interact with the view you’re modifying.

digitalCrownRotation always takes a binding as the first parameter to anything which implements the BinaryFloatingPoint protocol, such as a Double. Build and run again. This time, when you scroll the Digital Crown, you’ll see the number change.

Limiting the Scroll Range

Generally, having the number scroll infinitely in either direction isn’t what you’ll want to accomplish. You can limit the range of values by adding two more parameters.

Update your method call like so:

.digitalCrownRotation($number, from: 0.0, through: 12.0)

You specified a lower limit of 0.0 and an upper limit of 12.0. Build and run the project. You’ll see some surprising results.

Scrolling all the way in either direction displays a lower value of -0.1 and an upper value of 12.1. When using ranges, you must add one more parameter to tell the Digital Crown how much to change the value.

Now, update the method call again:

.digitalCrownRotation($number, from: 0.0, through: 12.0, by: 0.1)

Build and run again. This time, you’ll get the results you were expecting.

Note

Always include the by: parameter when specifying from: and through:. That way there is no doubt of what the expected behavior is.

The Joys of Floating-Point

Remember that a floating-point value is seldom exactly what you expect it to be. The OS tracks floating-point to many more decimal places than a human does. To see what the Digital Crown is doing, add this right before .focusable():

.onChange(of: number) { print($0) }

onChange(of:perform:) lets you display a property’s new value any time it changes. Activate Xcode’s console by pressing Shift-Command-C. Then build and run yet again.

As you scroll the Digital Crown, you’ll see output like this in the console:

9.74101986498215 9.735646908273624 9.730977724331446 9.726920129971415 9.700000000000001

There are two main takeaways from that output:

  1. The bound property updates with many more values than you’d assume.
  2. The bound property is usually, but not always, limited by the by: parameter when rotation stops.

The first point doesn’t matter in an app like the one you’re currently working on since the display formats the value as you’d expect. The user will never see those intermediate values.

What is important to keep in mind is the second point. When the Digital Crown stops moving, you might have a value of 9.700000000000001 instead of the expected 9.7. Depending on what you’ve bound the Digital Crown’s value to, that may or may not make a difference.

If you need to perform an equality check against a floating point number, use the roundedmethod, like this:

if (value * 10).rounded(.towardZero) / 10 == 9.7 {
  ...
}

rounded(.towardZero) rounds the value provided to an integral value by always rounding toward zero. You multiply and divide by ten to the power of X, where X is the number of decimal places you’re comparing against.

In the example, 9.7 has a single decimal place, so you compare against 10. If you were comparing against 9.78, then it’s two decimal places, thus 10 ^ 2, or 100.

Sensitivity

When scrolling the Digital Crown, you can specify how sensitive the update should be. Think of the sensitivity as how much you need to scroll the crown for a change to take effect. Add another parameter:

.digitalCrownRotation(
  $number,
  from: 0.0,
  through: 12.0,
  by: 0.1,
  sensitivity: .high
)

Build and run, paying attention to how much you have to turn the Digital Crown to scroll through the full range. Next, change the value from .high to .medium and try again. Finally, change .medium to .low and try a final time.

When the value is .low, you’ll notice you have to turn the Digital Crown quite a bit more than when you set the value to .high. The default value is .high.

Wrapping Around

Until now, when you reached the from or through values, the number stopped changing. You could continue turning the Digital Crown, but it wouldn’t make any updates.

For a number range, stopping makes sense. In other scenarios, where the actual value isn’t visible to the end-user, wrapping the value around to the starting value might make sense.

In other words, you can scroll up to 12, as normal, but if you keep scrolling the value goes to 0 and starts climbing again. Apple calls this continuous scrolling. The default is false, but you can enable it by adding another parameter:

.digitalCrownRotation(
  $number,
  from: 0.0,
  through: 12.0,
  by: 0.1,
  sensitivity: .high,
  isContinuous: true
)

Build and run again. Continuously scroll the Digital Crown, and you’ll see the numbers wrap around repeatedly.

Haptic Feedback

By default, scrolling the Digital Crown provides a small amount of haptic feedback to the user. If that doesn’t make sense for your app, you can turn it off by using the final parameter to the digitalCrownRotation call:

.digitalCrownRotation(
  $number,
  from: 0.0,
  through: 12.0,
  by: 0.1,
  sensitivity: .high,
  isContinuous: true,
  isHapticFeedbackEnabled: false
)

Note

The simulator never provides haptic feedback. :]

Pong

While scrolling numbers is super fun, you won’t always show the actual numeric value to your app’s user.

In 1972, Atari released Pong, the first commercially successful video game. At the time, it was groundbreaking. Show it to your kids today… I dare you. :]

Each player controlled a paddle that only moved up and down to block an incoming ball. Seems like a perfect use case for the Digital Crown to me!

Open Pong.xcodeproj from this project’s starter materials. Then build and run, preparing yourself for a visual feast:

img

While clearly a gorgeous app, you currently have no way to move the paddle, so the player on the left scores continuously. Time to fix that.

Hooking Up the Digital Crown

Open ContentView. You’ll see that Pong is implemented as a SpriteKit game. The SpriteView(scene:) initializer makes adding a SpriteKit scene to your SwiftUI app easy. To provide the scene’s size to SpriteKit, the SpriteView is wrapped in a GeometryReader.

You need to link the crownPosition to the value of the Digital Crown. Add these two modifiers right after the SpriteView line:

.focusable()
.digitalCrownRotation($crownPosition)

In this case, you don’t need any of the numerous options available on the .digitalCrownRotation call. All that matters is you constantly update the value of crownPosition. The SpriteKit scene will handle the bounds checking. Your ContentViewshouldn’t know or care about the scene’s logic.

Now that you’ve linked the Digital Crown to the scene, it’s time to move those paddles.

Paddle Movement

Open PongScene, which implements all of the SpriteKit scene logic. Scroll down to the overridden update(_:). SpriteKit will call update(_:) once per frame before it simulates any actions or physics.

Your goal is to have the defender’s paddle move in reaction to the user rotating the Digital Crown. Movement means an action taking place, so update(_:) is the proper location to respond to the Digital Crown’s value changing.

First, you need to determine whether the user rotated the Digital Crown since the last frame update. At the end of the method, add:

let newPosition = crownPosition

// 1
defer { previousCrownPosition = newPosition }

// 2
guard newPosition != previousCrownPosition else { return }

Here’s what the code does:

  1. Regardless of why you exit the method, the code ensures the current crown position reflects as previousCrownPosition on the next update.
  2. If there’s no change, you exit the method right away to keep performance as fast as possible.

Once you know the paddle needs to move, assign a new position by adding this code to the end of the method:

// 1
let offset = newPosition - previousCrownPosition
let y = paddleBeingMoved.position.y + offset

// 2
guard minPaddleY ... maxPaddleY ~= y else { return }

// 3
paddleBeingMoved.position.y = y

In the preceding code, you:

  1. Calculate the amount by which the user scrolled the Digital Crown, storing the paddle’s new location.
  2. Perform bounds checking on the paddle’s location. The scene knows the minimum and maximum y values, so if you continue to turn the Digital Crown, the paddle doesn’t leave the game zone.
  3. Everything is copacetic, so you tell the paddle about its new location.

The operators used in step two may be unfamiliar to you. The ... range operator works similarly to the more familiar ..< operator. The difference is the former includes the final value, whereas the latter doesn’t. Using a range operator with the contains operator (~=) lets you determine if a value is in the range.

Step two is a more concise syntax for this equivalent code:

guard y >= minPaddleY && y <= maxPaddleY else {
  return
}

Build and run again. You should now have a playable Pong video game in your wrist!

Note

This was my first ever SpriteKit project. If you’re a professional game developer, try not to cringe too much. :]

Key Points

  • Remember to add the focusable method modifier when using the Digital Crown.
  • If you need to compare two floating-point values for equality, use the rounded(.towardZero) method.