Apps with Attitude - Learn iOS 8 App Development, Second Edition (2014)

Learn iOS 8 App Development, Second Edition (2014)

Chapter 16. Apps with Attitude

In a feat of miniaturization that would make Wayne Szalinski1 proud, most iOS devices are equipped with an array of sensors that detect acceleration, rotation, and magnetic orientation—which is a lot of “ations.” The combined output of these sensors, along with a little math, will tell your app with surprising accuracy the attitude the device is being held in, whether it’s being moved or rotated (and how fast), the direction of gravity, and the direction of magnetic north. You can incorporate this into your app to give it an uncanny sense of immediacy. You can present information based on the direction the user is holding their device, control games through physical gestures, tell them whether the picture they’re about to take is level, and so much more.

In Chapter 4, you used the high-level “device shake” and “orientation change” events to trigger animations in the EightBall app. In this chapter, you’ll plug directly into the low-level accelerometer information and react to instantaneous changes in the device’s position. In this chapter, you will learn to do the following:

· Collect accelerometer and other device motion data

· Use timers

You’ll also get some more practice using affine transformations in custom view objects and use some of the fancy new animation features added in iOS 7. Let’s get started.

Note You will need a provisioned iOS device to test the code in this chapter. The iOS simulator does not emulate accelerometer data.

Leveler

The app you’re going to create is a simple, digital level called Leveler.2 It’s a one-screen app that displays a dial indicating the inclination (angle from an imaginary vertical plumb line) of the device, as shown in Figure 16-1.

image

Figure 16-1. Leveler design

Creating Leveler

Create a new Xcode project, as follows:

1. Use the single-view application template.

2. Set the product name to Leveler.

3. Set the language to Swift.

4. Set devices to Universal.

5. After creating the project, edit the supported interface orientations to support all device orientations.

Leveler is going to need some image and source code resources. You’ll find the image files in the Learn iOS Development Projects image Ch 16 image Leveler (Resources) folder. Add the hand.png and hand@2x.png files to the Images.xcassets image catalog. In the finished Leveler-1 project folder, locate the DialView.swift file. Add it to your project too, alongside your other source files. Remember to check the Copy items into destination group’s folder option in the import dialog. You’ll also find a set of app icons in the Leveler (Icons) folder that you can drop into the AppIcon group of the image catalog.

You’ll first lay out and connect the views that will display the inclination before getting to the code that gathers the accelerometer data.

Pondering DialView

The source file you just added contains the code for a custom UIView object that draws a circular “dial.” After reading Chapter 11, you shouldn’t have any problem figuring out how it works. The most interesting aspect is the use of affine transforms in the graphics context. In Chapter 11, you applied affine transforms to a view object, so it appeared either offset or scaled from its actual frame. In DialView, an affine transform is applied to the graphics context before drawing into it. Anything drawn afterward is translated using that transform.

In DialView, this technique is used to draw the tick marks and angle labels around the inside of the “dial.” If you’re interested, find the drawRect(_:) function in DialView.swift. The significant bits of code are in bold. Distracting code has been replaced with ellipses.

let circleDegrees = 360
let minorTickDegrees = 3
...

override func drawRect(rect: CGRect) {
let context = UIGraphicsGetCurrentContext()
let bounds = self.bounds
let radius = bounds.height/2.0
...
CGContextTranslateCTM(context,radius,radius)
let tickAngle = CGFloat(minorTickDegrees)*CGFloat(M_PI/180.0)
let rotation = CGAffineTransformMakeRotation(tickAngle)
for var angle = 0; angle < circleDegrees; angle += minorTickDegrees {
... draw one vertical tick and label ...
CGContextConcatCTM(context,rotation);
}
}

The drawRect(_:) function first applies a translate transform to the context. This offsets the drawing coordinates, effectively changing the origin of the view’s local coordinate system to the center of the view. (The view is always square, as you’ll see later.) After applying this transform, if you drew a shape at (0,0), it will now draw at the center of the view, rather than the upper-left corner.

The loop draws one vertical tick mark and an optional text label below it. At the end of the loop, the drawing coordinates of the context are rotated 3°. The second time through the loop, the tick mark and label will be rotated 3°. The third time through the loop all drawing will be rotated 6°, and so on, until the entire dial has been drawn. Context transforms accumulate.

The key concept to grasp is that transformations applied to the Core Graphics context affect the coordinate system of what’s being drawn into the view, as shown in Figure 16-2. Context transforms don’t change its frame, bounds, or where it appears in its superview.

image

Figure 16-2. Graphics context transformation

To change how the view appears in its superview, you set the transform property of the view, as you did in the Shapely app. And that’s exactly what the view controller will do (later) to rotate the dial on the screen. This underscores the difference between using affine transforms while drawing versus using a transform to alter how the finished view appears.

Also note that the view draws itself only once. All of this complicated code in drawRect(_:) executes only when the view is first drawn or resized. Once the view is drawn, the cached image of the dial appears in the display and gets rotated by the view’s transform property. This second use of a transform simply transcribes the pixels in the cached image; it doesn’t cause the view to redraw itself at the new angle. In this respect, the drawing is efficient. This is important because later you’re going to animate it.

