常见手势
TapGesture(点击)
SwiftUI 中的手势可以通过 modifier 方便地调用,比如最常见的点击手势,我们可以这样使用:
@State private var swiftImageScale: CGFloat = 2.0 Image(systemName: "swift") .foregroundColor(.blue) .font(.largeTitle) .padding() .scaleEffect(swiftImageScale) .onTapGesture { withAnimation { swiftImageScale = swiftImageScale == 1.0 ? 2.0 : 1.0 } }
这里展示的是单击的使用,如果我们需要指定点击次数,比如双击、三击等手势,可以指定 count 的值,方法签名如下:
public func onTapGesture(count: Int = 1, perform action: @escaping () -> Void) -> some View
笔者原本想做一个点击屏幕后,Swift 的 logo 图标移动到点击位置的动画效果,无奈现在的 SwiftUI 还不支持这样的特性,相信后面的版本会加入这样的 api。当然,我们也可以通过 UIKit 来实现这样的功能,这里不再赘述。
LongPressGesture
@State private var changeColor = false Image(systemName: "swift") .foregroundColor(changeColor ? .blue : .orange) .font(.system(size: 80)) .padding() .onLongPressGesture(minimumDuration: 1, maximumDistance: 60) { withAnimation { changeColor.toggle() } }
上面的代码表示当我们长按图标时,1 秒之后图标会变色。
长按手势的时长通过参数 minimumDuration 给定,maximumDistance 表示手势可以移动的距离,只要在给定时长内,手势的移动距离没有超过指定值,手势就会触发。
DragGesture
SwiftUI 中几乎所有的手势都可以通过如下两个方法监听手势的状态:
- onChanged :手势过程中的状态变化
- onEnded : 手势结束事件
下面是一个拖拽手势的应用,我们通过 onChanged 方法可以获取手势当前位置,使苹果 logo 跟随手势移动,当手势结束时,在 onEnded 方法回调中,将 logo 置回初始位置,具体效果可以在示例中体验。
@State private var offset = CGSize.zero @State private var dragEnded = false Image(systemName: "applelogo") .font(.system(size: 80)) .padding() .offset(offset) .scaleEffect(dragEnded ? 2.0 : 1.0) .gesture( DragGesture() .onChanged({ value in withAnimation { offset = value.translation } }) .onEnded({ value in withAnimation { offset = .zero dragEnded.toggle() } }) ) .frame(height: 200)
MagnificationGesture
MagnificationGesture 用于缩放视图,参考代码如下:
@State private var magnificationScale: CGFloat = 1 Image(systemName: "pyramid") .font(.system(size: 120)) .padding() .scaleEffect(magnificationScale) .gesture( MagnificationGesture() .onChanged { scale in print(scale) magnificationScale = scale } .onEnded { scale in } ) .frame(height: 300)
RotationGesture
RotationGesture 用于旋转视图,参考代码如下:
@State private var degress: Double = 0 Image(systemName: "airplane") .font(.system(size: 120)) .padding() .rotationEffect(.degrees(degress)) .gesture( RotationGesture() .onChanged { angle in degress = angle.degrees } ) .frame(height: 200)
手势优先级
有时我们会在一个页面上添加多个手势,势必造成手势的冲突,比如下面的代码:
VStack { Text("Tap") .font(.largeTitle) .onTapGesture { print("点击文字") } } .onTapGesture { print("点击容器") }
当我们点击屏幕是,文字区域拦截了手势,控制台会输出“点击文字”。如果我们需要在特定条件下触发容器的点击事件,可以使用 highPriorityGesture
修改手势的优先级来实现:
VStack { Text("Tap") .font(.largeTitle) .onTapGesture { print("点击文字") } } .highPriorityGesture( TapGesture() .onEnded{ _ in print("点击容器") } )
手势的组合
在实际开发中,我们常常会遇到一个视图有多个手势的情况,这个时候,我们需要使用恰当的组合来处理多个手势。SwiftUI 提供了以下三种手势组合:
- Simultaneous:同时,多个手势可以同时响应
- Sequence:有序,多个手势按照既定顺序执行
- Exclusive:互斥,即同一时刻只有一个手势会生效
Simultaneous
这里我们还是以上一章节中的 RotationGesture 为例,我们在旋转手势的基础上,再添加一个放大手势。如此,当双指在旋转飞机的同时我们同时可以进行缩放操作。示例代码如下:
@State private var degress: Double = 0 @State private var scale: CGFloat = 1.0 let rotationGesture = RotationGesture().onChanged { value in self.degress = value.degrees }.onEnded { _ in self.degress = 0 } let magnificationGesture = MagnificationGesture().onChanged { value in self.scale = value }.onEnded { _ in self.scale = 1.0 } let simultaneousGesture = rotationGesture.simultaneously(with: magnificationGesture) return Image(systemName: "airplane") .font(.system(size: 120)) .padding() .rotationEffect(.degrees(degress)) .scaleEffect(scale) .gesture(simultaneousGesture) .animation(.easeInOut, value: degress) .animation(.easeInOut, value: scale) .frame(height: 200)
Sequence
Sequence 顾名思义是有序的,比如我们下面这个例子,当我们直接拖拽 Swift logo 图标时,手势是不会响应的,只有我们长按一秒后,拖拽手势才会激活。示例代码如下:
@State private var offset = CGSize.zero @State private var dragActived = false let longPressGesture = LongPressGesture(minimumDuration: 1).onEnded { _ in dragActived = true } let dragGesture = DragGesture().onChanged { (value) in self.offset = value.translation }.onEnded { _ in self.offset = .zero self.dragActived = false } let sequenceGesture = longPressGesture.sequenced(before: dragGesture) return Image(systemName: "swift") .foregroundColor(dragActived ? .blue : .primary) .font(.system(size: 100)) .padding() .offset(offset) .gesture(sequenceGesture) .animation(.easeInOut, value: offset) .animation(.easeInOut, value: dragActived) .frame(height: 200)
Exclusive
设想我们有如下场景,当我们拖拽视图时,视图会随着手势移动,而当我们长按视图时,视图会缩放,这两个手势同时绑定在一个视图上,而只有一个手势会被激活,另一个手势会被忽略。
如下就是一个这样的实例,我们可以拖拽苹果的 logo,或者长按缩放 logo,但二者只有一个手势会被激活。参考代码如下:
@State private var longPressEnded = false @State private var offset = CGSize.zero let longPressGesture = LongPressGesture(minimumDuration: 1).onEnded{ _ in longPressEnded.toggle() } let dragGesture = DragGesture().onChanged { (value) in self.offset = value.translation }.onEnded { _ in self.offset = .zero } let exclusiveGesture = longPressGesture.exclusively(before: dragGesture) return Image(systemName: "applelogo") .font(.system(size: 80)) .padding() .offset(offset2) .scaleEffect(longPressEnded ? 1.8 : 1.0) .gesture(exclusiveGesture) .animation(.easeInOut, value: offset2) .animation(.easeInOut, value: longPressEnded) .frame(height: 200)
@GestureState 和 GestureMask的使用
@GestureState
@GestureState 同之前讲过的 @State 一样,也是一个属性包装器,它使用的场景有限,只针对手势。@GestureState 可以时刻追踪手势的状态,方便我们监听手势过程中相关属性的变化。并且在手势结束后,会将需要监听的属性置为初始状态。
下面我们举个例子来说明它的使用方法:
@GestureState private var offset = CGSize.zero LinearGradient(gradient: .init(colors: [.red, .orange, .yellow]), startPoint: .topLeading, endPoint: .bottomTrailing) .frame(width: 150, height: 100) .cornerRadius(15) .offset(offset) .gesture( DragGesture() .updating($offset) { (value, state, transaction) in state = value.translation } )
我们声明了一个渐变色的卡片,并且添加了一个拖拽手势。卡片会跟随手指移动,并且释放手势时,会回到初始的 offset。
.updating($offset) { (value, state, transaction) in state = value.translation }
这行代码绑定了已声明的属性值 offset,并以闭包形式返回手势的状态,三个返回值含义如下:
- value:当前手势的状态,对不同的手势,其返回值是不同的类型,具体值类型取决于当前手势类型。比如这里的拖拽手势,我们获取的是偏移量(.translation),同样我们还能获得 value.location、value.startLocation 等
- state:in-out 值,更新它的值,就可以更新我们需要监听的属性值(即 offset)
- transaction:in-out 值,存储着当前手势转态更新过程的上下文信息
GestureMask
GestureMask 为我们方便地管理手势提供了以下 4 种选择:
- .all:添加手势的视图和子视图都能响应相应的手势
- .gesture:只有添加手势的视图能响应手势,子视图无法响应
- .subviews:子视图能响应手势,添加手势的视图无法响应
- .none:添加手势的视图和子视图都不能响应手势
下面我们举例说明具体用法:
@State private var innerScale: CGFloat = 1.0 @State private var outScale: CGFloat = 1.0 var body: some View { let outTap = TapGesture() .onEnded { _ in outScale = outScale == 1.0 ? 1.1 : 1.0 } let innerTap = TapGesture() .onEnded { _ in innerScale = innerScale == 1.0 ? 1.1 : 1.0 } return ZStack { Circle() .frame(height: 200) .foregroundColor(.blue) .scaleEffect(outScale) Circle() .frame(height: 100) .foregroundColor(.white) .scaleEffect(innerScale) .highPriorityGesture(innerTap) } .frame(height: 300) .animation(.default, value: outScale) .animation(.default, value: innerScale) .gesture(outTap, including: .all) }
如示例所示,上面是两个大小不一的叠加的圆,我们分别给父视图 ZStack 和 子视图(直径为 100 的圆)添加了两个点击手势,点击时会分别缩放大圆和小圆。
.gesture(outTap, including: .all)
的意思是给父视图添加点击手势,并且子视图的手势(innerTap)也能响应。现在你可以在示例里试试点击效果,我们发现两个圆都能响应相应的手势。
另外三种类型我也在示例中做了展示,你可以对照上面的内容加深理解。对应的代码只需要将 including 参数分别修改成 .gesture、.subviews 或 .none。