UICollectionView 自定義拖動(dòng)重排

CollectionView 相關(guān)內(nèi)容:

1. iOS 自定義圖片選擇器 3 - 相冊(cè)列表的實(shí)現(xiàn)
2. UICollectionView自定義布局基礎(chǔ)
3. UICollectionView自定義拖動(dòng)重排
4. iOS13 中的 CompositionalLayout 與 DiffableDataSource
5. iOS14 中的UICollectionViewListCell、UIContentConfiguration 以及 UIConfigurationState

在使用UICollectionView的過(guò)程中,有時(shí)候會(huì)有拖動(dòng)排序這樣的需求,本篇主要針對(duì)自定義布局的拖動(dòng)重排,效果如下:

image

若對(duì)UICollectionView和其自定義布局不是很了解的朋友可以看看之前的兩篇內(nèi)容
1.自定義圖片選擇器 3 - 相冊(cè)列表的實(shí)現(xiàn)(UICollectionView)
2.UICollectionViewLayout 自定義布局基礎(chǔ)

上一篇中我們對(duì)UICollectionView的自定義布局有了基礎(chǔ)的認(rèn)識(shí),本篇將在其基礎(chǔ)上進(jìn)行“拖動(dòng)重排”的探索。
說(shuō)起拖動(dòng)重排,蘋(píng)果爸爸在 iOS9 加入了響應(yīng)的方法予以支持,可很多項(xiàng)目都還得支持iOS8,我們還是得先自己實(shí)現(xiàn)下拖動(dòng)重排,加深下理解。iOS9的相關(guān)方法放在文末介紹。

iOS8

拖動(dòng)重排,首先聯(lián)想到了 UITableView,蘋(píng)果已經(jīng)實(shí)現(xiàn)了其拖動(dòng)重排的功能,只需要我們進(jìn)行相應(yīng)的設(shè)置即可。那么更加自由的UICollectionView則需要自己實(shí)現(xiàn),且在iOS9之前都沒(méi)有較便利的功能支持。

這就需要我們自己實(shí)現(xiàn)了。

拖動(dòng)重排 可以分為“拖動(dòng)”和“重排”兩個(gè)部分,前者主要涉及到手勢(shì)的狀態(tài)監(jiān)聽(tīng),后者主要涉及到布局的更新。

我們就先從拖動(dòng)開(kāi)始,給CollectionView添加一個(gè)長(zhǎng)按手勢(shì)(UILongPressGestureRecognizer),并在合適的地方初始化它。

為了更好的理解,筆者沒(méi)有進(jìn)行封裝,選擇在prepareLayout中初始化,在init中初始化要考慮到此時(shí)的collectionView可能還并未被創(chuàng)建,我們給視圖添加手勢(shì)的動(dòng)作就可能失效。

- (void)prepareLayout {
    [super prepareLayout];
    //因prepareLayout會(huì)多次調(diào)用,這里需要判斷是否已經(jīng)創(chuàng)建,避免多次重復(fù)添加導(dǎo)致占用過(guò)多資源影響性能。
    //更好的方法是給整個(gè)Layout設(shè)置一個(gè)拖動(dòng)重排的開(kāi)關(guān)來(lái)控制手勢(shì)是否生效
    if (!_longPress) {
        _longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
                                                                   action:@selector(longPress:)];
        _longPress.minimumPressDuration = 0.3; 
        [self.collectionView addGestureRecognizer:_longPress];
        
    }
    /*
        其他
    */
}

這時(shí)候,我們的CollectionView就有了長(zhǎng)按手勢(shì),且有一個(gè)方法(longPress:)接收手勢(shì)。

拖動(dòng)的動(dòng)作可以分解成“開(kāi)始”-“移動(dòng)”-“結(jié)束”

我們的longPress就成了下面這樣:

