ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI - refreshable ScrollView
    SwiftUI 2024. 11. 22. 20:40

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

    이번 포스팅에서는 refreshable 기능을 가진 ScrollView를 만들어보려고 합니다 🙋🏻

     

    사실, 이전 SwiftUI에서 refreshable이라는 뷰 모디파이어가 존재합니다.

     

    관련해서 포스팅은 아래에서 정리해봤어요!

     

     

    SwiftUI - refreshable

    안녕하세요. 그린입니다🍏 이번 포스팅에서는 SwiftUI의 List에서 사용 가능한 refreshable이라는 새로 고침 기능에 대해 알아보겠습니다🙌 우선 해당 기능은 iOS 15 부터 사용이 가능해요! 참고로 WWDC

    green1229.tistory.com

     

    요약하자면, 사실 아주 유용한 모디파이어에요.

    스크롤 환경에서 최상단에서 아래로 드래그 했을 시 새로고침 되는 기능을 아주 손쉽게 구현해줬으니까요..!!

     

    그런데 이걸 아주 잘 사용하면 되는데 문제가 있었습니다 🥲

     

    바로, 이 모디파이어를 List에서 사용하면 버전 별 문제가 없지만 예를들어 ScrollView로 구현한다음에 이 모디파이어를 사용하면 iOS 17 이상 버전에서는 문제가 없지만, iOS 16 버전에서는 문제가 발생해요 😭

     

    동작을 안하는게 아니라, 레이아웃이 망가집니다.

     

    이에 대해서 왜 그런가 찾아보니 iOS 16 beta 4 이전까지 문제가 많다고 하더라구요.

    제가 겪었던 현상은 스크롤 뷰 내부 컨텐츠 높이 계산 오류에 가까웠어요.

    리프레쉬 하고 나니까 컨텐츠가 다시 원래 상태가 아니라 스크롤이 더 되어서 점차 밑으로 내려가더라구요ㅠ...

    뷰가 제대로 업데이트 되지 않는 느낌이였습니다.

     

    완전 들어맞는 간증 레퍼런스는 찾을 수 없었지만, 맥락은 비슷해서 하나 가져왔습니다.

     

     

    Nested ScrollView in a List + refreshable strange behaviour in iOS 16

    In my SwiftUI app I've a List with nested ScrollView, since I've updated my iPhone to iOS 16 the refresh on the main List has a strange behavior. It seems that every ScrollView has their own refres...

    stackoverflow.com

     

    무튼 그래서, 결국 iOS 모든 버전을 포괄하여 사용하여야 하는것도 있었고 사실 refreshable 모디파이어가 iOS 15부터 해당되잖아요?

    그렇기에 그 밑 버전까지 고려한다면 사용할 수 없었죠.

     

    그래서 한번 커스텀하게 새로고침 기능 탑재한 스크롤 뷰를 한번 만들어 보기로 했습니다.

    거기다가 스크롤 오프셋 감지까지 곁들인...

     

    그럼 한번 바로 보시죠!

     


    refreshable SrollView

    앞에서 큰 설명을 많이 했으니 바로 코드부터 볼께요!

     

    import SwiftUI
    
    public struct RefreshOffsetObservableScrollView<Content: View>: View {
      @State private var isRefreshing = false
      var axes: Axis.Set = .vertical
      var showsIndicators: Bool = true
      @Binding var scrollOffset: CGPoint
      var onRefresh: (() -> Void)?
      @ViewBuilder var content: (ScrollViewProxy) -> Content
      @Namespace var coordinateSpaceName: Namespace.ID
      
      public init(
        _ axes: Axis.Set = .vertical,
        showsIndicators: Bool = true,
        scrollOffset: Binding<CGPoint>,
        onRefresh: (() -> Void)? = nil,
        @ViewBuilder content: @escaping (ScrollViewProxy) -> Content
      ) {
        self.axes = axes
        self.showsIndicators = showsIndicators
        self._scrollOffset = scrollOffset
        self.onRefresh = onRefresh
        self.content = content
      }
      
      public var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators) {
          ScrollViewReader { scrollViewProxy in
            ZStack(alignment: .top) {
              if onRefresh != nil {
                RefreshIndicator(isRefreshing: isRefreshing, offset: scrollOffset.y)
                  .frame(height: 0)
              }
              
              content(scrollViewProxy)
                .id("top")
                .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
                        )
                      )
                  }
                }
                .onChange(of: scrollOffset) { newOffset in
                  if newOffset == .zero {
                    withAnimation {
                      scrollViewProxy.scrollTo("top", anchor: .top)
                    }
                  }
                }
            }
          }
        }
        .coordinateSpace(name: coordinateSpaceName)
        .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
          scrollOffset = value
          
          if let onRefresh = onRefresh,
             value.y < -100,
             !isRefreshing {
            isRefreshing = true
            onRefresh()
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
              isRefreshing = false
            }
          }
        }
      }
    }
    
    // MARK: - 리프레쉬 인디케이터
    private struct RefreshIndicator: View {
      let isRefreshing: Bool
      let offset: CGFloat
      
      var body: some View {
        if offset < 0 {
          ProgressView()
            .scaleEffect(min(abs(offset) / 50, 1))
            .frame(height: max(abs(offset), 0))
        }
      }
    }

     

    해당 컴포넌트가 코드의 전부입니다.

    직접 커스텀하게 리프레쉬를 시켜주려는 조건은 사용자가 최상단에서 100만큼 아래로 스크롤 했을때 새로고침 동작으로 간주하고 이에 리프레쉬 인디케이터를 노출하면서 새로고침 액션을 받아 수행하는것이죠!

     

    이에 대해서 스크롤 오프셋의 감지가 필요합니다.

    받아온 컨텐츠의 백그라운드로 GeometryReader로 파악해줍니다.

     

    여기서 하나 빼먹은것은 ScrollOffsetPreferenceKey의 구현인데요.

    아래와 같이 간단해요.

     

    struct ScrollOffsetPreferenceKey: PreferenceKey {
      static var defaultValue: CGPoint { .zero }
      static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
        value.x += nextValue().x
        value.y += nextValue().y
      }
    }

     

    이렇게 환경 키 인스턴스를 통해서 값을 감지하고 이에 대해 적절히 처리해주죠.

    y < -100이라는 조건이 최상단에서 100만큼 아래로 땡겼을때 새로고침으로 판단하고 이에 리프레쉬 상태값을 변경하면서 액션을 취해주죠.

     

    그리고 적절히 2초동안 새로고침을 일어나게 해주면서 상태값을 다시 변경해주는 형태입니다.

     

    그럼 한번 어떻게 적용되는지 볼까요?

     

    import SwiftUI
    
    struct ContentView: View {
      @State var scrollOffset: CGPoint = .zero
      @State var isDisplayTopView: Bool = false
      
      var body: some View {
        VStack {
          if isDisplayTopView {
            Text("리프레쉬 성공!")
          }
          
          RefreshOffsetObservableScrollView(
            scrollOffset: $scrollOffset,
            onRefresh: {
              isDisplayTopView = true
            }
          ) { _ in
            Rectangle()
              .fill(.green)
              .frame(height: 400)
              .padding(.top, 30)
          }
        }
        .padding()
      }
    }

     

    간단히 이런 뷰에 올렸습니다.

    리프레쉬 성공이라는 상단 뷰는 상태값으로 제어됩니다.

    해당 리프레쉬 기능을 가진 스크롤 뷰를 사용해 새로고침 액션을 하면 해당 상단 뷰를 노출시켜주는거죠.

    이렇게 onRefresh 클로저 액션을 잘 담아주면 되는 아주 간단한 동작이죠!

     

    그럼 한번 돌려볼까요?

     

     

    요렇게 최상단에서 아래로 땡겨서 새로고침을 하면 새로고침이 적용되어 상단 뷰가 나타나죠 😁

     

    아주 쉽게 해당 컴포넌트를 적절히 입맛에 맞게 변경해서 리프레쉬 기능을 가진 거기다 스크롤오프셋 감지를 곁들인 스크롤 뷰를 만들어서 미니멈 타겟에 상관없이 사용해볼 수 있습니다 😃

     


    마무리

    SwiftUI는 정말 타겟에 영향을 많이 받는것 같아요.

    앞으로도 계속 더 그럴것 같아서 타겟에 영향을 받지 않게 왠만하면 직접 커스텀하게 만드는게 속편할때가 많습니다..ㅎㅎ

Designed by Tistory.