This example shows how to create a custom dragging behavior by Subclassing UIDynamicBehavior
and subclassing UICollectionViewFlowLayout
. In the example, we have UICollectionView
that allows for the selection of multiple items. Then with a long press gesture those items can be dragged in an elastic, “springy” animation driven by a UIDynamicAnimator
.
The dragging behavior is produced by combining a low-level behavior that adds a UIAttachmentBehavior
to the for corners of a UIDynamicItem
and a high-level behavior that manages the low-level behavior for a number of UIDynamicItems
.
We can begin by creating this low-level behavior, we’ll call RectangleAttachmentBehavior
final class RectangleAttachmentBehavior: UIDynamicBehavior
{
init(item: UIDynamicItem, point: CGPoint)
{
// Higher frequency more "ridged" formation
let frequency: CGFloat = 8.0
// Lower damping longer animation takes to come to rest
let damping: CGFloat = 0.6
super.init()
// Attachment points are four corners of item
let points = self.attachmentPoints(for: point)
let attachmentBehaviors: [UIAttachmentBehavior] = points.map
{
let attachmentBehavior = UIAttachmentBehavior(item: item, attachedToAnchor: $0)
attachmentBehavior.frequency = frequency
attachmentBehavior.damping = damping
return attachmentBehavior
}
attachmentBehaviors.forEach
{
addChildBehavior($0)
}
}
func updateAttachmentLocation(with point: CGPoint)
{
// Update anchor points to new attachment points
let points = self.attachmentPoints(for: point)
let attachments = self.childBehaviors.flatMap { $0 as? UIAttachmentBehavior }
let pairs = zip(points, attachments)
pairs.forEach { $0.1.anchorPoint = $0.0 }
}
func attachmentPoints(for point: CGPoint) -> [CGPoint]
{
// Width and height should be close to the width and height of the item
let width: CGFloat = 40.0
let height: CGFloat = 40.0
let topLeft = CGPoint(x: point.x - width * 0.5, y: point.y - height * 0.5)
let topRight = CGPoint(x: point.x + width * 0.5, y: point.y - height * 0.5)
let bottomLeft = CGPoint(x: point.x - width * 0.5, y: point.y + height * 0.5)
let bottomRight = CGPoint(x: point.x + width * 0.5, y: point.y + height * 0.5)
let points = [topLeft, topRight, bottomLeft, bottomRight]
return points
}
}
@implementation RectangleAttachmentBehavior
- (instancetype)initWithItem:(id<UIDynamicItem>)item point:(CGPoint)point
{
CGFloat frequency = 8.0f;
CGFloat damping = 0.6f;
self = [super init];
if (self)
{
NSArray <NSValue *> *pointValues = [self attachmentPointValuesForPoint:point];
for (NSValue *value in pointValues)
{
UIAttachmentBehavior *attachment = [[UIAttachmentBehavior alloc]initWithItem:item attachedToAnchor:[value CGPointValue]];
attachment.frequency = frequency;
attachment.damping = damping;
[self addChildBehavior:attachment];
}
}
return self;
}
- (void)updateAttachmentLocationWithPoint:(CGPoint)point
{
NSArray <NSValue *> *pointValues = [self attachmentPointValuesForPoint:point];
for (NSInteger i = 0; i < pointValues.count; i++)
{
NSValue *pointValue = pointValues[i];
UIAttachmentBehavior *attachment = self.childBehaviors[i];
attachment.anchorPoint = [pointValue CGPointValue];
}
}
- (NSArray <NSValue *> *)attachmentPointValuesForPoint:(CGPoint)point
{
CGFloat width = 40.0f;
CGFloat height = 40.0f;
CGPoint topLeft = CGPointMake(point.x - width * 0.5, point.y - height * 0.5);
CGPoint topRight = CGPointMake(point.x + width * 0.5, point.y - height * 0.5);
CGPoint bottomLeft = CGPointMake(point.x - width * 0.5, point.y + height * 0.5);
CGPoint bottomRight = CGPointMake(point.x + width * 0.5, point.y + height * 0.5);
NSArray <NSValue *> *pointValues = @[[NSValue valueWithCGPoint:topLeft], [NSValue valueWithCGPoint:topRight], [NSValue valueWithCGPoint:bottomLeft], [NSValue valueWithCGPoint:bottomRight]];
return pointValues;
}
@end
Next we can create the high-level behavior that will combine a number of RectangleAttachmentBehavior
.
final class DragBehavior: UIDynamicBehavior
{
init(items: [UIDynamicItem], point: CGPoint)
{
super.init()
items.forEach
{
let rectAttachment = RectangleAttachmentBehavior(item: $0, point: point)
self.addChildBehavior(rectAttachment)
}
}
func updateDragLocation(with point: CGPoint)
{
// Tell low-level behaviors location has changed
self.childBehaviors.flatMap { $0 as? RectangleAttachmentBehavior }.forEach { $0.updateAttachmentLocation(with: point) }
}
}
@implementation DragBehavior
- (instancetype)initWithItems:(NSArray <id<UIDynamicItem>> *)items point: (CGPoint)point
{
self = [super init];
if (self)
{
for (id<UIDynamicItem> item in items)
{
RectangleAttachmentBehavior *rectAttachment = [[RectangleAttachmentBehavior alloc]initWithItem:item point:point];
[self addChildBehavior:rectAttachment];
}
}
return self;
}
- (void)updateDragLocationWithPoint:(CGPoint)point
{
for (RectangleAttachmentBehavior *rectAttachment in self.childBehaviors)
{
[rectAttachment updateAttachmentLocationWithPoint:point];
}
}
@end
Now with our behaviors in place, the next step is to add them to our collection view when. Because normally we want a standard grid layout we can subclass UICollectionViewFlowLayout
and only change attributes when dragging. We do this mainly through overriding layoutAttributesForElementsInRect
and using the UIDynamicAnimator's
convenience method itemsInRect
.
final class DraggableLayout: UICollectionViewFlowLayout
{
// Array that holds dragged index paths
var indexPathsForDraggingElements: [IndexPath]?
// The dynamic animator that will animate drag behavior
var animator: UIDynamicAnimator?
// Custom high-level behavior that dictates drag animation
var dragBehavior: DragBehavior?
// Where dragging starts so can return there once dragging ends
var startDragPoint = CGPoint.zero
// Bool to keep track if dragging has ended
var isFinishedDragging = false
// Method to inform layout that dragging has started
func startDragging(indexPaths selectedIndexPaths: [IndexPath], from point: CGPoint)
{
indexPathsForDraggingElements = selectedIndexPaths
animator = UIDynamicAnimator(collectionViewLayout: self)
animator?.delegate = self
// Get all of the draggable attributes but change zIndex so above other cells
let draggableAttributes: [UICollectionViewLayoutAttributes] = selectedIndexPaths.flatMap {
let attribute = super.layoutAttributesForItem(at: $0)
attribute?.zIndex = 1
return attribute
}
startDragPoint = point
// Add them to high-level behavior
dragBehavior = DragBehavior(items: draggableAttributes, point: point)
// Add high-level behavior to animator
animator?.addBehavior(dragBehavior!)
}
func updateDragLocation(_ point: CGPoint)
{
// Tell high-level behavior that point has updated
dragBehavior?.updateDragLocation(with: point)
}
func endDragging()
{
isFinishedDragging = true
// Return high-level behavior to starting point
dragBehavior?.updateDragLocation(with: startDragPoint)
}
func clearDraggedIndexPaths()
{
// Reset state for next drag event
animator = nil
indexPathsForDraggingElements = nil
isFinishedDragging = false
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{
let existingAttributes: [UICollectionViewLayoutAttributes] = super.layoutAttributesForElements(in: rect) ?? []
var allAttributes = [UICollectionViewLayoutAttributes]()
// Get normal flow layout attributes for non-drag items
for attributes in existingAttributes
{
if (indexPathsForDraggingElements?.contains(attributes.indexPath) ?? false) == false
{
allAttributes.append(attributes)
}
}
// Add dragged item attributes by asking animator for them
if let animator = self.animator
{
let animatorAttributes: [UICollectionViewLayoutAttributes] = animator.items(in: rect).flatMap { $0 as? UICollectionViewLayoutAttributes }
allAttributes.append(contentsOf: animatorAttributes)
}
return allAttributes
}
}
extension DraggableLayout: UIDynamicAnimatorDelegate
{
func dynamicAnimatorDidPause(_ animator: UIDynamicAnimator)
{
// Animator has paused and done dragging; reset state
guard isFinishedDragging else { return }
clearDraggedIndexPaths()
}
}
@interface DraggableLayout () <UIDynamicAnimatorDelegate>
@property (nonatomic, strong) NSArray <NSIndexPath *> *indexPathsForDraggingElements;
@property (nonatomic, strong) UIDynamicAnimator *animator;
@property (nonatomic, assign) CGPoint startDragPoint;
@property (nonatomic, assign) BOOL finishedDragging;
@property (nonatomic, strong) DragBehavior *dragBehavior;
@end
@implementation DraggableLayout
- (void)startDraggingWithIndexPaths:(NSArray <NSIndexPath *> *)selectedIndexPaths fromPoint:(CGPoint)point
{
self.indexPathsForDraggingElements = selectedIndexPaths;
self.animator = [[UIDynamicAnimator alloc]initWithCollectionViewLayout:self];
self.animator.delegate = self;
NSMutableArray *draggableAttributes = [[NSMutableArray alloc]initWithCapacity:selectedIndexPaths.count];
for (NSIndexPath *indexPath in selectedIndexPaths)
{
UICollectionViewLayoutAttributes *attributes = [super layoutAttributesForItemAtIndexPath:indexPath];
attributes.zIndex = 1;
[draggableAttributes addObject:attributes];
}
self.startDragPoint = point;
self.dragBehavior = [[DragBehavior alloc]initWithItems:draggableAttributes point:point];
[self.animator addBehavior:self.dragBehavior];
}
- (void)updateDragLoactionWithPoint:(CGPoint)point
{
[self.dragBehavior updateDragLocationWithPoint:point];
}
- (void)endDragging
{
self.finishedDragging = YES;
[self.dragBehavior updateDragLocationWithPoint:self.startDragPoint];
}
- (void)clearDraggedIndexPath
{
self.animator = nil;
self.indexPathsForDraggingElements = nil;
self.finishedDragging = NO;
}
- (void)dynamicAnimatorDidPause:(UIDynamicAnimator *)animator
{
if (self.finishedDragging)
{
[self clearDraggedIndexPath];
}
}
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSArray *existingAttributes = [super layoutAttributesForElementsInRect:rect];
NSMutableArray *allAttributes = [[NSMutableArray alloc]initWithCapacity:existingAttributes.count];
for (UICollectionViewLayoutAttributes *attributes in existingAttributes)
{
if (![self.indexPathsForDraggingElements containsObject:attributes.indexPath])
{
[allAttributes addObject:attributes];
}
}
[allAttributes addObjectsFromArray:[self.animator itemsInRect:rect]];
return allAttributes;
}
@end
Finally, we’ll create a view controller that will create our UICollectionView
and handle our long press gesture.