onScrollGeometryChange

2026. 2. 9. 21:26·iOS/SwiftUI
반응형

👋 들어가기 전

 

사이드에서, pullToRefresh와 Pagination Loading을 위해, 조금 old한 방법으로 구현을 했느네,

 

iOS18.0 이상부터 사용가능한 좋은 API가 있어서 그걸로 대체해보려가한다..


우리 프로젝트는 18.0이상이니깐..


😂 기존 코드와 문제점

 

1️⃣ GeometryReader + PreferenceKey +coordinateSpace

  • 부가적인 코드가 너무 많음
  • 코드 흐름 파악이 힘듬

 

2️⃣ 타이밍을 운에 걸어야함

  • ScrollView 높이를 얻기 위해 GeometryReader 내부의 proxy.size.height을 onChange에서 계속 받음

 

기능상 큰 문제는 없지만, 편법을 하기위한 트레이드 오프 생각보다 작지는 않아보인다.

오늘의 주인공으로 해당 문제를 한번 해결해보자.

 

결국 나는 scrollView의 높이와, Content 높이를 얻으면 된다.


🦸 구세주 등장

 

🧩 역할

스크롤 뷰 geometry 정보 변화를 실시간으로 추적하여 전달

 

역할 자체가 그냥 내꺼다..

 

📌 구성 요소

 

nonisolated
func onScrollGeometryChange<T>(
    for type: T.Type,
    of transform: @escaping (ScrollGeometry) -> T,
    action: @escaping (T, T) -> Void
) -> some View where T : Equatable

 

  • type: 관찰하고자 하는 타입
  • transform: action쪽에 넘겨주기 전에 데이터를 가공하는 역할, ScrollGeometry는 아래의 정보를 제공
    • contentOffset: 현재 스크롤 위치
    • contentSize: 전체 콘텐츠 길이
    • containerSize: 화면에 보이는 스크롤 뷰의 크기
    • contentInsets: 상하좌우 여백
  • action: transform에서 전달 받은 값을 oldValue,newValue 형태로 전달.

⏳ 기존 코드

import SwiftUI

public struct OffsetKey: PreferenceKey {
  public static var defaultValue: CGFloat = .zero
  public static func reduce(
    value: inout CGFloat,
    nextValue: () -> CGFloat
  ) {
    value = nextValue()
  }
}

public extension View {

  /// 해당 좌표계에서, 특정 edge에 해당되는 값을 얻음
  /// - Parameters:
  ///   - coordinateSpace: 좌표게 네임스페이스
  ///   - edge: CGRectEdge
  ///   - offset: 핸들러 함수
  /// - Returns: edge값
  @ViewBuilder
  func offset(
    coordinateSpace: String,
    edge: CGRectEdge,
    offset: @escaping ((CGFloat) -> Void)
  ) -> some View {
    self
      .overlay {
        GeometryReader { proxy in
          let frame = proxy.frame(in: .named(coordinateSpace))

          let value: CGFloat = switch edge {
          case .minXEdge: frame.minX
          case .maxXEdge: frame.maxX
          case .minYEdge: frame.minY
          case .maxYEdge: frame.maxY
          }

          Color.clear
            .preference(key: OffsetKey.self, value: value)
            .onPreferenceChange(OffsetKey.self) {
              offset($0)
            }
        }
      }
  }
}

 

@Observable
private class RefreshableViewModel: NSObject, UIGestureRecognizerDelegate {
  /// 새로고침 트리거 임계치를 넘었는지 여부
  var isEligible: Bool = false

  /// 실제 새로고침 동작이 진행 중인지 여부
  var isRefreshing: Bool = false

  /// 사용자가 아래로 당긴 총 스크롤 거리
  var scrollOffset: CGFloat = .zero

  /// 콘텐츠 자체의 현재 위치 오프셋
  var contentOffset: CGFloat = .zero

  /// 0~1 사이 진행도 (인디케이터 표시용)
  var progress: CGFloat = .zero

