SwiftUI

SwiftUI에서 ScrollOffset을 감지하는 ScrollView 구현하기

GREEN.1229 2024. 4. 9. 18:01

안녕하세요. 그린입니다 🍏

이번 포스팅에서는 SwiftUI로 커스텀한 ScrollOffset 값을 감지하여 사용하는 ScrollView 구현을 해보겠습니다 🙋🏻

 

우선 SwiftUI에서 기본적인 ScrollView 컴포넌트만으로는 스크롤 된 수치인 ScrollOffset을 감지할 수가 없습니다.

이에 background로 GeometryReader를 활용해서 커스텀하게 구현해줘야해요.

그래서 이번 포스팅에서 커스텀한 scrollOffset을 감지하는 OffsetObservableScrollView를 만들어 보겠습니다!

 


OffsetObservableScrollView

우선, 구현부터 살펴보시죠!

 

struct OffsetObservableScrollView<Content: View>: View {
  var axes: Axis.Set = .vertical
  var showsIndicators: Bool = true
  
  @Binding var scrollOffset: CGPoint
  @ViewBuilder var content: (ScrollViewProxy) -> Content
  
  @Namespace var coordinateSpaceName: Namespace.ID
  
  init(
    _ axes: Axis.Set = .vertical,
    showsIndicators: Bool = true,
    scrollOffset: Binding<CGPoint>,
    @ViewBuilder content: @escaping (ScrollViewProxy) -> Content
  ) {
    self.axes = axes
    self.showsIndicators = showsIndicators
    self._scrollOffset = scrollOffset
    self.content = content
  }
  
  var body: some View {
    ScrollView(axes, showsIndicators: showsIndicators) {
      ScrollViewReader { scrollViewProxy in
        content(scrollViewProxy)
          .background {
            GeometryReader { geometryProxy in
              Color.clear
                .preference(
                  key: ScrollOffsetPreferenceKey.self,
                  value: CGPoint(
                    x: -geometryProxy.frame(in: .named(coordinateSpaceName)).minX,
                    y: -geometryProxy.frame(in: .named(coordinateSpaceName)).minY
                  )
                )
            }
          }
      }
    }
    .coordinateSpace(name: coordinateSpaceName)
    .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
      scrollOffset = value
    }
  }
  
  private struct ScrollOffsetPreferenceKey: SwiftUI.PreferenceKey {
    static var defaultValue: CGPoint { .zero }
    
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
      value.x += nextValue().x
      value.y += nextValue().y
    }
  }
}

 

보시면 기본 ScrollView 컴포넌트를 사용하면서 ScrollViewReader로 추후 있을 수 있는 scrollProxy를 통해 이동을 구현하기 위한 전초 작업 코드를 작성합니다.

(현재 코드에서는 사용하진 않아요~)

 

그리고, 클로저 인자로 content를 받아 해당 background 모디파이어를 이용하여 본격적으로 scrollOffset 탐지를 위해 밑에 깔리는 배경을 안보이게 만들어줍니다.

 

흔히 SwiftUI에서 이런 코드를 작성하기 위해선 Color.clear로 백그라운드를 깔아 보이지 않게 하지만, 전체 크기를 감지할 수 있도록 하는것이죠.

 

그리고, ScrollOffsetPreferenceKey를 구현하여, 실제 scrollOffset의 값을 변경해주도록 하는데요.

깔린 배경에서 preference를 이용해 key값을 가져오고 value를 지정해줍니다.

 

마지막으로, scrollView에다 onPreferenceChange를 이용해, 해당 키값이 변할때마다 scrollOffset의 값이 변하도록 구현해줍니다.

 

이제 커스텀한 OffsetObservableScrollView를 만들었으니 사용해볼까요?


OffsetObservableScrollView 사용하기

struct ContentView: View {
  let evenIndexColors: [Color] = [.green, .yellow, .red, .blue]
  let oddIndexColors: [Color] = [.red, .brown, .blue, .orange, .gray]
  @State var scrollOffset: CGPoint = .zero
  
  var body: some View {
    OffsetObservableScrollView(.horizontal, scrollOffset: $scrollOffset) { _ in
      VStack(alignment: .leading, spacing: 30) {
        SubView(colors: evenIndexColors)
        
        SubView(colors: oddIndexColors)
      }
    }
    .padding()
  }
}

 

사용은 아주 간단해요.

스크롤뷰에 담길 content를 넣어주면 끝입니다.

그리고 설정 시, 상위 뷰에서 scrollOffset 상태 변수를 가지고 넘겨주는것이죠.

그럼 이제, 상위에서도 scrollOffset을 판단해 원하는 작업에 사용될 수 있습니다.

 

어떻게 유의미하게 써볼까요?

 

우선 저는 이런 작업을 해보려고 해요!

 