- (void)longPress:(UILongPressGestureRecognizer *)sender {
    CGPoint point = [sender locationInView:sender.view];
    switch (sender.state) {
        case UIGestureRecognizerStateBegan:
            //有了拖動(dòng)手勢(shì),就自然加入了一個(gè)辨識(shí)當(dāng)前拖動(dòng)狀態(tài)的參數(shù)
            _isItemDragging = YES;
            [self beginLongPress:point];
            break;
        case UIGestureRecognizerStateChanged:
            _isItemDragging = YES;
            [self updateLongPress:point];
            break;
        default:
            _isItemDragging = NO;
            [self endLongPress:point];
            break;
    }
}

添加了手勢(shì)相關(guān)的部分,我們還需要一個(gè)臨時(shí)的拖動(dòng)視圖,這個(gè)拖動(dòng)視圖也就是用戶(hù)所選中并拖動(dòng)的那個(gè)item,需要它是為了讓用戶(hù)知道自己的拖動(dòng)的狀態(tài),有個(gè)良好的交互反饋。

這個(gè)拖動(dòng)視圖在監(jiān)聽(tīng)手勢(shì)的三個(gè)方法里進(jìn)行“初始化”-“更新?tīng)顟B(tài)”-"消失移除"

- (void)beginLongPress:(CGPoint)point {
    NSIndexPath * indexPath = [self.collectionView indexPathForItemAtPoint:point];
    if (nil == indexPath) {
        return;
    }
    UICollectionViewCell * cell = [self.collectionView cellForItemAtIndexPath:indexPath];
    self.dragSnapView = [cell snapshotViewAfterScreenUpdates:YES];
    self.dragSnapView.frame = cell.frame;
    self.dragSnapView.alpha = 0.8;
    self.dragSnapView.transform = CGAffineTransformMakeScale(1.2, 1.2);
    self.dragOffset = CGPointMake(self.dragSnapView.center.x - point.x, self.dragSnapView.center.y - point.y);
    
    [self.collectionView addSubview:self.dragSnapView];
}

- (void)updateLongPress:(CGPoint)point {
    //根據(jù)手勢(shì)的移動(dòng)長(zhǎng)度來(lái)決定拖動(dòng)視圖的位置
    CGPoint center = CGPointMake(point.x + self.dragOffset.x, point.y + self.dragOffset.y);
    self.dragSnapView.center = center;
    /*
        此處有重排的關(guān)鍵部分代碼
    */
}

- (void)endLongPress:(CGPoint)point {
    /*
        這里有一段向最終位置靠攏的動(dòng)畫(huà)
    */
    [wself.dragSnapView removeFromSuperview];
}

到這里,我們關(guān)于單純的拖動(dòng)部分就完成了,運(yùn)行后會(huì)發(fā)現(xiàn)有個(gè)拖動(dòng)視圖跟著我們的手指。

那么現(xiàn)在我們就可以著手實(shí)現(xiàn)重排了。實(shí)現(xiàn)重排前我們要分析下,重排需要注意的有哪些點(diǎn)?

  1. 拖動(dòng)的過(guò)程中,重排僅僅是CollectionView的每個(gè)元素的位置變化,而在拖動(dòng)結(jié)束時(shí),需要將拖動(dòng)的結(jié)果反饋給數(shù)據(jù)源進(jìn)行更新。
  2. 鑒于第1點(diǎn),當(dāng)我們?cè)谕蟿?dòng)的過(guò)程中檢測(cè)到需要更新布局時(shí)我們需要重新計(jì)算所有item的布局
  3. 重新計(jì)算完布局的信息后,就調(diào)用 invalidateLayout 來(lái)更新布局。
  4. 這里有個(gè)坑,我們?cè)谝淮瓮蟿?dòng)的過(guò)程內(nèi),發(fā)生更新布局后,后續(xù)的拖動(dòng)中再檢測(cè)是否需要更新時(shí)要特別注意,我們的檢測(cè)方法需使用的是本次拖動(dòng)中最新的布局信息,否則會(huì)造成一個(gè)邊界判斷的Bug。
分析完就該動(dòng)手了

將手勢(shì)的三個(gè)方法更新一下:

