DragGesture를 사용하면 드래그 기능을 구현할 수 있습니다

 

DragGesture를 사용하기 전에 알아둘 것

DragGesture를 사용하기 전에 미리 알아둬야 하는 것들이 있습니다.

 

첫번째로 offset 입니다.

 

offset 을 사용한다면 x축과 y축을 통해 현재 위치가 뷰의 어디에 위치하는지 알 수 있습니다.

https://apple-document.tistory.com/222

 

아래 코드처럼 offset을 사용하게 된다면 컨텐츠의 위치를 이동시켜 원하는 뷰를 볼 수 있도록 해줍니다.

GeometryReader { geometry in
    VStack {
        HStack(spacing: 0) {
            ForEach(0..<3) { index in
                getViewFor(index: index)
                    .frame(width: geometry.size.width, height: geometry.size.height)
            }
        }
        .frame(width: geometry.size.width * 3, height: geometry.size.height, alignment: .leading)
        .offset(x: -CGFloat(selectedTab) * geometry.size.width + verticalOffset)

 

offset 변수의 값을 드래그 했을 때 변화시켜 다이나믹하게 다음에 이동할 뷰를 미리 보여줄 수 있습니다.

.offset(x: -CGFloat(selectedTab) * geometry.size.width + offset)

 

offset을 적용하여 뷰의 위치를 원하는 곳으로 이동할 수 있게 됩니다.

GeometryReader

offset을 알게 되었다면 이제 GeometryReader 입니다. 화면의 뷰를 드래그하여 원하는 뷰로 이동하기 위해서는 GeometryReader를 사용해야 합니다. GemetryReader를 사용하게 된다면 부모 레이아웃의 크기를 알아내서 컨텐츠의 위치 및 사이즈를 조절할 수 있습니다.

 

아래 코드를 보면서 먼저 어떻게 사용되는지 설명하겠습니다.

GeometryReader { geometry in
    HStack(spacing: 0) {
        ForEach(0..<tabs.count, id: \.self) { index in
            getViewFor(index: index)
                .frame(width: geometry.size.width, height: geometry.size.height)
        }
    }
    .offset(x: -CGFloat(selectedTab) * geometry.size.width + verticalOffset)
    .animation(.easeInOut, value: selectedTab)
    .animation(.easeInOut, value: verticalOffset)
    .gesture(
        DragGesture()
            .onChanged { value in
                verticalOffset = value.translation.width
            }
            .onEnded { value in
                let threshold = geometry.size.width / 2
                if -value.predictedEndTranslation.width > threshold && selectedTab < tabs.count - 1 {
                    selectedTab += 1
                } else if value.predictedEndTranslation.width > threshold && selectedTab > 0 {
                    selectedTab -= 1
                }
                verticalOffset = 0
            }
    )
}

 

GeometryReader { geometry in
            VStack {

 

 

GeometryReader 는 VStack 으로 구성된 컨텐츠들을 모두 클로저 안에 담아내고 있습니다. 그리고 GeometryReader 는 클로저에 값을 반환하는데 이를 geometry 로 이름을 지었습니다.

HStack(spacing: 0) {
    ForEach(0..<3) { index in
        getViewFor(index: index)
            .frame(width: geometry.size.width, height: geometry.size.height)
    }
}

 

 

해당 geometry 를 사용하여 GeometryReader 에서 가장 최상위 뷰인 VStack의 크기(높이,너비)에 접근할 수 있습니다.

접근한 이후 화면에 나타낼 뷰의 크기를 framewidthheight로 지정합니다.

.offset(x: -CGFloat(selectedTab) * geometry.size.width + offset)

 

geometry는 offset에도 적용됩니다. HStack의 너비는 iPhone 15 Pro 기준으로 393인 것으로 확인하였습니다.

 

이후 중요한 gesture 부분입니다.

.gesture(
    DragGesture()
        .onChanged { value in
            verticalOffset = value.translation.width
        }
        .onEnded { value in
            let threshold = geometry.size.width / 2
            if -value.predictedEndTranslation.width > threshold && selectedTab < tabs.count - 1 {
                selectedTab += 1
            } else if value.predictedEndTranslation.width > threshold && selectedTab > 0 {
                selectedTab -= 1
            }
            verticalOffset = 0
        }
)

 

gesture 에는 화면을 드래그 하는 것을 만들기 위해서 DragGesture()를 사용합니다.

.onChanged { value in
    verticalOffset = value.translation.width
}

onChanged 에서는 화면을 드래그하기 시작했을 때 verticalOffset의 값을 translation.width 를 사용하여 현재 드래그된 너비의 거리를 저장합니다.

 

translation 이란

translation 은 현재 드래그된 거리를 나타냅니다. 이는 제스처가 시작된 지점에서 현재 위치까지의 이동 거리를 나타냅니다.

translation 에는 width 와 height 등이 존재하여 원하는 값을 사용하여 드래그되는 위치를 추적할 수 있습니다.

즉 translation.width 는 가로로 얼만큼 드래그 되었는지를 나타내는 값을 의미하고

    translation.height는 세로로 얼만큼 드래그 되었는지를 나타내는 값을 의미합니다.

 

.onEnded { value in
    let threshold = geometry.size.width / 2
    if -value.predictedEndTranslation.width > threshold && selectedTab < tabs.count - 1 {
        selectedTab += 1
    } else if value.predictedEndTranslation.width > threshold && selectedTab > 0 {
        selectedTab -= 1
    }
    verticalOffset = 0
}

onEnded 에서는 화면 드래그가 끝난 시점에서 특정 동작을 수행하는 코드입니다.

 

threshold 즉 임곗값을 geometry의 너비의 절반으로 설정합니다. (이를 통해 화면을 절반만큼 드래그 했을 때 뷰를 옮길 수 있습니다)

predictedEndTranslation.width 를 사용하여 제스처가 끝날 시점에 위치의 값이 임곗값보다 크면서 동시에 selectedTab 의 값이 뷰의 개수를 넘지 않는다면 뷰를 이동하도록 selectedTab 의 값을 1과 -1을 증가시킵니다.

  • 화면을 오른쪽에서 왼쪽으로 드래그하면 predicatedEndTranslation.width 는 음의 값으로 가까워집니다.
    • predicatedEndTranslation.width의 값이 -196.5 보다 값이 작아진다면 화면을 오른쪽 뷰로 넘어가게 됩니다 (iPhone15 Pro 기준)
  • 화면을 왼쪽에서 오른쪽으로 드래그하면 predicatedEndTranslation.width 는 양의 값으로 가까워집니다.
    • predicatedEndTranslation.width의 값이 196.5 보다 값이 커지게 된다면 화면을 왼쪽 뷰로 넘어가게 됩니다 (iPhone15 Pro 기준)

 

 

 

결과물

 

전체코드

import SwiftUI

struct MainContainer: View {
    
    @State private var selectedTab = 1
    @State private var verticalOffset: CGFloat = 0
    private let tabs: [String] = ["자산", "소비﹒수입", "연말정산"]
    
    var body: some View {
        VStack {
            HStack {
                ForEach(Array(tabs.enumerated()), id: \.offset) { obj in
                    Button {
                        selectedTab = obj.offset
                        print(selectedTab)
                    } label: {
                        Text(obj.element)
                            .foregroundStyle(obj.offset == selectedTab ? Color.accentColor : Color.gray)
                    }
                    .frame(maxWidth: .infinity)
                }
            }
            
            Divider()
            
            GeometryReader { geometry in
                HStack(spacing: 0) {
                    ForEach(0..<tabs.count, id: \.self) { index in
                        getViewFor(index: index)
                            .frame(width: geometry.size.width, height: geometry.size.height)
                    }
                }
                .offset(x: -CGFloat(selectedTab) * geometry.size.width + verticalOffset)
                .onAppear {
                    print(geometry.size.width)
                }
                .animation(.easeInOut, value: selectedTab)
                .animation(.easeInOut, value: verticalOffset)
                .gesture(
                    DragGesture()
                        .onChanged { value in
                            verticalOffset = value.translation.width
                        }
                        .onEnded { value in
                            let threshold = geometry.size.width / 2
                            if -value.predictedEndTranslation.width > threshold && selectedTab < tabs.count - 1 {
                                selectedTab += 1
                            } else if value.predictedEndTranslation.width > threshold && selectedTab > 0 {
                                selectedTab -= 1
                            }
                            verticalOffset = 0
                        }
                )
            }
        }
    }
    
    @ViewBuilder
    private func getViewFor(index: Int) -> some View {
        switch index {
        case 0:
            Text("First View")
        case 1:
            Text("Second View")
        case 2:
            Text("Third View")
        default:
            EmptyView()
        }
    }
}

#Preview {
    MainContainer()
}

 

ytw_developer