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)重排,效果如下:
若對(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)?
- 拖動(dòng)的過(guò)程中,重排僅僅是CollectionView的每個(gè)元素的位置變化,而在拖動(dòng)結(jié)束時(shí),需要將拖動(dòng)的結(jié)果反饋給數(shù)據(jù)源進(jìn)行更新。
- 鑒于第1點(diǎn),當(dāng)我們?cè)谕蟿?dòng)的過(guò)程中檢測(cè)到需要更新布局時(shí)我們需要重新計(jì)算所有item的布局
- 重新計(jì)算完布局的信息后,就調(diào)用 invalidateLayout 來(lái)更新布局。
- 這里有個(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ù)中分別做了哪些事情:
- beginLongPress: 我們記錄了初始化本次手勢(shì)的起點(diǎn)位置。
- updateLongPress:我們檢測(cè)了當(dāng)前的拖動(dòng)的位置,在需要重排時(shí)重新計(jì)算布局并重新布局所有的item,這也是我們重排功能最重要的核心。
- 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)。