- (void)beginLongPress:(CGPoint)point {
    //獲取當(dāng)前拖動(dòng)item
    NSIndexPath * indexPath = [self.collectionView indexPathForItemAtPoint:point];
    if (nil == indexPath) {
        return;
    }
    
    // 當(dāng)前itemd是否支持拖動(dòng)
    if (!_moveBeganBlock(indexPath)) {
        return;
    }
    
    // 初始化拖動(dòng)相關(guān)數(shù)據(jù)
    _startDragIndexPath = indexPath;
    _currentDragIndexPath = indexPath;
    
    // 初始化拖動(dòng)視圖
    UICollectionViewCell * cell = [self.collectionView cellForItemAtIndexPath:indexPath];
    self.dragSnapView = [cell snapshotViewAfterScreenUpdates:YES];
    self.dragSnapView.frame = cell.frame;
    self.dragSnapView.alpha = 0.8;
    self.dragSnapView.transform = CGAffineTransformMakeScale(1.2, 1.2);
    self.dragOffset = CGPointMake(self.dragSnapView.center.x - point.x, self.dragSnapView.center.y - point.y);
    [self.collectionView addSubview:self.dragSnapView];
}

- (void)updateLongPress:(CGPoint)point {
    //更新拖動(dòng)視圖
    CGPoint center = CGPointMake(point.x + self.dragOffset.x, point.y + self.dragOffset.y);
    self.dragSnapView.center = center;
    
    //檢測(cè)是否需要更新布局
    NSIndexPath * indexPath = [self getIndexPathWithPosition:point];
    if (indexPath) {
        if ((_currentDragIndexPath.row != indexPath.row) || (_currentDragIndexPath.section != indexPath.section)) {
            [self reloadLayoutItemWithPreviousIndexPath:_currentDragIndexPath targetIndexPath:indexPath exchange:NO];
            [self invalidateLayout];
            _currentDragIndexPath = indexPath;
        }
    }
}

- (void)endLongPress:(CGPoint)point {
    CGPoint center = CGPointMake(point.x + self.dragOffset.x, point.y + self.dragOffset.y);
    _dragOffset = CGPointMake(0, 0);
    NSIndexPath * indexPath = [self getIndexPathWithPosition:center];
    if (indexPath) {
        //交換indexPath
        if ((_startDragIndexPath.row != indexPath.row) || (_startDragIndexPath.section != indexPath.section)) {
            [self reloadLayoutItemWithPreviousIndexPath:_startDragIndexPath targetIndexPath:indexPath exchange:YES];
        }
        _currentDragIndexPath = indexPath;
    } else {
        _currentDragIndexPath = _startDragIndexPath;
    }
    
    //移除拖動(dòng)視圖
    [_dragSnapView removeFromSuperview];
    _dragSnapView = nil;
    
    //調(diào)用回調(diào)將拖動(dòng)結(jié)束的數(shù)據(jù)回傳給調(diào)用者
    if (_moveEndBlock) {
        _moveEndBlock(_startDragIndexPath, _currentDragIndexPath);
        UICollectionViewLayoutAttributes * attributes = [self layoutAttributesForItemAtIndexPath:_currentDragIndexPath];
        attributes.hidden = NO;
    }
    [self invalidateLayout];
    _currentDragIndexPath = nil;
    _startDragIndexPath = nil;
}

由上代碼可以看出我們?cè)谌齻€(gè)函數(shù)中分別做了哪些事情:

  1. beginLongPress: 我們記錄了初始化本次手勢(shì)的起點(diǎn)位置。
  2. updateLongPress:我們檢測(cè)了當(dāng)前的拖動(dòng)的位置,在需要重排時(shí)重新計(jì)算布局并重新布局所有的item,這也是我們重排功能最重要的核心。
  3. endLongPress:獲取到最終的拖動(dòng)位置,并通過(guò)DataSource傳輸給代理方,是否要改變數(shù)據(jù)的判定交由代理方去處理。隱藏并移除創(chuàng)建的拖動(dòng)視圖。

重新計(jì)算布局

在 updateLongPress 方法中,我們重新計(jì)算布局的方法 reloadLayoutItemWithPreviousIndexPath中該如何實(shí)現(xiàn)?需要注意哪些細(xì)節(jié),以及相關(guān)的影響有哪些?

