UICollectionView 自定义拖动重排

CollectionView 相关内容:

1. iOS 自定义图片选择器 3 - 相册列表的实现
2. UICollectionView自定义布局基础
3. UICollectionView自定义拖动重排
4. iOS13 中的 CompositionalLayout 与 DiffableDataSource
5. iOS14 中的UICollectionViewListCell、UIContentConfiguration 以及 UIConfigurationState

在使用UICollectionView的过程中,有时候会有拖动排序这样的需求,本篇主要针对自定义布局的拖动重排,效果如下:

image

若对UICollectionView和其自定义布局不是很了解的朋友可以看看之前的两篇内容
1.自定义图片选择器 3 - 相册列表的实现(UICollectionView)
2.UICollectionViewLayout 自定义布局基础

上一篇中我们对UICollectionView的自定义布局有了基础的认识,本篇将在其基础上进行“拖动重排”的探索。
说起拖动重排,苹果爸爸在 iOS9 加入了响应的方法予以支持,可很多项目都还得支持iOS8,我们还是得先自己实现下拖动重排,加深下理解。iOS9的相关方法放在文末介绍。

iOS8

拖动重排,首先联想到了 UITableView,苹果已经实现了其拖动重排的功能,只需要我们进行相应的设置即可。那么更加自由的UICollectionView则需要自己实现,且在iOS9之前都没有较便利的功能支持。

这就需要我们自己实现了

拖动重排 可以分为“拖动”和“重排”两个部分,前者主要涉及到手势的状态监听,后者主要涉及到布局的更新

我们就先从拖动开始,给CollectionView添加一个长按手势(UILongPressGestureRecognizer),并在合适的地方初始化它。

为了更好的理解,笔者没有进行封装,选择在prepareLayout中初始化,在init中初始化要考虑到此时的collectionView可能还并未被创建,我们给视图添加手势的动作就可能失效。

- (void)prepareLayout {
    [super prepareLayout];
    //因prepareLayout会多次调用,这里需要判断是否已经创建,避免多次重复添加导致占用过多资源影响性能。
    //更好的方法是给整个Layout设置一个拖动重排的开关来控制手势是否生效
    if (!_longPress) {
        _longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
                                                                   action:@selector(longPress:)];
        _longPress.minimumPressDuration = 0.3; 
        [self.collectionView addGestureRecognizer:_longPress];
        
    }
    /*
        其他
    */
}

这时候,我们的CollectionView就有了长按手势,且有一个方法(longPress:)接收手势。

拖动的动作可以分解成“开始”-“移动”-“结束”

我们的longPress就成了下面这样:

- (void)longPress:(UILongPressGestureRecognizer *)sender {
    CGPoint point = [sender locationInView:sender.view];
    switch (sender.state) {
        case UIGestureRecognizerStateBegan:
            //有了拖动手势,就自然加入了一个辨识当前拖动状态的参数
            _isItemDragging = YES;
            [self beginLongPress:point];
            break;
        case UIGestureRecognizerStateChanged:
            _isItemDragging = YES;
            [self updateLongPress:point];
            break;
        default:
            _isItemDragging = NO;
            [self endLongPress:point];
            break;
    }
}

添加了手势相关的部分,我们还需要一个临时的拖动视图,这个拖动视图也就是用户所选中并拖动的那个item,需要它是为了让用户知道自己的拖动的状态,有个良好的交互反馈。

这个拖动视图在监听手势的三个方法里进行“初始化”-“更新状态”-"消失移除"

