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。


Hosted by Coding Pages