不同于众多的内置控件,SwiftUI 没有采用对 UIGestureRecognizer(或 NSGestureRecognizer)进行包装的方式,而是重构了自己的手势体系。SwiftUI 手势在某种程度上降低了运用门槛,但由于缺少供给底层数据的 API,严重制约了开发者的深度定制才能。在 SwiftUI 下,我们无法具有类似构建全新 UIGestureRecongnizer 的才能。所谓的自界说手势,其实只是对体系预置手势的重构而已。本文将经过几个示例,演示怎么运用 SwiftUI 供给的原生手法定制所需手势。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

根底

预置手势

SwiftUI 目前供给了 5 种预置手势,分别为点击、长按、拖拽、缩放和旋转。像onTapGesture之类的调用方式,实际上是为了便捷而创立的视图扩展。

  • 点击(TapGesture)

    可设定点击次数(单击、双击)。是运用频率最高的手势之一。

  • 长按(LongPressGesture)

    当按压满意了设定时长后,可触发指定闭包。

  • 拖拽(DragGesture)

    SwiftUI 将 Pan 和 Swipe 合二为一,方位变化时,供给拖动数据。

  • 缩放(MagnificationGesture)

    两指缩放。

  • 旋转(RotationGesture)

    两指旋转。

点击、长按、拖拽仅支持单指。SwiftUI 没有供给手指数设定功用。

除了上述供给给开发者运用的手势外,SwiftUI 其实还有很多的内部(非公开)手势给体系控件运用,例如:ScrollGesture、_ButtonGesture 等。

Button 内置手势的完成比 TapGesture 更杂乱。除了供给了更多的调用机遇外,而且支持了对按压区域尺寸的智能处理(提高手指触击成功率)。

Value

SwiftUI 会依据手势的类型供给不同的数据内容。

  • 点击:数据类型为 Void
  • 长按:数据类型为 Bool,开始按压后供给 true
  • 拖拽:供给了最全面的数据信息,包含当时方位、偏移量、事情时刻、预测结尾、预测偏移量等内容
  • 缩放:数据类型为 CGFloat,缩放量
  • 旋转:数据类型为 Angle,旋转角度

运用map办法,能够将手势供给的数据转换成其他的类型,方便之后的调用。

机遇

SwiftUI 手势内部没有状况一说,经过设置与指定机遇对应的闭包,手势会在适当的机遇主动进行调用。

  • onEnded

    在手势结束时履行的操作

  • onChanged

    当手势供给的值发生变化时履行的操作。只在 Value 契合 Equatable 时供给,因此 TapGesture 不支持。

  • updating

    履行机遇同 onChanged 相同。对 Value 没有特别约定,相较 onChanged ,增加了更新手势特点(GestureState)和获取 Transaction 的才能。

不同的手势,对机遇的重视点有所区别。点击一般只重视 onEnded;onChanged(或 updating)在拖拽、缩放、旋转中效果更大;长按只要在满意了设定时长的情况下,才会调用 onEnded。

GestureState

专门为 SwiftUI 手势开发的特点包装器类型,可作为依赖项驱动视图更新。相较 State 有如下不同:

  • 只能在手势的 updating 办法中修正,在视图其它的地方为只读
  • 在手势结束时,与之相关(运用 updating 进行相关)的手势会主动将其内容康复到它的初始值
  • 经过 resetTransaction 能够设置康复初始数据时的动画状况

组合手势的手法

SwiftUI 供给了几个用于手势的组合办法,能够将多个手势连接起来,重构成其他用途的手势。

  • simltaneously(一起识别)

    将一个手势与另一个手势相结合,创立一个一起识别两个手势的新手势。例如将缩放手势与旋转手势组合,完成一起对图片进行缩放和旋转。

  • sequenced(序列识别)

    将两个手势连接起来,只要在第一个手势成功后,才会履行第二个手势。比如,将长按和拖拽连接起来,完成只要当按压满意一定时刻后才允许拖拽。

  • exclusively(排他性识别)

    合并两个手势,但只要其间一种手势能够被识别。体系会优先考虑第一个手势。