  /// 아래 도달했는 지를 추적하는 변수
  var maxYoffset: CGFloat = .zero

  /// 스크롤뷰 Height
  var scrollViewHeight: CGFloat = .zero

  /// 페이지 네이션을 위한 로딩
  var isLoadingNextPage: Bool = false

  /// 모든 상태 초기화 (refresh 종료 시 호출)
  func reset() {
    isEligible = false
    isRefreshing = false
    scrollOffset = .zero
    contentOffset = .zero
    progress = .zero
  }

  /// 전역 Pan 제스처 추가 → 스크롤 release 시점 감지용
  func addGesture() {
    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onGestureChange(gesture:)))
    panGesture.delegate = self

    // 루트 뷰에 제스처를 붙여서 ScrollView와 동시에 감지
    UIApplication.shared.rootViewController?.view.addGestureRecognizer(panGesture)
  }

  /// 뷰 사라질 때 제스처 제거
  func removeGesture() {
    UIApplication.shared.rootViewController?.view.gestureRecognizers?.removeAll()
  }

  /// 사용자가 손을 뗐는지 감지
  @objc func onGestureChange(gesture: UIPanGestureRecognizer) {
    if gesture.state == .cancelled || gesture.state == .ended {
      // 아직 refresh 중이 아니고, 충분히 당겨졌다면 refresh 가능 상태로 전환
      if !isRefreshing {
        isEligible = scrollOffset > indicatorViewHeight
      }
    }
  }

  /// ScrollView의 pan 제스처와 동시에 인식 허용
  func gestureRecognizer(
    _ gestureRecognizer: UIGestureRecognizer,
    shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
  ) -> Bool {
    return true
  }
}

public struct RefreshableView<
  Content: View,
  IndicatorView: View
>: View {
  private let showBottomIndicator: Bool
  private let content: () -> Content
  private let topIndicatorView: () -> IndicatorView
  private let onRefresh: () async -> Void
  private let onLoadMore: (() async -> Void)?

  @State private var viewModel = RefreshableViewModel()

  /// pullToRefresh + 페이지네이션 로딩을 지원하는 컨테이너 뷰
  /// - Parameters:
  ///   - showBottomIndicator: 바텀 페이지네이션용 인디케이터 사용 여부
  ///   - content: 스크롤뷰안에 넣을 내용 관련 뷰
  ///   - topIndicatorView: pullToRefresh에 표현 될 뷰
  ///   - onRefresh: pullToRefresh 시작 이벤트 전달
  ///   - onLoadMore: 페이지네이션 로딩 이벤트 전달
  /// - Note: 하단 페이지네이션 인디케이터는 아 커스텀 할 지 모름
  public init(
    showBottomIndicator: Bool,
    @ViewBuilder content: @escaping () -> Content,
    @ViewBuilder topIndicatorView: @escaping () -> IndicatorView = { TPLIndicatorView() },
    onRefresh: @escaping () async -> Void,
    onLoadMore: (() async -> Void)? = nil
  ) {
    self.showBottomIndicator = showBottomIndicator
    self.content = content
    self.topIndicatorView = topIndicatorView
    self.onRefresh = onRefresh
    self.onLoadMore = onLoadMore
  }

  public var body: some View {
    GeometryReader { proxy in
      ScrollView(.vertical, showsIndicators: true) {
        VStack(spacing: .zero) {
          ZStack {
            topIndicatorView()
              .scaleEffect(0.6 + (viewModel.progress * 0.4)) // 당길수록 커짐
          }
          .frame(height: indicatorViewHeight * viewModel.progress) // 당긴 비율만큼 높이 증가
          .opacity(viewModel.progress) // 점점 나타남
          .offset(y: viewModel
            .isEligible ? -max(0, viewModel.contentOffset) :
            -max(0, viewModel.scrollOffset)
          )

          content()

          if viewModel.isLoadingNextPage {
            TPLIndicatorView()
          }
        }
        .offset(coordinateSpace: "SCROLL", edge: .minYEdge) { offset in
          viewModel.contentOffset = offset

          if !viewModel.isEligible {
            let progress = (offset / indicatorViewHeight).clamped(to: 0 ... 1)
            viewModel.scrollOffset = offset
            viewModel.progress = progress
          }

          if viewModel.isEligible && !viewModel.isRefreshing {
            viewModel.isRefreshing = true
          }
        }
        .offset(coordinateSpace: "SCROLL", edge: .maxYEdge) { offset in
          if showBottomIndicator && !viewModel.isLoadingNextPage {
            viewModel.maxYoffset = offset
            checkIfReachedBottom()
          }
        }
      }
      .coordinateSpace(name: "SCROLL")
      .onChange(of: proxy.size.height) { _, value in
        viewModel.scrollViewHeight = value
      }
    }
    .onAppear(perform: viewModel.addGesture)
    .onDisappear(perform: viewModel.removeGesture)
    .onChange(of: viewModel.isRefreshing) { _, value in
      if value {
        Task {
          await onRefresh()

          withAnimation(.easeIn) {
            viewModel.reset()
          }
        }
      }
    }
    .onChange(of: viewModel.isLoadingNextPage) { _, value in
      if value {
        Task {
          await onLoadMore?()

          viewModel.isLoadingNextPage = false
        }
      }
    }
  }

  private func checkIfReachedBottom() {
    let threshold: CGFloat = 50
    if viewModel.maxYoffset - threshold <= viewModel.scrollViewHeight {
      viewModel.isLoadingNextPage = true
    }
  }
}

