스유를 사용해서 구현하다보면 스크롤 이벤트를 처리하기 까다로운 기능들이 몇몇 있는데 그중 흔하게 사용하는
offset 체크 -> 일정높이에서의 이벤트, scroll의 처음부분, 끝부분처리 UX기능에 적용가능
스크롤 방향 체크 -> 위아래로 스크롤하며 자연스러운 UX기능에 적용가능
와 같은 이벤트를 프로퍼티로 제공해주지않기때문에
직접 구현해서 사용해야하는 번거로움이 있어요
이런 뷰가있을떄
스크롤이벤트를 구현해보도록 할게요
아래코드는 간단한 뷰만 구현한 껍데기코드에요
struct ContentView: View {
private let data: [String] = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16"]
@State private var direct: Direct = .none
@State private var offset: CGFloat = .zero
var body: some View {
ScrollView {
LazyVStack(pinnedViews: .sectionHeaders) {
Section {
ForEach(data, id: \.self) {
CellView(title: $0)
}
} header: {
HeaderView(direct: $direct, offset: $offset)
}
}
}
.clipped()
}
enum Direct {
case none
case up
case down
var title: String {
switch self {
case .none: return "ㅇㅇ"
case .up: return "위"
case .down: return "아래"
}
}
}
struct HeaderView: View {
@Binding var direct: Direct
@Binding var offset: CGFloat
var body: some View {
ZStack {
Color.orange
VStack {
Text("Header View")
Text("\(direct.title)로 스크롤중")
Text("현재위치: \(offset)")
}
}
.frame(height: 100)
}
}
struct CellView: View {
let title: String
var body: some View {
ZStack {
Color.green
Text(title)
}
.frame(height: 50)
}
}
}
PreferenceKey를 이용해서 offset을 측정할거에요
뷰에서 생성한 이름있는 값
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: Value
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
PreferenceKey프로토콜을 채택하면 이런 구현부를 볼 수 있어요
Value에는 사용할 타입을 넣어주면되요
reduce함수는 뷰로부터 값을 받았을때
현재값을 지금 들어온값과 어떻게 계산할건지 정의해주는 부분이에요
지금같은경우는 +를통해 값을 누적시키도록 했어요
구현을 하고 스크롤을하면 아래처럼 찍히는데요
보이는것과 같이 nextValue는 0이고
value가 진짜값으로들어와요 inout이기때문에 그 값에 적용이되는거죠
여기서 value값이 변하면
onPreferenceChange에서 값을 확인할 수 있어요
reduce함수에 아무것도 구현하지않아도 변경된 value이 print찍히는걸 알 수 있죠
이제 구현해볼까요?
먼저 프로토콜을 채택한 구조체를 만들거에요
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}
value값에 어떤처리를해줘야
위에서말한 onPreferenceChange가 호출이되서 새로운 값을 받아 올 수 있고
그 값을이용해서 처리를 할 수 있어요
즉, 0을 더하던 어떤 값을 더하던 계산을 했다 해줘야 변경됫다고 인지하더라구요
value = 3 ❌
value += 0 ✅
value += nextValue() ✅
이 구조체를 어디에쓰느냐
원하는 위치에 선언해서쓰면되요
이 뷰가 있는 부분을 기준으로 offset을 옵저빙할거에요
private var scrollObservableView: some View {
GeometryReader { proxy in
let offsetY = proxy.frame(in: .global).origin.y
Color.clear
.preference(
key: ScrollOffsetKey.self,
value: offsetY
)
.onAppear { // 나타날때 뷰의 최초위치를 저장하는 로직
viewModel.setOriginOffset(offsetY)
}
}
.frame(height: 0)
}
GeometryReader을 이용해서 view frame을 가져올거구요
Color.clear를 이용해서 안보이게 하면
결론적으론
컬러가 없고 hegiht 0인 뷰를 몰래 만들어서 해당 뷰의 위치를 체크하는 방식으로 offset을 옵저빙하는거죠
해당 뷰가 그려지고 이동하면서
preference() 저부분이 실행되고
그중에 frame의 origin y값을 전달하는 방식이죠
저값을 받는 부분은
위에서 본 body부분의 scrollView에
onPreferenceChange를 정의해주면 되겠네요
var body: some View {
ScrollView {
scrollObservableView
LazyVStack(pinnedViews: .sectionHeaders) {
Section {
ForEach(data, id: \.self) {
CellView(title: $0)
}
} header: {
HeaderView(direct: $direct, offset: $offset)
}
}
}
.clipped()
.onPreferenceChange(ScrollOffsetKey.self) { // 추가부분
viewModel.setOffset($0)
}
}
지금까지 구현한걸 요약하면
1. PreferenceKey 프로토콜 채택한 구조체만들기
2. 컬러 없고 height 0인 뷰에 전달할 값 정의하기
3. onPreferenceChange로 값 받기
이젠 이값을 이용해서
스크롤위치와
스크롤방향을 알아볼게요
우선 뷰모델을 하나 정의할게요
컬러가 없고 height가 0인뷰가 생기는 위치는 정의하기 나름이기떄문에
정의된 위치를 0으로 잡기위해 한번만 origin위치를 저장하는 변수와
계속 변경되는 offset을 저장할 변수를 정의했어요
final class ViewModel: ObservableObject {
var offset: CGFloat = 0
var originOffset: CGFloat = 0
var isCheckedOriginOffset: Bool = false
func setOriginOffset(_ offset: CGFloat) {
guard !isCheckedOriginOffset else { return }
self.originOffset = offset
isCheckedOriginOffset = true
}
func setOffset(_ offset: CGFloat) {
self.offset = offset
}
}
스크롤위치는 간단하죠
계속변경된 offset정보를 가지고있는 변수를 보여주면 끝이죠
struct ContentView: View {
private let data: [String] = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16"]
@StateObject private var viewModel: ViewModel = ViewModel()
...
var body: some View {
ScrollView {
scrollObservableView // 옵저버 위치
LazyVStack(pinnedViews: .sectionHeaders) {
Section {
ForEach(data, id: \.self) {
CellView(title: $0)
}
} header: {
HeaderView(direct: $viewModel.direct, offset: $viewModel.offset)
}
}
}
.clipped()
.onPreferenceChange(ScrollOffsetKey.self) {
viewModel.setOffset($0)
}
}
...
}
현재위치는 safe area바로아래이기때문에
아이폰 13기준 최초위치는 47일거에요
빌드를 시키면
잘 뜨네요!
스크롤도하면 위치가 변하는것도 볼 수 있어요
또한 이전값이랑 비교해서
위로 스크롤 하는지
아래로 하는지도 알 수 있겠죠?
앞에서 정의한 뷰를 보면
private var scrollObservableView: some View {
GeometryReader { proxy in
let offsetY = proxy.frame(in: .global).origin.y
Color.clear
.preference(
key: ScrollOffsetKey.self,
value: offsetY
)
.onAppear {
viewModel.setOriginOffset(offsetY)
}
}
.frame(height: 0)
}
var body: some View {
ScrollView {
scrollObservableView
LazyVStack(pinnedViews: .sectionHeaders) {
...
}
}
.onPreferenceChange(ScrollOffsetKey.self) {
viewModel.setOffset($0)
}
}
setOffset -> setOriginOffset 순으로 함수가호출되요
즉, Color.clear쪽에서
preference() 호출후
onAppear()가 불린다는 것이죠
그래서초기화 될 때 예방코드를 작성해주셔야해요
final class ViewModel: ObservableObject {
@Published var offset: CGFloat = 0
@Published var direct: Direct = .none
private var originOffset: CGFloat = 0
private var isCheckedOriginOffset: Bool = false
func setOriginOffset(_ offset: CGFloat) {
guard !isCheckedOriginOffset else { return }
self.originOffset = offset
self.offset = offset
isCheckedOriginOffset = true
}
func setOffset(_ offset: CGFloat) {
guard isCheckedOriginOffset else { return }
if self.offset < offset {
direct = .down
} else if self.offset > offset {
direct = .up
} else {
direct = .none
}
self.offset = offset
}
}
주의해야할점으로는
pan제스처가아니라 뷰의 위치이기때문에 bounce되는 위치도 스크롤 방향에 적용된다는 점이죠
전체코드입니다
enum Direct {
case none
case up
case down
var title: String {
switch self {
case .none: return "ㅇㅇ"
case .up: return "위"
case .down: return "아래"
}
}
}
struct ContentView: View {
private let data: [String] = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16"]
@StateObject private var viewModel: ViewModel = ViewModel()
private var scrollObservableView: some View {
GeometryReader { proxy in
let offsetY = proxy.frame(in: .global).origin.y
Color.clear
.preference(
key: ScrollOffsetKey.self,
value: offsetY
)
.onAppear {
viewModel.setOriginOffset(offsetY)
}
}
.frame(height: 0)
}
var body: some View {
ScrollView {
scrollObservableView
LazyVStack(pinnedViews: .sectionHeaders) {
Section {
ForEach(data, id: \.self) {
CellView(title: $0)
}
} header: {
HeaderView(direct: $viewModel.direct, offset: $viewModel.offset)
}
}
}
.clipped()
.onPreferenceChange(ScrollOffsetKey.self) {
viewModel.setOffset($0)
}
}
struct HeaderView: View {
@Binding var direct: Direct
@Binding var offset: CGFloat
var body: some View {
ZStack {
Color.orange
VStack {
Text("Header View")
Text("\(direct.title)로 스크롤중")
Text("현재위치: \(offset)")
}
}
.frame(height: 100)
}
}
struct CellView: View {
let title: String
var body: some View {
ZStack {
Color.green
Text(title)
}
.frame(height: 50)
}
}
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}
}
final class ViewModel: ObservableObject {
@Published var offset: CGFloat = 0
@Published var direct: Direct = .none
private var originOffset: CGFloat = 0
private var isCheckedOriginOffset: Bool = false
func setOriginOffset(_ offset: CGFloat) {
guard !isCheckedOriginOffset else { return }
self.originOffset = offset
self.offset = offset
isCheckedOriginOffset = true
}
func setOffset(_ offset: CGFloat) {
guard isCheckedOriginOffset else { return }
if self.offset < offset {
direct = .down
} else if self.offset > offset {
direct = .up
} else {
direct = .none
}
self.offset = offset
}
}
뷰모델에는 최초위치가 저장되어있으니까 이값을 이용하면
지금 지점이 스크롤뷰의 제일 상단인지도 체크할 수 있어요
👍
'iyOmSd > Title: SwiftUI' 카테고리의 다른 글
[SwiftUI] @FocusState (0) | 2022.10.27 |
---|---|
[SwiftUI] TextField Placeholder (0) | 2022.10.26 |
[SwiftUI] Animation과 Transition (0) | 2022.08.19 |
[SwiftUI] Namespace + matchedGeometryEffect(feat. 상단탭바 UI) (0) | 2022.07.14 |
[SwiftUI] Button CornerRadius 효과주기 border, stroke, strokeBorder (0) | 2022.02.26 |