组合后的手势,Value 类型也将发生变化。仍可运用 map 将其转换成愈加易用的数据类型。

手势的界说方式

一般开发者会在视图内部创立自界说手势,如此代码量较少,且容易与视图中其它数据结合。例如,下面的代码在视图中创立了一个可一起支持缩放和旋转的手势:

struct GestureDemo: View {
    @GestureState(resetTransaction: .init(animation: .easeInOut)) var gestureValue = RotateAndMagnify()
    var body: some View {
        let rotateAndMagnifyGesture = MagnificationGesture()
            .simultaneously(with: RotationGesture())
            .updating($gestureValue) { value, state, _ in
                state.angle = value.second ?? .zero
                state.scale = value.first ?? 0
            }
        return Rectangle()
            .fill(LinearGradient(colors: [.blue, .green, .pink], startPoint: .top, endPoint: .bottom))
            .frame(width: 100, height: 100)
            .shadow(radius: 8)
            .rotationEffect(gestureValue.angle)
            .scaleEffect(gestureValue.scale)
            .gesture(rotateAndMagnifyGesture)
    }
    struct RotateAndMagnify {
        var scale: CGFloat = 1.0
        var angle: Angle = .zero
    }
}

别的,也能够将手势创立成契合 Gesture 协议的结构体,如此界说的手势,非常适合被重复运用。

经过将手势或手势处理逻辑封装成视图扩展可进一步简化运用难度。

为了突显某些方面的功用,下文中供给的演示代码或许看起来比较繁琐。实际运用时,可自行简化。

示例一:轻扫

1.1 目标

创立一个轻扫(Swipe)手势,侧重演示怎么创立契合 Gesture 协议的结构体,并对手势数据进行转换。

1.2 思路

在 SwiftUI 预置手势中,仅有 DragGesture 供给了可用于判别移动方向的数据。依据偏移量来确定轻扫方向,运用 map 将冗杂的数据转换成简单的方向数据。

1.3 完成

public struct SwipeGesture: Gesture {
    public enum Direction: String {
        case left, right, up, down
    }
    public typealias Value = Direction
    private let minimumDistance: CGFloat
    private let coordinateSpace: CoordinateSpace
    public init(minimumDistance: CGFloat = 10, coordinateSpace: CoordinateSpace = .local) {
        self.minimumDistance = minimumDistance
        self.coordinateSpace = coordinateSpace
    }
    public var body: AnyGesture<Value> {
        AnyGesture(
            DragGesture(minimumDistance: minimumDistance, coordinateSpace: coordinateSpace)
                .map { value in
                    let horizontalAmount = value.translation.width
                    let verticalAmount = value.translation.height
                    if abs(horizontalAmount) > abs(verticalAmount) {
                        if horizontalAmount < 0 { return .left } else { return .right }
                    } else {
                        if verticalAmount < 0 { return .up } else { return .down }
                    }
                }
        )
    }
}
public extension View {
    func onSwipe(minimumDistance: CGFloat = 10,
                 coordinateSpace: CoordinateSpace = .local,
                 perform: @escaping (SwipeGesture.Direction) -> Void) -> some View {
        gesture(
            SwipeGesture(minimumDistance: minimumDistance, coordinateSpace: coordinateSpace)
                .onEnded(perform)
        )
    }
}

1.4 演示

struct SwipeTestView: View {
    @State var direction = ""
    var body: some View {
        Rectangle()
            .fill(.blue)
            .frame(width: 200, height: 200)
            .overlay(Text(direction))
            .onSwipe { direction in
                self.direction = direction.rawValue
            }
    }
}

在 SwiftUI 下定制手势

