史上第二走心的 iOS11 Drag & Drop 教程

si1ence發表於2017-12-14

2017.11.02

話不多說,先上效果圖

普通view拖拽效果

TableView拖拽效果

CollectionView效果

muti-touch效果

多app互動

世界上最大的男性交友網站有demo

一.Tips:你必須要知道的概念

1. Drag 和 Drop 是什麼呢?
  • 一種以圖形展現的方式把資料從一個 app 移動或拷貝到另一個 app(僅限iPad),或者在程式內部進行
  • 充分利用了 iOS11 中新的檔案系統,只有在請求資料的時候才會去移動資料,而且保證只傳輸需要的資料
  • 通過非同步的方式進行傳輸,這樣就不會阻塞runloop,從而保證在傳輸資料的時候使用者也有一個順暢的互動體驗

drag和drop的基本互動圖和支援的控制元件

2. 安全性:
  • 拖拽複製的過程不像剪下板那樣,而是保證資料只對目標app可見
  • 提供資料來源的app可以限制本身的資料來源只可在本 app 或者 公司組app 之間有許可權使用,當然也可以開放於所有 app,也支援企業使用者的管理配置
3. dragSession 的過程
  • Lift:使用者長按 item,item 脫離螢幕
  • Drag :使用者開始拖拽,此時可進行 自定義檢視預覽、新增其他item新增內容、懸停進行導航(即iPad 中開啟別的app)
  • Set Down :此時使用者無非想進行兩種操作:取消拖拽 或者 在當前手指離開的位置對 item 進行 drop 操作
  • Data Transfer :目標app 會向 源app 進行資料請求
  • 這些都是圍繞互動這一概念構造的:即類似手勢識別器的概念,接收到使用者的操作後,進行view層級的改變

史上第二走心的 iOS11 Drag & Drop 教程

4. Others
  • 需要給使用者提供 muti-touch 的使用,這一點也是為了支援企業使用者的管理配置(比如一個手指選中一段文字,長按其處於lifting狀態,另外一個手指選中若干張圖片,然後開啟郵件,把文字和圖片放進郵件,視覺反饋是及時的,動畫效果也很棒)

iPad 可實現的功能還是很豐富的

####二、以CollectionView 為例,講一下整個拖拽的api使用情況

在API設計方面,分為兩個步驟:Drag 和 Drop,對應著兩套協議 UICollectionViewDragDelegateUICollectionViewDropDelegate,因此在建立 CollectionView 的時候要增加以下程式碼:

- (void)buildCollectionView {
    _collectionView = [[UICollectionView alloc] initWithFrame:self.view.bounds collectionViewLayout:flowLayout];
    [_collectionView registerClass:[WPFImageCollectionViewCell class] forCellWithReuseIdentifier:imageCellIdentifier];
    _collectionView.delegate = self;
    _collectionView.dataSource = self;
    // 設定代理物件
    _collectionView.dragDelegate = self;
    _collectionView.dropDelegate = self;

    _collectionView.dragInteractionEnabled = YES;
    _collectionView.reorderingCadence = UICollectionViewReorderingCadenceImmediate;
    _collectionView.springLoaded = YES;
    _collectionView.backgroundColor = [UIColor whiteColor];
}
複製程式碼

