Detecting Pauses
Behavior lifetimes vary. After adding a behavior to an animator, you leave it in place for varying degrees of time: until some application state has changed, until the animation has come to a stopping point (or has reasonably coalesced to the point where the user perceives it as having stopped), or until the application ends. The lifetime you select depends on the kind of behavior you define. For example, a collision behavior that keeps views inside a parent view controller may persist indefinitely. You might remove a snap behavior as soon as the view has moved to the newly requested position or a push behavior as soon as the impulse has finished.
The problem is, however, that the built-in dynamic animator can take a long time to detect that the views it manages have stopped moving. Consider the following list of times and frames for a snapped view:
[0.03] NSRect: {{121.55639, 217.55638}, {66.88723, 66.88723}} [0.07] NSRect: {{91.418655, 206.41866}, {81.162689, 81.162689}} [0.10] NSRect: {{60.333874, 201.33388}, {83.332253, 83.332253}} [0.13] NSRect: {{44.293236, 204.29323}, {79.413528, 79.413528}} [0.17] NSRect: {{42.394054, 213.39406}, {68.211891, 68.211891}} [0.20] NSRect: {{44.46402, 221.46402}, {60.071957, 60.071957}} [0.23] NSRect: {{44.94722, 222.94722}, {61.105556, 61.105556}} [0.27] NSRect: {{47.207447, 223.70744}, {60.58511, 60.58511}} [0.30] NSRect: {{49.458027, 223.45802}, {60.083942, 60.083942}} [0.33] NSRect: {{50.481998, 222.48199}, {60.035999, 60.035999}} [0.37] NSRect: {{50.987999, 221.98801}, {60.023998, 60.023998}} [0.40] NSRect: {{51, 221.5}, {60, 60}} [0.43] NSRect: {{50.5, 221.5}, {60, 60}} [0.47] NSRect: {{50, 221.5}, {60, 60}} [0.50] NSRect: {{50, 222}, {60, 60}} [0.53] NSRect: {{50, 222}, {60, 60}} [0.57] NSRect: {{50, 222}, {60, 60}} ...[snipped 0.60 to 1.10]... [1.13] NSRect: {{50, 222}, {60, 60}} [1.17] NSRect: {{50, 222}, {60, 60}} Elapsed time: 1.167326
This view reaches its final position after half a second has passed. The dynamic animator does not pause until 1.17 seconds—more than double the required time. In user experience terms, those extra 0.67 seconds can feel like forever.
The reason for the delay becomes clear when you sneak down into the animator and look up the view’s linear and angular velocity:
[0.60] NSRect: {{50, 222}, {60, 60}} Linear Velocity: NSPoint: {1.8314272, 1.0867469} Angular Velocity: 0.000001
Those values do not drop to 0 until that extra time has passed:
[1.17] NSRect: {{50, 222}, {60, 60}} Linear Velocity: NSPoint: {0, 0} Angular Velocity: 0.000000
In a practical sense, the velocities are meaningless once the view frame stops changing. When you know in advance that no outside forces will impel a view to start moving again after it’s reached a resting point, leverage this information. Trim down your waiting time by tracking a view’s frame.
Listing 6-1 defines a watcher class that monitors views until they stop changing. After a view has remained fixed for a certain period of time (here for at least 0.1 seconds), this class contacts a delegate and lets it know that the view has stopped moving. That callback enables you to update your dynamic animator and remove the behavior so the animator can more quickly come to a pause.
When run with the same snap animation as the previous example, the new watcher detects the final frame at 0.50. By 0.60, the delegate knows to stop the animation, and the entire sequence stops nearly 0.55 seconds earlier:
[0.47] NSRect: {{50, 221.5}, {60, 60}} [0.50] NSRect: {{50, 222}, {60, 60}} [0.53] NSRect: {{50, 222}, {60, 60}} [0.57] NSRect: {{50, 222}, {60, 60}} [0.60] NSRect: {{50, 222}, {60, 60}} Elapsed time: 0.617352
Use this kind of short-cutting approach to re-enable GUI items that might otherwise be inaccessible to users once you know that the animation has come to a usable end point. While this example implements a pixel-level test, you might vary this approach to detect low angular velocities and other “close enough” tests to help end the animation effects within a reasonable amount of time.
Listing 6-1 Watching Views
// Info stores the most recent frame, count, delegate @interface WatchedViewInfo : NSObject @property (nonatomic) CGRect frame; @property (nonatomic) NSUInteger count; @property (nonatomic) CGFloat pointLaxity; @property (nonatomic) id <ViewWatcherDelegate> delegate; @end @implementation WatchedViewInfo @end // Watcher class @implementation ViewWatcher { NSMutableDictionary *dict; } - (instancetype) init { if (!(self = [super init])) return self; dict = [NSMutableDictionary dictionary]; _pointLaxity = 10; return self; } // Determine whether two frames are “close enough” BOOL CompareFrames(CGRect frame1, CGRect frame2, CGFloat laxity) { if (CGRectEqualToRect(frame1, frame2)) return YES; CGRect intersection = CGRectIntersection(frame1, frame2); CGFloat testArea = intersection.size.width * intersection.size.height; CGFloat area1 = frame1.size.width * frame1.size.height; CGFloat area2 = frame2.size.width * frame2.size.height; return ((fabs(testArea - area1) < laxity) && (fabs(testArea - area2) < laxity)); } // See whether the view has stopped moving - (void) checkInOnView: (NSTimer *) timer { int kThreshold = 3; // must remain for 0.3 secs // Fetch the view and the info UIView *view = (UIView *) timer.userInfo; NSNumber *key = @((int)view); WatchedViewInfo *watchedViewInfo = dict[key]; // Matching frame? If so update count BOOL steadyFrame = CompareFrames(watchedViewInfo.frame, view.frame, _pointLaxity); if (steadyFrame) watchedViewInfo.count++; // Threshold met if (steadyFrame && (watchedViewInfo.count > kThreshold)) { [timer invalidate]; [dict removeObjectForKey:key]; [watchedViewInfo.delegate viewDidPause:view]; return; } if (steadyFrame) return; // Replace frame with new frame watchedViewInfo.frame = view.frame; watchedViewInfo.count = 0; } - (void) startWatchingView: (UIView *) view withDelegate: (id <ViewWatcherDelegate>) delegate { NSNumber *key = @((int)view); WatchedViewInfo *watchedViewInfo = [[WatchedViewInfo alloc] init]; watchedViewInfo.frame = view.frame; watchedViewInfo.count = 1; watchedViewInfo.delegate = delegate; dict[key] = watchedViewInfo; [NSTimer scheduledTimerWithTimeInterval:0.03 target:self selector:@selector(checkInOnView:) userInfo:view repeats:YES]; } @end
Creating a Frame-Watching Dynamic Behavior
While the solution in Listing 6-1 provides general view oversight, you can implement the frame checker in a much more intriguing form: as the custom dynamic behavior you see in Listing 6-2. This approach that adapts Listing 6-1 to a new form requires just a couple adjustments to work as a behavior:
- The behavior from the checkInOnView: method is now implemented in the behavior’s action property. This block is called directly by the animator, using its own timing system, so the threshold is slightly higher in this implementation than in Listing 6-1.
- Instead of calling back to a delegate, this approach unloads both the watcher and the client behavior directly in the action block. This may be problematic if the behavior controls additional items, but for snap behaviors and their single items, it is a pretty safe approach.
To enable the watcher, you must add it to the animator as a separate behavior. Here’s how you allocate it and initialize it with a client view and an affected behavior:
UISnapBehavior *snapBehavior = [[UISnapBehavior alloc] initWithItem:testView snapToPoint:p]; [self.animator addBehavior:snapBehavior]; WatcherBehavior *watcher = [[WatcherBehavior alloc] initWithView:testView behavior:snapBehavior]; [self.animator addBehavior:watcher];
Once it is added, it works just like Listing 6-1, iteratively checking the view’s frame to wait for a steady state.
Listing 6-2 Watching Views with a Dynamic Behavior
// Create custom frame watcher @interface WatcherBehavior : UIDynamicBehavior - (instancetype) initWithView: (UIView *) view behavior: (UIDynamicBehavior *) behavior; @property (nonatomic) CGFloat pointLaxity; // defaults to 10 @end // Store the view, its most recent frame, and a count @interface WatcherBehavior () @property (nonatomic) UIView *view; @property (nonatomic) CGRect mostRecentFrame; @property (nonatomic) NSInteger count; @property (nonatomic) UIDynamicBehavior *customBehavior; @end @implementation WatcherBehavior - (instancetype) initWithView: (UIView *) view behavior: (UIDynamicBehavior *) behavior { if (!(self = [super init])) return self; // Initialize instance _view = view; _mostRecentFrame = _view.frame; _count = 0; _pointLaxity = 10; _customBehavior = behavior; // Create custom action for the behavior __weak typeof(self) weakSelf = self; self.action = ^{ __strong typeof(self) strongSelf = weakSelf; UIView *view = strongSelf.view; CGRect currentFrame = view.frame; CGRect recentFrame = strongSelf.mostRecentFrame; BOOL steadyFrame = CompareFrames(currentFrame, recentFrame, strongSelf.pointLaxity); if (steadyFrame) strongSelf.count++; NSInteger kThreshold = 5; if (steadyFrame && (strongSelf.count > kThreshold)) { [strongSelf.dynamicAnimator removeBehavior:strongSelf.customBehavior]; [strongSelf.dynamicAnimator removeBehavior:strongSelf]; return; } if (!steadyFrame) { strongSelf.mostRecentFrame = currentFrame; strongSelf.count = 0; } }; return self; } @end