UIKit Dynamics with UICollectionView
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
Section titled “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
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
Section titled “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;}
@endNext 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) } }}Objective-C
Section titled “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]; }}
@endNow 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() }}Objective-C
Section titled “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;}
@endFinally, we’ll create a view controller that will create our UICollectionView and handle our long press gesture.
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
Section titled “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]; }}
@endFor more information 2013 WWDC Session “Advanced Techniques with UIKit Dynamics”