- (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 {
    //根据手势的移动长度来决定拖动视图的位置
    CGPoint center = CGPointMake(point.x + self.dragOffset.x, point.y + self.dragOffset.y);
    self.dragSnapView.center = center;
    /*
        此处有重排的关键部分代码
    */
}

- (void)endLongPress:(CGPoint)point {
    /*
        这里有一段向最终位置靠拢的动画
    */
    [wself.dragSnapView removeFromSuperview];
}

到这里,我们关于单纯的拖动部分就完成了,运行后会发现有个拖动视图跟着我们的手指。

那么现在我们就可以着手实现重排了。实现重排前我们要分析下,重排需要注意的有哪些点?

  1. 拖动的过程中,重排仅仅是CollectionView的每个元素的位置变化,而在拖动结束时,需要将拖动的结果反馈给数据源进行更新。
  2. 鉴于第1点,当我们在拖动的过程中检测到需要更新布局时我们需要重新计算所有item的布局
  3. 重新计算完布局的信息后,就调用 invalidateLayout 来更新布局。
  4. 这里有个坑,我们在一次拖动的过程内,发生更新布局后,后续的拖动中再检测是否需要更新时要特别注意,我们的检测方法需使用的是本次拖动中最新的布局信息,否则会造成一个边界判断的Bug。
分析完就该动手了

将手势的三个方法更新一下:

- (void)beginLongPress:(CGPoint)point {
    //获取当前拖动item
    NSIndexPath * indexPath = [self.collectionView indexPathForItemAtPoint:point];
    if (nil == indexPath) {
        return;
    }
    
    // 当前itemd是否支持拖动
    if (!_moveBeganBlock(indexPath)) {
        return;
    }
    
    // 初始化拖动相关数据
    _startDragIndexPath = indexPath;
    _currentDragIndexPath = indexPath;
    
    // 初始化拖动视图
    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 {
    //更新拖动视图
    CGPoint center = CGPointMake(point.x + self.dragOffset.x, point.y + self.dragOffset.y);
    self.dragSnapView.center = center;
    
    //检测是否需要更新布局
    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;
    }
    
    //移除拖动视图
    [_dragSnapView removeFromSuperview];
    _dragSnapView = nil;
    
    //调用回调将拖动结束的数据回传给调用者
    if (_moveEndBlock) {
        _moveEndBlock(_startDragIndexPath, _currentDragIndexPath);
        UICollectionViewLayoutAttributes * attributes = [self layoutAttributesForItemAtIndexPath:_currentDragIndexPath];
        attributes.hidden = NO;
    }
    [self invalidateLayout];
    _currentDragIndexPath = nil;
    _startDragIndexPath = nil;
}

由上代码可以看出我们在三个函数中分别做了哪些事情:

  1. beginLongPress: 我们记录了初始化本次手势的起点位置。
  2. updateLongPress:我们检测了当前的拖动的位置,在需要重排时重新计算布局并重新布局所有的item,这也是我们重排功能最重要的核心。
  3. endLongPress:获取到最终的拖动位置,并通过DataSource传输给代理方,是否要改变数据的判定交由代理方去处理。隐藏并移除创建的拖动视图。

重新计算布局

在 updateLongPress 方法中,我们重新计算布局的方法 reloadLayoutItemWithPreviousIndexPath中该如何实现?需要注意哪些细节,以及相关的影响有哪些?

什么时候需要重新计算布局?

在拖动的过程中,我们并不需要时时刻刻都去重新布局,仅仅是在会发生item交换时才需要。而交换只会发生在我们的拖动手势进入到其他item区域时,所以只需要检测手势是否进入到其他item的区域就可以了。

在 updateLongPress中,可以看到我们添加了是否需要重新计算布局的判断。

布局的重新计算

当判断需要重新布局时,我们就重新计算一遍布局信息,本文Demo重新布局方法如下:

- (void)reloadLayoutItemWithPreviousIndexPath:(NSIndexPath *)previousIndexPath targetIndexPath:(NSIndexPath *)targetIndexPath exchange:(BOOL)exchange {
    // 此处的交替位置,仅用于计算frame的顺序,新创建了 dataArray 方便理解。
    // 并未更改indexPath, 要注意绘制Cell的时候是以indexPath的先后来的,与attrubutesd的顺序无关
    NSMutableArray * dataArray = [_attrubutesArray mutableCopy];
    UICollectionViewLayoutAttributes * temp = _attrubutesArray[previousIndexPath.row];
    [dataArray removeObjectAtIndex:previousIndexPath.row];
    [dataArray insertObject:temp atIndex:targetIndexPath.row];
    
    //开始重置所有Item的坐标大小,以及位置标识(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;
        //配置临时变量用于计算
        CGRect frame;
        frame.size = temp.frame.size;
        
        //***重置坐标以及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];
        
        //偏移当前坐标至尾部
        x += frame.size.width + _itemSpace;
    }
    _attrubutesArray = [tempArray mutableCopy];
}