######1. 建立CollectionView注意點總結:

  • dragInteractionEnabled 屬性在 iPad 上預設是YES,在 iPhone 預設是 NO,只有設定為 YES 才可以進行 drag 操作

  • reorderingCadence (重排序節奏)可以調節集合檢視重排序的響應性。 是 CollectionView 獨有的屬性(相對於UITableView),因為 其獨有的二維網格的佈局,因此在重新排序的過程中有時候會發生元素迴流了,有時候只是移動到別的位置,不想要這樣的效果,就可以修改這個屬性改變其相應性

    • UICollectionViewReorderingCadenceImmediate:預設值,當開始移動的時候就立即迴流集合檢視佈局,可以理解為實時的重新排序
    • UICollectionViewReorderingCadenceFast:如果你快速移動,CollectionView 不會立即重新佈局,只有在停止移動的時候才會重新佈局
    • UICollectionViewReorderingCadenceSlow:停止移動再過一會兒才會開始迴流,重新佈局
  • springLoaded :彈簧載入是一種導航和啟用控制元件的方式,在整個系統中,當處於 dragSession 的時候,只要懸浮在cell上面,就會高亮,然後就會啟用

    • UITableView 和 UICollectionView 都可以使用該方式載入,因為他們都遵守 UISpringLoadedInteractionSupporting 協議
    • 當使用者在單元格使用彈性載入時,我們要選擇 CollectionView 或tableView 中的 item 或cell
    • 使用 - (BOOL)collectionView:shouldSpringLoadItemAtIndexPath:withContext:來自定義也是可以的
  • collectionView:itemsForAddingToDragSession: atIndexPath: :該方法是muti-touch對應的方法

    • 當接收到新增item響應時,會呼叫該方法向已經存在的drag會話中新增item
    • 如果需要,可以使用提供的點(在集合檢視的座標空間中)進行其他命中測試。
    • 如果該方法未實現,或返回空陣列,則不會將任何 item 新增到拖動,手勢也會正常的響應
- (NSArray<UIDragItem *> *)collectionView:(UICollectionView *)collectionView itemsForAddingToDragSession:(id<UIDragSession>)session atIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point {
    NSItemProvider *itemProvider = [[NSItemProvider alloc] initWithObject:self.dataSource[indexPath.item]];
    UIDragItem *item = [[UIDragItem alloc] initWithItemProvider:itemProvider];
    return @[item];
}
複製程式碼

再放一遍這個效果圖

2. UICollectionViewDragDelegate(初始化和自定義拖動方法)
  • collectionView: itemsForBeginningDragSession:atIndexPath:提供一個 給定 indexPath 的可進行 drag 操作的 item(類似 hitTest: 方法周到該響應的view )如果返回 nil,則不會發生任何拖拽事件

由於是返回一個陣列,因此可以根據自己的需求來實現該方法:比如拖拽一個item,就可以把該組的所有 item 放進 dragSession 中,右上角會有小藍圈圈顯示個數(但是這種情況下要對陣列進行重新排序,因為陣列中的最後一個元素會成為Lift 操作中的最上面的一個元素,排序後可以讓最先進入dragSession的item放在lift效果的最前面)

- (NSArray<UIDragItem *> *)collectionView:(UICollectionView *)collectionView itemsForBeginningDragSession:(id<UIDragSession>)session atIndexPath:(NSIndexPath *)indexPath {
    
    NSItemProvider *itemProvider = [[NSItemProvider alloc] initWithObject:self.dataSource[indexPath.item]];
    UIDragItem *item = [[UIDragItem alloc] initWithItemProvider:itemProvider];
    self.dragIndexPath = indexPath;
    return @[item];
}
複製程式碼

史上第二走心的 iOS11 Drag & Drop 教程

  • collectionView:dragPreviewParametersForItemAtIndexPath:允許對從取消或返回到 CollectionView 的 item 使用自定義預覽,如果該方法沒有實現或者返回nil,那麼整個 cell 將用於預覽
    • UIDragPreviewParameters 有兩個屬性:
      • backgroundColor 設定背景顏色,因為有的檢視本身就是半透明的,新增背景色視覺效果更好
      • visiblePath設定檢視的可見區域,比如可以自定義為圓角矩形或圖中的某一塊區域等,但是要注意裁剪的Rect 在目標檢視中必須要有意義;該屬性也要標記一下center方便進行定位

裁剪圖中的某一塊區域

選取的區域也可以大於這張圖,實現新增相框的效果

再高階的功能可以實現目標區域內新增多個rect到dragSession