Creating the Views

You’re going to add a label object to the storyboard file and then write code in ViewController to programmatically create the DialView and the image view that displays the “needle” of the dial. Start with the Main.storyboard file.

Drag a label object into the interface. Using the attributes inspector, change the following:

· Text: 360° (press Option+Shift+8 to type the degree symbol)

· Color: White Color

· Font: System 90.0

· Alignment: middle

Select the label object and choose Editor image Size to Fit Content. Select the root view object and change its background color to Black Color. At this point, your interface should look like the one in Figure 16-3.

image

Figure 16-3. Adding an angle label to the interface

Select the label and click the pin constraints control. Add a top constraint and set its value to 0, as shown on the left in Figure 16-4. Click the Align Constraints control. Add a Horizontal Center in Container View constraint, as shown on the right in Figure 16-4, also making sure its value is 0.

image

Figure 16-4. Adding label constraints

Note Notice that no constraints for the height, width, left, right, or bottom were set for the label view, yet Interface Builder is perfectly happy with the layout. That’s because views like UILabel have an intrinsic size. For a label, it’s the size of the text in the label. In the absence of constraints that would determine the view’s size (height and width), iOS uses its intrinsic size, which is enough information to determine its frame.

Switch to the assistant editor. The editing pane will split, and the ViewController.swift file will appear in the pane on the right. Add this outlet property to the ViewController class:

@IBOutlet var angleLabel: UILabel!

Connect the outlet to the label view in the interface, as shown in Figure 16-5.

image

Figure 16-5. Connecting angle label outlet

You’ll create and position the other two views programmatically. Switch back to the standard editor and select the ViewController.swift file. You’ll need the name of the image resource file and some instance variables to keep a reference to the dial and image view objects. Start by adding those to the beginning of the ViewController class (new code in bold):

