Custom Behaviors
Apple provides a library of default behaviors that includes forces (attachments, collisions, gravity, pushes, and snaps) and “dynamic items” that describe how a physics body reacts to forces. You can also create your own behaviors that operate with dynamic animators. This section discusses how you might do this in your own projects.
You choose from two approaches when creating custom dynamic behaviors. First, you can hook your changes onto an existing behavior and transform its updates to some new style. That’s the approach Apple uses in the Dynamic Catalog example that converts an attachment point animator to a boundary animation. It transforms an elastic attachment to view morphing. Second, you can create a new behavior and establish your own rules for coalescing its results over time. This approach enables you create any kind of behavior you can imagine, as long as you express it with regard to the animator’s timeline. Both have advantages and drawbacks.
Creating Custom Dynamic Items
Before jumping into custom behaviors, you need to understand dynamic items more fully. Dynamic items are the focal point of the dynamic animation process. Until this point, I have used views as dynamic items—after all, they provide the bounds, center, and transform properties required to act in this role—but dynamic items are not necessarily views. They are merely objects that conform to the UIDynamicItem protocol. This protocol ensures that these properties are available from conforming objects. Because of this abstraction, you can dynamically animate custom objects as easily as you animate views.
Consider the following class. It consists of nothing more than three properties, ensuring that it conforms to the UIDynamicItem protocol:
@interface CustomDynamicItem : NSObject <UIDynamicItem> @property (nonatomic) CGRect bounds; @property (nonatomic) CGPoint center; @property (nonatomic) CGAffineTransform transform; @end @implementation CustomDynamicItem @end
After adding this class to your project, you can instantiate and set properties however you like. For example, you might use the following lines of code to create a new custom item:
item = [[CustomDynamicItem alloc] init]; item.bounds = CGRectMake(0, 0, 100, 100); item.center = CGPointMake(50, 50); item.transform = CGAffineTransformIdentity;
Once you have established a dynamic item, you may pass it to a behavior and add that behavior to an animator, just as you would with a view:
animator = [[UIDynamicAnimator alloc] init]; UIPushBehavior *push = [[UIPushBehavior alloc] initWithItems:@[item] mode:UIPushBehaviorModeContinuous]; push.angle = M_PI_4; push.magnitude = 1.0; [animator addBehavior:push]; push.active = YES;
What happens next, however, may surprise you. If you monitor the item, you’ll find that its center property updates, but its bounds and transform remain untouched:
2014-12-01 13:33:08.177 Hello World[55151:60b] Bounds: [0, 0, 100, 100], Center: (86 86), Transform: Theta: {0.000000 radians, 0.000000°} Scale: {1.000000, 1.000000} Translation: {0.000000, 0.000000} 2014-12-01 13:33:09.176 Hello World[55151:60b] Bounds: [0, 0, 100, 100], Center: (188 188), Transform: Theta: {0.000000 radians, 0.000000°} Scale: {1.000000, 1.000000} Translation: {0.000000, 0.000000} 2014-12-01 13:33:10.175 Hello World[55151:60b] Bounds: [0, 0, 100, 100], Center: (351 351), Transform: Theta: {0.000000 radians, 0.000000°} Scale: {1.000000, 1.000000} Translation: {0.000000, 0.000000} 2014-12-01 13:33:11.176 Hello World[55151:60b] Bounds: [0, 0, 100, 100], Center: (568 568), Transform: Theta: {0.000000 radians, 0.000000°} Scale: {1.000000, 1.000000} Translation: {0.000000, 0.000000}
This curious state of affair happens because the dynamic animator remains completely agnostic as to the kind of underlying object it serves. This abstract CustomDynamicItem class provides no links between its center property and its bounds property the way a view would. If you want these items to update synchronously, you must add corresponding methods. For example, you might implement a solution like this:
- (void) setCenter:(CGPoint)center { _center = center; _bounds = RectAroundCenter(_center, _bounds.size); } - (void) setBounds:(CGRect)bounds { _bounds = bounds; _center = RectGetCenter(bounds); }
I’m not going to present a full implementation that allows the item to respond to transform changes—for two reasons. First, in real life, you almost never want to create custom items in this fashion. Second, when you actually do need this, you’ll be far better off using an actual view as an underlying model. Allowing a UIView instance to do the math for you will save you a lot of grief, especially since you’re trying to emulate a view in the first place.
Subverting Dynamic Behaviors
As mentioned earlier, Apple created a Dynamic Catalog example that redirects the results of an attachment behavior to create a bounds animation. It accomplishes this by building an abstract dynamic item class. This class redirects all changes applied to the item’s center to a client view’s width and height. This means that while the physics engine thinks it’s bouncing around a view in space, the actual expressions of those dynamics are producing bounds shifts. The following code performs this mapping:
// Map bounds to center - (CGPoint)center { return CGPointMake(_item.bounds.size.width, _item.bounds.size.height); } // Map center to bounds - (void)setCenter:(CGPoint)center { _item.bounds = CGRectMake(0, 0, center.x, center.y); }
I dislike this approach for the following reasons:
- The animator isn’t animating the view’s center at the point you think it is. You must establish an anchor point within the view’s own coordinate system so the center values make any sense to use.
- All you’re getting back from this exercise is a damped sinusoid, as in Listing 5-2. Just use a damped sinusoid to begin with, and you’ll avoid any unintentional side effects.
- How often are you just sitting around in your development job, thinking, “Hey, I’ll just take the output of a physics emulation system and map its results into another dimension so I can create an overly complex sample application that has no general reuse value?” Right, me either.
Better Custom Dynamic Behaviors
As you read this section, remember that better is a relative term. The biggest problem when it comes to custom dynamic behaviors is that Apple has not released a public API that keeps a completely custom item animating until it reaches a coalesced state. This means that while Listing 6-5 offers a more satisfying solution than Apple’s solution, it’s still a hack.
The main reason for this is that while built-in dynamic behaviors can tell the animator “Hey, I’m done now” by using private APIs that allow the animator to stop, you and I cannot tickle the animator to make sure it keeps on ticking. Enter this class’s “clock mandate.” It’s a gravity behavior added to the ResizableDynamicBehavior as a child.
The gravity behavior works on an invisible view, which is itself added to the animated view so that it belongs to the right hierarchy. (This is an important step so you don’t generate exceptions.) Once it is added, the gravity behavior works forever. When you’re ready for the dynamic behavior to end, simply remove it from its parent. Without this extra trick, the animation ends on its own about a half second after you start it.
I developed the damped equation used in the action block after playing with graphing. As Figure 6-1 shows, I was looking for a curve that ended after about one and a half cycles. You cannot depend on the animator’s elapsed time, which doesn’t reset between behaviors. To power my curve, I made sure to create a clock for each behavior and use that in the action block.
Figure 6-1 A fast-decaying sin curve provides a nice match to the view animation.
A few final notes on this one:
- You need to attach some sort of built-in animator like gravity, or your action property will not be called. Gravity offers the simple advantage of never ending.
- You must establish the bounds as is done here, or your view immediately collapses to a 0 size.
- The identity transform in the last step isn’t strictly necessary, but I wanted to ensure that I cleaned up after myself as carefully as possible.
- To slow down the effect, reduce the number of degrees traveled per second. In this case, it goes 2 * pi every second.
- To increase or decrease the animation magnitude, adjust the multiplier. Here it is 1 + 0.5 * the scale. The 1 is the identity scale, and you should keep it as is. Tweak the 0.5 value up to expand the scaling or down to diminish it.
- You can bring the animation to coalescence faster or slower by adjusting the final multiplier in the exponentiation. Here it is set to 2.0, which produces fairly rapid damping. Higher values produce stronger damping; lower values allow the animation to continue longer.
Listing 6-5 Extending a Custom Behavior’s Lifetime
@interface ResizableDynamicBehavior () @property (nonatomic, strong) UIView *view; @property (nonatomic) NSDate *startingTime; @property (nonatomic) CGRect frame; @property (nonatomic) UIGravityBehavior *clockMandate; @property (nonatomic) UIView *fakeView; @end @implementation ResizableDynamicBehavior - (instancetype) initWithView: (UIView *) view { if (!view) return nil; if (!(self = [super init])) return self; _view = view; _frame = view.frame; // Establish a falling view to keep the timer going _fakeView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)]; [view addSubview:_fakeView]; _clockMandate = [[UIGravityBehavior alloc] initWithItems:@[_fakeView]]; [self addChildBehavior:_clockMandate]; // The action block is called at every animation cycle __weak typeof(self) weakSelf = self; self.action = ^{ __strong typeof(self) strongSelf = weakSelf; // Start or update the clock if (!strongSelf.startingTime) strongSelf.startingTime = [NSDate date]; CGFloat time = [[NSDate date] timeIntervalSinceDate:strongSelf.startingTime]; // Calculate the current change CGFloat scale = 1 + 0.5 * sin(time * M_PI * 2) * exp(-1.0 * time * 2.0); // Apply the bounds and transform CGAffineTransform transform = CGAffineTransformMakeScale(scale, scale); strongSelf.view.bounds = (CGRect){.size = strongSelf.frame.size}; strongSelf.view.transform = transform; [strongSelf.dynamicAnimator updateItemUsingCurrentState:strongSelf.view]; // Stop after 3 * Pi if (time > 1.5) { [strongSelf removeChildBehavior:strongSelf.clockMandate]; [strongSelf.fakeView removeFromSuperview]; strongSelf.view.transform = CGAffineTransformIdentity; } }; return self; } @end
Custom Secondary Behaviors
You do far less work when your custom behavior acts side-by-side with a known system-supplied one. You don’t have to establish an overall animation end point, the way Listing 6-5 does. Consider Listing 6-6, which creates a behavior that modifies a view transformation over time. This class is duration agnostic. Its only customizable feature is an acceleration property, which establishes how fast the changes accelerate to an end point.
With custom behaviors, it’s really important that you not tie yourself to a set timeline. While a system-supplied snap behavior might end after 80 updates or so, you should never rely on knowing that information in advance. In contrast, with keyframes, you are free to interpolate a function over time. With dynamics, you establish a system that coalesces, reaching a natural stopping point on its own. For example, Listing 6-6 uses velocity and acceleration to drive its changes from 0% to 100%, applying an easing function to that transit to produce a smooth animated result. At no point does the behavior reference elapsed time. Instead, all updates are driven by the dynamic animation’s heartbeat and applied whenever the action method is called.
Figure 6-2 shows the animation in action, with the two behaviors acting in parallel. As the views draw near to their snap points, they apply the requested transforms to finish with a coordinated pile of views.
Figure 6-2 In this animation, a snap behavior draws the views together, and a transformation behavior angles each item to form a tight nest.
Listing 6-6 Building a Transform-Updating Behavior
- (instancetype) initWithItem: (id <UIDynamicItem>) item transform: (CGAffineTransform) transform; { if (!(self = [super init])) return self; // Store the passed information _item = item; _originalTransform = item.transform; _targetTransform = transform; // Initialize velocity and acceleration _velocity = 0; _acceleration = 0.0025; // The weak and strong workarounds used here avoid retain cycles // when using blocks. ESTABLISH_WEAK_SELF; self.action = ^(){ ESTABLISH_STRONG_SELF; // Pull out the original and destination transforms CGAffineTransform t1 = strongSelf.originalTransform; CGAffineTransform t2 = strongSelf.targetTransform; // Original CGFloat xScale1 = sqrt(t1.a * t1.a + t1.c * t1.c); CGFloat yScale1 = sqrt(t1.b * t1.b + t1.d * t1.d); CGFloat rotation1 = atan2f(t1.b, t1.a); // Target CGFloat xScale2 = sqrt(t2.a * t2.a + t2.c * t2.c); CGFloat yScale2 = sqrt(t2.b * t2.b + t2.d * t2.d); CGFloat rotation2 = atan2f(t2.b, t2.a); // Calculate the animation acceleration progress strongSelf.velocity = velocity + strongSelf.acceleration; strongSelf.percent = strongSelf.percent + strongSelf.velocity; CGFloat percent = MIN(1.0, MAX(strongSelf.percent, 0.0)); percent = EaseOut(percent, 3); // Calculated items CGFloat targetTx = Tween(t1.tx, t2.tx, percent); CGFloat targetTy = Tween(t1.ty, t2.ty, percent); CGFloat targetXScale = Tween(xScale1, xScale2, percent); CGFloat targetYScale = Tween(yScale1, yScale2, percent); CGFloat targetRotation = Tween(rotation1, rotation2, percent); // Create transforms CGAffineTransform scaleTransform = CGAffineTransformMakeScale(targetXScale, targetYScale); CGAffineTransform rotateTransform = CGAffineTransformMakeRotation(targetRotation); CGAffineTransform translateTransform = CGAffineTransformMakeTranslation(targetTx, targetTy); // Combine and apply transforms CGAffineTransform t = CGAffineTransformIdentity; t = CGAffineTransformConcat(t, rotateTransform); t = CGAffineTransformConcat(t, scaleTransform); t = CGAffineTransformConcat(t, translateTransform); strongSelf.item.transform = t; }; return self; }