欢迎咱们重视我的微信公众号:小林居酒屋

TL;DR

  1. SwiftUI Gesture 有 .possible, .active, .ended, .failed 四种内部状况。
  2. 依赖 onEnded(_:) 来清理数据是不可靠的,其无法探测到 Gesture .failed这种情形。
  3. 运用 GestureState 来更新 Gesture 所产生的数据,其能保证 Gesture 状况在重置时数据也被重置为初始值。

布景

前一阵子,搭档遇到了有关 Gesture 的问题,碰巧我之前写过一些 Gesture 相关的代码,就一同研讨了一下。

详细问题是,他运用 PartialSheet 做了一个半屏分享面板,这个分享面板向上拉动的时候,会有一个弹性拉扯的作用。

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

但假如用户在用一个手指向上拉扯面板时,再运用第二个手指接力向上拉动,整个面板就会卡滞在当时拉伸的状况,松手后无法主动回弹到正常高度。

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

也能够参考提交的 issue 中的视频

github.com/AndreaMiott…

问题最小 Demo

老规矩,榜首步是将问题简化成一个最小 Demo.

由于 PartialSheet 是开源库,让咱们得以以低成本的方法探求其内部形变是由什么组件提供的,clone 下来一顿翻,发现面板的弹性形变,由 DragGesture 提供,即经过监测用户手指向上滑动的长度,运用一个公式换算成面板拉伸的长度。

将其面板代码简化和笼统,咱们能写出下面的 Demo:

struct ContentView: View {
 @State var offset: CGSize = .zero
 var gesture: some Gesture {
  DragGesture()
   .onChanged { value in
    offset = value.translation
   }
   .onEnded { _ in
    offset = .zero
   }
 }
 var body: some View {
  Color.blue
   .offset(offset)
   .gesture(gesture)
 }
}

这段代码的逻辑十分简略和好理解,假如拖拽蓝色的色块,拖拽的间隔则被赋予到色块的 offset(_:) 中。当用户完毕拖动,在 DragGestureonEnded(_:) 中将 offset 设置为零,此刻色块回正。

依照 bug 复现路径,先用一根手指拖动色块,然后再参加第二根手指,此刻色块中止移动,松手后色块无法正常回正,这和搭档反馈的问题极其类似。

Debug 与修正

从现象上来看,第二根手指参加一同拖拽后呈现问题,可能有两种状况

  1. offset 被设置,但 Binding 机制呈现问题,没有正确告诉到 View 进行更新;
  2. onChanged(:_)onEnded(:_) 的 action 没有被正确触发,offset 停留在只要一根手指时最终的值中。

对此咱们经过 Log 来验证想法(在 Gesture 相关的 debug 中,我会更习气运用 Log 来验证想法,由于 Gesture 是带状况和时间的,断点很有可能对整个点击事情产生较大影响):

var gesture: some Gesture {
 DragGesture()
 .onChanged { value in
     offset = value.translation
      print("[D] DragGesture.onChanged(_:) call")
  }
 .onEnded { _ in
      offset = .zero
      print("[D] DragGesture.onEnded(_:) call")
  }
}

跑起来,重现 bug 时发现,当第二根手指参加后,onChanged(:_) 的 action 当即中止触发,一切手指松开后, onEnded(:_) 的 action 没有被触发。好像是 DragGesture在第二根手指参加后,进入了一种状况,这种状况既不归于 changed 也不归于 ended,所以两个 action 都无法唤起,Demo 的状况在此刻呈现无法回正的错乱。

事务的需求是,用户的手指从屏幕离开后,面板能正常回弹,对应到咱们这个简略 Demo 中,即是色块能在手松开时能将 offset 设为 .zero. 所以咱们需求在 DragGesture 进入不明状况后,仍能将 offset 重设为 .zero. 这种需求与 GestureState 所能提供的才能十分类似,它的文档描述如下:

A property wrapper type that updates a property while the user performs a gesture and resets the property back to its initial state when the gesture ends.

那么咱们修改问题 Demo 为:

struct ContentView: View {
 @GestureState var offset: CGSize = .zero
 var gesture: some Gesture {
  DragGesture()
   .updating($offset) { value, offset, _ in
    offset = value.translation
   }
 }
 var body: some View {
  Color.blue
   .offset(offset)
   .gesture(gesture)
 }
}

