-
ScrollTargetBehaviorSwiftUI 2023. 9. 18. 12:29
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 SwiftUI의 ScrollTargetBehavior라는것에 대해 학습해보려 합니다 🙋🏻
우선 이 ScrollTargetBehavior이 어떤건지 알아야겠죠?
ScrollTargetBehavior?
iOS 17 이상부터 SwiftUI에서 새롭게 지원하는 기능입니다.
protocol ScrollTargetBehavior
이렇게 프로토콜로 스크롤 가능한 뷰의 스크롤 동작을 정의하는 유형이에요.
즉 쉽게 말해 우리가 SwiftUI의 ScrollView 컴포넌트를 사용하여 스크롤링을 할때 어떤 스크롤 동작을 해줄지를 정의할 수 있는것이죠.
그럼 이 기능이 SwiftUI에서 어떤 현실적으로의 기능을 해줄 수 있을까요?
ScrollTargetBehavior이 없기전
우선 그냥 스크롤뷰로 일반적으로 구현하고 스크롤링을 시킨다면?
이렇게 우리가 알고 있는 스크롤링 동작을 하겠죠?
근데 우리가 ScrollTargetBehavior를 알려고 하는 목적도 스크롤링을 할때 어떤 스크롤 동작을 정의해줄 수 있다는것이 있잖아요?
그래서 우리는 이런 구현을 해보고 싶어요.
바로 아래 구현된 예시를 볼까요?
스크롤뷰로 요런 기능을 해보고 싶어요.
즉, 스크롤을 할때 마구마구 넘어가는게 아니라 페이징처럼 해당 컨텐츠로 오프셋이 자동으로 맞춰지는 동작말이죠!
흔히, 온보딩 화면이나 사진첩이나 등등 화면을 페이징처럼 넘기는곳에서 많이 사용되는 기능이죠?
근데 이 기능이 SwiftUI에서 쉽게 지원해주지 않아요.
이걸 하기 위해서는 기존에는 크게 두가지 방법이 있었을텐데요.
1️⃣ UIScrollView를 이용하기
SwiftUI에서 지원되지 않으면 결국 UIKit을 이용해야하죠?
즉 UIScrollView를 이용해 isPagingEnabled 속성을 사용하여 만들 수 있습니다.
UIScrollView.appearance().isPagingEnabled = true
요렇게 말이죠!
근데 짜치잖아요?
SwiftUI로 다 만들어서 하고 싶은데 말이죠...그래서 두번째 SwiftUI로 우회하여 구현할 수 있는 방법으로는..
2️⃣ TabView 이용하기
SwiftUI의 TabView를 이용할 수 있습니다.
아주 편리하죠.
또 아니면 세번째 방법으로는 ScrollViewReader와 GeometryReader등을 활용해서 스크롤을 감지하고 그 오프셋도 추적하고 복잡하게 직접 일일히 다 로직을 정하여 억지로 꾸역꾸역 구현할 수는 있겠죠!?
그런데 이런 방법으로 우회하는것 자체가 우리가 의도한 ScrollView 컴포넌트에서 동작하는건 아니잖아요.
그래서 이렇게 iOS 17 이전에는 불편하게 우회하여 사용했다면 iOS 17부터는 이제 오늘 주인공인 ScrollTargetBehavior로 편리하게 해줄 수 있습니다.
그럼 한번 사용해볼까요?
scrollTargetBehavior
우선 가장 기본적으로 scrollTargetBehavior라는 뷰 모디파이어를 살펴볼 수 있어야 해요.
func scrollTargetBehavior(_ behavior: some ScrollTargetBehavior) -> some View
선언을 보면 ScrollTargetBehavior 타입을 받는걸 볼 수 있어요.
즉, ScrollTargetHehavior 프로토콜을 채택하여 구현된 구조체 타입이 들어올 확률이 다분하겠죠?
해당 뷰 모디파이어의 역할을 제공된 축에서 스크롤 가능한 뷰의 스크롤 동작을 설정해줍니다 😀
즉 기본적으로 감속률과 스크롤 동작 상태를 사용해 스크롤 동작이 끝나는 위치를 계산하죠.
즉 ScrollTargetBehavior를 통해 이 논리를 커스텀하게 정의할수 있습니다.
즉 이 ScrollTargetBehavior는 기본적으로 SwiftUI에서 제공하는 내장된것을 사용할 수도 있고 직접 커스텀하게 만들 수 있습니다!
그럼 이제 기본적으로 제공하는것과 커스텀하게 만든 ScrollTargetBehavior을 이용해 사용해보겠습니다!
scrollTargetBehavior 사용하기
1️⃣ paging
내부적으로 스크롤 대상을 컨테이너 기반 도형에 맞춰 정렬하는 스크롤 동작을 가집니다.
즉, 위 우리가 원했던 우회했던것처럼 페이징 방식을 이용할 수 있어요!
사용은 이렇게 합니다!
ScrollView { ... } .scrollTargetBehavior(.paging)
쉽고 간단하죠?
내부적으로는 PagingScrollTargetBehavior 타입을 가지고 있습니다.
struct PagingScrollTargetBehavior
ScrollTargetBehavior를 채택하여 내부적으로 updateTarget 메서드를 구현한 방식으로 말이죠!
이 updateTarget 메서드는 밑에서 살펴볼꺼에요 😉
실제 동작은 위 페이징 샘플과 같아 생략하겠습니다!
2️⃣ viewAligned
스크롤 대상을 paging에서는 컨테이너 기반 도형에 맞췄다면 viewAligned는 뷰 기반 형상에 정렬하는 스크롤 동작을 가집니다.
스크롤 뷰가 항상 스크롤 대상을 뷰의 기하학적 구조에 맞춰 정렬된 직사각형에 정렬해야 할 때 이 동작을 사용할 수 있습니다.
사용은 역시나 기본적으로 내장되어 있기에 간단합니다!
ScrollView { LazyHStack { ... } .scrollTargetLayout() } // 1️⃣ .scrollTargetBehavior(.viewAligned) // 2️⃣ .scrollTargetBehavior(.viewAligned(limitBehavior: .always))
내부적으로는 ViewAlignedScrollTargetBehavior 타입을 가지고 있습니다.
struct ViewAlignedScrollTargetBehavior
물론 동일하게 ScrollTargetBehavior를 채택하고 내부적으로 updateTarget 메서드를 구현한 방식이겠죠?
여기서 LazyHStack에 scrollTargetLayout을 붙여줬습니다.
해당 모디파이어를 적용하면 LazyHStack 레이아웃 컨테이너에 해당 레이아웃의 각 개별 뷰가 정렬될 수 있도록 설정해줍니다.
즉, true로 설정하면 페이징처럼 각 개별뷰가 정렬되도록 설정되고 false는 정렬되지 않습니다.
한번 scrollTargetLayout의 값을 변경해서 동작을 살펴볼까요?
우선 scrollTargetLayout의 선언은 이렇습니다.
public func scrollTargetLayout(isEnabled: Bool = true) -> some View
기본 isEnabled 인자 값이 true죠.
그렇기에 true일때는 아래와 같이 스크롤 시 개별 뷰로 오프셋이 정렬되죠!
그럼 이 true로 하면 사실 paging이지 않냐라고 할 수 있을텐데 이후 바로 다뤄볼 viewAligned의 limitBehavior과 연관이 있으니 거기서 보시죠!
먼저 말하자면 동일하지 않습니다 🥲
근데 반면 false라면 스크롤 시 정렬되지 않습니다.
그럼 이제 남겨둔 마지막으로 볼 것이 viewAligned 케이스를 호출할때 limitBehavior라는것을 설정할 수가 있어요.
기본적으로 설정하지 않는다면 automatic입니다.
LimitBehavior는 한번에 스크롤 할 수 있는 뷰의 양을 정의하는 유형입니다.
struct LimitBehavior
즉 세가지 기본적으로 내장되어 사용할 수 있는 유형이 있는데요.
1️⃣ automatic
- 기본값으로 자동으로 제한을 둡니다.
2️⃣ always
- 항상 동작을 제한합니다.
3️⃣ never
- 제한하지 않습니다.
기본적으로 뷰 정렬 동작은 가로/세로 스크롤 시 컴팩트한 각각의 가로/세로 크기 클래스일 때, 스크롤 가능한 뷰 수에 제한을 두지 않습니다.
한번 always와 never의 차이를 볼까요?
우선 always는 항상 스크롤 할 수 있는 뷰의 수를 제한하는거라고 했죠?
요렇게 끝까지 스크롤을 해도 되지 않고 스크롤 되는 수의 제한이 걸리죠.
반면 never라면?
스크롤 될 수 있는 뷰의 수의 제한이 없습니다.
그래서 마구 스크롤이 되어도 많이 스크롤되게 넘어가서 위 scrollTargetBehavior가 true이기에 뷰 정렬이 맞춰지는것이죠!
여기서 paging하고 always로 스크롤 제한을 두는것의 차이는 paging은 하나씩만 뷰를 넘어갈 수 있다면 always는 하나만 넘어가게 제한될 수도 있지만, 두세개로 스크롤 강도에 따라 제한될 수 있습니다.
자 이제 마지막으로! 커스텀하게 만드는 방법을 알아볼까요?
3️⃣ 커스텀한 ScrollTargetBehavior 만들어 사용하기
우선 ScrollTargetBehavior 프로토콜을 채택하여 만들 수가 있는데요.
해당 프로토콜을 채택하면 필수적으로 구현해야할 메서드가 있습니다.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) public protocol ScrollTargetBehavior { func updateTarget(_ target: inout ScrollTarget, context: Self.TargetContext) typealias TargetContext = ScrollTargetBehaviorContext }
바로 요 updateTarget 메서드이죠.
즉 우리가 위에서 기본 내장된것들을 다뤄봤는데 이것들은 내부적으로 해당 메서드를 구현해서 동작을 넣어준것이죠.
해당 메서드는 스크롤 가능한 뷰가 스크롤되어야 하는 제안된 대상을 업데이트합니다.
시스템은 다음 두가지 주요 경우에 이 메서드를 호출합니다!
1️⃣ 스크롤 동작이 끝나면 감속률을 사용해 자연스럽게 스크롤되는 위치를 계산
2️⃣ 스크롤 가능한 뷰의 크기가 변경되면 새로운 크기에 따라 스크롤해야 하는 위치를 계산하고 이 값을 메서드의 대상으로 제공
결국 해당 메서드로 스크롤 가능한 뷰가 다른 위치로 스크롤 되도록 하는 계산된 대상을 재정의할 수 있죠!
여기서 ScrollTarget은 스크롤 뷰가 스크롤을 시도해야 하는 대상을 정의하는 타입입니다.
anchor와 rect를 사용할 수 있는데요.
anchor는 스크롤 가능한 뷰의 표시 영역 내 사각형을 정렬해야하는 앵커이고, rect는 스크롤 가능한 뷰가 시도하고 포함해야하는 범위 즉 rect 그자체죠.
다음으로 TargetContext는 스크롤 동작이 스크롤 대상을 업데이트하는 컨텍스트입니다.
그럼 정말 간단하게 한번 임의적으로 우리만의 커스텀한 ScrollTargetBehavior를 만들어볼까요?
struct CustomScrollTargetBehavior: ScrollTargetBehavior { func updateTarget(_ target: inout ScrollTarget, context: TargetContext) { target.rect.origin = .init(x: 300, y: 0) } }
이렇게 ScrollTargetBehavior를 채택해서 CustomScrollTargetBehavior 타입을 만들어줬어요.
그리고 스크롤이 될때 동작 제어를 어떤 스크롤이 일어나던 x 포지션을 300만큼 이동된곳을 origin으로 주었습니다.
그리고 이렇게 사용하죠.
ScrollView { ... } .scrollTargetBehavior(CustomScrollTargetBehavior())
그럼 어떻게 동작하는지 볼까요?
어떤 스크롤이 일어나도 결국 origin x 300에 맞춰 동작이 제어됩니다.
지금은 어색하게 테스트용으로 이렇게 동작 제어를 극단적으로 해봤지만, 더 커스텀한 동작 제어를 해야할때 해당 목표에 맞게 커스텀하게 충분히 만들 수 있다는걸 확인했어요!
마무리
이렇게 iOS 17에서 SwiftUI 스크롤 뷰를 조금 더 편리하게 사용할 수 있는 기능에 대해 알아봤습니다!
점점 발전되가고 있긴한데 항상 느끼지만 공식 문서 설명이 너무 빈약해지는것 같습니다 😭
레퍼런스
https://developer.apple.com/documentation/swiftui/scrolltargetbehavior
'SwiftUI' 카테고리의 다른 글
allowsHitTesting을 통한 뷰 터치 이벤트 넘기기 (54) 2023.10.13 SwiftUI에서 View의 Size 측정하기 (56) 2023.09.27 DatePicker & Picker 사용하기 (43) 2023.09.11 SwiftUI의 onChange 사용 시 주의할 부분 (45) 2023.09.05 UIApplicationDelegateAdaptor (48) 2023.08.28