# TapGesture
onTapGesture
모디파이어를 사용하면 탭 제스처 동작을 구현할 수 있다. 파라미터에 count
값을 전달할 수 있는데, 탭 카운트를 기준으로 perform
클로저를 실행하게 된다. 예를 들어 count
에 2를 전달하는 경우 더블탭 이후에 클로저가 실행된다.
Text("\(tapCount)")
.onTapGesture(count: 2) {
tapCount = 0
}
onTapGesture
모디파이어 외에 범용적으로 쓰이는 제스처 모디파이어로 .gesture(_ Gesture)
가 있다. 제스처 인스턴스를 직접 전달하고 파라미터 내에서 onEnded
모디파이어까지 정의하면 된다.
Image(systemName: "minus.circle")
.gesture(TapGesture().onEnded({ tapCount -= 1 }))
제스처는 다른 뷰들과의 인터랙션에 따라 탭 제스처로 인식되기까지 시간이 소요되기 때문에, onEnded
에서 동작을 처리하는 것이 좋다.
코드 가독성을 위해 제스처를 속성으로 빼어 구현하는 것이 좋다.
var tapToPlus: some Gesture {
TapGesture()
.onEnded {
tapCount += 1
}
}
var tapToMinus: some Gesture {
TapGesture()
.onEnded {
tapCount -= 1
}
}
위 코드에서 some
키워드를 사용하여 타입을 선언한 이유는 onEnded
모디파이어를 통해 리턴되는 최종 타입이 struct _EndedGesture<Content> where Content : Gesture
인데, onEnded
내부 타입을 캡슐화 하기 위해 some
키워드로 타입을 선언한다.
onEnded
역시 Gesture
타입 기반으로 동작하기 때문에 Opaque Type
으로 사용된다.
제스처는 순서가 매우 중요하다. 하나의 뷰에 여러 제스처를 동시에 등록하는 것은 얼마든지 가능하다.
Image(systemName: "plus.circle")
.gesture(tapToPlus)
.gesture(doubleTapToPlus)
위와 같이 싱글탭 제스처인 tapToPlus
가 먼저 제스처에 등록된다면 싱글탭 이후 tapToPlus
제스처 onEnded
가 호출되어 탭제스처가 종료되어 이후에 등록된 doubleTapToPlus
가 무시된다.
doubleTapToPlus
제스처를 먼저 등록하면 자체적으로 등록되어 있는 더블탭 간격 안에 더블탭이 이루어졌는지 여부를 체크하고, 이루어진 경우 doubleTapToPlus
를 실행하고 그렇지 않은 경우 뒤에 등록된 tapToPlus
제스처가 실행된다.
# Long Press Gesture
롱 프레스 제스처는 길게 눌렀을때의 제스처를 다룬다. minimumDuration
과 maximumDistance
를 통해 최소 탭 유지 시간과 최대 탭 유지 시간을 처리할 수 있다.
var longPressGesture: some Gesture {
LongPressGesture()
.onEnded { _ in
showOriginal.toggle()
}
}
onEnded
나 onLongPressGesture
에서 onPressingChanged
클로저에 전달되는 Bool
파라미터값은 탭 유지에 따른 값이 아니다. 롱 프레스 제스처가 인식된 순간 perform
클로저가 실행되고, 클로저 실행이 끝남과 동시에 onEnded
가 호출되며 onPressingChanged
역시 false
값이 바로 전달된다.
롱 프레스가 중단된 경우에도 동일하게 onEnded
가 호출되고 onPressingChanged
에 false
가 전달된다.
# Drag Gesture
뷰 모디파이어중 onDrag
모디파이어는 드래그 앤 드롭을 구현할때 사용되는 것으로 드래그 제스처와는 관계가 없다. 따라서 드래그 제스처 구현을 위해서는 직접 속성에 구현해두어야 한다.
@State private var currentTranslation: CGSize = .zero
@State private var finalTranslation: CGSize = .zero
var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
currentTranslation = value.translation
}
.onEnded { value in
currentTranslation = .zero
var translation = finalTranslation
translation.width += value.translation.width
translation.height += value.translation.height
finalTranslation = translation
}
}
좌표값 변경에 따라 현재 위치를 클로저 파라미터에 전달되는 값의 translation
속성으로 초기화한다. onChanged
만 구현해두면 제스처 종료 이후에 .zero
위치로 초기화 되어버리는데, 마지막 좌표값을 속성으로 추가 관리해야한다. 이후 offset
모디파이어를 추가한다.
VStack {
Circle()
.foregroundColor(.yellow)
.frame(width: 100, height: 100)
.offset(finalTranslation)
.offset(currentTranslation)
.gesture(dragGesture)
}
드래그 제스처 로직 구현시 onEnded
에 업데이트 하던 위치 상태값을 zero
로 초기화해주는 코드가 있는데, @GestureState
로 오프셋 상태값을 관리하면 초기화 코드는 작성하지 않아도 된다.
@GestureState
를 통한 위치 상태값 관리 코드는 아래와 같게 된다.
@GestureState private var currentTranslation: CGSize = .zero
@State private var finalTranslation: CGSize = .zero
var dragGesture: some Gesture {
DragGesture()
.updating($currentTranslation, body: { value, state, transaction in
state = value.translation
})
.onEnded { value in
var translation = finalTranslation
translation.width += value.translation.width
translation.height += value.translation.height
finalTranslation = translation
}
}
드래그 제스처의 updating
모디파이어에 @GestureState
바인딩을 전달하고 body
클로저에서 위치값을 초기화해준다.
transaction
은 애니메이션 컨텍스트를 관리할때 사용된다.
# Magnification Gesture
매그니피케이션 제스처는 핀치 제스처라고도 불린다.
struct MagnificationGesture_Tutorials: View {
@State private var finalScale: CGFloat = 1.0
@State private var latestScale: CGFloat = 1.0
var pinchGesture: some Gesture {
MagnificationGesture()
.onChanged({ scale in
let delta = scale / latestScale
latestScale = scale
finalScale *= delta
})
.onEnded { scale in
latestScale = 1
}
}
var body: some View {
Image("swiftui-logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
.scaleEffect(finalScale)
.gesture(pinchGesture)
}
}
드래그 제스처와 유사하게 마지막 스케일 값과 현재 스케일 값 두개를 관리해야 한다. 크기값의 변화량을 가지고 리사이징을 해야 로직대로 동작하기 때문에 delta
라는 변수를 새로 둔 것이다.
latestScale
이 1이어야 하는 이유는 확대 이후의 마지막 스케일값이 비율적으로 1이어야 하기 때문이다.
# Rotation Gesture
rotationEffect
모디파이어를 사용하여 회전 제스처를 구현한다. 마지막 각도 상태값을 유지해야 하는 방식이 여기서도 동일하다.
@State private var finalAngle: Angle = .degrees(0)
@State private var latestAngle: Angle = .degrees(0)
var rotateGesture: some Gesture {
RotationGesture()
.onChanged { angle in
let delta = angle - latestAngle
latestAngle = angle
finalAngle += delta
}
.onEnded { angle in
latestAngle = .degrees(0)
}
}
var body: some View {
Image("swiftui-logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
.rotationEffect(finalAngle)
.gesture(rotateGesture)
}
각도 변화량을 누적시키는 형태로 구현한다.
# Sequence Gesture
시퀀스 제스처를 사용하면 제스처에 순서를 부여할 수 있다. 선행제스처.sequenced(before: 후행제스처)
형태로 gesture
모디파이어에 전달하면 선행 제스처 클로저 뒤에 후행 제스처 인식이 이루어지도록 구성할 수 있다.
VStack {
Circle()
.gesture(longPress.gesture.sequenced(before: drag.gesture))
}
SequenceGesture
에는 onEnded
모디파이어도 있다. 후행 제스처까지 실행이 모두 끝나고 호출되는 클로저이다.
별도의 속성으로 꺼내어 직접 구현할때는 SequenceGesture
로 코드를 작성하면 된다.
var sequencedGesture: some Gesture {
SequenceGesture(longPress.gesture, drag.gesture)
.onEnded { _ in
longPress.activated = false
}
}
# Simultaneous Gesture
두 제스처를 동시에 인식하려면 SimultaneousGesture
를 사용한다.
Image("swiftui-logo")
.gesture(rotation.gesture.simultaneously(with: magnification.gesture))
simultaneously
모디파이어를 사용하여 두 제스처를 등록하거나, 속성으로 분리하는 경우 SimultaneousGesture
로 제스처 인스턴스를 추가한다.
var simulataneousGesture: some Gesture {
SimultaneousGesture(rotation.gesture, magnification.gesture)
}
# Exclusive Gesture
익스클루시브 제스처를 추가하면 특정 조건에 따라 파라미터에 전달되는 제스처를 무시할 수 있다.
VStack {
if currentGestureType == .rotation {
logo
.rotationEffect(rotation.finalAngle)
.scaleEffect(magnification.finalScale)
.gesture(rotation.gesture.exclusively(before: magnification.gesture))
} else {
logo
.rotationEffect(rotation.finalAngle)
.scaleEffect(magnification.finalScale)
.gesture(magnification.gesture.exclusively(before: rotation.gesture))
}
}
굳이 익스클루시브 제스처를 사용하지 않고도 분기처리를 통해 등록 제스처를 하나만 등록하는 것으로도 구현 가능하다.