-
SwiftUI - refreshable ScrollViewSwiftUI 2024. 11. 22. 20:40
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 refreshable 기능을 가진 ScrollView를 만들어보려고 합니다 🙋🏻
사실, 이전 SwiftUI에서 refreshable이라는 뷰 모디파이어가 존재합니다.
관련해서 포스팅은 아래에서 정리해봤어요!
요약하자면, 사실 아주 유용한 모디파이어에요.
스크롤 환경에서 최상단에서 아래로 드래그 했을 시 새로고침 되는 기능을 아주 손쉽게 구현해줬으니까요..!!
그런데 이걸 아주 잘 사용하면 되는데 문제가 있었습니다 🥲
바로, 이 모디파이어를 List에서 사용하면 버전 별 문제가 없지만 예를들어 ScrollView로 구현한다음에 이 모디파이어를 사용하면 iOS 17 이상 버전에서는 문제가 없지만, iOS 16 버전에서는 문제가 발생해요 😭
동작을 안하는게 아니라, 레이아웃이 망가집니다.
이에 대해서 왜 그런가 찾아보니 iOS 16 beta 4 이전까지 문제가 많다고 하더라구요.
제가 겪었던 현상은 스크롤 뷰 내부 컨텐츠 높이 계산 오류에 가까웠어요.
리프레쉬 하고 나니까 컨텐츠가 다시 원래 상태가 아니라 스크롤이 더 되어서 점차 밑으로 내려가더라구요ㅠ...
뷰가 제대로 업데이트 되지 않는 느낌이였습니다.
완전 들어맞는 간증 레퍼런스는 찾을 수 없었지만, 맥락은 비슷해서 하나 가져왔습니다.
무튼 그래서, 결국 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는 정말 타겟에 영향을 많이 받는것 같아요.
앞으로도 계속 더 그럴것 같아서 타겟에 영향을 받지 않게 왠만하면 직접 커스텀하게 만드는게 속편할때가 많습니다..ㅎㅎ
'SwiftUI' 카테고리의 다른 글
SwiftUI에서 인터랙티브 푸시 네비게이션 사용하기 (30) 2024.12.12 SwiftUI - 키보드 노출 여부에 따른 뷰 오프셋 조정 (30) 2024.11.29 Pagination (feat. SwiftUI & MVVM) (18) 2024.11.15 SwiftUI로 테두리 오버레이 효과 만들기 (feat. ZStack) (5) 2024.11.14 SwiftUI의 Text에 stroke 적용하기 (2) 2024.11.07