🏋️ 개선 코드

import SwiftUI

private let indicatorViewHeight: CGFloat = 20 + Spacing.sp600 * 2

@Observable
private class RefreshableViewModel: NSObject, UIGestureRecognizerDelegate {
  /// 새로고침 트리거 임계치를 넘었는지 여부
  var isEligible: Bool = false

  /// 실제 새로고침 동작이 진행 중인지 여부
  var isRefreshing: Bool = false

  /// 사용자가 아래로 당긴 총 스크롤 거리
  var scrollOffset: CGFloat = .zero

  /// 콘텐츠 자체의 현재 위치 오프셋
  var contentOffset: CGFloat = .zero

  /// 0~1 사이 진행도 (인디케이터 표시용)
  var progress: CGFloat = .zero

  /// 페이지 네이션을 위한 로딩
  var isLoadingNextPage: Bool = false

  /// 모든 상태 초기화 (refresh 종료 시 호출)
  func reset() {
    isEligible = false
    isRefreshing = false
    scrollOffset = .zero
    contentOffset = .zero
    progress = .zero
  }

  /// 전역 Pan 제스처 추가 → 스크롤 release 시점 감지용
  func addGesture() {
    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onGestureChange(gesture:)))
    panGesture.delegate = self

    // 루트 뷰에 제스처를 붙여서 ScrollView와 동시에 감지
    UIApplication.shared.rootViewController?.view.addGestureRecognizer(panGesture)
  }

  /// 뷰 사라질 때 제스처 제거
  func removeGesture() {
    UIApplication.shared.rootViewController?.view.gestureRecognizers?.removeAll()
  }

  /// 사용자가 손을 뗐는지 감지
  @objc func onGestureChange(gesture: UIPanGestureRecognizer) {
    if gesture.state == .cancelled || gesture.state == .ended {
      // 아직 refresh 중이 아니고, 충분히 당겨졌다면 refresh 가능 상태로 전환
      if !isRefreshing {
        isEligible = scrollOffset > indicatorViewHeight
      }
    }
  }

  /// ScrollView의 pan 제스처와 동시에 인식 허용
  func gestureRecognizer(
    _ gestureRecognizer: UIGestureRecognizer,
    shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
  ) -> Bool {
    return true
  }
}

public struct RefreshableView<
  Content: View,
  IndicatorView: View
