Implementing Snap Zones
One of my favorite dynamic animator tricks involves creating snap zones—areas of your interface that pull in dragged items once they overlap a particular region. This approach allows you to collect items into well-managed zones and offer a pleasing “snap-into-place” animation. In the general form shown in Listing 6-3, there’s no further test beyond whether a dragged view has strayed into a zone. However, you might want to expand the approach to limit blue items to blue zones or red items to red zones, and so forth.
Listing 6-3 assumes that users will have access to multiple zones and even that a view might move from one zone directly to another. It uses a tagging scheme to keep track of this potential reparenting. A free view has no current parent and can move freely about. When a free view overlaps a snap zone, however, it suspends dragging by disabling the view’s gesture recognizer and adds a snap-to-parent behavior. The view slides into place into its new parent. Once it arrives, as the dynamic animator pauses, the recognizer is re-enabled.
Allowing a view to escape from its new parent’s bounds is the tricky bit—and the motivating reason for the view tagging. You do not want a view to recapture its child unless the dragging gesture has ended, which is why this method keeps track of the gesture state. With new parents, however, the snap behavior is added (and the gesture is suspended) as soon as a view strays over the line. Balancing the escapes and the captures ensures that the user experience is snappy and responsive and does not thwart the user’s desires to remove a view from a parent.
Listing 6-3 Handling Multiple Snap Zones
- (void) draggableViewDidMove: (NSNotification *) note { // Check for view participation UIView *draggedView = note.object; UIView *nca = [draggedView nearestCommonAncestorWithView: _animator.referenceView]; if (!nca) return; // Retrieve state UIGestureRecognizer *recognizer = (UIGestureRecognizer *) draggedView.gestureRecognizers.lastObject; UIGestureRecognizerState state = [recognizer state]; // View frame and current attachment CGRect draggedFrame = draggedView.frame; BOOL free = draggedView.tag == 0; for (UIView *dropZone in _dropZones) { // Make sure all drop zones are views if (![dropZone isKindOfClass:[UIView class]]) continue; // Overlap? CGRect dropFrame = dropZone.frame; BOOL overlap = CGRectIntersectsRect(draggedFrame, dropFrame); // Free moving if (!overlap && free) { continue; } // Newly captured if (overlap && free) { if (suspendedRecognizer) { NSLog(@”Error: attempting to suspend second recognizer”); break; } // New parent. // CAPTURED is an integer offset for tagging suspendedRecognizer = recognizer; suspendedRecognizer.enabled = NO; // stop! draggedView.tag = CAPTURED + dropZone.tag; // mark as captured UISnapBehavior *behavior = [[UISnapBehavior alloc] initWithItem:draggedView snapToPoint:RectGetCenter(dropFrame)]; [_animator addBehavior:behavior]; break; } // Is this the current parent drop zone? BOOL isParent = (dropZone.tag + CAPTURED == draggedView.tag); // Current parent if (overlap && isParent) { switch (state) { case UIGestureRecognizerStateEnded: { // Recapture UISnapBehavior *behavior = [[UISnapBehavior alloc] initWithItem:draggedView snapToPoint:RectGetCenter(dropFrame)]; [_animator addBehavior:behavior]; break; } default: { // Still captured but no op break; } } break; } // New parent if (overlap) { suspendedRecognizer = recognizer; suspendedRecognizer.enabled = NO; // stop! draggedView.tag = CAPTURED + dropZone.tag; UISnapBehavior *behavior = [[UISnapBehavior alloc] initWithItem:draggedView snapToPoint:RectGetCenter(dropFrame)]; [_animator addBehavior:behavior]; break; } } }