该方法的核心功能是计算出在拖动过程中,计算出临时的布局信息。笔者在一开始替换了布局信息的初始顺序,只是为了在for循环中不再加入额外的判断。

小结:
至此,一个简易的拖动重排就已经实现了,实际项目中可能会涉及到一些动画等其他细节上的需求,在实现过程中只要注意好拖动重排的各部分机制就不会发生问题。iOS9之前因为要自己实现,所以我们使用了自定义的手势,并根据其处理相关的事件,

那么在iOS9开始,我们应该怎么做呢?

iOS9+

在 iOS9 之前我们需要自己实现拖动重排的功能,若交互动效更加复杂,我们的工作难度将会很艰巨。好消息是从iOS9开始,系统有提供部分方法以支持拖动重排,减少底层逻辑的工作量。

当然,手势,还是要添加的,只是这一次我们触发手势后调用的就是系统提供的方法了:

- (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);

添加到手势的方法里如下:

- (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 都是临时变量用于拖动过程中的判断,与自己实现的拖动重排逻辑一样。

然后就要注意拖动中的几个方法了,系统提供如下:

- (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);

我们这里只用到前两个方法,前者是拖动到别的item区域时会触发的回调,我们在这里进行布局的重排更新,而第二个方法(layoutAttributesForInteractivelyMovingItemAtIndexPath)是在拖动中,被拖动的item属性的一个回调。

因为这两个方法都是系统提供,且只在触发时回调,与我们自己实现的方法相比,节省了实现“被拖动视图”一系列功能,以及拖动中的各种判断。我们的代码也相较于手动实现简洁了不少:

- (UICollectionViewLayoutAttributes *)layoutAttributesForInteractivelyMovingItemAtIndexPath:(NSIndexPath *)indexPath withTargetPosition:(CGPoint)position {
    //1
    //我们将拖动的视图跟随手势的位置,并将视图大小缩放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属性,简单的交换是不会起任何作用的
            [self reloadLayoutItemWithPreviousIndexPath:previousIndexPath targetIndexPath:indexPath];
        }
    }
    return indexPath;
}

至此,已经实现了简易的iOS9的拖动重排,为简化程序表述逻辑,笔者的示例为最简单的一个section数据源。针对多section的数据源,需要添加相关的逻辑。

总结:
1.拖动重排的过程中要对拖动时各个 item 的状态有明确的认识.
2.不同的自定义布局会有不同的边界情况,要针对这些情况做特定处理才能使自己的拖动重排更加自然。
3.拖动重排的过程中涉及到对会影响数据源的操作,都回调给调用者,并对调用者的反馈做出响应。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,829评论 1 331
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,603评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,846评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,600评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,780评论 3 272
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,695评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,136评论 2 293
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,862评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,453评论 0 229
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,942评论 2 233
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,347评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,790评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,293评论 3 221
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,839评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,448评论 0 181
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,564评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,623评论 2 249

推荐阅读更多精彩内容

  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    X先生_未知数的X阅读 15,931评论 3 118
  • 最近创业先生分享给我一篇文章,意思是说富人穷养孩子出人才,而穷人富养孩子出祸害。 肯从这类文章中感悟人生的人,要么...
    superlady173阅读 1,533评论 0 5
  • 有伟大的准备,才有伟大的成就。我们经常会说好的开始是成功的一半。机会都是留给有准备的人。首先,机会来了。自己什么都...
    展颜_0e45阅读 44评论 0 0
  • 你是如此的美好少年,如同山间的清风。我想和你一起骑着自行车数遍人生中的公路牌,不管是春天,不管是夏天,不管是秋天,...
    徐果儿阅读 62评论 0 0
  • Life provide you time and space. It's up to you to fill i...
    潘小欠阅读 370评论 0 1