WWDC 2017 iOS11 新特性 Drag and Drop 解析

WWDC 2017 剛結束,雖然如預期的一樣,缺少意料之外的驚喜,但依舊有不少新的特性和 API 值得圈點。拋開 Core ML 以及 ARKit 這些影響深遠的亮點不談,目前搶眼的系統升級,莫過于 UIKit 中新增的 Drag and Drop 特性了。

拖拽的意義

在閱讀本文之前,建議讀者先親手把玩下 Drag and Drop 的各種姿勢,有過實際的操作體驗,才能更好的明白一些 API 設計背后的考量。

現階段只有 iPad 上能支持不同 App 之間的內容拖拽共享,iPhone 上只能在 App 內部拖拽內容,iPhone 上的這一限制使得 Drag and Drop 大打折扣,有可能是出于屏幕尺寸以及操作體驗方面的考量。不過這還不是最終版本,后續 Apple 有可能會做出調整,畢竟拖拽帶來的可能性太多了。

Drag and Drop 允許不同 App 之間通過拖拽的方式共享內容,雖然 session video 中的演示(從相冊中拖拽圖片到 mail app)稍顯簡單,但這一新特性的想象空間遠不止此,拖拽的操作方式開啟了一個新的數據內容流動通道,內容的提供方和內容的消費方可以是不同 App,讓 App 能更專注于自己擅長的領域,分工協作為用戶提供更美妙的體驗。

比如,之前在微信聊天的時候,如果有一個不得不發的表情,用戶只能先從搜狗輸入法將圖片保存的相冊或剪切板,再通過額外的步驟輸入到微信中,有了 Drag and Drop 之后,這一流程能簡化到一步完成,就好像微信和搜狗輸入是同一個 App 一樣,在協同工作。

不只是圖片,廣義上的內容涵蓋,文本,鏈接,語音,圖片,視頻等等,不同內容組合在一起又能呈現不一樣的形式。拖拽無論是在操作體驗上,還是內容流通上都將把 iOS 系統的易用性帶上一個新的臺階。

下面我會結合一個實際的場景來介紹如何使用 Drag and Drop 特性。從數月前開始,我一直利用零碎的時間在開發一款個人 App:TKeyboard。TKeyboard 有一個很酷的特性,可以在 iPhone 上實時瀏覽 Mac 的文件系統,當我看到 Drag and Drop 時,一個腦洞應景而生。如果可以將 TKeyboard 中的圖片直接拖拽到其他 App 中,那么你的 Mac 電腦就成了 iPhone 的一個備用存儲,Mac 上的圖片資源一步操作就能傳遞到其他 App 中,很美妙不是嗎?先看下效果圖:

使用新特性,新 API 也是個寶貴的學習過程,如果讓你來設計這么一個看似簡單,拓展性好,兼容性強的 Drag and Drop 功能,你會如何來實施呢?整個流程雖然談不上復雜,但環節多,會稍顯繁瑣。我們來看看 Apple 的工程師是如何做的。

Drag 與 Drop 可以分開來學習,因為你的 App 很可能只實現 Drag 或者 Drop 其中一項功能。

Drag

先看下最基礎的場景,如何將 App 中的內容 Drag 起來。

Drag 的對象是我們平時所接觸的 UI 控件,UILabel,UIImageView,或者自定義的 View。讓控件可拖動,只需要給控件添加 UIDragInteraction 對象:

//EFinderTodayCell.h
@interface EFinderTodayCell : UIView 
@end
  
//EFinderTodayCell.m
- (void)enableDrag
{
    if (IOS11) {
        UIDragInteraction* drag = [[UIDragInteraction alloc] initWithDelegate:self];
        [self addInteraction:drag];
        self.userInteractionEnabled = true;
    }
}

EFinderTodayCell 作為 UIView 的子類,在添加 UIDragInteraction 對象之后,就具備了可被 Drag 的行為,接下來 Drag 的交互控制都通過 UIDragInteractionDelegate 來實現。

UIDragInteractionDelegate 中提供了不少方法,可以對 Drag 的行為做不同程度的定制,一個個看:

- (NSArray<UIDragItem *> *)dragInteraction:(UIDragInteraction *)interaction itemsForBeginningSession:(id<UIDragSession>)session
{
    NSArray* items = [self itemsForSession:session];
    return items;
}

單指長按某個 View 時,如果添加了 UIDragInteraction,Drag 即刻啟動,進入 itemsForBeginningSession 的回調,這個方法中出現的三個類,關系也十分簡單。一個 UIDragInteraction 可以包含多個 UIDragSession,每個 UIDragSession 又可以包含多個 UIDragItem。UIDragItem 則是 Drop 時接收方所受到的對象。

