iOS長按移動Table View Cells

前言

最近參與了事務流程工具化組件的開發,其中有一個模塊需要通過長按移動Table View Cells,來達到調整任務的需求,在此記錄下開發過程中的實現思路。完成后的效果如下圖所示:

長按移動cell.gif

實現思路

  • 添加手勢
    首先給 collection view 添加一個 UILongGestureRecognizer,在項目中一般使用懶加載的方式來對對象進行初始化:
- (UICollectionView *)collectionView {
    if (!_collectionView) {
        _collectionView =  [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:self.flowLayout];
        
        _collectionView.backgroundColor = [UIColor whiteColor];
        _collectionView.dataSource = self;
        _collectionView.delegate = self;
        
        [_collectionView registerClass:[TLCMainCollectionViewCell class] forCellWithReuseIdentifier:[TLCMainCollectionViewCell identifier]];
        
        _collectionView.showsHorizontalScrollIndicator = NO;
        _collectionView.showsVerticalScrollIndicator = NO;
        _collectionView.bounces = YES;
        _collectionView.decelerationRate = 0;
        
        [_collectionView addGestureRecognizer:self.longPress];
    }
    return _collectionView;
}
- (UILongPressGestureRecognizer *)longPress {
    if (!_longPress) {
        _longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGestureRecognized:)];
    }
    return _longPress;
}

在用戶長按后,觸犯長按事件,先獲取到當前手勢所在的collection view位置,再做后續的處理。

- (void)longPressGestureRecognized:(UILongPressGestureRecognizer *)sender {
    
    CGPoint location = [sender locationInView:sender.view];
    
    UIGestureRecognizerState state = sender.state;
    switch (state) {
            case UIGestureRecognizerStateBegan: {
                [self handleLongPressStateBeganWithLocation:location];
            }
            break;
            
            case UIGestureRecognizerStateChanged: {
            }
            break;
            
            case UIGestureRecognizerStateEnded:
            case UIGestureRecognizerStateCancelled: {
                [self longGestureEndedOrCancelledWithLocation:location];
            }
            break;
            
        default:
            break;
    }
}
  • 長按手勢狀態為開始
    主要處理兩個方面的事務,一為獲取當前長按手勢所對應的Table View Cell的鏡像,將其添加到 Collection View上。二為一些初始狀態的設置,后續在移動后網絡請求出錯及判斷當前手勢所處的Table View和上一次是否一致需要使用到。最后調用startPageEdgeScroll開啟定時器。
- (void)handleLongPressStateBeganWithLocation:(CGPoint)location {
    
    TLCMainCollectionViewCell *selectedCollectionViewCell = [self currentTouchedCollectionCellWithLocation:location];
    
    NSIndexPath *touchIndexPath = [self longGestureBeganIndexPathForRowAtPoint:location atTableView:selectedCollectionViewCell.tableView];
   
    if (!selectedCollectionViewCell || !touchIndexPath) {
        return ;
    }
    self.selectedCollectionViewCellRow = [self.collectionView indexPathForCell:selectedCollectionViewCell].row;
    
    // 已完成的任務,不支持排序
    TLPlanItem *selectedItem = [self.viewModel itemAtIndex:self.selectedCollectionViewCellRow
                                              subItemIndex:touchIndexPath.section];
    if (!selectedItem || selectedItem.finish) {
        return;
    }
    selectedItem.isHidden = YES;
    
    self.snapshotView = [self snapshotViewWithTableView:selectedCollectionViewCell.tableView
                                            atIndexPath:touchIndexPath];
    [self.collectionView addSubview:self.snapshotView];
    
    self.selectedIndexPath = touchIndexPath;
    self.originalSelectedIndexPathSection = touchIndexPath.section;
    self.originalCollectionViewCellRow = self.selectedCollectionViewCellRow;
    self.previousPoint = CGPointZero;
    
    [self startPageEdgeScroll];
}
  • 長按手勢狀態為改變
    longPressGestureRecognized方法中,可以發現,長按手勢狀態改變時,并未做任何的操作,主要原因是如果在此做Table View Cells的移動操作,如果數據超過一屏幕,無法自動將未在屏幕上的數據滾動顯示出來。所以在長按手勢狀態為開始時,如果觸摸點在Table View Cell上,開啟定時器,來處理長按手勢狀態為改變時的情況。
