| // 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 |