- (nullable UIDragPreviewParameters *)collectionView:(UICollectionView *)collectionView dragPreviewParametersForItemAtIndexPath:(NSIndexPath *)indexPath {
    // 可以在該方法內使用 貝塞爾曲線 對單元格的一個具體區域進行裁剪
    UIDragPreviewParameters *parameters = [[UIDragPreviewParameters alloc] init];
    
    CGFloat previewLength = self.flowLayout.itemSize.width;
    CGRect rect = CGRectMake(0, 0, previewLength, previewLength);
    parameters.visiblePath = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:5];
    parameters.backgroundColor = [UIColor clearColor];
    return parameters;
}
複製程式碼
  • 還有一些對於 drag 生命週期對應的回撥方法,可以在這些方法裡新增各種動畫效果
/* 當 lift animation 完成之後開始拖拽之前會呼叫該方法
 * 該方法肯定會對應著 -collectionView:dragSessionDidEnd: 的呼叫
 */
- (void)collectionView:(UICollectionView *)collectionView dragSessionWillBegin:(id<UIDragSession>)session {
    NSLog(@"dragSessionWillBegin --> drag 會話將要開始");
}

// 拖拽結束的時候會呼叫該方法
- (void)collectionView:(UICollectionView *)collectionView dragSessionDidEnd:(id<UIDragSession>)session {
    NSLog(@"dragSessionDidEnd --> drag 會話已經結束");
}
複製程式碼

當然也可以在這些方法裡面設定自定義的dragPreview,比如 iPad 中原生的通訊圖、地圖所展現的功能

在 dragSessionWillBegin 方法裡面自定義 preview 檢視

3. UICollectionViewDropDelegate(遷移資料和自定義釋放動畫)

Drop手勢的流程圖

  • collectionView:performDropWithCoordinator: 方法使用 dropCoordinator 去置頂如果處理當前 drop 會話的item 到指定的最終位置, 同時也會根據drop item返回的資料更新資料來源
    • 當使用者開始進行 drop 操作的時候會呼叫這個方法
    • 如果該方法不做任何事,將會執行預設的動畫
    • 注意:只有在這個方法中才可以請求到資料
    • 請求的方式是非同步的,因此不要阻止資料的傳輸,如果阻止時間過長,就不清楚資料要多久才能到達,系統甚至可能會kill掉你的應用
- (void)collectionView:(UICollectionView *)collectionView performDropWithCoordinator:(id<UICollectionViewDropCoordinator>)coordinator {
    
    NSIndexPath *destinationIndexPath = coordinator.destinationIndexPath;
    UIDragItem *dragItem = coordinator.items.firstObject.dragItem;
    UIImage *image = self.dataSource[self.dragIndexPath.row];
    // 如果開始拖拽的 indexPath 和 要釋放的目標 indexPath 一致,就不做處理
    if (self.dragIndexPath.section == destinationIndexPath.section && self.dragIndexPath.row == destinationIndexPath.row) {
        return;
    }
    
    // 更新 CollectionView
    [collectionView performBatchUpdates:^{
        // 目標 cell 換位置
        [self.dataSource removeObjectAtIndex:self.dragIndexPath.item];
        [self.dataSource insertObject:image atIndex:destinationIndexPath.item];
        
        [collectionView moveItemAtIndexPath:self.dragIndexPath toIndexPath:destinationIndexPath];
    } completion:^(BOOL finished) {
        
    }];
    
    [coordinator dropItem:dragItem toItemAtIndexPath:destinationIndexPath];
}
複製程式碼
  • collectionView: dropSessionDidUpdate: withDestinationIndexPath: 該方法是提供釋放方案的方法,雖然是optional,但是最好實現
    • 當 跟蹤 drop 行為在 tableView 空間座標區域內部時會頻繁呼叫(因此要儘量減少這個方法的工作量,否則幀率就會降低)
    • 當drop手勢在某個section末端的時候,傳遞的目標索引路徑還不存在(此時 indexPath 等於 該 section 的行數),這時候會追加到該section 的末尾
    • 在某些情況下,目標索引路徑可能為空(比如拖到一個沒有cell的空白區域)
    • 請注意,在某些情況下,你的建議可能不被系統所允許,此時系統將執行不同的建議
    • 你可以通過 -[session locationInView:] 做你自己的命中測試
    • UICollectionViewDropIntent對應的三個列舉值
      • UICollectionViewDropIntentUnspecified 將會接收drop,但是具體的位置要稍後才能確定;不會開啟一個缺口,可以通過新增視覺效果給使用者傳達這一資訊
      • UICollectionViewDropIntentInsertAtDestinationIndexPathdrop將會被插入到目標索引中;將會開啟一個缺口,模擬最後釋放後的佈局
      • UICollectionViewDropIntentInsertIntoDestinationIndexPathdrop 將會釋放在目標索引路徑,比如該cell是一個容器(集合),此時不會像 ? 那個屬性一樣開啟缺口,但是該條目標索引對應的cell會高亮顯示
      • 補充:UITableView 在以上對應列舉值基礎上,還有一個特有的 automatic 屬性,可以自動判斷是放入資料夾還是開啟缺口進入目標索引
    • UIDropOperation對應的四種狀態。第四種 forbidden 是不允許在當前位置drop:比如要把一個圖片放在一個資料夾內,但是這個資料夾是隻讀的,就會出現這個圖示
      史上第二走心的 iOS11 Drag & Drop 教程