class ViewController: UIViewController {
let handImageName = "hand"
@IBOutlet var angleLabel: UILabel!
var dialView: DialView!
var needleView: UIImageView!

Create the two views when the view controller loads its view. Since this is the app’s only view controller, this will happen only once. Find the viewDidLoad() function and add the following bold code:

override func viewDidLoad() {
super.viewDidLoad()
dialView = DialView(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
view.addSubview(dialView)
needleView = UIImageView(image: UIImage(named: handImageName))
needleView.contentMode = .ScaleAspectFit
view.insertSubview(needleView, aboveSubview: dialView)
adaptInterface()
}

When the view is loaded, the additional code creates new DialView and UIImageView objects, adding both to the view. Notice that needleView is deliberately placed in front of dialView.

No attempt is made to size or position these views. That happens when the view is displayed or rotated. Catch those events by adding these two functions:

override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
positionDialViews()
}

override func viewWillTransitionToSize(size: CGSize, image
withTransitionCoordinator coordinator: image
UIViewControllerTransitionCoordinator) {
coordinator.animateAlongsideTransition( {
(context) in self.positionDialViews()
},
completion: nil )
}

Just before the view appears for the first time, you call positionDialViews() to position the dialView and needleView objects. The second function is called whenever the view controller changes size. For a single-view app, like Leveler, that’s going to happen only when the device is rotated. In this function, you call positionDialViews() again to animate the transition to the new size. This works because the closure block you pass as the first argument is executed as a block-based animation. And as you know from Chapter 11, all you have to do in an animation block is set the view properties you want animated—which is exactly what positionDialViews() does.

You’ll also want to add this function for the iPhone version:

override func supportedInterfaceOrientations() -> Int {
return Int(UIInterfaceOrientationMask.All.rawValue)
}

While you edited the supported orientations for the app, remember (from Chapter 14) that each view controller dictates which orientations it supports. By default, the iPhone’s UIViewController does not support upside-down orientation. This code overrides that to allow all orientations.

Finally, you’ll need the code for adaptInterface() (in Listing 16-1) and positionDialViews() (in Listing 16-2).

Listing 16-1. adaptInterface( )

func adaptInterface() {
if let label = angleLabel {
var fontSize: CGFloat = 90.0
if traitCollection.horizontalSizeClass == .Compact {
fontSize = 60.0
}
label.font = UIFont.systemFontOfSize(fontSize)
}
}

Your adaptInterface() function considers the horizontalSizeClass and adjusts the font size of the angle label to 60.0 points for compact devices (iPhone, iPod) and leaves it at 90.0 points for larger interfaces (iPad). The horizontalSizeClass doesn’t change during rotation, so adaptInterface() needs to be called once (from viewDidLoad()). If adaptInterface() made other adjustments or Apple releases new devices with a horizontalSizeClass that could change while your app is running, you would need to call it fromviewWillTransitionToTraitCollection(...).

Listing 16-2. positionDialViews( )

func positionDialViews() {
let viewBounds = view.bounds
let labelFrame = angleLabel.frame
let topEdge = ceil(labelFrame.maxY+labelFrame.height/3.0)
let dialRadius = viewBounds.maxY-topEdge
let dialHeight = dialRadius*2.0
dialView.transform = CGAffineTransformIdentity
dialView.frame = CGRect(x: 0.0,
y: 0.0,
width: dialHeight,
height: dialHeight)
dialView.center = CGPoint(x: viewBounds.midX,
y: viewBounds.maxY)
dialView.setNeedsDisplay()

let needleSize = needleView.image.size
let needleScale = dialRadius/needleSize.height
var needleFrame = CGRect(x: 0.0,
y: 0.0,
width: needleSize.width*needleScale,
height: needleSize.height*needleScale)
needleFrame.origin.x = viewBounds.midX-needleFrame.width/2.0
needleFrame.origin.y = viewBounds.maxY-needleFrame.height
needleView.frame = CGRectIntegral(needleFrame)
}

positionDialViews() looks like a lot of code, but all it’s doing is sizing the dialView so it is square, positioning its center at the bottom center of the view, and sizing it so its top edge is just under the bottom edge of the label view. The needleView is then positioned so it’s centered and anchored to the bottom edge and scaled so its height equals the visible height of the dial. This is a lot harder to describe than it is to see, so just run the app and see what I mean in Figure 16-6.

image

Figure 16-6. Dial and needle view positioning

That pretty much completes all of the view design and layout. Now you need to get the accelerometer information and make your app do something.

Getting Motion Data

All iOS devices (as of this writing) have accelerometer hardware. The accelerometer senses the force of acceleration along three axes: x, y, and z. If you face the screen of your iPhone or iPad in portrait orientation, the x-axis is horizontal, the y-axis is vertical, and the z-axis is the line that goes from you, through the middle of the device, perpendicular to the screen’s surface, as shown in Figure 16-7.

image

Figure 16-7. Orientation of accelerometer axes

You can use accelerometer information to determine when the device changes speed and in what direction. Assuming it’s not accelerating (much), you can also use this information to infer the direction of gravity since gravity exerts a constant force on a stationary body. This is the information iOS uses to determine when you’ve flipped your iPad on its side or when you’re shaking your iPhone.

In addition to the accelerometer, recent iOS devices also include a gyroscope and a magnetometer. The former detects changes in rotation around the three axes (pitch, roll, yaw), and the magnetometer detects the orientation of a magnetic field. Barring magnetic interference, this will tell you the device’s attitude relative to magnetic north. (This is a fancy way of saying it has a compass.)

Your app gets to all of this information through a single gatekeeper class: CMMotionManager. The CMMotionManager class collects, interprets, and delivers movement and attitude information to your app. You tell it what kind(s) of information you want (accelerometer, gyroscope, compass), how often you want to receive updates, and how those updates are delivered to your app. Your Leveler app will use only accelerometer information, but the general pattern is the same for all types of motion data:

1. Create an instance of CMMotionManager.

2. Set the frequency of updates.

3. Choose what information you want and how your app will get it (pull or push).

4. When you’re ready, start the delivery of information.

5. Process motion data as it occurs.

6. When you’re done, stop the delivery of information.

There’s no better place to start than step 1.

Creating CMMotionManager

CoreMotion is not part of the standard UIKit framework. At the beginning of your ViewController.swift file, pull in the CoreMotion framework definitions (new code in bold):

import UIKit
import CoreMotion

You’ll need a variable to store your CMMotionManager object and a constant to specify how fast you want motion data updates. Add both to your ViewController class:

lazy var motionManager = CMMotionManager()
let accelerometerPollingInterval: NSTimeInterval = 1.0/15.0

The motionManager variable is automatically initialized with a CMMotionManager object. Notice that the property is lazy. Lazy properties in Swift are automatically initialized, but instead of being initialized when your ViewController object is created, it waits until someone requests the value for the first time. This defers the construction of the CMMotionManager object until it’s actually needed.

You’ve completed the first step in using motion data—a new CMMotionManager object will be created and stored in the motionManager property.

Caution Do not create multiple instances of CMMotionManager. If your app has two or more controllers that need motion data, they must share a single instance of CMMotionManager. I suggest creating a property in your application delegate that returns a singletonCMMotionManager object, which can then be shared with any other objects that needs it.

Now perform the second step in using motion data. Locate the viewWillAppear() function and add this code to the end of the function:

motionManager.accelerometerUpdateInterval = accelerometerPollingInterval

This statement tells the manager how long to wait between measurements. This property is expressed in seconds. For most apps, 10 to 30 times a second is adequate, but extreme apps might need updates as often as 100 times a second. For this app, you’ll start with 15 updates per second by setting the accelerometerUpdateInterval property to 1/15th of a second.

Starting and Stopping Updates

To perform the third and fourth steps in getting motion data, return to the viewWillAppear() function and add this statement to the end:

motionManager.startAccelerometerUpdates()

After creating and configuring the motion manager, you request that it begin collecting accelerometer data. The accelerometer information reported by CMMotionManager won’t be accurate—or even change—until you begin its update process. Once started, the motion manager code works tirelessly in the background to monitor any changes in acceleration and report those to your app.

Tip To conserve battery life, your app should request updates from the motion manager only while your app needs them. For this app, motion events are used for the lifetime of the app, so there’s no code to stop them. If you added a second view controller, however, that didn’t use the accelerometer, you’d want to add code to viewWillDisappear() to call stopAccelerometerUpdates().

Push Me, Pull You

It might not look you like you’ve performed the third step in getting motion data, but you did. It was implied when you called the startAccelerometerUpdates() function. This function starts gathering motion data, but it’s up to your app to periodically ask what those values are. This is called the pull approach; the CMMotionManager object keeps the motion data current, and your app pulls the data from it as needed.

The alternative is the push approach. To use this approach, call the startAccelerometerUpdatesToQueue(_:,withHandler:) function instead. You pass it an operation queue and a closure that gets executed the moment motion data is updated. This is much more complicated to implement because the closure code is executed on a separate thread, so all of your motion data handling code must be thread-safe. You really need this approach only if your app must absolutely, positively process motion data the instant it becomes available. There are few apps that fall into this category.

Timing Is Everything

Now you’re probably wondering how your app “periodically” pulls the motion data it’s interested in. The motion manager doesn’t post any notifications or send your object any delegate messages. What you need is an object that will remind your app to do something at regular intervals. It’s called a timer, and iOS provides just that. At the end of the viewWillAppear() function, add this statement:

NSTimer.scheduledTimerWithTimeInterval( accelerometerPollingInterval,
target: self,
selector: "updateAccelerometerTime:",
userInfo: nil,
repeats: true)

An NSTimer object provides a timer for your app. It is one of the sources of events that I mentioned in Chapter 4 but never got around to talking about.

Note Making a functioning timer is a two-step process: you must create the timer object and then add it to the run loop. The scheduledTimerWithTimeInterval(_:,target:,selector:,userInfo:,repeats:) function does both for you. If you create a timer object directly, you have to call the addTimer(_:,forMode:) function of your thread’s run loop object before the timer will do anything.

Timers come in two flavors: single-shot or repeating. A timer has a timeInterval property and a function it will call on an object. After the amount of time in the timeInterval property has passed, the timer fires. At the next opportunity, the event loop will call the function of the target object. If it’s a one-shot timer, that’s it; the timer becomes invalid and stops. If it’s a repeating timer, it continues running, waiting until another timeInterval amount of time has passed before firing again. A repeating timer continues to fire until you call its invalidate()function.

Caution Don’t use timers to poll for events—such as waiting for a web page to load—that you could have determined using event messages, delegate functions, notifications, or code blocks. Timers should be used only for time-related events and periodic updates. If you want to know why, reread the beginning of Chapter 4.

The code you added to viewWillAppear() creates and schedules a timer that calls your view controller object’s updateAccelerometerTime(_:) function approximately 15 times a second. This is the same rate that the motion manager is updating its accelerometer information. There’s no point in checking for updates any faster or slower than the CMMotionManager object is gathering them.

Everything is in place, except the updateAccelerometerTime(_:) and rotateDialView(_:) functions. While still in ViewController.swift, add the first function.

func updateAccelerometerTime(timer: NSTimer) {
if let data = motionManager.accelerometerData {
let acceleration = data.acceleration
let rotation = atan2(-acceleration.x,-acceleration.y)
rotateDialView(rotation)
}
}

The first statement retrieves the accelerometerData property of the motion manager. Since you only started gathering accelerometer information, this is the only motion data property that’s valid. This property is a CMAccelerometerData object, and that object has only one property: acceleration. The acceleration property contains three numbers: x, y, and z. Each value is the instantaneous force being exerted along that axis, measured in Gs.3 Assuming the device isn’t being moved around, the measurements can be combined to determine thegravitational vector; in other words, you can figure out which way is down.

Your app doesn’t need all three. You only need to determine which direction is up in the x-y plane, because that’s where the dial lives. Ignoring the force along the z-axis, the arctangent function calculates the angle of the gravitational vector in the x-y plane. The result is used to rotate thedialView by that same angle. Simple, isn’t it?

Note You might have questioned why the arctangent function was given the negative values of x and y. It’s because the dial points up, not down. Flipping the direction of the force values calculates the angle away from gravity.

Complete the app by writing the rotateDialView(_:) function:

func rotateDialView(rotation: Double) {
dialView.transform = CGAffineTransformMakeRotation(CGFloat(rotation))

var degrees = Int(round(-rotation*180.0/M_PI))
if degrees < 0 {
degrees += 360
}
angleLabel.text = "\(degrees)°"
}

The first statement in the function turns the rotation parameter into an affine transform that rotates dialView. The rest of the code converts the rotation value from radians into degrees, makes sure it’s not negative, and uses that to update the label view.

It’s time to plug in your provisioned iOS device, run your app, and play with the results, as shown in Figure 16-8. Notice how the app switches orientation as you rotate it. If you lock the device’s orientation, it won’t do that, but the dial still works.

image

Figure 16-8. Working Leveler app

Herky-Jerky

Your app works, and it was pretty easy to write, but boy is it hard to look at. If it works anything like the way is does on my devices, the dial jitters constantly. Unless the device is perfectly still, it’s almost impossible to read.

It would be really nice if the dial moved more smoothly—a lot more smoothly. That sounds like a job for animation. What you want is an animation that makes the dial appear to have mass, gently drifting toward the instantaneous inclination reported by the hardware.

That sounds a lot like the Sprite Kit physics bodies you used in Chapter 14. Unfortunately, Sprite Kit’s physics simulation works only with SKNode objects. To use Sprite Kit, you’d have to redesign your entire view around SKView and then re-create the needle, dial, and label usingSKNodes. And SKNodes don’t draw themselves, so you’ll have to rewrite your drawRect(_:) function to draw into an off-screen image and provide that to the SKSpriteNode. Thinking about it, you might as well create a new project and start over.

Another approach would be to smooth out the updates yourself by clamping the rate at which the view is rotated. To make it look really nice, you might even go so far as to add some calculations that gives the dial simulated mass, acceleration, drag, and so on. But as I mentioned in Chapter 11, the do-it-yourself approach to animation is fraught with complications, is usually a lot of work, and often results in substandard performance.

Don’t panic! iOS has a solution that will let you build off the work you’ve already done. It’s called View Dynamics, and it’s a physics engine for UIVIew objects. It’s simpler than the physics simulator in Sprite Kit, but intentionally so. Like Core Animation, its aim is to make it easy to add simple physical behaviors to UIView objects—not for creating asteroid games.

Like Spite Kit, you use View Dynamics by describing the “forces” acting on a view and let the dynamic animator create an animation that simulates the view’s reaction to those forces. Unlike Sprite Kit, these properties are not part of UIView. Instead, you create a set of behavior objects that you attach to the views you want animated. Let’s get started.

Using Dynamic Animation

Dynamic animation involves three players.

· The dynamic animator object

· One or more behavior objects

· One or more view objects

The dynamic animator is the object that performs the animation. It contains a complex physic engine that’s remarkably intelligent. You’ll need to create a single instance of the dynamic animator.

Animation occurs when you create behavior objects and add those to the dynamic animator. A behavior describes a single impetus or attribute of a view. iOS includes predefined behaviors for gravity, acceleration, friction, collisions, connections, and more, and you’re free to invent your own. A behavior is associated with one or more view (UIView) objects, imparting that particular behavior to all of its views. The dynamic animator does the work of combining multiple behaviors for a single view—acceleration plus gravity plus friction, for example—to decide how that view will react.

So, the basic formula for dynamic animation is as follows:

1. Create an instance of UIDynamicAnimator.

2. Create one or more UIDynamicBehavior objects, attached to UIView objects.

3. Add the UIDynamicBehavior objects to the UIDynamicAnimator.

4. Sit back and enjoy the show.

You’re now ready to add View Dynamics to Leveler.

Creating the Dynamic Animator

You’ll need to create a dynamic animator object, and for that you’ll need an instance variable to save it in, so add an instance variable for your animator to your ViewController class. While you’re here, add some constants and a variable to contain an attachment behavior, all of which will be explained shortly.

var animator: UIDynamicAnimator!
let springAnchorDistance: CGFloat = 4.0
let springDamping: CGFloat = 0.7
let springFrequency: CGFloat = 0.5
var springBehavior: UIAttachmentBehavior?

Start a new function to create the behaviors. Call it attachDialBehaviors().

func attachDialBehaviors() {
if animator != nil {
animator.removeAllBehaviors()
} else {
animator = UIDynamicAnimator(referenceView: view)
}

Note You can find the finished version of Leveler using View Dynamics in the Learn iOS Development Projects image Ch 16 image Leveler-2 folder.

The first thing you do is to create a new UIDynamicAnimator object, if you haven’t yet. When you create a dynamic animator, you must specify a view that will be used to establish the coordinate system the dynamic animator will use. The dynamic animator uses its own coordinate system, called the reference coordinate system, so that view objects in different view hierarchies (each with their own coordinate system) can interact with one another in a unified coordinate space. Using the reference coordinate system, you could, for example, have a view in your content view controller collide with a button in the toolbar, even though they reside in different superviews.

Note You cannot create the dynamic animator in the variable’s initialization statement (var animator = UIDynamicAnimator(referenceView: view)) because the initializer for UIDynamicAnimator needs the view controller’s view property. During object initialization, the inherited view property hasn’t been initialized yet and can’t be used—a classic chicken-and-egg problem. The solution is to create the dynamic animator outside your object’s initializer. The order and rules of object initialization are explained in Chapter 20.

For your app, make the reference coordinate system that of your view controller’s root view. This makes all dynamic animator coordinates the same as your local view coordinates. Won’t that be convenient? (Yes, it will.)

The first half of the if statement discards all added behaviors, in the case where the dynamic animator has already been created. This should never happen, but it ensures attachDialBehaviors() starts with a clean slate.

Defining Behaviors

So, what behaviors do you think the dial view should have? If you look through the behaviors supplied by iOS, you won’t find a “rotation” behavior. But the dynamic animator will rotate a view if the forces acting on that view would cause it to rotate. Rotating the dial view, therefore, isn’t any more difficult than rotating a record platter, a merry-go-round, a lazy Susan, or anything similar: anchor the center of the object and apply an oblique force to one edge.

You’ll accomplish this using two attachment behaviors. An attachment behavior connects a point in your view with either a similar point in another view or a fixed point in space, called an anchor. The length of the attachment can be inflexible, creating a “towbar” relationship that keeps the attachment point at a fixed distance, or it can be flexible, creating a “spring” relationship that tugs on the view when the other end of the attachment moves. To rotate the dial view, you’ll use one of each, as shown in Figure 16-9.

image

Figure 16-9. dialView attachment behaviors

Return to your new attachDialBehaviors() function and create the behavior that will pin the center of the dial view.

let dialCenter = dialView.center
let pinBehavior = UIAttachmentBehavior(item: dialView,
attachedToAnchor: dialCenter)
animator.addBehavior(pinBehavior)

The attachment behavior defines a rigid attachment from the center of the dial view to a fixed anchor point at the same location. When you create an attachment behavior, the existing distance between the two attachment points defines its initial length, which in this case is 0. Since the attachment is inflexible and its length is 0, the net effect is to pin the center of the view at that coordinate. The view’s center can’t move from that spot.

Note Most dynamic behaviors can be associated with any number of view objects. Gravity, for example, can be applied to a multitude of view objects equally. The attachment behavior, however, creates a relationship between two attachment points and therefore associates with only one or two view objects.

All that remains is to add that behavior to the dynamic animator. All by itself, this doesn’t accomplish much, except to prevent the view from being moved to a new location. Things get interesting when you add a second attachment behavior, using the following code:

let dialRect = dialView.frame
let topCenter = CGPoint(x: dialRect.midX, y: dialRect.minY)
let topOffset = UIOffset(horizontal: 0.0, vertical: topCenter.y-dialCenter.y)
springBehavior = UIAttachmentBehavior(item: dialView,
offsetFromCenter: topOffset,
attachedToAnchor: topCenter)
springBehavior!.damping = springDamping
springBehavior!.frequency = springFrequency
animator.addBehavior(springBehavior)
}

The first two statements calculate the point at the top center of the view. A second attachment behavior is created. This time the attachment point is not in the center of the view but at its top-center (expressed as an offset from its center).

Again, the anchor point is the same location as the attachment point, creating a zero-length attachment. What’s different is that the damping and frequency properties are then set to something other than their default values. This creates a “springy” connection between the anchor point and the attachment point. But since the anchor and the attachment point are currently the same, no force is applied (yet).

Of course, none of the behaviors is going to get created until you call attachDialBehaviors(). You’ll need to do this when the views are initially positioned in viewWillAppear(_\:) (new code in bold).

override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
positionDialViews()
attachDialBehaviors()

You’ll need to do it again if the view is ever resized. In this case, you’ll want to cancel all of the dynamic animations, let the resize transition complete, and then re-create them for the newly resized views. Edit the code in viewWillTransitionToSize(...) so it looks like this (new code in bold):

animator?.removeAllBehaviors()
coordinator.animateAlongsideTransition({ (context) in
self.positionDialViews()
},
completion: { (context) in
self.attachDialBehaviors()
})

Animating the Dial

The stage is set, and all of the players are in place. You’ve defined a behavior that pins the center of the dial to a specific position, and a second that will “tug” the top-center point toward a second anchor point. The action begins when you move that second anchor point, as shown in Figure 16-9.

Locate the rotateDialView(_:) function and delete the first statement—the one that created an affine transform and applied it to the view. Replace that code with the following (new code in bold):

func rotateDialView(rotation: Double) {
if let spring = springBehavior {
let center = dialView.center
let radius = dialView.frame.height/2.0 + springAnchorDistance
let anchorPoint = CGPoint(x: center.x+CGFloat(sin(rotation))*radius,
y: center.y-CGFloat(cos(rotation))*radius )
spring.anchorPoint = anchorPoint
}

Instead of the traditional approach of telling the graphics system what kind of change you want to see (rotate the view by a certain angle), you describe a change to the physical environment and let the dynamic animator simulate the consequences. In this app, you moved the anchor point attached to the top-center point of the view. Moving the anchor point creates an attraction between the new anchor point and the attachment point in the view. Since the center of the view is pinned by the first behavior, the only way the top point of the view can get closer to the new anchor point is to rotate the view, and that’s exactly what happens.

Run the app and see the effect. The dial acts much more like a “real” dial. There’s acceleration, deceleration, and even oscillation. These effects are all courtesy of the physics engine in the dynamic animator.

Try altering the values of springAnchorDistance, springDamping, and springFrequency and observe how this affects the dial. For extra credit, add a third behavior that adds some “drag” to the dial. Create a UIDynamicItemBehavior object, associate it with the dial view, and set its angularResistance property to something other than 0; I suggest starting with a value of 2.0. Don’t forget to add the finished behavior to the dynamic animator. Your code should look something like this:

let drag = UIDynamicItemBehavior(items: [dialView])
drag.angularResistance = 2.0
animator.addBehavior(drag)

You now have a nifty inclinometer that’s silky smooth and fun to watch. Now that you know how easy it is to add motion data to your app and simulate motion using View Dynamics, let’s take a look at some of the other sources of motion data.

Getting Other Kinds of Motion Data

As of this writing, your app can use three other kinds of motion data. You can collect and use the other kinds of data instead of, or in addition to, the accelerometer data. Here are the kinds of motion data iOS provides:

· Gyroscope: Measures the rate at which the device is being rotated around its three axes

· Magnetometer: Measures the orientation of the surrounding magnetic field

· Device motion: Combines information from the accelerometer, magnetometer, and gyroscope to produce useful values about the device’s motion and position in space

Using the other kinds of motion data is identical to what you’ve done with the accelerometer data, with one exception. Not all iOS devices have a gyroscope or a magnetometer. You will have to decide whether your app must have these capabilities or can function in their absence. That decision will dictate how you configure your app’s project and write your code. Let’s start with the gyroscope.

Gyroscope Data

If you’re interested in the instantaneous rate at which the device is being rotated—logically equivalent to the accelerometer data but for angular force—gather gyroscope data. You collect gyroscope data almost exactly as you do accelerometer data. Begin by setting thegyroUpdateInterval property of the motion manager object and then call either the startGyroUpdates() or startGyroUpdatesToQueue(_:,withHandler:) function.

The gyroData property returns a CMGyroData object, which has a single rotationRate property value. This property has three values: x, y, and z. Each value is the rate of rotation around that axis, in radians per second.

You must consider the possibility that the user’s device doesn’t have a gyroscope. There are two approaches.

· If your app requires gyroscopic hardware to function, add the gyroscope value to the UIRequiredDeviceCapabilities of your app’s property list.

· If you app can run with, or without, a gyroscope, test the gyroAvailable property of the motion manager object.

The first approach makes the gyroscope hardware a requirement for your app to run. If added to your app’s property list, iOS won’t allow the app to be installed on a device that lacks a gyroscope. The App Store may hide the app from users who lack a gyroscope or warn them that your app may not run on their device.

You add this key exactly the way you added the gamekit capability requirement in Chapter 14. Find the section “Adding GameKit to Your App” in Chapter 14 and follow the instructions for editing the Required Device Capabilities collection, substituting gyroscope forgamekit.

If your app can make use of gyroscope data but could live without it, test for the presence of a gyroscope by reading the gyroAvailable property of the motion manager object. If it’s true, feel free to start and use the gyroscope data. If it’s false, make other arrangements.

Magnetometer Data

The magnitude and direction of the magnetic field surrounding your device are available via the magnetometer data. By now, this is going to sound like a broken record.

1. Set the frequency of magnetometer updates using the magnetometerUpdateInterval property.

2. Start magnetometer measurements calling either the startMagnetometerUpdates() or startMagnetometerUpdatesToQueue(_:,withHandler:) function.

3. The magnetometerData property returns a CMMagnetometerData object with the current readings.

4. The CMMagnetometerData object’s sole property is the magneticField property, which contains three values: x, y, and z. Each is the direction and strength of the field along that axis, in μT (microteslas).

5. Either add the magnetometer value to your app’s Required Device Capabilities property or check the magnetometerAvailable property to determine whether the device has one.

Like the accelerometer and gyroscope data, the magnetometerDate property returns the raw, unfiltered, magnetic field information. This will be a combination of the earth’s magnetic field, the device’s own magnetic bias, any ambient magnetic fields, magnetic interference, and so on.

Teasing magnetic north from this data is a little tricky. What looks like north might be a microwave oven. Similarly, the accelerometer data can change because the device was tilted or because it’s in a moving car or both. You can unravel some of these conflicting indicators by collecting and correlating data from multiple instruments. For example, you can tell the difference between a tilt and a horizontal movement by examining the changes to both the accelerometer and the gyroscope; a tilt will change both, but a horizontal movement will register only on the accelerometer.

If you’re getting the sinking feeling that you should have been paying more attention in your physics and math classes, you can relax; iOS has you covered.

Device Motion and Attitude

The CMMotionManager also provides a unified view of the device’s physical position and movements through its device motion interface. The device motion properties and functions combine the information from the accelerometer, gyroscope, and sometimes the magnetometer. It assimilates all of this data and produces a filtered, unified, calibrated picture of the device’s motion and position in space.

You use device motion in much the way you used the preceding three instruments.

1. Set the frequency of device motion updates using the deviceMotionUpdateInterval property.

2. Start device motion updates by calling one of these functions: startDeviceUpdates(), startDeviceMotionUpdatesToQueue(_:,withHandler:), startDeviceMotionUpdatesUsingReferenceFrame(_:), orstartDeviceMotionUpdatesUsingReferenceFrame(_:,toQueue:,withHandler:).

3. The deviceMotion property returns a CMDeviceMotion object with the current motion and attitude information.

4. Determine whether device motion data is available using the deviceMotionAvailable property.

There are two big differences between the device motion and previous interfaces. When starting updates, you can optionally provide a CMAttitudeReferenceFrame constant that selects a frame of reference for the device. There are four choices:

· Direction of the device is arbitrary

· Direction is arbitrary, but use the magnetometer to eliminate “yaw drift”

· Direction is calibrated to magnetic north

· Direction is calibrated to true north (requires location services)

The neutral reference position of your device can be imagined by placing your iPhone or iPad flat on a table in front of you, with the screen up and the home button toward you. The line from the home button to the top of the device is the y-axis. The x-axis runs horizontally from the left side to the right. The z-axis runs through the device, straight up and down.

Spinning your device, while still flat on the table, changes its direction. It’s this direction that the reference frame is concerned with. If the direction doesn’t matter, you can use either of the arbitrary reference frames. If you need to know the direction in relationship to true or magnetic north, use one of the calibrated reference frames.

Note Not all attitude reference frames are available on every device. Use the availableAttitudeReferenceFrames() function to determine which ones the device supports.

The second big difference is the CMDeviceMotion object. Unlike the other motion data objects, this one has several properties, listed in Table 16-1.

Table 16-1. Key CMDeviceMotion Properties

Property

Description

attitude

A CMAttitude object that describes the actual attitude (position in space) of the device described as a triplet of property values (pitch, roll, and yaw). Additional properties describe the same information in mathematically equivalent forms, both as a rotation matrix and a quaternion.

rotationRate

A structure with three values (x, y, and z) describing the rate of rotation around those axes.

userAcceleration

A CMAcceleration structure (x, y, and z) describing the motion of the device.

magneticField

A CMCalibratedMagneticField structure (x, y, z, and accuracy) that describes the direction of the earth’s magnetic field.

At first glance, all of this information would appear to be the same as the data from the accelerometer, gyroscope, and magnetometer—just repackaged. It’s not. The CMDeviceMotion object combines the information from multiple instruments to divine a more holistic picture of what the device is doing. Specifically:

· The attitude property combines information from the gyroscope to measure changes in angle, the accelerometer to determine the direction of gravity, and sometimes the magnetometer to calibrate direction (rotation around the z-axis) and prevent drift.

· The userAcceleration property correlates accelerometer and gyroscope data, excluding the force of gravity and changes in attitude, to provide an accurate measurement of acceleration.

· The magneticField property adjusts for the device bias and attempts to compensate for magnetic interference.

In all, the device motion interface is much more informed and intelligent. If there’s a downside, it’s that it requires more processing power, which steals app performance and battery life. If all your app needs is a general idea of motion or rotation, then the raw data from the accelerometer or gyroscope is all you need. But if you really want to know the device’s position, direction, or orientation, then the device motion interface has it figured out for you.

Note A device may need to be tilted in a circular pattern to help calibrate the magnetometer. iOS will automatically present a display that prompts the user to do this if you set the showsDeviceMovementDisplay property of CMMotionManager to true.

Measuring Change

If your app needs to know the rate of change of any of the motion measurements, it needs time information. For example, to measure the change in angular rotation, you’d subtract the current rate from the previous rate and divide that by the time delta between the two samples.

But where can you find out when these measurements were taken? In earlier sections I wrote, “CMAccelerometerData’s only property is acceleration,” along with similar statements about CMGyroData and CMMagnetometerData. That’s not strictly true.

The CMAccelerometerData, CMGyroData, CMMagnetometerData, and CMDeviceMotion classes are all subclasses of CMLogItem. The CMLogItem class defines a timestamp property, which all of the aforementioned classes inherit.

The timestamp property records the exact time the measurement was taken, allowing your app to accurately compare samples and calculate their rate of change, record them for posterity, or use them for any other purpose you might imagine.

Tip If you need to calculate the change in attitude (subtracting the values of two CMAttitude objects), the multiplyByInverseOfAttitude(_:) function will do the math for you.

Summary

In this chapter you tapped into the unfiltered data of the device’s accelerometer, gyroscope, and magnetometer. You know how to configure the data you want to collect, interpret it, and use timers to collect it. You also learned how to exploit the device motion data for a more informed view of the device’s position in space. There’s almost no motion or movement that your app can’t detect and react to.

Well, almost. Despite the incredibly detailed information about the direction of the device and how it’s being moved around, there’s still one piece of information missing: where the device is located. You’ll solve that remaining mystery in the next chapter.

_________________

1Wayne Szalinski was the hapless inventor in the movie Honey, I Shrunk the Kids.

2Look up the word leveler for an interesting factoid on English history.

3G is the force of gravity, equal to an acceleration of approximately 9.81 meters per second every second.