我們可以給一個 UI 元素安裝多個 UIDragInteraction,通過設置 enabled 屬性來決定啟用哪一個 UIDragInteraction,手指 A 長按 UI 元素的時候,啟用的 UIDragInteraction 對象會生成一個 UIDragSession 對象,如果手指不松開,另一個手指 B 重新長按另一個 UI 元素,則會建立一個新的 UIDragSession,手指 B 如果點擊另一個 UI 元素,則會添加一個新的 UIDragItem。理清三者的關系是深度定制 Drag 的前提,可以用下圖表示:

如何生成 UIDragItem 呢?這里又需要幾個新對象:

- (NSArray*)itemsForSession:(id<UIDragSession>)session
{
    NSItemProvider* provider = [[NSItemProvider alloc] initWithObject:_item];
    UIDragItem* item = [[UIDragItem alloc] initWithItemProvider:provider];
    item.localObject = _item;
    
    return @[item];
}

UIDragItem 包含一個 NSItemProvider 對象,NSItemProvider 對象則包含一個 id<NSItemProviderWriting> 對象。protocol NSItemProviderWriting 則定義了 UIDragItem 中所包含的數據最后以何種形式提供個 Drop 方。我們看一個樣例 model 類如何實現 NSItemProviderWriting:

//TFinderItem.h
@interface TFinderItem : NSObject <NSItemProviderWriting>
@end
  
//TFinderItem.m
#pragma mark- NSItemProviderWriting
- (NSArray<NSString *>*)writableTypeIdentifiersForItemProvider
{
    return @[@"public.jpeg", @"public.png"];
}

- (nullable NSProgress *) loadDataWithTypeIdentifier:(nonnull NSString *)typeIdentifier forItemProviderCompletionHandler:(nonnull void (^)(NSData * _Nullable, NSError * _Nullable))completionHandler {
    
    //發起網絡請求,獲取數據...
    self.providerCompleteBlock = completionHandler;
    return [NSProgress new];
}

writableTypeIdentifiersForItemProvider 返回 UIDragItem 所提供的 UTI,數據的接收方通過 UTI 知道我們所傳遞的數據格式。

數據從 Server 獲取回來之后,通過 completionHandler 以 NSData 形式傳遞即可。Drop 的實現者通過 UTI 和 UIDragItem 中的 NSData 即可取出自己感興趣的數據,UIDragItem 的組成可以用下圖表示:

從上面兩張圖就能看出 Drag and Drop 的大致設計思路,這些類之間是以類似 tree 的關系組合在一起,對象雖多,但結構清晰。理解了這些關鍵類之間的關系,再看 UIDragInteractionDelegate 中的各個回調方法,各自在什么場景下觸發就了然于胸了。

//某個 UI 元素安裝了 UIDragInteraction,單指長按時生成 UIDragSession,進入回調,索取 UIDragItem。

- (NSArray<UIDragItem *> *)dragInteraction:(UIDragInteraction *)interaction itemsForBeginningSession:(id<UIDragSession>)session
{
}
//手指 A 長按某個 UI 元素后,手指 B 單擊另外的 UI 元素,進入回調,允許添加更多的 UIDragItem 到當前 UIDragSession 中。

- (NSArray<UIDragItem *> *)dragInteraction:(UIDragInteraction *)interaction itemsForAddingToSession:(id<UIDragSession>)session withTouchAtPoint:(CGPoint)point
{
}
//有另外的 UIDragItem 通過單擊加入到 UIDragSession 中,通知其他 UIDragInteractionDelegate

- (void)dragInteraction:(UIDragInteraction *)interaction session:(id<UIDragSession>)session willAddItems:(NSArray<UIDragItem *> *)items forInteraction:(UIDragInteraction *)addingInteraction
{
}
//單指長按某個 UI 元素,Drag 開始,生成新的 UIDragSession,進入回調

- (void)dragInteraction:(UIDragInteraction *)interaction sessionWillBegin:(id<UIDragSession>)session
{
}

其他回調就不一一列舉了。

Drag 另一個重要的定制是對拖動的 UI 元素生成 Preview,并在不同的階段改變 Preview 的形態。

當單指長按 UI 元素時,元素會被舉起(Lift),Lift 動畫由系統自動生成,但需要我們通過如下方法來提供 Preview:

- (nullable UITargetedDragPreview *)dragInteraction:(UIDragInteraction *)interaction previewForLiftingItem:(UIDragItem *)item session:(id<UIDragSession>)session
{
    UIDragPreviewParameters* params = [UIDragPreviewParameters new];
    params.backgroundColor = [UIColor clearColor];
    
    UITargetedDragPreview* preview = [[UITargetedDragPreview alloc] initWithView:_iconView parameters:params];
    
    return preview;
}