- (void)startPageEdgeScroll {
    self.edgeScrollTimer = [CADisplayLink displayLinkWithTarget:self selector:@selector(pageEdgeScrollEvent)];
    [self.edgeScrollTimer addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

在定時器觸發的事件中,處理兩個方面的事情,移動cell和滾動ScrollView

- (void)pageEdgeScrollEvent {
    [self longGestureChanged:self.longPress];
    
    CGFloat snapshotViewCenterOffsetX =  [self touchSnapshotViewCenterOffsetX];
    
    if (fabs(snapshotViewCenterOffsetX) > (TLCMainViewControllerFlowLayoutWidthOffset-20)) {
        //橫向滾動
        [self handleScrollViewHorizontalScroll:self.collectionView viewCenterOffsetX:snapshotViewCenterOffsetX];
    } else {
        //垂直滾動
        [self handleScrollViewVerticalScroll:[self selectedCollectionViewCellTableView]];
    }
}

在長按手勢觸摸點位置改變時,處理對應cell的移除和插入動作。橫向滾動和垂直滾動主要是根據不同情況設置對應的Table ViewCollection View的內容偏移量。可以在文末的鏈接中查看源碼。

- (void)longGestureChanged:(UILongPressGestureRecognizer *)sender {
    
    CGPoint currentPoint = [sender locationInView:sender.view];
    TLCMainCollectionViewCell *currentCollectionViewCell = [self currentTouchedCollectionCellWithLocation:currentPoint];
    if (!currentCollectionViewCell) {
        currentCollectionViewCell = [self collectionViewCellAtRow:self.selectedCollectionViewCellRow];
    }
    
    TLCMainCollectionViewCell *lasetSelectedCollectionViewCell = [self collectionViewCellAtRow:self.selectedCollectionViewCellRow];
    
    //判斷targetTableView是否改變
    BOOL isTargetTableViewChanged = NO;
    if (self.selectedCollectionViewCellRow != currentCollectionViewCell.indexPath.row) {
        isTargetTableViewChanged = YES;
        self.selectedCollectionViewCellRow = currentCollectionViewCell.indexPath.row;
    }
    //獲取到需要移動到的目標indexpath
    NSIndexPath *targetIndexPath = [self longGestureChangeIndexPathForRowAtPoint:currentPoint
                                                        collectionViewCell:currentCollectionViewCell];
    
    NSIndexPath *lastSelectedIndexPath = self.selectedIndexPath;
    
    TLCMainCollectionViewCell *selectedCollectionViewCell = [self collectionViewCellAtRow:self.selectedCollectionViewCellRow];
    //判斷跟上一次長按手勢所處的Table View是否相同,如果相同,移動cell,
    //如果不同,刪除上一次所定義的cell,插入到當前位置
    if (isTargetTableViewChanged) {
        if ([[self selectedCollectionViewCellTableView] numberOfSections]>targetIndexPath.section) {
            [[self selectedCollectionViewCellTableView] scrollToRowAtIndexPath:targetIndexPath
                                                              atScrollPosition:UITableViewScrollPositionNone animated:YES];
        }
        
        TLPlanItem *moveItem = [self.viewModel itemAtIndex:lasetSelectedCollectionViewCell.indexPath.row
                                              subItemIndex:lastSelectedIndexPath.section];
        [self.viewModel removeObject:moveItem
                           itemIndex:lasetSelectedCollectionViewCell.indexPath.row];
        [self.viewModel insertItem:moveItem
                             index:self.selectedCollectionViewCellRow
                      subItemIndex:targetIndexPath.section];

        [lasetSelectedCollectionViewCell updateCellWithData:[self planItemsAtIndex:lasetSelectedCollectionViewCell.indexPath.row]];
        [lasetSelectedCollectionViewCell.tableView deleteSections:[NSIndexSet indexSetWithIndex:lastSelectedIndexPath.section]
                                                 withRowAnimation:UITableViewRowAnimationNone];

        [selectedCollectionViewCell updateCellWithData:[self planItemsAtIndex:self.selectedCollectionViewCellRow]];
        [selectedCollectionViewCell.tableView insertSections:[NSIndexSet indexSetWithIndex:targetIndexPath.section]
                                            withRowAnimation:UITableViewRowAnimationNone];
    } else {
        BOOL isSameSection = lastSelectedIndexPath.section == targetIndexPath.section;
        UITableViewCell *targetCell = [self tableView:[self selectedCollectionViewCellTableView]
                                selectedCellAtSection:targetIndexPath.section];
        if (isSameSection || !targetCell ) {
            [self modifySnapshotViewFrameWithTouchPoint:currentPoint];
            return;
        }
        
        TLPlanItem *item = [self.viewModel itemAtIndex:self.selectedCollectionViewCellRow
                                          subItemIndex:lastSelectedIndexPath.section];
        [self.viewModel removeObject:item
                           itemIndex:self.selectedCollectionViewCellRow];
        [self.viewModel insertItem:item
                             index:self.selectedCollectionViewCellRow
                      subItemIndex:targetIndexPath.section];
        
        [selectedCollectionViewCell updateCellWithData:[self planItemsAtIndex:self.selectedCollectionViewCellRow]];
        [selectedCollectionViewCell.tableView moveSection:lastSelectedIndexPath.section
                                                toSection:targetIndexPath.section];
    }
    
    self.selectedIndexPath = targetIndexPath;
    //改變長按cell鏡像的位置
    [self modifySnapshotViewFrameWithTouchPoint:currentPoint];
}
  • 長按手勢狀態為取消或結束
    取消計時器,設置Collection View的偏移量,讓其Collection View Cell位于屏幕的中心,發送網絡請求,去調整任務的排序,同時將鏡像視圖隱藏,并將其所對應的Table View Cell顯示出來。
- (void)longGestureEndedOrCancelledWithLocation:(CGPoint)location {
    
    [self stopEdgeScrollTimer];
    
    CGPoint contentOffset = [self.flowLayout targetContentOffsetForProposedContentOffset:self.collectionView.contentOffset
                                                                   withScrollingVelocity:CGPointZero];
    [self.collectionView setContentOffset:contentOffset animated:YES];
    
    UITableViewCell *targetCell = [[self selectedCollectionViewCellTableView] cellForRowAtIndexPath:self.selectedIndexPath];
    
    if ([self canAdjustPlanRanking]) {
        [self adjustPlanRanking];
    }
    TLPlanItem *slectedItem = [self.viewModel itemAtIndex:self.selectedCollectionViewCellRow subItemIndex:self.selectedIndexPath.section];
    [UIView animateWithDuration:0.25 animations:^{
        self.snapshotView.transform = CGAffineTransformIdentity;
        self.snapshotView.frame = [self snapshotViewFrameWithCell:targetCell];
        
    } completion:^(BOOL finished) {
        targetCell.hidden = NO;
        slectedItem.isHidden = NO;
        [self.snapshotView removeFromSuperview];
        self.snapshotView = nil;
    }];
}
  • 數據的處理
    在移動和插入Table View Cell時,需要將其所對應的數據做響應的改變,數據相關的操作均放在TLCMainViewModel對象中。
@interface TLCMainViewModel : NSObject 

/**
 今日要做、下一步要做和以后要做
 */
@property (nonatomic, readonly, strong) NSArray <NSString *> *titleArray; 

/**
 獲取計劃列表
 
 @param completion  TLTodoModel
 */
- (void)obtainTotalPlanListWithTypeCompletion:(TLSDKCompletionBlk)completion;

/**
 添加計劃

 @param requestItem requestItem
 @param completion 完成回調
 */
- (void)addPlanWithReq:(TLPlanItemReq *)requestItem
           atIndexPath:(NSIndexPath *)indexPath
            completion:(TLSDKCompletionBlk)completion;

/**
 返回顯示的collectionViewCell的個數

 @return 數據的個數
 */
- (NSInteger)numberOfItems;

/**
 根據type獲取對應的數據

 @param index 位置
 @return 此計劃所對應的數據
 */
- (NSMutableArray<TLPlanItem *> *)planItemsAtIndex:(NSInteger)index;

/**
 刪除某個計劃
 
 @param itemIndex 單項數據在數組中的位置,如今日計劃中的數據,itemIndex為0
 @param subItemIndex  單項數據數組中所在的位置
 @param completion 完成回調
 */
- (void)deletePlanAtItemIndex:(NSInteger)itemIndex
                 subItemIndex:(NSInteger)subItemIndex
                   completion:(dispatch_block_t)completion;


/**
 修改計劃狀態:完成與非完成
 
 @param itemIndex 單項數據在數組中的位置,如今日計劃中的數據,itemIndex為0
 @param subItemIndex  單項數據數組中所在的位置
 @param completion 完成回調
 */
- (void)modiflyPlanStateAtItemIndex:(NSInteger)itemIndex
                       subItemIndex:(NSInteger)subItemIndex
                         completion:(TLSDKCompletionBlk)completion;


/**
 修改計劃的title和重點標記狀態

 @param itemIndex 單項數據在數組中的位置,如今日計劃中的數據,itemIndex為0
 @param subItemIndex 單項數據數組中所在的位置
 @param targetItem 目標對象
 @param completion 完成回調
 */
- (void)modiflyItemAtIndex:(NSInteger)itemIndex
              subItemIndex:(NSInteger)subItemIndex
                targetItem:(TLPlanItem *)targetItem
                completion:(dispatch_block_t)completion;


/**
 移除數據

 @param item item
 @param itemIndex 單項數據在數組中的位置
 */
- (void)removeObject:(TLPlanItem *)item
           itemIndex:(NSInteger)itemIndex;

/**
 插入數據
 
 @param item 插入的對象模型
 @param itemIndex 單項數據在數組中的位置,如今日計劃中的數據,itemIndex為0
 @param subItemIndex 單項數據數組中所在的位置
 */
- (void)insertItem:(TLPlanItem *)item
             index:(NSInteger)itemIndex
      subItemIndex:(NSInteger)subItemIndex;

/**
 獲取數據

 @param itemIndex 一級index
 @param subItemIndex 二級index
 @return 數據模型
 */
- (TLPlanItem *)itemAtIndex:(NSInteger)itemIndex
               subItemIndex:(NSInteger)subItemIndex; 

/**
 重置數據
 */
- (void)reset;

/**
 保存長按開始時的數據
 */
- (void)storePressBeginState;

@end

代碼完善

cell未居中顯示問題

2018年2月1號
在iPhone系統版本為iOS8.xiOS9.x時,會出現以后要做界面不會回彈的情況。如下圖所示:

bug1.png

經排查,是在UICollectionViewFlowLayout類中的

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity

方法計算得出的proposedContentOffset有偏差,修改后如下所示:

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
    CGFloat rawPageValue = self.collectionView.contentOffset.x / [self tlc_pageWidth];
    CGFloat currentPage = (velocity.x > 0.0) ? floor(rawPageValue) : ceil(rawPageValue);
    CGFloat nextPage = (velocity.x > 0.0) ? ceil(rawPageValue) : floor(rawPageValue);
    
    BOOL pannedLessThanAPage = fabs(1 + currentPage - rawPageValue) > 0.5;
    BOOL flicked = fabs(velocity.x) > [self tlc_flickVelocity];
    CGFloat actualPage = 0.0;
    
    if (pannedLessThanAPage && flicked) {
        proposedContentOffset.x = nextPage * [self tlc_pageWidth];
        actualPage = nextPage;
    } else {
        proposedContentOffset.x = round(rawPageValue) * [self tlc_pageWidth];
        actualPage = round(rawPageValue);
    } 
    if (lround(actualPage) >= 1) {
        proposedContentOffset.x -= 4.5;
    } 
    //下面為添加的代碼
    if (lround(actualPage) >= 2) {
        proposedContentOffset.x = self.collectionView.contentSize.width - TLCScreenWidth;
    }
    
    return proposedContentOffset;
}
在系統版本為iOS9.x時,輸入框會上一段距離問題

2018年2月12號
在機型為iPhone SE,系統版本為iOS9.x時,新建計劃時,新建窗口會上移一段,如下圖所示:

適配問題.png

分析發現,應該是監聽鍵盤高度變化時,輸入框的高度計算在特定機型的特定版本上計算錯誤,將原有的計算frame的來布局的方式改為自動布局。監聽鍵盤高度改變的代碼修改后如下:


- (void)viewWillAppear:(BOOL)animated{
    
    [super viewWillAppear:animated]; 
    [self addObserverForKeybord];
}

- (void)viewWillDisappear:(BOOL)animated {
    
    [super viewWillDisappear:animated];
    [self.view endEditing:YES];
    [self removeobserverForKeybord];
}
#pragma mark - keyboard observer

- (void)addObserverForKeybord {
    
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(keyboardWillShow:)
                                                 name:UIKeyboardWillShowNotification
                                               object:nil];
    
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(keyboardWillHide:)
                                                 name:UIKeyboardWillHideNotification
                                               object:nil];
}

