UIKit Dynamics with UICollectionView
Introduction#
UIKit Dynamics is a physics engine integrated into UIKit. UIKit Dynamics offers a set of API that offers interoperability with a UICollectionView
and UICollectionViewLayout
Creating a Custom Dragging Behavior with UIDynamicAnimator
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
Swift
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
}
}
Objective-C
@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
.
Swift
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) }
}
}
Objective-C
@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
.
Swift
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()
}
}
Objective-C
@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.
Swift
final class ViewController: UIViewController
{
// Collection view that displays cells
lazy var collectionView: UICollectionView =
{
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: DraggableLayout())
collectionView.backgroundColor = .white
collectionView.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(collectionView)
collectionView.topAnchor.constraint(equalTo: self.topLayoutGuide.bottomAnchor).isActive = true
collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
collectionView.bottomAnchor.constraint(equalTo: self.bottomLayoutGuide.topAnchor).isActive = true
return collectionView
}()
// Gesture that drives dragging
lazy var longPress: UILongPressGestureRecognizer =
{
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(self.handleLongPress(sender:)))
return longPress
}()
// Array that holds selected index paths
var selectedIndexPaths = [IndexPath]()
override func viewDidLoad()
{
super.viewDidLoad()
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
collectionView.addGestureRecognizer(longPress)
}
func handleLongPress(sender: UILongPressGestureRecognizer)
{
guard let draggableLayout = collectionView.collectionViewLayout as? DraggableLayout else { return }
let location = sender.location(in: collectionView)
switch sender.state
{
case .began:
draggableLayout.startDragging(indexPaths: selectedIndexPaths, from: location)
case .changed:
draggableLayout.updateDragLocation(location)
case .ended, .failed, .cancelled:
draggableLayout.endDragging()
case .possible:
break
}
}
}
extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource
{
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int
{
return 1000
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
{
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
cell.backgroundColor = .gray
if selectedIndexPaths.contains(indexPath) == true
{
cell.backgroundColor = .red
}
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
// Bool that determines if cell is being selected or unselected
let isSelected = !selectedIndexPaths.contains(indexPath)
let cell = collectionView.cellForItem(at: indexPath)
cell?.backgroundColor = isSelected ? .red : .gray
if isSelected
{
selectedIndexPaths.append(indexPath)
}
else
{
selectedIndexPaths.remove(at: selectedIndexPaths.index(of: indexPath)!)
}
}
}
Objective-C
@interface ViewController () <UICollectionViewDelegate, UICollectionViewDataSource>
@property (nonatomic, strong) UICollectionView *collectionView;
@property (nonatomic, strong) UILongPressGestureRecognizer *longPress;
@property (nonatomic, strong) NSMutableArray <NSIndexPath *> *selectedIndexPaths;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
self.collectionView.delegate = self;
self.collectionView.dataSource = self;
[self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
[self.collectionView addGestureRecognizer:self.longPress];
self.selectedIndexPaths = [[NSMutableArray alloc]init];
}
- (UICollectionView *)collectionView
{
if (!_collectionView)
{
_collectionView = [[UICollectionView alloc]initWithFrame:CGRectZero collectionViewLayout:[[DraggableLayout alloc]init]];
_collectionView.backgroundColor = [UIColor whiteColor];
_collectionView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:_collectionView];
[_collectionView.topAnchor constraintEqualToAnchor:self.topLayoutGuide.bottomAnchor].active = YES;
[_collectionView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor].active = YES;
[_collectionView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor].active = YES;
[_collectionView.bottomAnchor constraintEqualToAnchor:self.bottomLayoutGuide.topAnchor].active = YES;
}
return _collectionView;
}
- (UILongPressGestureRecognizer *)longPress
{
if (!_longPress)
{
_longPress = [[UILongPressGestureRecognizer alloc]initWithTarget:self action:@selector(handleLongPress:)];
}
return _longPress;
}
- (void)handleLongPress:(UILongPressGestureRecognizer *)sender
{
DraggableLayout *draggableLayout = (DraggableLayout *)self.collectionView.collectionViewLayout;
CGPoint location = [sender locationInView:self.collectionView];
if (sender.state == UIGestureRecognizerStateBegan)
{
[draggableLayout startDraggingWithIndexPaths:self.selectedIndexPaths fromPoint:location];
}
else if(sender.state == UIGestureRecognizerStateChanged)
{
[draggableLayout updateDragLoactionWithPoint:location];
}
else if(sender.state == UIGestureRecognizerStateEnded || sender.state == UIGestureRecognizerStateCancelled || sender.state == UIGestureRecognizerStateFailed)
{
[draggableLayout endDragging];
}
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
return 1000;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
cell.backgroundColor = [UIColor grayColor];
if ([self.selectedIndexPaths containsObject:indexPath])
{
cell.backgroundColor = [UIColor redColor];
}
return cell;
}
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath
{
BOOL isSelected = ![self.selectedIndexPaths containsObject:indexPath];
UICollectionViewCell *cell = [collectionView cellForItemAtIndexPath:indexPath];
if (isSelected)
{
cell.backgroundColor = [UIColor redColor];
[self.selectedIndexPaths addObject:indexPath];
}
else
{
cell.backgroundColor = [UIColor grayColor];
[self.selectedIndexPaths removeObject:indexPath];
}
}
@end
For more information 2013 WWDC Session “Advanced Techniques with UIKit Dynamics”