什么時(shí)候需要重新計(jì)算布局?

在拖動(dòng)的過(guò)程中,我們并不需要時(shí)時(shí)刻刻都去重新布局,僅僅是在會(huì)發(fā)生item交換時(shí)才需要。而交換只會(huì)發(fā)生在我們的拖動(dòng)手勢(shì)進(jìn)入到其他item區(qū)域時(shí),所以只需要檢測(cè)手勢(shì)是否進(jìn)入到其他item的區(qū)域就可以了。

在 updateLongPress中,可以看到我們添加了是否需要重新計(jì)算布局的判斷。

布局的重新計(jì)算

當(dāng)判斷需要重新布局時(shí),我們就重新計(jì)算一遍布局信息,本文Demo重新布局方法如下:

- (void)reloadLayoutItemWithPreviousIndexPath:(NSIndexPath *)previousIndexPath targetIndexPath:(NSIndexPath *)targetIndexPath exchange:(BOOL)exchange {
    // 此處的交替位置,僅用于計(jì)算frame的順序,新創(chuàng)建了 dataArray 方便理解。
    // 并未更改indexPath, 要注意繪制Cell的時(shí)候是以indexPath的先后來(lái)的,與attrubutesd的順序無(wú)關(guān)
    NSMutableArray * dataArray = [_attrubutesArray mutableCopy];
    UICollectionViewLayoutAttributes * temp = _attrubutesArray[previousIndexPath.row];
    [dataArray removeObjectAtIndex:previousIndexPath.row];
    [dataArray insertObject:temp atIndex:targetIndexPath.row];
    
    //開(kāi)始重置所有Item的坐標(biāo)大小,以及位置標(biāo)識(shí)(indexPath)
    CGFloat y = _sectionInsets.top;
    CGFloat x = _sectionInsets.left;

    NSMutableArray * tempArray = [NSMutableArray array];
    for (NSInteger index = 0; index < dataArray.count; index += 1) {
        UICollectionViewLayoutAttributes * temp = dataArray[index];
        BOOL isDragItem = targetIndexPath.row == index;
        temp.alpha = isDragItem ? 0 : 1;
        //配置臨時(shí)變量用于計(jì)算
        CGRect frame;
        frame.size = temp.frame.size;
        
        //***重置坐標(biāo)以及indexPath
        if (x + frame.size.width > [[UIApplication sharedApplication].delegate window].bounds.size.width) {
            x = _sectionInsets.left;
            y += (_itemHeight + _lineSpace);
        }
        frame.origin = CGPointMake(x, y);
        temp.frame = frame;
        if (exchange) {
            temp.indexPath = [NSIndexPath indexPathForRow:index inSection:0];
        }
        [tempArray addObject:temp];
        
        //偏移當(dāng)前坐標(biāo)至尾部
        x += frame.size.width + _itemSpace;
    }
    _attrubutesArray = [tempArray mutableCopy];
}

該方法的核心功能是計(jì)算出在拖動(dòng)過(guò)程中,計(jì)算出臨時(shí)的布局信息。筆者在一開(kāi)始替換了布局信息的初始順序,只是為了在for循環(huán)中不再加入額外的判斷。

小結(jié):
至此,一個(gè)簡(jiǎn)易的拖動(dòng)重排就已經(jīng)實(shí)現(xiàn)了,實(shí)際項(xiàng)目中可能會(huì)涉及到一些動(dòng)畫(huà)等其他細(xì)節(jié)上的需求,在實(shí)現(xiàn)過(guò)程中只要注意好拖動(dòng)重排的各部分機(jī)制就不會(huì)發(fā)生問(wèn)題。iOS9之前因?yàn)橐约簩?shí)現(xiàn),所以我們使用了自定義的手勢(shì),并根據(jù)其處理相關(guān)的事件,

那么在iOS9開(kāi)始,我們應(yīng)該怎么做呢?

iOS9+