- (void)removeobserverForKeybord {
    
    [[NSNotificationCenter defaultCenter] removeObserver:self
                                                    name:UIKeyboardWillShowNotification
                                                  object:nil];
    
    [[NSNotificationCenter defaultCenter] removeObserver:self
                                                    name:UIKeyboardWillHideNotification
                                                  object:nil];
}

- (void)keyboardWillShow:(NSNotification *)notification {
    
    CGRect keyboardBounds;
    [[notification.userInfo valueForKey:UIKeyboardFrameEndUserInfoKey] getValue:&keyboardBounds];
    NSNumber *duration = [notification.userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey];
    NSNumber *curve = [notification.userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey];
    
    keyboardBounds = [self.view convertRect:keyboardBounds toView:nil];
    
    [self.inputProjectView mas_updateConstraints:^(MASConstraintMaker *make) {
        make.bottom.equalTo(self.view).offset(-CGRectGetHeight(keyboardBounds));
    }];
    
    //設置動畫
    [UIView beginAnimations:nil context:NULL];
    [UIView setAnimationBeginsFromCurrentState:YES];
    [UIView setAnimationDuration:[duration doubleValue]];
    [UIView setAnimationCurve:[curve intValue]];
    
    [self layoutIfNeeded];
    
    [UIView commitAnimations];
}