>: View {
  private let showBottomIndicator: Bool
  private let content: () -> Content
  private let topIndicatorView: () -> IndicatorView
  private let onRefresh: () async -> Void
  private let onLoadMore: (() async -> Void)?

  @State private var viewModel = RefreshableViewModel()

  /// pullToRefresh + 페이지네이션 로딩을 지원하는 컨테이너 뷰
  /// - Parameters:
  ///   - showBottomIndicator: 바텀 페이지네이션용 인디케이터 사용 여부
  ///   - content: 스크롤뷰안에 넣을 내용 관련 뷰
  ///   - topIndicatorView: pullToRefresh에 표현 될 뷰
  ///   - onRefresh: pullToRefresh 시작 이벤트 전달
  ///   - onLoadMore: 페이지네이션 로딩 이벤트 전달
  /// - Note: 하단 페이지네이션 인디케이터는 아 커스텀 할 지 모름
  public init(
    showBottomIndicator: Bool,
    @ViewBuilder content: @escaping () -> Content,
    @ViewBuilder topIndicatorView: @escaping () -> IndicatorView = { TPLIndicatorView() },
    onRefresh: @escaping () async -> Void,
    onLoadMore: (() async -> Void)? = nil
  ) {
    self.showBottomIndicator = showBottomIndicator
    self.content = content
    self.topIndicatorView = topIndicatorView
    self.onRefresh = onRefresh
    self.onLoadMore = onLoadMore
  }

  public var body: some View {
    ScrollView(.vertical, showsIndicators: true) {
      VStack(spacing: .zero) {
        ZStack {
          topIndicatorView()
            .scaleEffect(0.6 + (viewModel.progress * 0.4)) // 당길수록 커짐
        }
        .frame(height: indicatorViewHeight * viewModel.progress) // 당긴 비율만큼 높이 증가
        .opacity(viewModel.progress) // 점점 나타남
        .offset(y: viewModel
          .isEligible ? -max(0, viewModel.contentOffset) :
          -max(0, viewModel.scrollOffset)
        )

        content()

        if viewModel.isLoadingNextPage {
          TPLIndicatorView()
        }
      }
    }
    // 2. 상단 오프셋 및 Progress 감시 (기본 offset 연산 대체)
    .onScrollGeometryChange(for: CGFloat.self) { geo in
      return -geo.contentOffset.y - geo.contentInsets.top // 현재 스크롤 Y 위치(-를 붙혀 양수로) - inset을 빼줘야 progress와 매피이됨
    } action: { _, offsetY in
      let scrollY = offsetY // 당길 때 양수가 되도록 반전
      viewModel.contentOffset = scrollY
      if !viewModel.isEligible {
        let progress = (scrollY / indicatorViewHeight).clamped(to: 0 ... 1)
        viewModel.scrollOffset = scrollY
        viewModel.progress = progress
        print("🍯 \(scrollY) \(progress)")
      }

      // 드래그 중 임계치를 넘었을 때 처리 (제스처와 연동)
      if viewModel.isEligible && !viewModel.isRefreshing {
        viewModel.isRefreshing = true
      }
    }
    // 3. 바닥 도달 감시 (checkIfReachedBottom 및 maxYoffset 대체)
    .onScrollGeometryChange(for: Bool.self) { geo in
      // 전체 콘텐츠 높이
      let totalContentHeight = geo.contentSize.height
      // 현재 보이는 영역 하단의 위치 (오프셋 + 화면 높이)
      let currentBottom = geo.contentOffset.y + geo.containerSize.height
      let threshold: CGFloat = 50

      return currentBottom >= (totalContentHeight - threshold)
    } action: { wasBottom, isBottom in
      // 바닥에 도달한 순간(false -> true)에만 실행
      if isBottom && !wasBottom {
        if showBottomIndicator && !viewModel.isLoadingNextPage && onLoadMore != nil {
          viewModel.isLoadingNextPage = true
        }
      }
    }
    .onAppear(perform: viewModel.addGesture)
    .onDisappear(perform: viewModel.removeGesture)
    .onChange(of: viewModel.isRefreshing) { _, value in
      if value {
        Task {
          await onRefresh()

          withAnimation(.easeIn) {
            viewModel.reset()
          }
        }
      }
    }
    .onChange(of: viewModel.isLoadingNextPage) { _, value in
      if value {
        Task {
          await onLoadMore?()

          viewModel.isLoadingNextPage = false
        }
      }
    }
  }
}

