SwiftUI

SwiftUI 手势的一些Tips

常见手势

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。