- (UICollectionViewDropProposal *)collectionView:(UICollectionView *)collectionView dropSessionDidUpdate:(id<UIDropSession>)session withDestinationIndexPath:(nullable NSIndexPath *)destinationIndexPath {
    UICollectionViewDropProposal *dropProposal;
    // 如果是另外一個app,localDragSession為nil,此時就要執行copy,通過這個屬性判斷是否是在當前app中釋放,當然只有 iPad 才需要這個適配
    if (session.localDragSession) {
        dropProposal = [[UICollectionViewDropProposal alloc] initWithDropOperation:UIDropOperationCopy intent:UICollectionViewDropIntentInsertAtDestinationIndexPath];
    } else {
        dropProposal = [[UICollectionViewDropProposal alloc] initWithDropOperation:UIDropOperationCopy intent:UICollectionViewDropIntentInsertAtDestinationIndexPath];
    }
    return dropProposal;
}
複製程式碼
  • collectionView:canHandleDropSession:通過該方法判斷對應的item 能否被 執行drop會話
    • 如果返回 NO,將不會呼叫接下來的代理方法
    • 如果沒有實現該方法,那麼預設返回 YES
- (BOOL)collectionView:(UICollectionView *)collectionView canHandleDropSession:(id<UIDropSession>)session {
    // 假設在該 drop 只能在當前本 app中可執行,在別的 app 中不可以
    if (session.localDragSession == nil) {
        return NO;
    }
    return YES;
}
複製程式碼
  • collectionView: dropPreviewParametersForItemAtIndexPath: 當 item 執行drop 操作的時候,可以自定義預覽圖
    • 如果沒有實現該方法或者返回nil,整個cell將會被用於預覽圖
    • 該方法會經由 -[UICollectionViewDropCoordinator dropItem:toItemAtIndexPath:]呼叫
    • 如果要去自定義佔位drop,可以檢視 UICollectionViewDropPlaceholder.previewParametersProvider
- (nullable UIDragPreviewParameters *)collectionView:(UICollectionView *)collectionView dropPreviewParametersForItemAtIndexPath:(NSIndexPath *)indexPath {

    return nil;
}
複製程式碼
  • 當然還有一些 常規的 drop 過程回撥的方法
/* 當drop會話進入到 collectionView 的座標區域內就會呼叫,
 * 早於- [collectionView dragSessionWillBegin] 呼叫
 */
- (void)collectionView:(UICollectionView *)collectionView dropSessionDidEnter:(id<UIDropSession>)session {
    NSLog(@"dropSessionDidEnter --> dropSession進入目標區域");
}