系統索取的是一個 UITargetedDragPreview 對象,UITargetedDragPreview 則由 UIView 的子類和 UIDragPreviewParameters 構成。UIDragPreviewParameters 可以設置 Preview 的展示參數,比如 backgroundColor 和 visiblePath。

visiblePath 是另一個重要的參數,它實際是一個 UIBezierPath 對象,可以給 Preview 添加特定形狀的 mask,比如可以通過如下代碼設置圓角:

UIDragPreviewParameters* params = [UIDragPreviewParameters new];
UIBezierPath* path = [UIBezierPath bezierPathWithRoundedRect:imgView.bounds cornerRadius:5];
params.visiblePath = path;

這里值得一提 UIDragPreview 和 UITargetedDragPreview 之間的差別。UIDragPreview init 方法中傳入的 View 必須存在于活躍 Window 上,否則 Preview 會展示空:

// view 必須存在活躍的 superView
- (instancetype)initWithView:(UIView *)view parameters:(UIDragPreviewParameters *)parameters

UITargetedDragPreview 中傳入的 View 無此要求,不過我們需要提供另一個 UIDragPreviewTarget 對象,來告訴 UITargetedDragPreview 在哪個 superView 和位置上展示 Preview,類似:

//Container 和 Center 分別指定 superView 和 位置
UIDragPreviewTarget* target = [[UIDragPreviewTarget alloc] initWithContainer:_iconView.superview center:_iconView.center];

UITargetedDragPreview* preview = [[UITargetedDragPreview alloc] initWithView:imgView parameters:params target:target];

另外還有一些 Drag 不同階段的回調,允許我們對被拖動的 UI 元素做動畫:

//Drag 發生時,將被拖動的圖片透明度改為 0.5
- (void)dragInteraction:(UIDragInteraction *)interaction willAnimateLiftWithAnimator:(id<UIDragAnimating>)animator session:(id<UIDragSession>)session
{
    [animator addAnimations:^{
        _iconView.alpha = 0.5;
    }];
}
//Drag 完成后,將被拖動的圖片透明度改為 1.0
- (void)dragInteraction:(UIDragInteraction *)interaction item:(UIDragItem *)item willAnimateCancelWithAnimator:(id<UIDragAnimating>)animator
{
    [animator addAnimations:^{
        _iconView.alpha = 1.0;
    }];
}
//Drag 取消后,將被拖動的圖片透明度改為 1.0
- (void)dragInteraction:(UIDragInteraction *)interaction session:(id<UIDragSession>)session didEndWithOperation:(UIDropOperation)operation
{
    _iconView.alpha = 1.0;
}

Drop

Drop 則可以看做是 Drag 的逆向過程,將 Drag 傳遞過來的 UIDragItem 解析后,取出自己感興趣的數據。Drop 流程所涉及到的對象,幾乎都是和 Drag 相對應的,理解了 Drag,再看 Drop 很好理解。

我們可以向目標 UI 元素添加 UIDropInteraction,使其具備接收來自 Drag 數據的能力:

- (void)enableDrop
{
    if (IOS11) {
        if (@available(iOS 11.0, *)) {
            UIDropInteraction* drop = [[UIDropInteraction alloc] initWithDelegate:self];
            [self addInteraction:drop];
        } 
    }
}

之后 Drop 的行為都交由 UIDropInteractionDelegate 來控制。

第一步先詢問 delegate 是否可以處理來自于 Drag 的數據:

- (BOOL)dropInteraction:(UIDropInteraction *)interaction canHandleSession:(id<UIDropSession>)session
{
    if (session.localDragSession != nil) { //ignore drag session started within app
        return false;
    }
    
    BOOL canHandle = false;
    canHandle = [session canLoadObjectsOfClass:[UIImage class]];
    return canHandle;
}

如果我們想忽略來自于 App 內部的 Drag,可以通過 localDragSession 這一屬性判斷,如果是來自于外部 App 的 Drag,localDragSession 為 nil。

UIDropSession 是由系統封裝好的對象,canLoadObjectsOfClass 可以讓我們判斷來自于 Drag 的數據里,是否有我們感興趣的類型。這是第一次系統向我們詢問是否對于 Drag 中的數據感興趣。

第二次且最后一次機會告知系統,是否能消化 Drag 中的數據:

- (UIDropProposal *)dropInteraction:(UIDropInteraction *)interaction sessionDidUpdate:(id<UIDropSession>)session
{
    if (@available(iOS 11.0, *)) {
        return [[UIDropProposal alloc] initWithDropOperation:UIDropOperationCopy];
    } 
}

如果此時發現 session 中的數據無法接收,可以返回 UIDropOperationCancel。

前面兩步通過之后,接下來是從 Session 中取出來自于 Drag 的數據:

- (void)dropInteraction:(UIDropInteraction *)interaction performDrop:(id<UIDropSession>)session
{
    [session loadObjectsOfClass:[UIImage class] completion:^(NSArray<__kindof id<NSItemProviderReading>> * _Nonnull objects) {
        for (id object in objects) {
            UIImage* image = (UIImage*)object;
            if (image) {
                //handle image
            }
        }
    }];
}

performDrop 中的操作最好是采用異步的方式,任何費時的操作都會導致主線程的卡頓,一旦時間過長,會被系統 watchdog 感知并 kill 掉。UIDropSession 所提供的 loadObjectsOfClass 回調會發生在工作線程,所以在 completion block 中如果有涉及 UI 的操作,記得切回主線程。

只需前面三個回調,即可接收來自于 Drag 中的圖片數據。比如從系統相冊 Drag 照片,在 performDrop 回調里就能取得 UIImage 對象。

另外需要注意用戶 Drag 時,只要不松開手指,可以持續進入以下三個回調:

//Drag 的 UI 元素進入 Drop 的區域
- (void)dropInteraction:(UIDropInteraction *)interaction sessionDidEnter:(id<UIDropSession>)session;

//Drag 的 UI 元素在 Drop 區域內反復移動,多次進入
- (UIDropProposal *)dropInteraction:(UIDropInteraction *)interaction sessionDidUpdate:(id<UIDropSession>)session
  
//Drag 的 UI 元素離開 Drop 的區域
- (void)dropInteraction:(UIDropInteraction *)interaction sessionDidExit:(id<UIDropSession>)session;

我們雖然無法在用戶 Drag 時,改變 Drag 的 preview,但用戶一旦松開手指,執行 drop 時,UIDropInteractionDelegate 中的以下兩個回調可以讓我們對 drop 的動畫效果做一定程度的定制:

//手指松開,控制 Drag Preview 如何自然的過渡到 Drop 之后的 Preview
- (nullable UITargetedDragPreview *)dropInteraction:(UIDropInteraction *)interaction previewForDroppingItem:(UIDragItem *)item withDefault:(UITargetedDragPreview *)defaultPreview;

//手指松開,Drop 時,控制 Drop 區域的其他 UI 元素如何展示動畫
- (void)dropInteraction:(UIDropInteraction *)interaction item:(UIDragItem *)item willAnimateDropWithAnimator:(id<UIDragAnimating>)animator;

這也是 Drop 體驗要做好真正復雜的部分,來自于 Drag 的數據可能是存在于網絡的,用戶 Drop 之后,提供 Drag 的 App 此時可能需要從網絡上獲取真正需要傳輸的數據,這是一個異步的過程,提供 Drop 功能的 App 需要竭盡所能,通過巧妙的動畫或者 UI 交互設計,讓用戶愿意等待這一“漫長”過程,而且能順暢自然的感知 Drag 的數據是如何過渡到 Drop 區域的。還需要處理各種異常場景,比如用戶不愿繼續等待選擇取消 Drop,比如 Drag 一方由于內部異常最終無法提供數據,比如最終抵達的數據超過 App 能承受的范圍(Image 尺寸過大,Text 過長)等等,每一個場景都需要動畫交互。所以這里才是功夫所在。

Drag and Drop 的關鍵 API 并不多,十個手指頭差不多能數過來,如何用好這些 API,如何把體驗做精細,如何把 Drag and Drop 中蘊含的更多可能性發掘出來,需要行業里的開發者們一起努力探索,毫不夸張的說,有時候一個新特性就能支撐一個新 App。

總結

Drag and Drop 是一種體驗上的創新,對于 iPad 這種大屏設備,多手指同時工作可以完成更復雜且實用的操作。我用右手 Drag 圖片之后,左手繼續在 iPad 上操作其他 App 完全不受影響,蘋果在 multi-touch 的體驗上應該是下足了功夫。iOS 11 針對 iPad 的優化,以及新款 iPad Pro 的各種硬件升級,可以看出蘋果對于未來 iPad 銷量增長寄以厚望。手機和 PC 之間的市場爭奪戰已日趨于平穩,iPad 或許是進一步蠶食 PC 市場份額的另一項利器,但如何在觸摸屏上,把交互和體驗做到 PC 一般自然舒暢還是項任重道遠的任務,WWDC 2017 或許是個新的起點。

推薦觀看:

WWDC 2017 | Session 203

WWDC 2017 | Session 213

WWDC 2017 | Session 223

WWDC 2017 | Session 227

Drag and Drop 開發者官方文檔

Drag and Drop 是個好 “API“,希望 iPhone 也能有。

本文已適配 iOS 11。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,737評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,699評論 2 370

推薦閱讀更多精彩內容