# 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

롱 프레스 제스처는 길게 눌렀을때의 제스처를 다룬다. minimumDurationmaximumDistance를 통해 최소 탭 유지 시간과 최대 탭 유지 시간을 처리할 수 있다.

var longPressGesture: some Gesture {
    LongPressGesture()
        .onEnded { _ in
            showOriginal.toggle()
        }
}

onEndedonLongPressGesture에서 onPressingChanged 클로저에 전달되는 Bool 파라미터값은 탭 유지에 따른 값이 아니다. 롱 프레스 제스처가 인식된 순간 perform 클로저가 실행되고, 클로저 실행이 끝남과 동시에 onEnded가 호출되며 onPressingChanged 역시 false값이 바로 전달된다.

롱 프레스가 중단된 경우에도 동일하게 onEnded가 호출되고 onPressingChangedfalse가 전달된다.

# 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))
    }
}

굳이 익스클루시브 제스처를 사용하지 않고도 분기처리를 통해 등록 제스처를 하나만 등록하는 것으로도 구현 가능하다.