不同于众多的内置控件,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
}
}
}
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
}
}
}
}
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)
}
}
}
}
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记事本】