#Preview {
  @Previewable @State var items: [Int] = Array(0 ..< 100)

  RefreshableView(
    showBottomIndicator: true,
    content: {
      VStack {
        ForEach(items, id: \.self) {
          Text("\($0)")
        }
      }
    },
    onRefresh: {
      try? await Task.sleep(nanoseconds: 3_000_000_000)
    },
    onLoadMore: {
      try? await Task.sleep(nanoseconds: 3_000_000_000)
      items.append(contentsOf: Array(100 ..< 120))
    }
  )
}

😀 소감 및 마무리

 

너무 깔끔해졌다...


출처

https://developer.apple.com/documentation/swiftui/view/onscrollgeometrychange(for:of:action:)

반응형

'iOS > SwiftUI' 카테고리의 다른 글

[WWDC2024] Create custom visual effects with SwiftUI  (0) 2025.11.19
PhotoPicker  (0) 2025.11.02
[WWDC 2023] Wind your way through advanced animations in SwiftUI  (0) 2025.10.30
[WWDC2023] Explore SwiftUI animation  (0) 2025.10.29
커스텀 DynamicScrollTabVIew 만들기  (0) 2025.10.26
'iOS/SwiftUI' 카테고리의 다른 글
  • [WWDC2024] Create custom visual effects with SwiftUI
  • PhotoPicker
  • [WWDC 2023] Wind your way through advanced animations in SwiftUI
  • [WWDC2023] Explore SwiftUI animation
Hamp
Hamp
남들에게 보여주기 부끄러운 잡다한 글을 적어 나가는 자칭 기술 블로그입니다.
  • Hamp
    Hamp의 분리수거함
    Hamp
  • 전체
    오늘
    어제
    • 분류 전체보기 (325) N
      • CS (30)
        • 객체지향 (2)
        • Network (7)
        • OS (6)
        • 자료구조 (1)
        • LiveStreaming (3)
        • 이미지 (1)
        • 잡다한 질문 정리 (0)
        • Hardware (2)
        • 이론 (6)
        • 컴퓨터 그래픽스 (0)
      • Firebase (3)
      • Programing Langauge (41)
        • swift (34)
        • python (6)
        • Kotlin (1)
      • iOS (133) N
        • UIKit (37)
        • Combine (1)
        • SwiftUI (33) N
        • Framework (7)
        • Swift Concurrency (22)
        • Tuist (6)
        • Setting (11)
        • Modularization (1)
        • Instruments (6)
      • PS (59)
        • 프로그래머스 (24)
        • 백준 (13)
        • LeetCode (19)
        • 알고리즘 (3)
      • Git (18)
        • 명령어 (4)
        • 이론 (2)
        • hooks (1)
        • config (2)
        • action (7)
      • Shell Script (2)
      • Linux (6)
        • 명령어 (5)
      • Spring (20)
        • 어노테이션 (6)
        • 튜토리얼 (13)
      • CI-CD (4)
      • Android (0)
        • Jetpack Compose (0)
      • AI (9)
        • 이론 (9)
        • MCP (0)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Swift
    dp
    Tuist
    dispatch
    투포인터
    IOS
    CS
    AVFoundation
    Spring
    boostcamp
    프로그래머스
    GIT
    백준
    lifecycle
    concurrency
    UIKit
    protocol
    dfs
    SwiftUI
    property
  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.0
Hamp
onScrollGeometryChange
상단으로

티스토리툴바