ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SwiftUI] 스크롤 방향에 반응하는 헤더 UI
    Swift/끄적끄적 2025. 8. 17. 19:49

    이번에 디자인 요구사항에 스크롤 방향에 따라 헤더를 보여주는 작업을 해야 했음!

     

    보통 글을 읽을 때 맨 위에 버튼을 누르기 위해서 다시 위로 가는 경우가 있는데

    편하게 스크롤만 올려주면 생기도록 해주는 거임!

    위 예시처럼 네이버 블로그 상단 탭(헤더)이 스크롤의 방향에 따라 보이거나 사라지거나

    이걸 구현해 보겠음!

     

    우선 스크롤 감지하는 코드부터 구현하겠음

     

    [전체 코드]

    struct HeaderDemo: View {
        @State private var directionText = "스크롤 방향"
        @State private var lastY: CGFloat = 0
    
        private let headerH: CGFloat = 56
        private let directionThreshold: CGFloat = 1.0
    
        var body: some View {
            ZStack(alignment: .top) {
                ScrollView {
                    GeometryReader { proxy in
                        let y = proxy.frame(in: .named("scroll")).minY
                        Color.clear
                            .frame(height: 0)
                            .onAppear { lastY = y }
                            .onChange(of: y) { oldY, newY in
                                let dy = newY - oldY
                                if dy < -directionThreshold {
                                    directionText = "⬇️ down"
                                } else if dy > directionThreshold {
                                    directionText = "⬆️ up"
                                }
                            }
                    }
                    .frame(height: 0)
    
                    VStack {
                        ForEach(0...30, id: \.self) { i in
                            RoundedRectangle(cornerRadius: 12)
                                .fill(Color.green)
                                .frame(height: 64)
                                .overlay(Text("Row \(i)"))
                                .padding(.horizontal)
                        }
                    }
                    .padding(.top, headerH)
                }
                .coordinateSpace(name: "scroll")
    
                HStack {
                    Text("테스트")
                        .font(.title2)
                    Spacer()
                }
                .padding(.horizontal, 20)
                .frame(height: headerH)
                .frame(maxWidth: .infinity)
                .background(.ultraThinMaterial)
    
                VStack {
                    HStack {
                        Spacer()
                        Text(directionText)
                            .padding()
                    }
                    Spacer()
                }
            }
        }
    }

     

    [코드 설명]

    ScrollView {
        GeometryReader { proxy in
            let y = proxy.frame(in: .named("scroll")).minY
            Color.clear
                .frame(height: 0)
                .onAppear { lastY = y }
                .onChange(of: y) { oldY, newY in
                    let dy = newY - oldY
                    if dy < -directionThreshold {
                        directionText = "⬇️ down"
                    } else if dy > directionThreshold {
                        directionText = "⬆️ up"
                    }
                }
        }
        .frame(height: 0)
    
        . . . (중략)
    }
    .coordinateSpace(name: "scroll")

     

    여기서 중요한 것은 GeometryReader라는 측정용 컨테이너임

     

    GeometryReader는 화면에 투명한 상자를 하나 놓고,

    그 상자가 현재 어디에 있고, 얼마나 큰지를 실시간으로 알려줄 수 있다고 보면 됨!

     

    기본적으로 이 상자는 자기에게 허용된 공간을 최대한 차지하려고 해서

    센서로 사용할 댄 'frame(height:0)'같은 제한을 걸어서 쓸 수 있음

     

    이 코드에서는 proxy로 GeometryReader의 측정 결과를 받아 사용되는데,

    proxy.frame(in:)은 '어떤 기준 지도'에서 이 상자의 위치/크기를 알려달라는 뜻임!

     

    .local, .global 같은 지도가 있는데 여기선 .name("scroll")을 사용해서

    하나의 지도만 측정할 수 있도록 한 거임!

    스크롤의 값을 안정적으로 받기 위해. coordinateSpace(name:)을 사용해서

    지도의 기준을 지정하는 코드와 함께 씀!

    1.6666666666666643 1.3333333333333357
    1.3333333333333357 1.0
    1.0 0.6666666666666643
    0.6666666666666643 0.0
    0.0 -0.3333333333333357
    -0.3333333333333357 -9.333333333333336
    -9.333333333333336 -19.333333333333336
    -19.333333333333336 -28.0
    -28.0 -29.666666666666664
    -29.666666666666664 -30.666666666666668
    -30.666666666666668 -36.66666666666667

    실제로 스크롤을 움직이면 oldY, newY의 값이 이렇게 변화되는 걸 확인할 수 있음 (print 적용)

    이 값을 이용해서 두 값의 차이가(newY - oldY)

    음수로 가고 있으면 아래 스크롤, 양수면 위로 스크롤로 판단이 되는 것!

     

    이제 스크롤이 올라가고 내려가는 것을 알았으니 헤더 부분을 나타났다가 사라지는 동작을 구현해 보겠음!

     

    [전체 코드]

    struct HeaderDemo: View {
        @State private var directionText = "스크롤 방향"
        @State private var lastY: CGFloat = 0
        @State private var headerHidden = false
    
        private let headerH: CGFloat = 56
        private let nearTopThreshold: CGFloat = -50   // 맨 위 근처면 항상 표시
        private let directionThreshold: CGFloat = 1.0
    
        var body: some View {
            ZStack(alignment: .top) {
                ScrollView {
                    GeometryReader { proxy in
                        let y = proxy.frame(in: .named("scroll")).minY
                        Color.clear
                            .frame(height: 0)
                            .onAppear { lastY = y }
                            .onChange(of: y) { oldY, newY in
                                let dy = newY - oldY
                                if newY > nearTopThreshold {
                                    headerHidden = false
                                    directionText = "스크롤 방향"
                                } else if dy < -directionThreshold {
                                    headerHidden = true    // 내려가는 중
                                    directionText = "⬇️ down"
                                } else if dy > directionThreshold {
                                    headerHidden = false   // 올라가는 중
                                    directionText = "⬆️ up"
                                }
                            }
                    }
                    .frame(height: 0)
    
                    VStack {
                        ForEach(0...30, id: \.self) { i in
                            RoundedRectangle(cornerRadius: 12)
                                .fill(Color.green)
                                .frame(height: 64)
                                .overlay(Text("Row \(i)"))
                                .padding(.horizontal)
                        }
                    }
                    .padding(.top, headerH)
                }
                .coordinateSpace(name: "scroll")
    
                HStack {
                    Text("테스트")
                        .font(.title2)
                    Spacer()
                }
                .padding(.horizontal, 20)
                .frame(height: headerH)
                .frame(maxWidth: .infinity)
                .background(.ultraThinMaterial)
                .offset(y: headerHidden ? -(headerH + 8) : 0) // 위로 숨김
                .opacity(headerHidden ? 0 : 1)
                .animation(.easeInOut(duration: 0.2), value: headerHidden)
    
                VStack {
                    HStack {
                        Spacer()
                        Text(directionText)
                            .padding()
                    }
                    Spacer()
                }
            }
            .background(Color(.systemBackground))
        }
    }

    [코드 설명]

    .onChange(of: y) { oldY, newY in
        let dy = newY - oldY
        if newY > nearTopThreshold {
            headerHidden = false
            directionText = "스크롤 방향"
        } else if dy < -directionThreshold {
            headerHidden = true    // 내려가는 중
            directionText = "⬇️ down"
        } else if dy > directionThreshold {
            headerHidden = false   // 올라가는 중
            directionText = "⬆️ up"
        }
    }

    간단하게 onChange 부분에서 headerHidden이라는 변수를 만들어 Bool 값을 처리해 줬음

    (nearTopThreshold는 맨 위에서도 미세한 스크롤에 사라지는 것을 방지하기 위해 특정 영역을 설정해 준 거임)

    HStack {
        Text("테스트")
            .font(.title2)
        Spacer()
    }
    .padding(.horizontal, 20)
    .frame(height: headerH)
    .frame(maxWidth: .infinity)
    .background(.ultraThinMaterial)
    .offset(y: headerHidden ? -(headerH + 8) : 0) // 위로 숨김
    .opacity(headerHidden ? 0 : 1)
    .animation(.easeInOut(duration: 0.2), value: headerHidden)

    그럼 hearderHidden의 Bool 값에 따라 나타나고 사라지는 것이 가능해짐!

    애니메이션이나, 어떻게 사라지게 할 건지는 취향차이 ㅎ

    'Swift > 끄적끄적' 카테고리의 다른 글

    [SwiftUI] @Observable과 @Bindable에 대하여  (0) 2025.10.13
    [SwiftUI] 더보기 영역 구현  (0) 2025.09.22
    [SwiftUI] confirmationDialog 활용법  (1) 2025.09.04
    CocoaPod 설치 과정  (3) 2024.10.24
    [Swift] ViewController 화면전환  (2) 2023.04.22

    댓글

Designed by Tistory.