/* 當 dropSession 不在collectionView 目標區域的時候會被呼叫
 */
- (void)collectionView:(UICollectionView *)collectionView dropSessionDidExit:(id<UIDropSession>)session {
    NSLog(@"dropSessionDidExit --> dropSession 離開目標區域");
}

/* 當dropSession 完成時會被呼叫,不管結果如何
 * 適合在這個方法裡做一些清理的操作
 */
- (void)collectionView:(UICollectionView *)collectionView dropSessionDidEnd:(id<UIDropSession>)session {
    NSLog(@"dropSessionDidEnd --> dropSession 已完成");
}
複製程式碼
4. 優化
  • 涉及到app間拖動的時候,比如把相簿中照片拖到郵件中,為什麼相簿中的小尺寸到了郵件中就剛剛和郵件中textView 寬度一致呢?
    • 在方法collectionView:itemsForBeginningDragSession: atIndexPath: 中,通過設定itemProvider.preferredPresentationSize 來設定item執行 drop 時的期望大小。這樣 郵件app 在後臺就能讀取到這個尺寸大小,從而正常地顯示
5. Placeholder

這個在demo裡沒寫,因為只有iPad才支援 app 間傳遞資料,我想史上第一走心的教程一定會詳細講述這個方法的

由於loadObject是非同步的,因此載入資料和顯示preview是兩條不同的時間線

  • 使用場景:拖拽的item需要從伺服器下載,比如拖拽相簿中儲存在iCloud 中的照片至郵件app中,就要先從 iCloud 下載,再進行下一步的展示,因此可能要等待一段時間才能下載完成,而且下載多個item還可能是亂序到達的。此時就需要PlaceHolder進行

  • 非同步載入資料的時候可以用 PlaceHolder 推遲更新資料來源直到資料載入完畢,從而保證UI 完全的響應性,不至於讓使用者長時間面對一個白板等待資料的傳輸

  • 如何建立PlaceHolder?通過釋放協調器dropCoordinator來建立,從而將其插入到佔位符中,並新增動畫

  • 使用PlaceHolder 注意事項:(app間拖拽的時候,從A app 拖拽到 B app,確定位置之後,B中還未獲取到資料,載入資料的過程中展示佔位動畫)

- (id<UICollectionViewDropPlaceholderContext>)dropItem:(UIDragItem *)dragItem toPlaceholder:(UICollectionViewDropPlaceholder*)placeholder;

  1. 不要使用 reloadData,使用 performBatchUpdates: 來替代(因為 reloadData 會重設一切,刪除一切 PlaceHolder)
  2. 可以使用 collectionView.hasUncommittedUpdates 來判斷當前 CollectionView 是否還存在 PlaceHolder
6.資料傳輸(iPhone 開發者瞭解概念即可)
  • 所有的資料載入都是通過拖放實現的,NSITemProvider可以為你提供資料傳輸的進度和取消操作

  • 提供資料:

// 建立一個 NSItemProvider 物件,傳遞一個適用的物件
UIImage *image = [UIImage imageNamed:@"photo"];
NSItemProvider *itemProvider = [[NSItemProvider alloc] initWithObject:image];
複製程式碼
  • 接收資料
    • loadObjectOfClass 返回一個progress
    • 呼叫一次loadObjectOfClass 只會返回一個特定的progress,通過KVO監聽 UIDropSession.progress可以獲得所有的進度
    • demo是針對 iPhone 開發的,因此沒有具體實現
// 該方法中載入資料的方式是非同步的,
NSProgress *progress = [itemProvider loadObjectOfClass:[UIImage class] completionHandler:^(id<NSItemProviderReading>  _Nullable object, NSError * _Nullable error) {
#waning 該回撥在一個非主佇列進行,如果更新UI要回到主執行緒
        UIImage *image = (UIImage *)object;
        // 使用image
    }];