在 iOS9 之前我們需要自己實(shí)現(xiàn)拖動(dòng)重排的功能,若交互動(dòng)效更加復(fù)雜,我們的工作難度將會(huì)很艱巨。好消息是從iOS9開(kāi)始,系統(tǒng)有提供部分方法以支持拖動(dòng)重排,減少底層邏輯的工作量。

當(dāng)然,手勢(shì),還是要添加的,只是這一次我們觸發(fā)手勢(shì)后調(diào)用的就是系統(tǒng)提供的方法了:

- (BOOL)beginInteractiveMovementForItemAtIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(9_0); // returns NO if reordering was prevented from beginning - otherwise YES
- (void)updateInteractiveMovementTargetPosition:(CGPoint)targetPosition NS_AVAILABLE_IOS(9_0);
- (void)endInteractiveMovement NS_AVAILABLE_IOS(9_0);
- (void)cancelInteractiveMovement NS_AVAILABLE_IOS(9_0);

添加到手勢(shì)的方法里如下:

- (void)longPress:(UILongPressGestureRecognizer *)sender {
    CGPoint point = [sender locationInView:sender.view];
    switch (sender.state) {
        case UIGestureRecognizerStateBegan:
        {
            NSIndexPath * indexPath = [self.collectionView indexPathForItemAtPoint:point];
            if (!indexPath) {
                return;
            }
            _isItemDragging = YES;
            _dragAttribute = _attrubutesArray[indexPath.row];
            [self.collectionView beginInteractiveMovementForItemAtIndexPath:indexPath];
        }
            break;
        case UIGestureRecognizerStateChanged:
            _isItemDragging = YES;
            [self.collectionView updateInteractiveMovementTargetPosition:point];
            break;
        case UIGestureRecognizerStateEnded:
            _isItemDragging = NO;
            [self.collectionView endInteractiveMovement];
            break;
        default:
            _isItemDragging = NO;
            [self.collectionView cancelInteractiveMovement];
            break;
    }
}

上面的 _isItemDragging 和 dragAttribute 都是臨時(shí)變量用于拖動(dòng)過(guò)程中的判斷,與自己實(shí)現(xiàn)的拖動(dòng)重排邏輯一樣。

然后就要注意拖動(dòng)中的幾個(gè)方法了,系統(tǒng)提供如下:

- (NSIndexPath *)targetIndexPathForInteractivelyMovingItem:(NSIndexPath *)previousIndexPath withPosition:(CGPoint)position NS_AVAILABLE_IOS(9_0);
- (UICollectionViewLayoutAttributes *)layoutAttributesForInteractivelyMovingItemAtIndexPath:(NSIndexPath *)indexPath withTargetPosition:(CGPoint)position NS_AVAILABLE_IOS(9_0);

- (UICollectionViewLayoutInvalidationContext *)invalidationContextForInteractivelyMovingItems:(NSArray<NSIndexPath *> *)targetIndexPaths withTargetPosition:(CGPoint)targetPosition previousIndexPaths:(NSArray<NSIndexPath *> *)previousIndexPaths previousPosition:(CGPoint)previousPosition NS_AVAILABLE_IOS(9_0);
- (UICollectionViewLayoutInvalidationContext *)invalidationContextForEndingInteractiveMovementOfItemsToFinalIndexPaths:(NSArray<NSIndexPath *> *)indexPaths previousIndexPaths:(NSArray<NSIndexPath *> *)previousIndexPaths movementCancelled:(BOOL)movementCancelled NS_AVAILABLE_IOS(9_0);

我們這里只用到前兩個(gè)方法,前者是拖動(dòng)到別的item區(qū)域時(shí)會(huì)觸發(fā)的回調(diào),我們?cè)谶@里進(jìn)行布局的重排更新,而第二個(gè)方法(layoutAttributesForInteractivelyMovingItemAtIndexPath)是在拖動(dòng)中,被拖動(dòng)的item屬性的一個(gè)回調(diào)。

因?yàn)檫@兩個(gè)方法都是系統(tǒng)提供,且只在觸發(fā)時(shí)回調(diào),與我們自己實(shí)現(xiàn)的方法相比,節(jié)省了實(shí)現(xiàn)“被拖動(dòng)視圖”一系列功能,以及拖動(dòng)中的各種判斷。我們的代碼也相較于手動(dòng)實(shí)現(xiàn)簡(jiǎn)潔了不少:

