WWDC 2017 iOS11 新特性 Drag and Drop 解析

EddieBiehl 7年前
   <p>WWDC 2017 刚结束,虽然如预期的一样,缺少意料之外的惊喜,但依旧有不少新的特性和 API 值得圈点。抛开 Core ML 以及 ARKit 这些影响深远的亮点不谈,目前抢眼的系统升级,莫过于 UIKit 中新增的 Drag and Drop 特性了。</p>    <h2>拖拽的意义</h2>    <p>在阅读本文之前,建议读者先亲手把玩下 Drag and Drop 的各种姿势,有过实际的操作体验,才能更好的明白一些 API 设计背后的考量。</p>    <p>现阶段只有 iPad 上能支持不同 App 之间的内容拖拽共享,iPhone 上只能在 App 内部拖拽内容,iPhone 上的这一限制使得 Drag and Drop 大打折扣,有可能是出于屏幕尺寸以及操作体验方面的考量。不过这还不是最终版本,后续 Apple 有可能会做出调整,毕竟拖拽带来的可能性太多了。</p>    <p>Drag and Drop 允许不同 App 之间通过拖拽的方式共享内容,虽然 session video 中的演示(从相册中拖拽图片到 mail app)稍显简单,但这一新特性的想象空间远不止此,拖拽的操作方式开启了一个新的数据内容流动通道,内容的提供方和内容的消费方可以是不同 App,让 App 能更专注于自己擅长的领域,分工协作为用户提供更美妙的体验。</p>    <p>比如,之前在微信聊天的时候,如果有一个不得不发的表情,用户只能先从搜狗输入法将图片保存的相册或剪切板,再通过额外的步骤输入到微信中,有了 Drag and Drop 之后,这一流程能简化到一步完成,就好像微信和搜狗输入是同一个 App 一样,在协同工作。</p>    <p>不只是图片,广义上的内容涵盖,文本,链接,语音,图片,视频等等,不同内容组合在一起又能呈现不一样的形式。拖拽无论是在操作体验上,还是内容流通上都将把 iOS 系统的易用性带上一个新的台阶。</p>    <p>下面我会结合一个实际的场景来介绍如何使用 Drag and Drop 特性。从数月前开始,我一直利用零碎的时间在开发一款个人 App:TKeyboard。TKeyboard 有一个很酷的特性,可以在 iPhone 上实时浏览 Mac 的文件系统,当我看到 Drag and Drop 时,一个脑洞应景而生。如果可以将 TKeyboard 中的图片直接拖拽到其他 App 中,那么你的 Mac 电脑就成了 iPhone 的一个备用存储,Mac 上的图片资源一步操作就能传递到其他 App 中,很美妙不是吗?先看下效果图:</p>    <p><img src="https://simg.open-open.com/show/4a19a87e19a4d878b0d41a1c337e3ec9.gif"></p>    <p>使用新特性,新 API 也是个宝贵的学习过程,如果让你来设计这么一个看似简单,拓展性好,兼容性强的 Drag and Drop 功能,你会如何来实施呢?整个流程虽然谈不上复杂,但环节多,会稍显繁琐。我们来看看 Apple 的工程师是如何做的。</p>    <p>Drag 与 Drop 可以分开来学习,因为你的 App 很可能只实现 Drag 或者 Drop 其中一项功能。</p>    <h2>Drag</h2>    <p>先看下最基础的场景,如何将 App 中的内容 Drag 起来。</p>    <p>Drag 的对象是我们平时所接触的 UI 控件,UILabel,UIImageView,或者自定义的 View。让控件可拖动,只需要给控件添加 UIDragInteraction 对象:</p>    <pre>  <code class="language-objectivec">//EFinderTodayCell.h  @interface EFinderTodayCell : UIView   @end      //EFinderTodayCell.m  - (void)enableDrag  {      if (IOS11) {          UIDragInteraction* drag = [[UIDragInteraction alloc] initWithDelegate:self];          [self addInteraction:drag];          self.userInteractionEnabled = true;      }  }</code></pre>    <p>EFinderTodayCell 作为 UIView 的子类,在添加 UIDragInteraction 对象之后,就具备了可被 Drag 的行为,接下来 Drag 的交互控制都通过 UIDragInteractionDelegate 来实现。</p>    <p>UIDragInteractionDelegate 中提供了不少方法,可以对 Drag 的行为做不同程度的定制,一个个看:</p>    <pre>  <code class="language-objectivec">- (NSArray<UIDragItem *> *)dragInteraction:(UIDragInteraction *)interaction itemsForBeginningSession:(id<UIDragSession>)session  {      NSArray* items = [self itemsForSession:session];      return items;  }</code></pre>    <p>单指长按某个 View 时,如果添加了 UIDragInteraction,Drag 即刻启动,进入 itemsForBeginningSession 的回调,这个方法中出现的三个类,关系也十分简单。一个 UIDragInteraction 可以包含多个 UIDragSession,每个 UIDragSession 又可以包含多个 UIDragItem。UIDragItem 则是 Drop 时接收方所受到的对象。</p>    <p>我们可以给一个 UI 元素安装多个 UIDragInteraction,通过设置 enabled 属性来决定启用哪一个 UIDragInteraction,手指 A 长按 UI 元素的时候,启用的 UIDragInteraction 对象会生成一个 UIDragSession 对象,如果手指不松开,另一个手指 B 重新长按另一个 UI 元素,则会建立一个新的 UIDragSession,手指 B 如果点击另一个 UI 元素,则会添加一个新的 UIDragItem。理清三者的关系是深度定制 Drag 的前提,可以用下图表示:</p>    <p><img src="https://simg.open-open.com/show/98ae233a0a2af44031b95fc671cf5b0f.png"></p>    <p>如何生成 UIDragItem 呢?这里又需要几个新对象:</p>    <pre>  <code class="language-objectivec">- (NSArray*)itemsForSession:(id<UIDragSession>)session  {      NSItemProvider* provider = [[NSItemProvider alloc] initWithObject:_item];      UIDragItem* item = [[UIDragItem alloc] initWithItemProvider:provider];      item.localObject = _item;            return @[item];  }</code></pre>    <p>UIDragItem 包含一个 NSItemProvider 对象,NSItemProvider 对象则包含一个 id<NSItemProviderWriting> 对象。protocol NSItemProviderWriting 则定义了 UIDragItem 中所包含的数据最后以何种形式提供个 Drop 方。我们看一个样例 model 类如何实现 NSItemProviderWriting:</p>    <pre>  <code class="language-objectivec">//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];  }</code></pre>    <p>writableTypeIdentifiersForItemProvider 返回 UIDragItem 所提供的 UTI,数据的接收方通过 UTI 知道我们所传递的数据格式。</p>    <p>数据从 Server 获取回来之后,通过 completionHandler 以 NSData 形式传递即可。Drop 的实现者通过 UTI 和 UIDragItem 中的 NSData 即可取出自己感兴趣的数据,UIDragItem 的组成可以用下图表示:</p>    <p><img src="https://simg.open-open.com/show/202c9982f758f202b95e44d5b3658dd0.png"></p>    <p>从上面两张图就能看出 Drag and Drop 的大致设计思路,这些类之间是以类似 tree 的关系组合在一起,对象虽多,但结构清晰。理解了这些关键类之间的关系,再看 UIDragInteractionDelegate 中的各个回调方法,各自在什么场景下触发就了然于胸了。</p>    <pre>  <code class="language-objectivec">//某个 UI 元素安装了 UIDragInteraction,单指长按时生成 UIDragSession,进入回调,索取 UIDragItem。    - (NSArray<UIDragItem *> *)dragInteraction:(UIDragInteraction *)interaction itemsForBeginningSession:(id<UIDragSession>)session  {  }</code></pre>    <pre>  <code class="language-objectivec">//手指 A 长按某个 UI 元素后,手指 B 单击另外的 UI 元素,进入回调,允许添加更多的 UIDragItem 到当前 UIDragSession 中。    - (NSArray<UIDragItem *> *)dragInteraction:(UIDragInteraction *)interaction itemsForAddingToSession:(id<UIDragSession>)session withTouchAtPoint:(CGPoint)point  {  }</code></pre>    <pre>  <code class="language-objectivec">//有另外的 UIDragItem 通过单击加入到 UIDragSession 中,通知其他 UIDragInteractionDelegate    - (void)dragInteraction:(UIDragInteraction *)interaction session:(id<UIDragSession>)session willAddItems:(NSArray<UIDragItem *> *)items forInteraction:(UIDragInteraction *)addingInteraction  {  }</code></pre>    <pre>  <code class="language-objectivec">//单指长按某个 UI 元素,Drag 开始,生成新的 UIDragSession,进入回调    - (void)dragInteraction:(UIDragInteraction *)interaction sessionWillBegin:(id<UIDragSession>)session  {  }</code></pre>    <p>其他回调就不一一列举了。</p>    <p>Drag 另一个重要的定制是对拖动的 UI 元素生成 Preview,并在不同的阶段改变 Preview 的形态。</p>    <p>当单指长按 UI 元素时,元素会被举起(Lift),Lift 动画由系统自动生成,但需要我们通过如下方法来提供 Preview:</p>    <pre>  <code class="language-objectivec">- (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;  }</code></pre>    <p>系统索取的是一个 UITargetedDragPreview 对象,UITargetedDragPreview 则由 UIView 的子类和 UIDragPreviewParameters 构成。UIDragPreviewParameters 可以设置 Preview 的展示参数,比如 backgroundColor 和 visiblePath。</p>    <p>visiblePath 是另一个重要的参数,它实际是一个 UIBezierPath 对象,可以给 Preview 添加特定形状的 mask,比如可以通过如下代码设置圆角:</p>    <pre>  <code class="language-objectivec">UIDragPreviewParameters* params = [UIDragPreviewParameters new];  UIBezierPath* path = [UIBezierPath bezierPathWithRoundedRect:imgView.bounds cornerRadius:5];  params.visiblePath = path;</code></pre>    <p>这里值得一提 UIDragPreview 和 UITargetedDragPreview 之间的差别。UIDragPreview init 方法中传入的 View 必须存在于活跃 Window 上,否则 Preview 会展示空:</p>    <pre>  <code class="language-objectivec">// view 必须存在活跃的 superView  - (instancetype)initWithView:(UIView *)view parameters:(UIDragPreviewParameters *)parameters</code></pre>    <p>UITargetedDragPreview 中传入的 View 无此要求,不过我们需要提供另一个 UIDragPreviewTarget 对象,来告诉 UITargetedDragPreview 在哪个 superView 和位置上展示 Preview,类似:</p>    <pre>  <code class="language-objectivec">//Container 和 Center 分别指定 superView 和 位置  UIDragPreviewTarget* target = [[UIDragPreviewTarget alloc] initWithContainer:_iconView.superview center:_iconView.center];    UITargetedDragPreview* preview = [[UITargetedDragPreview alloc] initWithView:imgView parameters:params target:target];</code></pre>    <p>另外还有一些 Drag 不同阶段的回调,允许我们对被拖动的 UI 元素做动画:</p>    <pre>  <code class="language-objectivec">//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;  }</code></pre>    <h2>Drop</h2>    <p>Drop 则可以看做是 Drag 的逆向过程,将 Drag 传递过来的 UIDragItem 解析后,取出自己感兴趣的数据。Drop 流程所涉及到的对象,几乎都是和 Drag 相对应的,理解了 Drag,再看 Drop 很好理解。</p>    <p>我们可以向目标 UI 元素添加 UIDropInteraction,使其具备接收来自 Drag 数据的能力:</p>    <pre>  <code class="language-objectivec">- (void)enableDrop  {      if (IOS11) {          if (@available(iOS 11.0, *)) {              UIDropInteraction* drop = [[UIDropInteraction alloc] initWithDelegate:self];              [self addInteraction:drop];          }       }  }</code></pre>    <p>之后 Drop 的行为都交由 UIDropInteractionDelegate 来控制。</p>    <p>第一步先询问 delegate 是否可以处理来自于 Drag 的数据:</p>    <pre>  <code class="language-objectivec">- (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;  }</code></pre>    <p>如果我们想忽略来自于 App 内部的 Drag,可以通过 localDragSession 这一属性判断,如果是来自于外部 App 的 Drag,localDragSession 为 nil。</p>    <p>UIDropSession 是由系统封装好的对象,canLoadObjectsOfClass 可以让我们判断来自于 Drag 的数据里,是否有我们感兴趣的类型。这是第一次系统向我们询问是否对于 Drag 中的数据感兴趣。</p>    <p>第二次且最后一次机会告知系统,是否能消化 Drag 中的数据:</p>    <pre>  <code class="language-objectivec">- (UIDropProposal *)dropInteraction:(UIDropInteraction *)interaction sessionDidUpdate:(id<UIDropSession>)session  {      if (@available(iOS 11.0, *)) {          return [[UIDropProposal alloc] initWithDropOperation:UIDropOperationCopy];      }   }</code></pre>    <p>如果此时发现 session 中的数据无法接收,可以返回 UIDropOperationCancel。</p>    <p>前面两步通过之后,接下来是从 Session 中取出来自于 Drag 的数据:</p>    <pre>  <code class="language-objectivec">- (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              }          }      }];  }</code></pre>    <p>performDrop 中的操作最好是采用异步的方式,任何费时的操作都会导致主线程的卡顿,一旦时间过长,会被系统 watchdog 感知并 kill 掉。UIDropSession 所提供的 loadObjectsOfClass 回调会发生在工作线程,所以在 completion block 中如果有涉及 UI 的操作,记得切回主线程。</p>    <p>只需前面三个回调,即可接收来自于 Drag 中的图片数据。比如从系统相册 Drag 照片,在 performDrop 回调里就能取得 UIImage 对象。</p>    <p>另外需要注意用户 Drag 时,只要不松开手指,可以持续进入以下三个回调:</p>    <pre>  <code class="language-objectivec">//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;</code></pre>    <p>我们虽然无法在用户 Drag 时,改变 Drag 的 preview,但用户一旦松开手指,执行 drop 时,UIDropInteractionDelegate 中的以下两个回调可以让我们对 drop 的动画效果做一定程度的定制:</p>    <pre>  <code class="language-objectivec">//手指松开,控制 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;</code></pre>    <p>这也是 Drop 体验要做好真正复杂的部分,来自于 Drag 的数据可能是存在于网络的,用户 Drop 之后,提供 Drag 的 App 此时可能需要从网络上获取真正需要传输的数据,这是一个异步的过程,提供 Drop 功能的 App 需要竭尽所能,通过巧妙的动画或者 UI 交互设计,让用户愿意等待这一“漫长”过程,而且能顺畅自然的感知 Drag 的数据是如何过渡到 Drop 区域的。还需要处理各种异常场景,比如用户不愿继续等待选择取消 Drop,比如 Drag 一方由于内部异常最终无法提供数据,比如最终抵达的数据超过 App 能承受的范围(Image 尺寸过大,Text 过长)等等,每一个场景都需要动画交互。所以这里才是功夫所在。</p>    <p>Drag and Drop 的关键 API 并不多,十个手指头差不多能数过来,如何用好这些 API,如何把体验做精细,如何把 Drag and Drop 中蕴含的更多可能性发掘出来,需要行业里的开发者们一起努力探索,毫不夸张的说,有时候一个新特性就能支撑一个新 App。</p>    <h2>总结</h2>    <p>Drag and Drop 是一种体验上的创新,对于 iPad 这种大屏设备,多手指同时工作可以完成更复杂且实用的操作。我用右手 Drag 图片之后,左手继续在 iPad 上操作其他 App 完全不受影响,苹果在 multi-touch 的体验上应该是下足了功夫。iOS 11 针对 iPad 的优化,以及新款 iPad Pro 的各种硬件升级,可以看出苹果对于未来 iPad 销量增长寄以厚望。手机和 PC 之间的市场争夺战已日趋于平稳,iPad 或许是进一步蚕食 PC 市场份额的另一项利器,但如何在触摸屏上,把交互和体验做到 PC 一般自然舒畅还是项任重道远的任务,WWDC 2017 或许是个新的起点。</p>    <p>推荐观看:</p>    <table>     <tbody>      <tr>       <td>WWDC 2017</td>       <td>Session 203</td>      </tr>     </tbody>    </table>    <table>     <tbody>      <tr>       <td>WWDC 2017</td>       <td>Session 213</td>      </tr>     </tbody>    </table>    <table>     <tbody>      <tr>       <td>WWDC 2017</td>       <td>Session 223</td>      </tr>     </tbody>    </table>    <table>     <tbody>      <tr>       <td>WWDC 2017</td>       <td>Session 227</td>      </tr>     </tbody>    </table>    <p><a href="/misc/goto?guid=4959750046080525819" rel="nofollow,noindex">Drag and Drop 开发者官方文档</a></p>    <p>Drag and Drop 是个好 “API“,希望 iPhone 也能有。</p>    <p>本文已适配 iOS 11。</p>    <p> </p>    <p>来自:http://mrpeak.cn/blog/ios11-dragdrop/</p>    <p> </p>