1.5 阐明

  • 为什么运用 AnyGesture

    在 Gesture 协议中,需求完成一个隐藏的类型办法:_makeGesture。苹果目前并没有供给应该怎么完成它的文档,好在 SwiftUI 供给了一个含有束缚的默许完成。当我们不在结构体中运用自界说的 Value 类型时,SwiftUI 能够推断出 Self.Body.Value,此时能够将 body 声明为some Gesture。但由于本例中运用了自界说 Value 类型,因此必须将 body 声明为AnyGesture<Value>,方可满意启用_makeGesture默许完成的条件。

  extension Gesture where Self.Value == Self.Body.Value {
    public static func _makeGesture(gesture: SwiftUI._GraphValue<Self>, inputs: SwiftUI._GestureInputs) -> SwiftUI._GestureOutputs<Self.Body.Value>
  }

1.6 缺乏与改进办法

本例中并没有对手势的继续时刻、移动速度等要素进行综合考量,当时的完成严厉意义上并不能算是真正轻扫。如果想完成严厉意义上的轻扫能够采用如下的完成办法:

  • 改成示例 2 的方式,用 ViewModifier 来包装 DragGesture
  • 用 State 记载滑动时刻
  • 在 onEnded 中,只要满意速度、距离、偏差等要求的情况下,才回调用户的闭包,并传递方向

示例二:计时按压

2.1 目标

完成一个能够记载时长的按压手势。手势在按压过程中,能够依据指定的时刻距离进行类似 onChanged 的回调。本例程侧重演示怎么经过视图修饰器包装手势的办法以及 GestureState 的运用。

2.2 思路

经过计时器在指定时刻距离后向闭包传递当时按压的继续时刻。运用 GestureState 保存点击开始的时刻,按压结束后,前次按压的起始时刻会被手势主动清除。

2.3 完成

public struct PressGestureViewModifier: ViewModifier {
    @GestureState private var startTimestamp: Date?
    @State private var timePublisher: Publishers.Autoconnect<Timer.TimerPublisher>
    private var onPressing: (TimeInterval) -> Void
    private var onEnded: () -> Void
    public init(interval: TimeInterval = 0.016, onPressing: @escaping (TimeInterval) -> Void, onEnded: @escaping () -> Void) {
        _timePublisher = State(wrappedValue: Timer.publish(every: interval, tolerance: nil, on: .current, in: .common).autoconnect())
        self.onPressing = onPressing
        self.onEnded = onEnded
    }
    public func body(content: Content) -> some View {
        content
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .updating($startTimestamp, body: { _, current, _ in
                        if current == nil {
                            current = Date()
                        }
                    })
                    .onEnded { _ in
                        onEnded()
                    }
            )
            .onReceive(timePublisher, perform: { timer in
                if let startTimestamp = startTimestamp {
                    let duration = timer.timeIntervalSince(startTimestamp)
                    onPressing(duration)
                }
            })
    }
}
public extension View {
    func onPress(interval: TimeInterval = 0.016, onPressing: @escaping (TimeInterval) -> Void, onEnded: @escaping () -> Void) -> some View {
        modifier(PressGestureViewModifier(interval: interval, onPressing: onPressing, onEnded: onEnded))
    }
}

2.4 演示

struct PressGestureView: View {
    @State var scale: CGFloat = 1
    @State var duration: TimeInterval = 0
    var body: some View {
        VStack {
            Circle()
                .fill(scale == 1 ? .blue : .orange)
                .frame(width: 50, height: 50)
                .scaleEffect(scale)
                .overlay(Text(duration, format: .number.precision(.fractionLength(1))))
                .onPress { duration in
                    self.duration = duration
                    scale = 1 + duration * 2
                } onEnded: {
                    if duration > 1 {
                        withAnimation(.easeInOut(duration: 2)) {
                            scale = 1
                        }
                    } else {
                        withAnimation(.easeInOut) {
                            scale = 1
                        }
                    }
                    duration = 0
                }
        }
    }
}

在 SwiftUI 下定制手势