- (UICollectionViewLayoutAttributes *)layoutAttributesForInteractivelyMovingItemAtIndexPath:(NSIndexPath *)indexPath withTargetPosition:(CGPoint)position {
    //1
    //我們將拖動(dòng)的視圖跟隨手勢(shì)的位置,并將視圖大小縮放1.2倍
    UICollectionViewLayoutAttributes * temp = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
    CGSize size = _dragAttribute.frame.size;
    [temp setFrame:CGRectMake(position.x - size.width / 2,
                              position.y - size.height / 2,
                              size.width,
                              size.height)];
    temp.transform = CGAffineTransformMakeScale(1.2, 1.2);
    return temp;
}

- (NSIndexPath *)targetIndexPathForInteractivelyMovingItem:(NSIndexPath *)previousIndexPath withPosition:(CGPoint)position {
    //2
    NSIndexPath * indexPath = [self getIndexPathWithPosition:position];
    if (indexPath) {
        if ((previousIndexPath.row != indexPath.row) || (previousIndexPath.section != indexPath.section)) {
            // 此處必須重置所有Item的屬性,尤其是indexPath屬性,簡(jiǎn)單的交換是不會(huì)起任何作用的
            [self reloadLayoutItemWithPreviousIndexPath:previousIndexPath targetIndexPath:indexPath];
        }
    }
    return indexPath;
}

至此,已經(jīng)實(shí)現(xiàn)了簡(jiǎn)易的iOS9的拖動(dòng)重排,為簡(jiǎn)化程序表述邏輯,筆者的示例為最簡(jiǎn)單的一個(gè)section數(shù)據(jù)源。針對(duì)多section的數(shù)據(jù)源,需要添加相關(guān)的邏輯。

總結(jié):
1.拖動(dòng)重排的過(guò)程中要對(duì)拖動(dòng)時(shí)各個(gè) item 的狀態(tài)有明確的認(rèn)識(shí).
2.不同的自定義布局會(huì)有不同的邊界情況,要針對(duì)這些情況做特定處理才能使自己的拖動(dòng)重排更加自然。
3.拖動(dòng)重排的過(guò)程中涉及到對(duì)會(huì)影響數(shù)據(jù)源的操作,都回調(diào)給調(diào)用者,并對(duì)調(diào)用者的反饋?zhàn)龀鲰憫?yīng)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過(guò)簡(jiǎn)信或評(píng)論聯(lián)系作者。

相關(guān)閱讀更多精彩內(nèi)容

  • 1、通過(guò)CocoaPods安裝項(xiàng)目名稱(chēng)項(xiàng)目信息 AFNetworking網(wǎng)絡(luò)請(qǐng)求組件 FMDB本地?cái)?shù)據(jù)庫(kù)組件 SD...
    陽(yáng)明AI閱讀 16,171評(píng)論 3 119
  • 最近創(chuàng)業(yè)先生分享給我一篇文章,意思是說(shuō)富人窮養(yǎng)孩子出人才,而窮人富養(yǎng)孩子出禍害。 肯從這類(lèi)文章中感悟人生的人,要么...
    superlady173閱讀 2,076評(píng)論 0 5
  • 有偉大的準(zhǔn)備,才有偉大的成就。我們經(jīng)常會(huì)說(shuō)好的開(kāi)始是成功的一半。機(jī)會(huì)都是留給有準(zhǔn)備的人。首先,機(jī)會(huì)來(lái)了。自己什么都...
    展顏_0e45閱讀 84評(píng)論 0 0
  • 你是如此的美好少年,如同山間的清風(fēng)。我想和你一起騎著自行車(chē)數(shù)遍人生中的公路牌,不管是春天,不管是夏天,不管是秋天,...
    徐果兒閱讀 79評(píng)論 0 0
  • Life provide you time and space. It's up to you to fill i...
    潘小欠閱讀 513評(píng)論 0 1

友情鏈接更多精彩內(nèi)容