두 SubView는 현재 하나의 스크롤뷰로 묶여 있지만, width가 다릅니다.

그렇기에 현재는 아래처럼 스크롤 시 끝나는 지점이 동일하지 않죠.

즉, 스크롤 속도가 동일하기에 그렇습니다.

 

 

여기서 만약 위의 스크롤 되는 속도를 늦춰서 스크롤이 끝나는 지점을 매끄럽고 자연스럽게 하려면, scrollOffset을 이용해서 해당 SubView의 offset을 적절히 컨트롤해줘야 합니다.

 

그걸 위해 한번 구현을 해볼께요!

 

먼저, 위 아래 SubView의 사이즈를 알아야 비율적으로 계산하여 수치를 맞출 수 있습니다.

 

이를 위해, 뷰의 사이즈를 구하는 onReadSize라는 모디파이어를 만들어보겠습니다.

 

public extension View {
  @ViewBuilder
  func onReadSize(_ perform: @escaping (CGSize) -> Void) -> some View {
    background {
      GeometryReader { geometryProxy in
        Color.clear
          .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
      }
    }
    .onPreferenceChange(SizePreferenceKey.self, perform: perform)
  }
}

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

 

배경을 깔고 위 커스텀 스크롤 뷰를 만든것처럼 SizePreferenceKey를 지정하여 사용해줍니다.

 

추가로, 처음에는 두 뷰의 사이즈가 같기에 비율을 나누면 런타임 에러가 나기때문에 offset 모디파이어를 바로 붙일 수 없습니다.

이에, 계산 후에 적용되도록 if 모디파이어를 만들어 줍니다.

 

@ViewBuilder
func `if`<Content: View>(
  _ condition: @autoclosure () -> Bool,
  transform: (Self) -> Content
) -> some View {
  if condition() {
    transform(self)
  } else {
    self
  }
}

 

그럼 이제 한번 실제 뷰에서 적용해볼까요?

 

import SwiftUI

struct ContentView: View {
  let evenIndexColors: [Color] = [.green, .yellow, .red, .blue]
  let oddIndexColors: [Color] = [.red, .brown, .blue, .orange, .gray]
  @State var scrollOffset: CGPoint = .zero
  @State var evenViewWidth: CGFloat = .zero
  @State var oddViewWidth: CGFloat = .zero
  
  var body: some View {
    OffsetObservableScrollView(.horizontal, scrollOffset: $scrollOffset) { _ in
      VStack(alignment: .leading, spacing: 30) {
        SubView(colors: evenIndexColors)
          .onReadSize { size in
            evenViewWidth = size.width
          }
          .if(evenViewWidth < oddViewWidth) {
            $0.offset(x: scrollOffset.x * ((1 - (evenViewWidth / oddViewWidth) + 0.14)))
          }
        
        SubView(colors: oddIndexColors)
          .onReadSize { size in
            oddViewWidth = size.width
          }
      }
    }
    .padding()
  }
}

 

각 서브뷰에 대해 크기 값을 판단하기 위해 onReadSize를 통해 width를 구해주고 ViewWidth 값에 넣어줍니다.

그리고, if 모디파이어를 통해 해당 조건에서 offset을 설정해줘요.

 

현재는 아래 서브뷰가 더 크기에 위 서브뷰의 속도를 늦춰주는 코드입니다.

scrollOffset.x는 스크롤 되는 값에 따라 변하기에 해당 1 기준으로 비율을 산정해줘요.

보통 0.0001 ~ 0.9999 사이에서 컨트롤 해주면 됩니다.

숫자가 낮을수록 원래 속도로 offset이 변할테고 높을 수록 낮은 속도로 offset이 변해서 사실상 스크롤 속도의 제어는 아니지만, 스크롤 속도의 차이가 나는것처럼 보일 수 있죠.

 

두 값을 나눈 결과를 1로 빼서 비율을 산정해줘요.

저기서 0.14를 더한것은 offset을 사이즈로 판단하기에 다를 수 있는 포인트여서 그 간극을 메꾸고자 넣은 수치이고 이는 뷰의 크기에 따라 조금씩 차이를 가질 수 있어요!

그렇기에, 이 수치는 항상 확인하고 조정해보시는것이 좋습니다.

 

아직 선형 방정식을 해결하지 못하여 완벽한 수식을 찾는 중입니다 🥲

 

그럼 동작을 볼까요?

 

의도한것처럼 스크롤 속도의 차이를 갖는것처럼 보이죠?

이렇게 offset이 변하는 비율을 변경해줌으로써 구현할 수 있어요.

 


마무리

이번 포스팅의 목적은 offset의 완벽한 비율을 계산하는것이 아닌 scrollOffset을 사용하기 위한 커스텀한 스크롤뷰를 만드는것이여서, offset 비율의 방정식은 해결하면 또 다루겠습니다!

이건 전초전... 😇