// 是否完成
BOOL isFinished = progress.isFinished;
// 當前已完成進度
CGFloat progressSoFar = progress.fractionCompleted;
    
[progress cancel];
複製程式碼
  • 註冊支援的檔案型別ID的時候,最好具體到特定的型別,比如最好使用“public.png”代替“public.image”,“public.utf8-plain-text”代替“public.plain-text”,當然如果是僅支援公司內部特定的app間傳遞,也可以完全自定義

  • 新概念:資料編組(Data Marshaling)

    • 提供資料有三種方式:

      • 直接提供NSData:itemProvider.registerDataRepresentation(...)
      • 提供一個檔案或者資料夾:itemProvider.registerFileRepresentation(...fileOptions:[])
      • 作為 File Provider 的引用:itemProvider.registerFileRepresentation(...fileOptions:[.openInPlace])
    • 接收資料也有三種方式:

      • 直接拷貝出NSData 的副本:itemProvider.loadDataRepresentation(...)
      • 將檔案或資料夾拷貝到自己的容器內:itemProvider.loadDataRepresentation(...)
      • 嘗試在本地開啟檔案:itemProvider.loadInPlaceFileRepresentation(...)
    • 資料編組直接做好了資料的轉換:

      • 提供者想要提供一個 NSData 型別資料,資料編組就直接將這個資料寫入檔案並提供url的副本
      • 如果提供者提供的是資料夾,然後資料編組就會把檔案壓縮並提供NSData
  • 最後稍微提到了File的另一個主題,也就是檔案系統的拖拽,在這裡大概敘述一下:

    • 檔案的拖拽可以設定三種許可權
      • 對所有人可見
      • 同一個 team 可見
      • 僅對自己可見
    • 檔案的拖拽有兩種選項:
      • 直接提供副本
      • 提供url(意味著多個app可以共享一個檔案),對方修改,本地可以看到修改的地方

三. UIView-Tips

UITableView 的api使用基本和 UICollectionView 一致,在此不再贅述,但是以下UIView的特性還要再強調以下

  • iPhone 專案上,在對view新增UIDragInteraction操作時,一定要設定其enable 屬性為YES,否則不會響應drag操作(iPhone預設為NO,iPad預設為YES)

  • UIDropProposal的屬性precise,如果設定為YES,檢視的點選測試區域將略高於使用者觸控位置,這能夠在檢視中進行更精確的放入,具體效果請看下圖

    • 當然如果使用這個屬性的話要在 targetPoint 新增一些UI的提示,給使用者確切的反饋
      這樣就能精準地放入文字中的特定位置
  • prefersFullSizePreview,預設情況下預覽圖都是等比例縮小的,因為過大是沒有意義的,遮擋螢幕就會影響到使用者互動,難以進行導航,但是有些時候也需要全尺寸的預覽圖(比如一個列表中需要重新佈局,此時將整個列表縮小是沒有意義的)

    • 但是有些情況下,系統始終會進行比例縮小,即使是設定了全尺寸預覽
      • 組合拖動:如果新增多個專案進行拖動
      • 如果將item拖動到另外一個app,也肯定會等比例縮小
  • [itemProvider loadObjectOfClass: completionHandler:]

    • 該方法回撥預設在主執行緒
    • 該方法返回一個progress,彙報載入的進度
    • 返回值 NSProgress 可以設定屬性 cancelled^cancellationHandler,也可以進行斷點續傳操作,因為資料傳輸可能需要很久,需要給使用者取消的權利
    • 如果不想要顯示這個進度,可以通過session.progressIndicatorStyle = UIDropSessionProgressIndicatorStyleNone; 來隱藏進度檢視。
    • 也可以通過KVO監聽progress實現自定義進度展示

?的方法控制效果

文章有點長,感謝您的閱讀。 每個iOS開發人員都應該瞭解的一篇非技術文章 demo地址

參考內容: Introducing Drag and Drop Mastering Drag and Drop Drag and Drop with Collection and Table View Data Delivery with Drag and Drop

相關文章