2.5 阐明

  • GestureState 数据的康复时刻在 onEnded 之前,在 onEnded 中,startTimestamp 现已康复为 nil
  • DragGesture 仍是最好的完成载体。TapGesture、LongPressGesture 均在满意触发条件后会主动终止手势,无法完成对任意时长的支持

2.6 缺乏及改进办法

当时的解决方案没有供给类似 LongPressGesture 按压中方位偏移限制设置,别的尚未在 onEnded 中供给本次按压的总继续时长。

  • 在 updating 中对偏移量进行判别,如果按压点的偏移超出了指定的范围,则中止计时。并在 updating 中,调用用户供给的 onEnded 闭包,并进行标记
  • 在手势的 onEnded 中,如果用户供给的 onEnded 闭包现已被调用,则不会再此调用
  • 运用 State 替换 GestureState,这样就能够在手势的 onEnded 中供给总继续时刻。需自行编写 State 的数据康复代码
  • 由于运用了 State 替换 GestureState,逻辑判别就能够从 updating 移动到 onChanged 中

示例三:顺便方位信息的点击

3.1 目标

完成供给触摸方位信息的点击手势(支持点击次数设定)。本例首要演示 simultaneously 的用法以及怎么挑选合适的回调时刻点(onEnded)。

3.2 思路

手势的呼应感觉应与 TapGesture 完全一致。运用 simultaneously 将两种手势联合起来,从 DrageGesture 中获取方位数据,从 TapGesture 中退出。

3.3 完成

public struct TapWithLocation: ViewModifier {
    @State private var locations: CGPoint?
    private let count: Int
    private let coordinateSpace: CoordinateSpace
    private var perform: (CGPoint) -> Void
    init(count: Int = 1, coordinateSpace: CoordinateSpace = .local, perform: @escaping (CGPoint) -> Void) {
        self.count = count
        self.coordinateSpace = coordinateSpace
        self.perform = perform
    }
    public func body(content: Content) -> some View {
        content
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: coordinateSpace)
                    .onChanged { value in
                        locations = value.location
                    }
                    .simultaneously(with:
                        TapGesture(count: count)
                            .onEnded {
                                perform(locations ?? .zero)
                                locations = nil
                            }
                    )
            )
    }
}
public extension View {
    func onTapGesture(count: Int = 1, coordinateSpace: CoordinateSpace = .local, perform: @escaping (CGPoint) -> Void) -> some View {
        modifier(TapWithLocation(count: count, coordinateSpace: coordinateSpace, perform: perform))
    }
}

3.4 演示

struct TapWithLocationView: View {
    @State var unitPoint: UnitPoint = .center
    var body: some View {
        Rectangle()
            .fill(RadialGradient(colors: [.yellow, .orange, .red, .pink], center: unitPoint, startRadius: 10, endRadius: 170))
            .frame(width: 300, height: 300)
            .onTapGesture(count:2) { point in
                withAnimation(.easeInOut) {
                    unitPoint = UnitPoint(x: point.x / 300, y: point.y / 300)
                }
            }
    }
}

在 SwiftUI 下定制手势

3.5 阐明

  • 当 DragGesture 的 minimumDistance 设置为 0 时,其第一条数据的发生时刻一定早于 TapGesture(count:1) 的激活时刻
  • 在 simultaneously 中,一共有三个 onEndend 机遇。手势 1 的 onEnded,手势 2 的 onEnded,以及合并后手势的 onEnded。在本例中,我们挑选在 TapGesture 的 onEnded 中回调用户的闭包

总结

当时 SwiftUI 的手势,暂处于运用门槛低但才能上限缺乏的状况,仅运用 SwiftUI 的原生手法无法完成非常杂乱的手势逻辑。将来找时刻我们再经过其它的文章来研究有关手势之间的优先级、运用 GestureMask 挑选性失效,以及怎么同 UIGestureRecognizer 合作创立杂乱手势等议题。

期望本文能够对你有所协助。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】