- (void)keyboardWillHide:(NSNotification *)notification {
    
    if([self.inputProjectView inputText].length > 0) {
        [self.inputProjectView resetText];
    }
    
    CGRect keyboardBounds;
    [[notification.userInfo valueForKey:UIKeyboardFrameEndUserInfoKey] getValue:&keyboardBounds];
    NSNumber *duration = [notification.userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey];
    NSNumber *curve = [notification.userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey];
    
    keyboardBounds = [self.view convertRect:keyboardBounds toView:nil];
    
    [self.inputProjectView mas_updateConstraints:^(MASConstraintMaker *make) {
        if (@available(iOS 11.0, *)) {
            make.bottom.equalTo(self.view).offset(self.view.safeAreaInsets.bottom+88);
        } else {
            make.bottom.equalTo(self.view).offset(88);
        }
        make.height.mas_equalTo(88);
    }];
    
    //設置動畫
    [UIView beginAnimations:nil context:NULL];
    [UIView setAnimationBeginsFromCurrentState:YES];
    [UIView setAnimationDuration:[duration doubleValue]];
    [UIView setAnimationCurve:[curve intValue]];
    
    [self layoutIfNeeded];
    
    [UIView commitAnimations];
}
切換輸入法時,輸入框被鍵盤遮住問題

在修復此問題后,自測時發現,輸入法由簡體拼音切換為表情符號時,輸入框會被鍵盤擋住,在代碼中打斷點發現UIKeyboardWillShowNotificationUIKeyboardWillChangeFrameNotification通知均未被觸發,同時對比微信發現,切換輸入法時,同時開啟了自動校正功能,所以參考添加如下代碼:

 _textView.internalTextView.autocorrectionType = UITextAutocorrectionTypeYes;

解決切換輸入法時,輸入框被鍵盤遮住的問題。

總結

除了上述Table View Cell移動的操作,在項目中還處理了創建事務和事務詳情相關的業務。在整個過程中,比較棘手的還是Table View Cell的移動,在開發過程中,有時數據的移動和Table View Cell的移動未對應上,造成Table View Cell布局錯亂,排查了很久。在項目開發過程中,還是需要仔細去分析問題,然后再去尋求方法去解決問題。

文章所對應的Demo請點這里
本文已經同步到我的個人技術博客: 傳送門 ,歡迎常來^^。
參考的文章鏈接如下
利用長按手勢移動 Table View Cells

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容