blob: 0998550127a7ce80323ca19dc71571fe39651370 [file] [log] [blame]
// Copyright 2016-present the Material Components for iOS authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#import "MDCCollectionViewEditor.h"
#import "MDCCollectionViewEditingDelegate.h"
#import "MaterialAvailability.h"
#import "MaterialShadowLayer.h"
#include <tgmath.h>
// Distance from center before we start fading the item.
static const CGFloat kDismissalDistanceBeforeFading = 50;
// Minimum alpha for an item being dismissed.
static const CGFloat kDismissalMinimumAlpha = (CGFloat)0.5;
// Simple linear friction applied to swipe velocity.
static const CGFloat kDismissalSwipeFriction = (CGFloat)0.05;
// Animation duration for dismissal / restore.
static const NSTimeInterval kDismissalAnimationDuration = 0.3;
static const NSTimeInterval kRestoreAnimationDuration = 0.2;
// Distance from collection view bounds that reorder panning should trigger autoscroll.
static const CGFloat kMDCAutoscrollPanningBuffer = 60;
// Distance collection view should offset during autoscroll.
static const CGFloat kMDCAutoscrollPanningOffset = 10;
/** Autoscroll panning direction. */
typedef NS_ENUM(NSInteger, MDCAutoscrollPanningDirection) {
kMDCAutoscrollPanningDirectionNone,
kMDCAutoscrollPanningDirectionUp,
kMDCAutoscrollPanningDirectionDown
};
/** A view that uses an MDCShadowLayer as its sublayer. */
@interface ShadowedSnapshotView : UIView
@end
@implementation ShadowedSnapshotView
+ (Class)layerClass {
return [MDCShadowLayer class];
}
@end
@interface MDCCollectionViewEditor () <UIGestureRecognizerDelegate>
@end
#if MDC_AVAILABLE_SDK_IOS(10_0)
@interface MDCCollectionViewEditor () <CAAnimationDelegate>
@end
#endif // MDC_AVAILABLE_SDK_IOS(10_0)
@implementation MDCCollectionViewEditor {
UILongPressGestureRecognizer *_longPressGestureRecognizer;
UIPanGestureRecognizer *_panGestureRecognizer;
CGPoint _selectedCellLocation;
ShadowedSnapshotView *_cellSnapshot;
CADisplayLink *_autoscrollTimer;
MDCAutoscrollPanningDirection _autoscrollPanningDirection;
}
@synthesize collectionView = _collectionView;
@synthesize delegate = _delegate;
@synthesize reorderingCellIndexPath = _reorderingCellIndexPath;
@synthesize dismissingCellIndexPath = _dismissingCellIndexPath;
@synthesize dismissingSection = _dismissingSection;
@synthesize editing = _editing;
#pragma mark - Public
- (instancetype)initWithCollectionView:(UICollectionView *)collectionView {
self = [super init];
if (self) {
_collectionView = collectionView;
_dismissingSection = NSNotFound;
// Setup gestures to handle collectionView editing.
SEL longPressSelector = @selector(handleLongPressGesture:);
_longPressGestureRecognizer =
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:longPressSelector];
_longPressGestureRecognizer.delegate = self;
[_collectionView addGestureRecognizer:_longPressGestureRecognizer];
SEL panSelector = @selector(handlePanGesture:);
_panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:panSelector];
_panGestureRecognizer.delegate = self;
_panGestureRecognizer.maximumNumberOfTouches = 1;
[_collectionView addGestureRecognizer:_panGestureRecognizer];
}
return self;
}
- (void)dealloc {
_longPressGestureRecognizer.delegate = nil;
_panGestureRecognizer.delegate = nil;
// Remove gesture recognizers to prevent duplicates when re-initializing this controller.
[_collectionView removeGestureRecognizer:_longPressGestureRecognizer];
[_collectionView removeGestureRecognizer:_panGestureRecognizer];
}
- (void)setEditing:(BOOL)editing {
[self setEditing:editing animated:NO];
}
- (void)setEditing:(BOOL)editing animated:(__unused BOOL)animated {
_editing = editing;
_collectionView.allowsMultipleSelection = editing;
// Clear any selected indexPaths.
for (NSIndexPath *indexPath in [_collectionView indexPathsForSelectedItems]) {
[_collectionView deselectItemAtIndexPath:indexPath animated:NO];
}
[CATransaction begin];
if (editing) {
// Notify delegate will begin editing.
if ([_delegate respondsToSelector:@selector(collectionViewWillBeginEditing:)]) {
[_delegate collectionViewWillBeginEditing:_collectionView];
}
[CATransaction setCompletionBlock:^{
// Notify delegate did begin editing.
if ([self.delegate respondsToSelector:@selector(collectionViewDidBeginEditing:)]) {
[self.delegate collectionViewDidBeginEditing:self.collectionView];
}
}];
} else {
// Notify delegate will end editing.
if ([_delegate respondsToSelector:@selector(collectionViewWillEndEditing:)]) {
[_delegate collectionViewWillEndEditing:_collectionView];
}
[CATransaction setCompletionBlock:^{
// Notify delegate did end editing.
if ([self.delegate respondsToSelector:@selector(collectionViewDidEndEditing:)]) {
[self.delegate collectionViewDidEndEditing:self.collectionView];
}
}];
}
[CATransaction commit];
}
- (void)updateReorderCellPosition {
if (_reorderingCellIndexPath) {
CGPoint userTouchPosition = [_longPressGestureRecognizer locationInView:self.collectionView];
[self updateCellSnapshotPosition:userTouchPosition];
}
}
- (NSTimeInterval)minimumPressDuration {
return _longPressGestureRecognizer.minimumPressDuration;
}
- (void)setMinimumPressDuration:(NSTimeInterval)minimumPressDuration {
_longPressGestureRecognizer.minimumPressDuration = minimumPressDuration;
}
#pragma mark - Private
- (void)updateCellSnapshotPosition:(CGPoint)newPosition {
if (_cellSnapshot) {
CGPoint newCellCenter = CGPointMake(_cellSnapshot.center.x, newPosition.y);
_cellSnapshot.center = newCellCenter;
}
}
- (NSArray *)attributesAtSection:(NSInteger)section {
UICollectionViewLayout *layout = _collectionView.collectionViewLayout;
NSIndexPath *indexPath;
NSMutableArray *sectionAttributes = [NSMutableArray array];
// Get all item attributes at section.
NSInteger numberOfItemsInSection = [_collectionView numberOfItemsInSection:section];
for (NSInteger i = 0; i < numberOfItemsInSection; ++i) {
indexPath = [NSIndexPath indexPathForItem:i inSection:section];
UICollectionViewLayoutAttributes *attribute =
[layout layoutAttributesForItemAtIndexPath:indexPath];
[sectionAttributes addObject:attribute];
}
// Headers/footers require section but ignore item of index path, so set to zero.
indexPath = [NSIndexPath indexPathForItem:0 inSection:section];
// Get header attributes at section.
UICollectionViewLayoutAttributes *headerAttribute =
[layout layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionHeader
atIndexPath:indexPath];
[sectionAttributes addObject:headerAttribute];
// Get footer attributes at section.
UICollectionViewLayoutAttributes *footerAttribute =
[layout layoutAttributesForSupplementaryViewOfKind:UICollectionElementKindSectionFooter
atIndexPath:indexPath];
[sectionAttributes addObject:footerAttribute];
return sectionAttributes;
}
- (NSInteger)swipedSectionAtLocation:(CGPoint)location {
// Returns section index being swiped for dismissal, or NSNotFound for an invalid swipes
// where location does not return an item or section header/footer.
CGRect contentFrame = _collectionView.frame;
contentFrame.size = _collectionView.contentSize;
NSArray *visibleAttributes =
[_collectionView.collectionViewLayout layoutAttributesForElementsInRect:contentFrame];
for (UICollectionViewLayoutAttributes *attribute in visibleAttributes) {
if (!CGRectIsNull(attribute.frame) && CGRectContainsPoint(attribute.frame, location)) {
return attribute.indexPath.section;
}
}
return NSNotFound;
}
#pragma mark - Snapshotting
- (UIView *)snapshotWithIndexPath:(NSIndexPath *)indexPath {
// Here we will take a snapshot of the collectionView item.
if (_cellSnapshot) {
[_cellSnapshot removeFromSuperview];
_cellSnapshot = nil;
}
// Create snapshot.
UICollectionViewLayoutAttributes *attributes =
[_collectionView.collectionViewLayout layoutAttributesForItemAtIndexPath:indexPath];
_cellSnapshot = [[ShadowedSnapshotView alloc] initWithFrame:attributes.frame];
UICollectionViewCell *cell = [_collectionView cellForItemAtIndexPath:indexPath];
[_cellSnapshot addSubview:[cell snapshotViewAfterScreenUpdates:YES]];
// Invalidate layout here to force attributes to now be hidden.
[_collectionView.collectionViewLayout invalidateLayout];
return _cellSnapshot;
}
- (UIView *)snapshotWithSection:(NSInteger)section {
// Here we will take a snapshot of the collectionView section items, header, and footer.
if (_cellSnapshot) {
[_cellSnapshot removeFromSuperview];
_cellSnapshot = nil;
}
// The snapshot frame encompasses all of the section items, header, and footer attribute frames.
NSArray *sectionAttributes = [self attributesAtSection:section];
CGRect snapshotFrame = CGRectNull;
for (UICollectionViewLayoutAttributes *attribute in sectionAttributes) {
if (!CGRectIsNull(attribute.frame) && !CGRectIsInfinite(attribute.frame)) {
snapshotFrame = CGRectUnion(snapshotFrame, attribute.frame);
}
}
// Create snapshot.
_cellSnapshot = [[ShadowedSnapshotView alloc] initWithFrame:snapshotFrame];
UIImageView *snapshotView =
[[UIImageView alloc] initWithImage:[self snapshotWithRect:snapshotFrame]];
[_cellSnapshot addSubview:snapshotView];
// Invalidate layout here to force attributes to now be hidden.
[_collectionView.collectionViewLayout invalidateLayout];
return _cellSnapshot;
}
- (UIImage *)snapshotWithRect:(CGRect)rect {
// Here we will take a snapshot of a rect within the collectionView.
UIGraphicsBeginImageContextWithOptions(rect.size, NO, 0.0);
CGContextRef cx = UIGraphicsGetCurrentContext();
CGContextTranslateCTM(cx, -rect.origin.x, -rect.origin.y);
// Save original collection view properties.
id<UICollectionViewDelegate> savedDelegate = _collectionView.delegate;
_collectionView.delegate = nil;
CGPoint savedContentOffset = _collectionView.contentOffset;
BOOL savedClipsToBounds = _collectionView.clipsToBounds;
_collectionView.clipsToBounds = NO;
// Hide any scroll indicators.
BOOL showsHorizontalScrollIndicator = _collectionView.showsHorizontalScrollIndicator;
BOOL showsVerticalScrollIndicator = _collectionView.showsVerticalScrollIndicator;
_collectionView.showsHorizontalScrollIndicator = NO;
_collectionView.showsVerticalScrollIndicator = NO;
// Render snapshot.
[_collectionView.layer renderInContext:cx];
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
// For code review, use the review queue listed in go/material-visionos-review.
UITraitCollection *current = [UITraitCollection currentTraitCollection];
CGFloat scale = current ? [current displayScale] : 1.0;
_collectionView.layer.rasterizationScale = scale;
#else
_collectionView.layer.rasterizationScale = [UIScreen mainScreen].scale;
#endif
_collectionView.layer.shouldRasterize = YES;
UIImage *screenshotImage = UIGraphicsGetImageFromCurrentImageContext();
// Reset collection view.
_collectionView.contentOffset = savedContentOffset;
_collectionView.showsHorizontalScrollIndicator = showsHorizontalScrollIndicator;
_collectionView.showsVerticalScrollIndicator = showsVerticalScrollIndicator;
_collectionView.delegate = savedDelegate;
_collectionView.clipsToBounds = savedClipsToBounds;
_collectionView.layer.shouldRasterize = NO;
UIGraphicsEndImageContext();
return screenshotImage;
}
- (void)applyLayerShadowing:(__unused CALayer *)layer {
MDCShadowLayer *shadowLayer = (MDCShadowLayer *)_cellSnapshot.layer;
shadowLayer.shadowMaskEnabled = NO;
shadowLayer.elevation = 3;
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
if ([gestureRecognizer isEqual:_longPressGestureRecognizer]) {
// Only allow longpress if collectionView is editing.
return self.isEditing;
} else if ([gestureRecognizer isEqual:_panGestureRecognizer]) {
// Only allow panning if collectionView is editing or dismissing item/section.
BOOL allowsSwipeToDismissItem = NO;
if ([_delegate respondsToSelector:@selector(collectionViewAllowsSwipeToDismissItem:)]) {
allowsSwipeToDismissItem = [_delegate collectionViewAllowsSwipeToDismissItem:_collectionView];
}
BOOL allowsSwipeToDismissSection = NO;
if ([_delegate respondsToSelector:@selector(collectionViewAllowsSwipeToDismissSection:)]) {
allowsSwipeToDismissSection =
[_delegate collectionViewAllowsSwipeToDismissSection:_collectionView];
}
return (self.isEditing || allowsSwipeToDismissItem || allowsSwipeToDismissSection);
}
return YES;
}
- (BOOL)gestureRecognizer:(__unused UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:
(__unused UIGestureRecognizer *)otherGestureRecognizer {
return YES;
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
// Prevent panning to dismiss when scrolling.
return ([gestureRecognizer isEqual:_panGestureRecognizer] &&
[otherGestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]);
}
- (BOOL)gestureRecognizer:(__unused UIGestureRecognizer *)gestureRecognizer
shouldReceiveTouch:(__unused UITouch *)touch {
BOOL allowsSwipeToDismissItem = NO;
if ([_delegate respondsToSelector:@selector(collectionViewAllowsSwipeToDismissItem:)]) {
allowsSwipeToDismissItem = [_delegate collectionViewAllowsSwipeToDismissItem:_collectionView];
}
BOOL allowsSwipeToDismissSection = NO;
if ([_delegate respondsToSelector:@selector(collectionViewAllowsSwipeToDismissSection:)]) {
allowsSwipeToDismissSection =
[_delegate collectionViewAllowsSwipeToDismissSection:_collectionView];
}
return (self.isEditing || allowsSwipeToDismissItem || allowsSwipeToDismissSection);
}
#pragma mark - LongPress Gesture Handling
- (void)handleLongPressGesture:(UILongPressGestureRecognizer *)recognizer {
switch (recognizer.state) {
case UIGestureRecognizerStateBegan: {
_selectedCellLocation = [recognizer locationInView:_collectionView];
_reorderingCellIndexPath = [_collectionView indexPathForItemAtPoint:_selectedCellLocation];
if ([_delegate respondsToSelector:@selector(collectionView:canMoveItemAtIndexPath:)] &&
![_delegate collectionView:_collectionView
canMoveItemAtIndexPath:_reorderingCellIndexPath]) {
_reorderingCellIndexPath = nil;
return;
}
// Notify delegate dragging has began.
if ([_delegate respondsToSelector:@selector(collectionView:
willBeginDraggingItemAtIndexPath:)]) {
[_delegate collectionView:_collectionView
willBeginDraggingItemAtIndexPath:_reorderingCellIndexPath];
}
// Create cell snapshot with shadowing.
[_collectionView addSubview:[self snapshotWithIndexPath:_reorderingCellIndexPath]];
[self applyLayerShadowing:_cellSnapshot.layer];
// Disable scrolling.
[_collectionView setScrollEnabled:NO];
break;
}
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateEnded: {
// Stop autoscroll.
[self stopAutoscroll];
NSIndexPath *currentIndexPath = _reorderingCellIndexPath;
if (currentIndexPath) {
UICollectionViewLayoutAttributes *attributes = [_collectionView.collectionViewLayout
layoutAttributesForItemAtIndexPath:currentIndexPath];
void (^completionBlock)(BOOL finished) = ^(__unused BOOL finished) {
// Notify delegate dragging has finished.
if ([self.delegate respondsToSelector:@selector(collectionView:
didEndDraggingItemAtIndexPath:)]) {
[self.delegate collectionView:self.collectionView
didEndDraggingItemAtIndexPath:self->_reorderingCellIndexPath];
}
[self restoreEditingItem];
// Re-enable scrolling.
[self.collectionView setScrollEnabled:YES];
};
[UIView animateWithDuration:kDismissalAnimationDuration
delay:0
options:UIViewAnimationOptionBeginFromCurrentState
animations:^{
self->_cellSnapshot.frame = attributes.frame;
}
completion:completionBlock];
}
break;
}
default:
break;
}
}
#pragma mark - Pan Gesture Handling
- (void)handlePanGesture:(UIPanGestureRecognizer *)recognizer {
if (_reorderingCellIndexPath) {
[self panToReorderWithRecognizer:recognizer];
} else {
[self panToDismissWithRecognizer:recognizer];
}
}
- (void)panToReorderWithRecognizer:(UIPanGestureRecognizer *)recognizer {
if (recognizer.state == UIGestureRecognizerStateChanged) {
// Update the snapshot's position when panning.
_selectedCellLocation = [recognizer locationInView:_collectionView];
[self updateCellSnapshotPosition:_selectedCellLocation];
// Determine moved index paths.
NSIndexPath *newIndexPath = [_collectionView indexPathForItemAtPoint:_selectedCellLocation];
NSIndexPath *previousIndexPath = _reorderingCellIndexPath;
if ((newIndexPath == nil) || [newIndexPath isEqual:previousIndexPath]) {
return;
}
// Autoscroll if the cell is dragged out of the collectionView's bounds.
CGFloat buffer = kMDCAutoscrollPanningBuffer;
if (_selectedCellLocation.y < CGRectGetMinY(self.collectionView.bounds) + buffer) {
[self startAutoscroll];
_autoscrollPanningDirection = kMDCAutoscrollPanningDirectionUp;
} else if (_selectedCellLocation.y > (CGRectGetMaxY(self.collectionView.bounds) - buffer)) {
[self startAutoscroll];
_autoscrollPanningDirection = kMDCAutoscrollPanningDirectionDown;
} else {
[self stopAutoscroll];
}
// Check delegate for permission to move item.
if ([_delegate respondsToSelector:@selector(collectionView:
canMoveItemAtIndexPath:toIndexPath:)]) {
if ([_delegate collectionView:_collectionView
canMoveItemAtIndexPath:previousIndexPath
toIndexPath:newIndexPath]) {
_reorderingCellIndexPath = newIndexPath;
// Notify delegate that item will move.
if ([_delegate respondsToSelector:@selector(collectionView:
willMoveItemAtIndexPath:toIndexPath:)]) {
[_delegate collectionView:_collectionView
willMoveItemAtIndexPath:previousIndexPath
toIndexPath:newIndexPath];
}
// Notify delegate item did move.
if ([_delegate respondsToSelector:@selector(collectionView:
didMoveItemAtIndexPath:toIndexPath:)]) {
[_delegate collectionView:_collectionView
didMoveItemAtIndexPath:previousIndexPath
toIndexPath:newIndexPath];
}
} else {
// Exit if delegate will not allow this indexPath to move.
return;
}
}
}
}
- (void)panToDismissWithRecognizer:(UIPanGestureRecognizer *)recognizer {
CGPoint translation = [recognizer translationInView:_collectionView];
CGPoint velocity = [recognizer velocityInView:_collectionView];
CGPoint location = [recognizer locationInView:_collectionView];
switch (recognizer.state) {
case UIGestureRecognizerStateBegan: {
if (fabs(velocity.y) > fabs(velocity.x)) {
// Exit if panning vertically.
return [self exitPanToDismissWithRecognizer:recognizer];
}
BOOL allowsSwipeToDismissSection = NO;
if ([_delegate respondsToSelector:@selector(collectionViewAllowsSwipeToDismissSection:)]) {
allowsSwipeToDismissSection =
[_delegate collectionViewAllowsSwipeToDismissSection:_collectionView];
}
BOOL allowsSwipeToDismissItem = NO;
if ([_delegate respondsToSelector:@selector(collectionViewAllowsSwipeToDismissItem:)]) {
allowsSwipeToDismissItem =
[_delegate collectionViewAllowsSwipeToDismissItem:_collectionView];
}
if (allowsSwipeToDismissSection && !self.isEditing) {
// Determine panned section to dismiss.
_dismissingSection = [self swipedSectionAtLocation:location];
if (_dismissingSection == NSNotFound) {
return [self exitPanToDismissWithRecognizer:recognizer];
}
// Check delegate for permission to swipe section.
if ([_delegate respondsToSelector:@selector(collectionView:canSwipeToDismissSection:)]) {
if ([_delegate collectionView:_collectionView
canSwipeToDismissSection:_dismissingSection]) {
// Notify delegate.
if ([_delegate respondsToSelector:@selector(collectionView:
willBeginSwipeToDismissSection:)]) {
[_delegate collectionView:_collectionView
willBeginSwipeToDismissSection:_dismissingSection];
}
} else {
// Cannot swipe so exit.
return [self exitPanToDismissWithRecognizer:recognizer];
}
}
// Create section snapshot.
[_collectionView insertSubview:[self snapshotWithSection:_dismissingSection] atIndex:0];
break;
} else if (allowsSwipeToDismissItem) {
// Determine panned index path to dismiss.
_dismissingCellIndexPath = [_collectionView indexPathForItemAtPoint:location];
if (!_dismissingCellIndexPath) {
return [self exitPanToDismissWithRecognizer:recognizer];
}
// Check delegate for permission to swipe item.
BOOL canSwipeToDismiss = NO;
if ([_delegate respondsToSelector:@selector(collectionView:
canSwipeInDirection:toDismissItemAtIndexPath:)]) {
canSwipeToDismiss =
[_delegate collectionView:_collectionView
canSwipeInDirection:(velocity.x > 0 ? UISwipeGestureRecognizerDirectionRight
: UISwipeGestureRecognizerDirectionLeft)
toDismissItemAtIndexPath:_dismissingCellIndexPath];
} else if ([_delegate respondsToSelector:@selector(collectionView:
canSwipeToDismissItemAtIndexPath:)]) {
canSwipeToDismiss = [_delegate collectionView:_collectionView
canSwipeToDismissItemAtIndexPath:_dismissingCellIndexPath];
}
if (canSwipeToDismiss) {
// Notify delegate.
if ([_delegate respondsToSelector:@selector(collectionView:
willBeginSwipeToDismissItemAtIndexPath:)]) {
[_delegate collectionView:_collectionView
willBeginSwipeToDismissItemAtIndexPath:_dismissingCellIndexPath];
}
} else {
// Cannot swipe so exit.
return [self exitPanToDismissWithRecognizer:recognizer];
}
// Create item snapshot.
[_collectionView insertSubview:[self snapshotWithIndexPath:_dismissingCellIndexPath]
atIndex:0];
break;
}
}
case UIGestureRecognizerStateChanged: {
if ([_delegate respondsToSelector:@selector(collectionView:
canSwipeInDirection:toDismissItemAtIndexPath:)]) {
// Do not allow swiping in a not allowed direction over the starting point, once the swiping
// has already started.
if (![_delegate collectionView:_collectionView
canSwipeInDirection:(translation.x > 0 ? UISwipeGestureRecognizerDirectionRight
: UISwipeGestureRecognizerDirectionLeft)
toDismissItemAtIndexPath:_dismissingCellIndexPath]) {
return;
}
}
// Update the tracked item's position and alpha.
CGAffineTransform transform;
CGFloat alpha;
// The item is fully opaque until it pans at least @c kDismissalDistanceBeforeFading points.
CGFloat panDistance = (CGFloat)fabs(translation.x) - kDismissalDistanceBeforeFading;
if (panDistance > 0) {
transform = [self transformItemDismissalToTranslationX:translation.x];
alpha = [self dismissalAlphaForTranslationX:translation.x];
} else {
// Pan the item.
transform = CGAffineTransformMakeTranslation(translation.x, 0);
alpha = 1;
}
_cellSnapshot.layer.transform = CATransform3DMakeAffineTransform(transform);
_cellSnapshot.alpha = alpha;
break;
}
case UIGestureRecognizerStateEnded: {
// Check the final translation, including the final velocity, to determine
// if the item should be dismissed.
CGFloat momentumX = velocity.x * kDismissalSwipeFriction;
CGFloat translationX = translation.x + momentumX;
if ([_delegate respondsToSelector:@selector(collectionView:
canSwipeInDirection:toDismissItemAtIndexPath:)]) {
// Do not allow to swipe to dismiss by ending the swipe towards a not allowed direction.
if (![_delegate collectionView:_collectionView
canSwipeInDirection:(translationX > 0 ? UISwipeGestureRecognizerDirectionRight
: UISwipeGestureRecognizerDirectionLeft)
toDismissItemAtIndexPath:_dismissingCellIndexPath]) {
[self restorePanningItemIfNecessaryWithMomentumX:momentumX];
return;
}
}
if (fabs(translationX) > [self distanceThresholdForDismissal]) {
// @c translationX is only guaranteed to be over the dismissal threshold;
// make sure the view animates all the way off the screen.
translationX = (CGFloat)copysign(
MAX(fabs(translationX), CGRectGetWidth(_collectionView.bounds)), translationX);
[self animateFinalItemDismissalToTranslationX:translationX];
} else {
[self restorePanningItemIfNecessaryWithMomentumX:momentumX];
}
break;
}
default: {
[self restorePanningItemIfNecessaryWithMomentumX:0];
break;
}
}
}
- (void)exitPanToDismissWithRecognizer:(UIPanGestureRecognizer *)recognizer {
// To exit, disable the recognizer immediately which forces it to drop out of the current
// loop and prevent any state updates. Then re-enable to allow future panning.
recognizer.enabled = NO;
recognizer.enabled = YES;
}
#pragma mark - Dismissal animation.
- (CGAffineTransform)transformItemDismissalToTranslationX:(CGFloat)translationX {
// Returns a transform that can be applied to the snapshot during dismissal. The
// translation will pan along the direction of the swipe.
CGFloat finalXTranslation = translationX;
if (finalXTranslation > 0) {
finalXTranslation = MAX(kDismissalDistanceBeforeFading, finalXTranslation);
} else {
finalXTranslation = MIN(-kDismissalDistanceBeforeFading, finalXTranslation);
}
return CGAffineTransformMakeTranslation(finalXTranslation, 0);
}
- (void)animateFinalItemDismissalToTranslationX:(CGFloat)translationX {
// Called at the end of a pan gesture that results in the item being dismissed.
// Animation that moves the dismissed item to the final location and fades it out.
CGAffineTransform transform = [self transformItemDismissalToTranslationX:translationX];
// Notify delegate of dismissed section.
if (_dismissingSection != NSNotFound) {
if ([_delegate respondsToSelector:@selector(collectionView:didEndSwipeToDismissSection:)]) {
[_delegate collectionView:_collectionView didEndSwipeToDismissSection:_dismissingSection];
}
}
// Notify delegate of dismissed item.
if (_dismissingCellIndexPath) {
if ([_delegate respondsToSelector:@selector(collectionView:
didEndSwipeToDismissItemAtIndexPath:)]) {
[_delegate collectionView:_collectionView
didEndSwipeToDismissItemAtIndexPath:_dismissingCellIndexPath];
}
}
[UIView animateWithDuration:kDismissalAnimationDuration
delay:0
options:UIViewAnimationOptionCurveEaseOut
animations:^{
self->_cellSnapshot.layer.transform = CATransform3DMakeAffineTransform(transform);
self->_cellSnapshot.alpha = 0;
}
completion:^(__unused BOOL finished) {
[self restoreEditingItem];
}];
}
- (void)restorePanningItemIfNecessaryWithMomentumX:(CGFloat)momentumX {
// If we never had a snapshot, or the snapshot never moved, then skip straight to cleanup.
if (_cellSnapshot == nil) {
[self cleanupDismissingInformation];
return;
}
if (CGAffineTransformIsIdentity(_cellSnapshot.transform)) {
[self restoreEditingItem];
return;
}
CAAnimationGroup *allAnimations = [CAAnimationGroup animation];
allAnimations.duration = kRestoreAnimationDuration;
CATransform3D startTransform = CATransform3DMakeAffineTransform(_cellSnapshot.transform);
CATransform3D midTransform = CATransform3DTranslate(startTransform, momentumX, 0, 0);
CATransform3D endTransform = CATransform3DIdentity;
CAKeyframeAnimation *transformAnimation = [CAKeyframeAnimation animationWithKeyPath:@"transform"];
transformAnimation.values = @[
[NSValue valueWithCATransform3D:startTransform], [NSValue valueWithCATransform3D:midTransform],
[NSValue valueWithCATransform3D:endTransform]
];
transformAnimation.calculationMode = kCAAnimationCubicPaced;
CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
opacityAnimation.fromValue = @(_cellSnapshot.alpha);
opacityAnimation.toValue = @1;
allAnimations.animations = @[ transformAnimation, opacityAnimation ];
allAnimations.delegate = self;
allAnimations.fillMode = kCAFillModeBackwards;
_cellSnapshot.layer.transform = CATransform3DIdentity;
_cellSnapshot.alpha = 1;
[_cellSnapshot.layer addAnimation:allAnimations forKey:nil];
}
- (void)animationDidStop:(__unused CAAnimation *)animation finished:(__unused BOOL)didFinish {
[self cancelPanningItem];
}
- (void)cancelPanningItem {
// Notify delegate of panned section cancellation.
if (_dismissingSection != NSNotFound) {
if ([_delegate respondsToSelector:@selector(collectionView:didCancelSwipeToDismissSection:)]) {
[_delegate collectionView:_collectionView didCancelSwipeToDismissSection:_dismissingSection];
}
}
// Notify delegate of panned index path cancellation.
if (_dismissingCellIndexPath) {
if ([_delegate respondsToSelector:@selector(collectionView:
didCancelSwipeToDismissItemAtIndexPath:)]) {
[_delegate collectionView:_collectionView
didCancelSwipeToDismissItemAtIndexPath:_dismissingCellIndexPath];
}
}
[self restoreEditingItem];
}
- (void)restoreEditingItem {
[self cleanupDismissingInformation];
[_collectionView.collectionViewLayout invalidateLayout];
}
- (void)cleanupDismissingInformation {
// Remove snapshot and reset item.
[_cellSnapshot removeFromSuperview];
_cellSnapshot = nil;
_dismissingSection = NSNotFound;
_dismissingCellIndexPath = nil;
_reorderingCellIndexPath = nil;
}
// The distance an item must be panned before it is dismissed. Currently half of the bounds width.
- (CGFloat)distanceThresholdForDismissal {
if (_cellSnapshot) {
return CGRectGetWidth(_cellSnapshot.bounds) / 2;
} else {
return CGRectGetWidth(_collectionView.bounds) / 2;
}
}
- (CGFloat)dismissalAlphaForTranslationX:(CGFloat)translationX {
translationX = (CGFloat)fabs(translationX) - kDismissalDistanceBeforeFading;
CGFloat adjustedThreshold = [self distanceThresholdForDismissal] - kDismissalDistanceBeforeFading;
CGFloat dismissalPercentage = (CGFloat)MIN(1, fabs(translationX) / adjustedThreshold);
return kDismissalMinimumAlpha + (1 - kDismissalMinimumAlpha) * (1 - dismissalPercentage);
}
#pragma mark - Reordering Autoscroll
- (void)startAutoscroll {
if (_autoscrollTimer) {
[self stopAutoscroll];
}
_autoscrollTimer = [CADisplayLink displayLinkWithTarget:self selector:@selector(autoscroll:)];
[_autoscrollTimer addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
}
- (void)stopAutoscroll {
if (_autoscrollTimer) {
[_autoscrollTimer invalidate];
_autoscrollTimer = nil;
_autoscrollPanningDirection = kMDCAutoscrollPanningDirectionNone;
}
}
- (void)autoscroll:(__unused CADisplayLink *)sender {
// Scrolls at each tick of CADisplayLink by setting scroll contentOffset. Animation is performed
// within UIView animation block rather than directly calling -setContentOffset:animated: method
// in order to prevent jerkiness in scrolling.
BOOL isPanningDown = _autoscrollPanningDirection == kMDCAutoscrollPanningDirectionDown;
CGFloat yOffset = kMDCAutoscrollPanningOffset * (isPanningDown ? 1 : -1);
CGFloat contentYOffset = self.collectionView.contentOffset.y;
// Quit early if scrolling past collection view bounds.
if ((!isPanningDown && contentYOffset <= 0) ||
(isPanningDown && contentYOffset >= self.collectionView.contentSize.height -
CGRectGetHeight(self.collectionView.bounds))) {
[self stopAutoscroll];
return;
}
[UIView animateWithDuration:0.3
delay:0
options:UIViewAnimationOptionBeginFromCurrentState
animations:^{
self.collectionView.contentOffset =
CGPointMake(0, MAX(0, contentYOffset + yOffset));
// Update the snapshot's position when panning.
CGPoint userTouchPosition =
[self->_longPressGestureRecognizer locationInView:self.collectionView];
[self updateCellSnapshotPosition:userTouchPosition];
}
completion:nil];
}
@end