对这个 Demo 再次复现问题时,发现色块会在第二根手指接触屏幕的一瞬间当即回正,假如依照苹果的文档,那么阐明 DragGesture 已经完结了当时手势的辨认,重置其状况了。

正好,契合咱们的需求,将 PartialSheet 中相关代码运用 GestureState 进行修正,修正代码详见:

github.com/AndreaMiott…

发版合回事务仓验证,没有问题。

收工下班。全文完。

(后边都是花絮了,没啥好看的)

花絮

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

DragGesture 辨认的过程中, onChanged(:_) 的 action 最开始能被执行,第二根手指参加后 GestureState 会被重置成初始状况,阐明整个 DragGesture 的已经完毕,但为什么 onEnded(:_) 的 action 并没有被执行,咱们来一探求竟。

由于 iOS16 SDK 躲藏了 SwiftUI 符号的原因,本次 debug 环境是 Xcode 13.4.1 + iOS 15.3 模拟器,运用 x86 汇编代码

仓库信息

咱们首先将 Demo 还原成最初的问题代码,在 onEnded(:_) 处增加断点,操作 Demo 让其正常完毕。取得断点仓库

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

在这个仓库中,#1 帧好像有咱们所需求的信息,长难句操练:

SwiftUI`partial apply forwarder for closure #1 () -> () in SwiftUI.EndedCallbacks.dispatch(phase: SwiftUI.GesturePhase<_0_0>, state: inout ()) -> Swift.Optional<() -> ()>:

这是一个在 EndedCallbacks.dispatch(phase: SwiftUI.GesturePhase<_0_0>, state: inout ()) -> Swift.Optional<() -> ()> 函数中界说的 closure.

开发者界说的 action,被保存在 EndedCallbacks 中,由体系在适宜的时候拉起

但这只是 EndedCallbacks.dispatch(phase: SwiftUI.GesturePhase<_0_0>, state: inout ()) -> Swift.Optional<() -> ()> 的一个闭包,咱们翻开 Hopper 找到对应的函数 dispatch(phase:state:) 函数,整个函数不算杂乱,翻译成伪代码大致如下:

func dispatch(phase: SwiftUI.GesturePhase<A>, state: inout ()) -> (() -> ())? {
 switch phase {
 case 0x2:
    return closure #1
 default:
    return nil
 }
}

当 phase 的 case 为 0x2 时,dispatch 函数会回来一个闭包,而其他状况则会回来空

GesturePhase

GesturePhase<A> 又是啥玩意呀。

翻阅 SwiftUI 官方文档和 interface 都没有这个符号,所以这是一个内部符号。(有一些 public 符号会被特意躲藏,但会暴露在 interface 文件中)

内部符号咱们能够经过 metadata 逐渐拔开其面纱,Swift 的 runtime 体系会依赖保存了类型基础信息的 metadata, 这些 metadata 会被存储在二进制中。关于 metadata 在二进制中的布局,能够见苹果的官方文档 Type Metadata, 对于内部的属性,能够着重重视 Nominal Type Descriptor. 但刚好:

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

行吧,自己动手丰衣足食, 咱们自己去挖 Nominal Type Descriptor 实践的二进制布局,幸而 Swift 是开源的。翻阅了一圈资料和源码,ContextDescriptorBuilder 这个人物。这个人物并不是详细的一个类,由基类经过承继的方法逐渐详细到某种类型的,在函数中 dispatch(phase:state:) 的汇编代码中, 咱们发现 swift 经过 swift_getEnumCaseMultiPayload 函数获取了 phase 0x2 的值:

mov    rdi, r14
mov    rsi, r15
call    imp___stubs__swift_getEnumCaseMultiPayload ; swift_getEnumCaseMultiPayload
cmp    eax, 0x2

所以 GesturePhase 大概率是一个 enum, 找到它的 ContextDescriptorBuilder, EnumContextDescriptorBuilder,其承继联系如下:

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

查看它的 layout 函数,把父类调用 inline 进来的代码:

void layout() {
 asImpl().computeIdentity();
 asImpl().addFlags();
 asImpl().addParent();
 asImpl().addName();
 asImpl().addAccessFunction();
 asImpl().addReflectionFieldDescriptor();
 asImpl().addLayoutInfo();
 asImpl().addGenericSignature();
 asImpl().maybeAddResilientSuperclass();
 asImpl().maybeAddMetadataInitialization();
 maybeAddCanonicalMetadataPrespecializations();
}

这儿是在逐渐增加 ContextDescriptor 的信息,从命名上观测,咱们需求的信息在 addReflectionFieldDescriptor 中。

翻开 Hopper,找到 GesturePhase 的 Nominal Type Descriptor,依照 layout 函数格式化

这儿留意,经过

void addReflectionFieldDescriptor() {
  // ...
 B.addRelativeAddress(IGM.getAddrOfReflectionFieldDescriptor(
  getType()->getDeclaredType()->getCanonicalType()));
}

可得知,在二进制中 Reflection Field Descriptor 是相对地址存储的,真实地址等于当时存储的值加上值所在的地址,这样做的一个优点是存储指针长度能够从 8 字节降至 4 字节。

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

跳转到 reflection metadata field descriptor SwiftUI.GesturePhase 后,又是一片二进制数据,这次结构信息在 Swift 文档上提都没提,持续 Swift 源码作业,在 FieldDescriptor 中找到其界说:

class FieldDescriptor {
 const FieldRecord *getFieldRecordBuffer() const {
  return reinterpret_cast<const FieldRecord *>(this + 1);
  }
public:
 const RelativeDirectPointer<const char> MangledTypeName;
 const RelativeDirectPointer<const char> Superclass;
 const FieldDescriptorKind Kind;
 const uint16_t FieldRecordSize;
 const uint32_t NumFields;
}
​
class FieldRecord {
 const FieldRecordFlags Flags;
public:
 const RelativeDirectPointer<const char> MangledTypeName;
 const RelativeDirectPointer<const char> FieldName;
}

有一堆基础信息和一个存储了 FieldRecord 的数组, FieldRecordFieldName 正是咱们所需求的信息,依照其结构格式化二进制数据

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

跳转到榜首个 FieldName 指向的地址(这儿也是相对地址)

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

那么GesturePhase 在 SwiftUI 中的界说大致是

enum GesturePhase {
 case possible
 case active
 case ended
 case failed
}

(这儿其实少了一个泛型和 case 所带的 payload,但不影响此次 debug,暂时疏忽)

所以,EndedCallbacks.dispatch(phase:state:) 对应的伪代码咱们能够改为:

func dispatch(phase: SwiftUI.GesturePhase<A>, state: inout ()) -> (() -> ())? {
 switch phase {
 case .ended:
    return closure #1
 case .possible, .active, .failed:
    return nil
 }
}

看到 .failed, 定论好像已经呼之欲出。

case .failed

回到问题 Demo, 咱们找到经过 hooper 找到 EndedCallbacks.dispatch(phase:state:) 的符号,并增加断点,射中之后在 swift_getEnumCaseMultiPayload 增加断点,让其输出 eax,和翻开主动持续选项,同时关掉 EndedCallbacks.dispatch(phase:state:) 符号断点

写点 SwiftUI: 不要再用 Gesture.onEnded(_:) 啦!

咱们能够发现,在一根手指拖拽时,GesturePhase 都是 0x1 .active, 完结拖拽后手松开,GesturePhase 转换成 0x2 .ended, onEnded(_:) action 被正常拉起。

eax = 0x00000001
eax = 0x00000001
eax = 0x00000001
eax = 0x00000002

但假如榜首根手指拖拽时,参加第二根手指,GesturePhase 会是 0x3 .failed

eax = 0x00000001
eax = 0x00000001
eax = 0x00000001
eax = 0x00000003

由于 EndedCallbacks.dispatch(phase:state:) 需求的是 0x2 .ended, 所以 0x3 .failed天然不会有呼应 ,而此刻 Gesture 的单次呼应流程已经完毕,依赖 onEnded(:_) 来重置状况的事务代码不会被执行,bug 因此而产生。

跋文

虽然事务上的 bug 原因是 SwiftUI Gesture 的真实状况躲藏直接导致的,但 SwiftUI 是一个推重数据驱动的结构, onChanged(:_)onEnded(:_) 多少仍有一些事情驱动的影子,经过事情的枚举来完结事务很可能就会由于事情的遗漏而导致事务上的问题。而运用 GestureState 这种纯数据流,不重视实践状况的方法更新页面,将会是咱们更好的挑选。