23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
스크롤 뷰는 큰 콘텐츠를 작은 화면에 표시할 때 가장 기본적이고 필수적인 UI 컴포넌트 중 하나입니다.
디스플레이 화면과 콘텐츠 사이에 놓인 일종의 망원경과 같다고 볼 수 있죠.
우주공간을 망원경으로 본다고 해서 우주의 크기가 망원경의 렌즈 크기에 국한되는 것이 아니듯, 스크롤 뷰를 사용하면 우주의 별만큼 많은 콘텐츠를 사용자가 둘러보게 할 수도 있습니다.
(우주의 별만큼 많은 콘텐츠 정보를 어떻게 로드할지는 따로 고민이 필요하겠지만요. 😉 )
물론 망원경이 있다고 해서 우주의 모든 별을 샅샅이 살펴볼 수는 없는 것처럼, 우리가 제공하는 콘텐츠 양이 방대해진다면 사용자가 한 번에 모든 정보를 확인할 수는 없게 됩니다.
그렇기 때문에 스크롤 뷰를 사용할 때는 언제나 더 나은 사용성을 위한 고민이 필요하고, 다른 단일 화면에 비해 다루는 정보량이 많아 성능 이슈가 발생하기 쉽기 때문에 세심한 주의가 필요합니다.
이 글은 모바일 환경, 그중에서도 Apple의 최신 UI 프레임워크이자, 에이닷 iOS 앱 개발에 사용되는 SwiftUI의 ScrollView를 살펴봅니다.
(일반명사로서의 스크롤 뷰가 아닌 SwiftUI에서 제공하는 기능을 의미하는 경우, 이하 ScrollView 영문명 그대로 칭하겠습니다.)
먼저 ScrollView의 기본적인 사용법을 알아본 뒤, iOS 버전이 업데이트되면서 추가된 기능들을 간단한 예제와 함께 살펴보고, UIKit의 UIScrollView와 비교해 아직 제공되지 않는 기능이 무엇인지 알아보겠습니다.
마지막으로 ScrollView에서 발생할 수 있는 대표적인 성능 이슈와 개선 방법에 대해 다루며 글을 마무리하려 합니다.
이 글에 정리된 내용들이 SwiftUI를 이용해 ScrollView를 사용하려는 분들에게 작게나마 도움이 되길 바라면서 시작해 보겠습니다.
ScrollView는 SwiftUI 프레임워크에서 제공하는 한 기능입니다.
SwiftUI는 Apple이 2019년에 내놓은 최신 UI 프레임워크로, iOS, iPadOS, watchOS, tvOS, visionOS, macOS의 UI 개발에 사용합니다.
UIKit이 뷰와 뷰컨트롤러에 기반해 화면을 "어떻게 그릴지"에 집중한 명령형(imperative) UI 프레임워크였다면,
SwiftUI는 화면에 "무엇을 그릴지"에 집중하여 UI를 직관적으로 구성할 수 있는 선언형(declarative) UI 프레임워크입니다.
UIKit에서 뷰컨트롤러와 delegate 패턴 등을 이용해 UI를 세밀하게 컨트롤하고 커스터마이징할 수 있었던 것에 반해 SwiftUI에서는 UI 컨트롤이 제한적이라는 단점이 있으나,
iOS 버전이 올라감에 따라 최신 기술들을 빠르게 지원하고, 실시간 프리뷰 기능을 지원하며, 코드를 보다 직관적이고 간결하게 작성할 수 있다는 장점이 있습니다.
오늘 살펴볼 스크롤 기능의 맥락에서도 이런 두 프레임워크의 차이가 그대로 드러납니다.
UIKit에서 제공하는 UIScrollView는 훨씬 세부적인 제어가 가능하지만, SwiftUI의 ScrollView는 코드가 간결하고, 기본적인 스크롤 동작을 직관적으로 작성할 수 있습니다.
두 프레임워크에서 제공하는 기능은 별도 섹션에서 비교해 보고, 여기서는 이 글의 주인공인 SwiftUI의 ScrollView에 대해 알아보겠습니다.
간결하고, 직관적이라는 SwiftUI에서 ScrollView는 어떻게 구성되어 있고, 어떻게 사용할 수 있을까요?
ScrollView는 앞서 언급했듯이 콘텐츠의 크기가 화면 크기를 초과할 때, 스크롤 하여 전체 콘텐츠를 볼 수 있게 해주는 뷰 컨테이너입니다.
ScrollView에는 스크롤의 대상이 되는 콘텐츠 영역과 스크롤 방향을 결정하는 축(axes)이 있습니다.
콘텐츠가 ScrollView의 크기를 벗어나면 벗어난 부분은 잘려서 보이지 않고, 사용자는 스크롤을 해야만 나머지 부분을 살펴볼 수 있습니다.
콘텐츠의 크기가 가로로 길다면 가로 방향으로, 세로로 길다면 세로 방향으로, 또는 두 가지 방향 모두로 스크롤 할 수 있도록 스크롤 축을 정할 수 있습니다.
ScrollView는 별도 옵션을 주지 않았다면 기본적으로 수직 방향의 스크롤을 제공하고 스크롤 시에 인디케이터를 노출합니다.
ScrollView의 콘텐츠 영역에는 어떤 뷰라도 들어갈 수 있지만, 일반적으로 여러 뷰를 보여주기 때문에 아래 예제의 VStack과 같은 컨테이너 뷰를 많이 사용하게 됩니다.
Text 뷰처럼 작은 뷰 100개를 수직 방향으로 쌓는 컨테이너 뷰를 사용했을 때, 화면 크기를 넘어가는 콘텐츠 뷰가 하나 만들어지게 되고,
ScrollView의 콘텐츠 영역에 이 뷰를 넣어주게 되면 세로로 스크롤 하여 콘텐츠 뷰 전체를 확인할 수 있게 됩니다.
var body: some View {
ScrollView {
// 콘텐츠 영역
VStack(alignment: .leading) {
ForEach(0..<100) {
Text("Row \($0)")
}
}
}
}가로 방향으로 긴 콘텐츠를 노출하기 위해 가로 방향으로 스크롤하고 스크롤 인디케이터를 미노출하고 싶다면
아래와 같이 .horizontal, showsIndicators: false 옵션을 주어 변경할 수 있습니다.
var body: some View {
ScrollView(.horizontal, showsIndicators: false) { // 축 방향과 스크롤바 노출 여부를 변경할 수 있습니다.
HStack(alignment: .center) {
ForEach(0..<100) {
Text("Column \($0)")
}
}
}
}ScrollView 안에 넣을 콘텐츠 뷰만 있다면 ScrollView를 사용하는 것은 어렵지 않아 보입니다. 감싸주기만 하면 되니까요.
UIKit에서 콘텐츠뷰의 크기를 설정하고, 제약조건을 설정해 줘야 하는 것과는 꽤 대조적입니다.
SwiftUI의 레이아웃 시스템에서 ScrollView의 크기는 내부 콘텐츠의 크기에 맞게 자동으로 설정되므로 명시적으로 콘텐츠의 크기를 설정할 필요가 없습니다.
만약 특정 크기의 스크롤 영역이 필요하다면 직접 콘텐츠뷰의 frame을 설정하면 됩니다.
아래 예시처럼 콘텐츠 뷰의 크기를 최대로 설정하고 뷰를 왼쪽 정렬하면 ScrollView가 화면에 꽉 차면서 스크롤바가 우측 끝에 노출되는 것을 확인할 수 있습니다.
var body: some View {
ScrollView {
VStack(alignment: .leading) {
ForEach(0..<100) {
Text("Row \($0)")
}
}
.frame(maxWidth: .infinity, alignment: .leading) // 콘텐츠 뷰의 가로 크기를 최대(infinity)로 설정하고, 내부 뷰들이 왼쪽 정렬(leading)되게 설정합니다.
}
}UIKit에서 비슷한 화면을 만들기 위해서 콘텐츠 크기와 제약조건을 설정하기 위한 코드가 최소한 이만큼이나 필요했던 것과 비교해 보면 그 간결성을 체감할 수 있습니다.
// UIKit
override func viewDidLoad() {
// 1. 스크롤 뷰 생성
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
// 2. 스크롤 뷰 내에 들어갈 콘텐츠 뷰 생성
let scrollContentView = UIStackView()
scrollContentView.axis = .vertical
scrollContentView.spacing = 0
scrollContentView.translatesAutoresizingMaskIntoConstraints = false
// 3. 스크롤뷰를 현재 뷰에 추가하고, 각각의 가장자리가 현재 뷰의 가장자리와 일치하도록 제약조건 설정
view.addSubview(scrollView)
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
scrollView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
// 4. 콘텐츠 뷰를 스크롤뷰에 추가하고, 콘텐츠 뷰의 각 가장자리가 스크롤 뷰의 가장자리와 일치하도록 제약조건 설정
scrollView.addSubview(scrollContentView)
scrollContentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
scrollContentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
scrollContentView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
scrollContentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
scrollContentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
// 5. 콘텐츠 뷰 내부에 작은 뷰들 100개 추가
for i in 0..<100 {
let labelView = UILabel()
labelView.text = "Row \(i)"
scrollContentView.addArrangedSubview(labelView)
}
}이렇게나 간결한 ScrollView의 문제는, 아이러니하게도 너무나 간결하다는 것 그 자체였습니다.
iOS 13.0의 초창기 ScrollView에는 정말 기본적인 기능만 탑재되어 있었습니다.
위에서 살펴본 기본적인 수직 및 수평 스크롤 기능, 콘텐츠 뷰에 맞춘 자동레이아웃, 그리고 스크롤바 인디케이터 노출 여부 정도만 설정할 수 있었죠.
UIKit을 사용하던 개발자들에게 SwiftUI는 애플에서 새로 내놓은 재미있는 장난감이었지만 실서비스에 SwiftUI의 ScrollView를 적용하기에는 무리가 있었습니다.
UIKit으로 세밀한 스크롤 컨트롤을 할 수 있었던 것에 비하면 SwiftUI에서 스크롤을 제어할 방법이 없다시피 했던 것도 큰 이유였지만,
무엇보다 가장 치명적이었던 원인은 대량의 데이터를 처리할 때 발생하는 성능 저하 문제였습니다.
ScrollView의 content offset은 현재 스크롤의 위치가 콘텐츠의 어느 지점에 위치해 있는지를 정확히 나타내는 좌푯값입니다.
콘텐츠 크기가 ScrollView의 크기보다 클 때, ScrollView는 사용자의 스크롤 행위가 끝나면 도착해야 하는 최종 좌표를 계산하고, 그 위치에 해당하는 콘텐츠 영역을 노출합니다.
따라서 콘텐츠의 크기가 얼마나 큰지, ScrollView의 크기는 얼마인지 사전에 알아야만 좌표를 정확히 계산할 수 있습니다.
UIKit을 사용하던 시절에는 그 값을 알려주기 위해 최소한의 제약조건들을 설정해 주어야 했습니다.
SwiftUI의 ScrollView는 콘텐츠 영역에 맞게 ScrollView의 크기가 자동으로 조절되기는 하지만 콘텐츠 크기를 내부적으로 계산하기 위해 기본적으로는 콘텐츠 영역의 뷰를 모두 계산하려고 시도합니다.
그래서 위 예제처럼 VStack을 이용해 콘텐츠 뷰를 그리려고 하면, Text 뷰 100개를 한 번에 계산하여 그리게 됩니다.
해당 뷰가 화면에 표시되고 있는지와 상관없이 모든 뷰를 전부 로드하기 때문에 수천수만 개의 데이터를 다루게 되는 실제 서비스 코드에서는 심각한 성능 이슈를 야기할 수 있었습니다.
그러니 아주 간단한 몇 개의 뷰만 포함한 기본적인 스크롤을 제공하는 게 아니라면 기존 코드를 대체할 수 있는 상황은 아니었습니다.
내부의 뷰가 재사용되려면 ScrollView+VStack조합이 아닌 List를 사용해야 했는데, 초창기에는 List를 자유롭게 커스텀 할 수 있는 것도 아니었기 때문에 그 어느 것도 선택하기 어려웠죠.
VStack을 이용해 Row 10,000개를 그리려고 하면 preview에서도 오류가 발생합니다.
이런 문제점들은 SwiftUI가 업데이트되면서 조금씩 해결되고 있습니다.
특히 iOS 14.0에서 대량의 데이터를 처리할 때, 화면에 보이는 항목만 렌더링하는 LazyVStack, LazyHStack이 추가되어 성능이 개선되었습니다.
iOS 17.0에서는 스크롤 위치를 감지하거나 스크롤이 끝난 후 정렬되는 위치를 지정할 수 있는 기능 등을 지원하며 큰 폭의 개선이 이루어졌습니다.
그리고 최근 발표된 iOS 18.0에는 기다리고 기다리던 Phase, Geometry, Visibility 정보를 얻을 수 있게 업데이트되며 UIKit에서 제공하던 기능들의 상당수를 SwiftUI가 커버할 수 있게 되었습니다.
서비스의 최소 OS 지원 버전에 따라서 사용할 수 있는 스크롤 기능들에 큰 차이가 있으므로 SwiftUI의 ScrollView를 사용할 생각이라면 기능의 변천사를 알아둘 필요가 있습니다.
이제 iOS 버전별로 추가된 기능을 하나하나 살펴보겠습니다.
각 예제에서는 공통으로 아래와 같은 간단한 데이터 구조체와 뷰를 사용해 스크롤 콘텐츠를 채우고 설명하겠습니다.
struct SomeItem: Identifiable {
let id: Int
let text: String = "Item"
static let previewItems = {
var items = [SomeItem]()
for i in 0..<10000 {
items.append(SomeItem(id: i))
}
return items
}()
}
struct SomeItemView: View {
let item: SomeItem
init(_ item: SomeItem) {
self.item = item
}
var body: some View {
Text("\(item.text) \(item.id)")
}
}최초의 ScrollView는 기본 구조에서 살펴본 것처럼 가장 기본적인 기능만이 제공되었습니다.
선언형 방식 UI 프레임워크의 초기 단계에서는 기존에 제공하던 명령형 방식처럼 세부적인 컨트롤을 제공하는 것이 어려웠는지 수직 및 수평 스크롤 방향을 설정하거나,
스크롤 인디케이터 노출 여부 정도만 정할 수 있는 상태로 공개되었습니다.
말 그대로 Lazy하게 동작하는 뷰컨테이너가 도입되면서 이전 버전의 가장 큰 문제였던 성능 이슈가 개선되었습니다.
기존 VStack, HStack과 달리 화면에 노출되는 뷰들만 Lazy하게 렌더링하기 때문에, ScrollView 콘텐츠 뷰에서 대량의 데이터를 처리할 수 있게 되었습니다.
WWDC 2020에서 ScrollViewReader라는 뷰 컨테이너가 소개되면서 드디어 스크롤 컨트롤이 가능해졌습니다.
스크롤 콘텐츠 영역에 노출되는 뷰들에 각각의 고유한 id를 부여하면, 이 id 값을 기반으로 스크롤을 이동할 수 있습니다.
SwiftUI에서 ForEach에 들어가는 데이터는 Identifiable 프로토콜을 준수해야 하므로, 각 데이터는 이미 고유한 id를 가지고 있습니다.
ForEach로 만들어진 각각의 자식 뷰들에는 이 id 값이 자동으로 부여되며, ScrollViewProxy/scrollTo(_ id: anchor:) 메서드를 사용하면 해당 id를 가진 뷰로 스크롤이 이동합니다.
ForEach를 사용하지 않는 뷰에는 View/id(_:) 수정자를 사용하여 id를 부여할 수 있습니다.
ForEach 내부의 뷰에도 View/id(_:) 수정자를 이용해 데이터와 무관한 별개의 id를 부여할 수도 있으나, 성능 이슈가 있을 수 있어 주의가 필요합니다.
anchor는 스크롤이 완료되었을 때 id에 해당하는 뷰가 화면 내에서 어디에 위치할지를 정의하는 값으로, SwiftUI의 UnitPoint 값을 사용해 최상단, 가운데 등의 위치를 지정할 수 있습니다.
var body: some View {
ScrollViewReader { proxy in
ScrollView {
Button("Scroll to bottom") {
proxy.scrollTo(9999) // id가 9999인 뷰로 이동합니다.
}
LazyVStack(alignment: .leading) {
ForEach(SomeItem.previewItems) { item in
SomeItemView(item) // ForEach에 의해 SomeItem의 id 값이 SomeItemView에 자동으로 부여됩니다.
}
}
Button("Scroll to top") {
proxy.scrollTo(0) // id가 0인 뷰로 이동합니다.
}
}
}
} var body: some View {
ScrollViewReader { proxy in
Button("Scroll to id 5000 with top anchor") {
proxy.scrollTo(5000, anchor: .top) // 5000번 뷰가 스크롤 영역의 가장 상단에 위치하도록 이동합니다.
}
Button("Scroll to id 5000 with center anchor") {
proxy.scrollTo(5000, anchor: .center) // 5000번 뷰가 스크롤 영역의 가운데 위치하도록 이동합니다.
}
Button("Scroll to id 5000 with bottom anchor") {
proxy.scrollTo(5000, anchor: .bottom) // 5000번 뷰가 스크롤 영역의 가장 하단에 위치하도록 이동합니다.
}
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
}
}
}
}
}스크롤이 가능해진 것은 좋았으나 아무래도 SwiftUI의 초창기 버전이다 보니 단점도 적지 않았습니다.
가장 먼저 id를 사용해 굉장히 제한적인 스크롤 컨트롤만 가능하다는 점을 들 수 있겠습니다. 기존 UIKit이 제공하던 세밀한 위치 제어와는 간격이 너무 크게 느껴지는 게 사실이죠.
게다가 ScrollViewReader라는 뷰가 제공하는 클로저 내부에서만 proxy를 사용할 수 있기 때문에,
뷰 바깥에서 스크롤 동작을 트리거하기 위해서는 proxy 객체를 어떻게든 바깥으로 전달해야 하는 상황이 종종 발생하는데, 이는 코드 복잡도가 올라가는 원인이 되기도 합니다.
화면이 크고 복잡해질수록, 데이터가 많아질수록 스크롤 컨트롤은 더더욱 까다로워지기 시작합니다.
Lazy Stack 내 자식 뷰들의 뷰 갱신 시점과 스크롤 이동 시점이 맞물리면서 스크롤이 제대로 일어나지 않는 일이 비일비재하게 됩니다.
스크롤 대상이 되는 뷰가 아직 그려지지 않았는데 scrollTo가 먼저 호출되어 이동할 대상이 없는 상태가 되거나,
scrollTo로 화면 이동이 일어난 뒤에 자식 뷰들이 업데이트되면서 위치가 어긋나는 등의 오류들이 발생하게 되고,
이를 해결하기 위해서는 SwiftUI의 뷰 갱신에 대한 전반적인 이해와, view에 부여되는 id에 대한 이해가 추가로 필요해 트러블 슈팅도 쉽지 않습니다.
ScrollViewReader는 컨테이너 뷰를 선언하는 방식을 그대로 가져왔지만, 명령형 메서드를 제공하기 때문에 사용 방법이 직관적으로 느껴지지 않기도 합니다.
뷰가 주는 proxy를 받아 사용하는 방식은 GeometryReader처럼 초창기 SwiftUI 버전에서 주로 보이는 설계인데요.
아무래도 당시에 선언적인 스타일을 명확히 잡지 못하고 방황하던 흔적은 아닐까 싶습니다.
화면에 "무엇"을 그릴지 코드로 선언하라는 SwiftUI의 철학과 비교해 보면 "무엇"에 해당하지 않지만, 뷰 형태를 띠고 있는 ScrollViewReader는 확실히 겉도는 감이 있습니다.
이런저런 단점들을 잔뜩 발견한 개발자들은 다음 스크롤 컨트롤 방식이 곧 등장하기를 기대했을 것 같습니다만...
이어지는 두 번의 SwiftUI 버전 업데이트에서도 이렇다 할 스크롤 컨트롤 API는 추가되지 않았습니다.
이에 개발자들은 원하는 스크롤 컨트롤 API를 직접 어떻게든 구현해 사용하거나, 아예 UIScrollView를 Wrapping해서 사용하는 등 나름의 방법을 찾아 사용하게 됩니다.
저는 전자처럼 이왕 SwiftUI를 사용할 거면 SwiftUI스러운 방식으로 해결하고 싶어 하는 사람이었고, 그 과정이 쉽지만은 않았다는 점만 언급하고 넘어가겠습니다. 🥲
ScrollView와 관련된 기능 추가는 없었습니다.
소소한 스크롤 옵션들이 추가되었습니다.
스크롤을 비활성화하거나, 스크롤 동작 시 키보드를 내리거나 유지하는 등의 옵션을 정의할 수 있게 되었고,
스크롤 인디케이터 노출 여부가 생성자에서 수정자로 이동해 이전보다 좀 더 선언적인 방식으로 API가 변경되었습니다.
이 버전에서 제공되는 뷰 수정자들은 그리 복잡하지 않기 때문에, 별도 예제 없이 목록만 훑어보고 넘어가겠습니다.
스크롤을 비활성화할 수 있습니다.
스크롤과 키보드를 내릴 수 있는 옵션을 제공합니다.
기본값은 automatic으로, 각 context에 맞게 동작하며, 스크롤과 무관하게 항상 키보드가 노출되길 바란다면 never를 지정해 주면 됩니다.
immediately를 설정하면 스크롤이 시작될 때 키보드가 내려가고, interactively를 설정한 경우에는 스크롤 동작이 키보드 영역까지 이어지는 경우 키보드가 그에 따라 내려가거나 다시 올라오기도 합니다.
스크롤 인디케이터를 보여주거나, 숨길 수 있습니다.
입력장치에 따라, 스크롤 인디케이터를 숨기면 상호작용이 어려운 경우가 있을 수 있습니다.
예를 들어 macOS에서 트랙패드를 사용한다면 인디케이터가 없어도 스크롤이 쉽겠지만 마우스를 사용한다면 인디케이터 없이는 사용이 불편할 겁니다.
그래서 입력장치에 따라 인디케이터를 숨기는 기본동작에는 차이가 있습니다. 입력장치와 무관하게 항상 숨기고 싶다면 never를 통해 숨길 수 있습니다.
List 뷰 등에 적용되는 기본 시스템 배경 색상을 숨길 수 있습니다.
스크롤 영역의 가장자리에 도달했을 때 바운스 동작을 제어하는 옵션을 제공합니다.
2023년부터 본격적으로 ScrollView의 기능이 대거 업데이트되기 시작합니다.
특히 2020년의 ScrollViewReader 이후로 계속 정체되어 있었던 스크롤 관련 API가 드디어 등장합니다.
물론 여전히 content offset을 읽거나 제어할 수는 없지만, 기존 방식 대비 좀 더 SwiftUI스러운 API 설계가 돋보입니다. 보다 간결하고, 보다 선언적입니다.
기존의 ScrollViewReader가 뷰 내부에서 명령형 방식으로 scrollTo(_: anchor:) 메서드를 호출하였다면,
scrollPosition(id:anchor:)은 ScrollView에 직접 적용할 수 있는 수정자 형태로 제공됩니다. 더는 Proxy를 사용하지 않습니다.
아무래도 SwiftUI의 버전이 올라가면서 SwiftUI스러움이란 무엇인지에 대해 방향성이 잡힌 것처럼 보입니다.
이 수정자는 id에 State 변수를 바인딩하므로 해당 변수의 상태변화에 따라 스크롤 위치가 자동으로 업데이트됩니다. 변수가 바인딩 되어있다는 말은 곧 양방향으로 변경 사항이 반영된다는 말과 같습니다.
다시 말해 스크롤 위치가 변함에 따라 변수도 업데이트되기 때문에 사용자가 스크롤 하는 현재 위치를 역으로 확인 할 수도 있어 지속적으로 스크롤 위치를 관리해야 할 때에도 사용할 수 있습니다.
scrollTo(_: anchor:)는 단방향의 일회성 업데이트만 제공했던 것과 확연히 차이가 나는 부분입니다.
상태 값이 곧 뷰에 반영되고, 뷰가 변경되면 상태 값도 업데이트되니 선언형 프레임워크에 잘 어울리는 설계처럼 보입니다.
단, 스크롤 대상이 되는 레이아웃 뷰에 scrollTargetLayout() 수정자를 적용하지 않으면 id값이 정상적으로 업데이트되지 않을 수 있으니, 주의가 필요합니다.
@State private var scrolledID: SomeItem.ID? // 1. 현재 스크롤 위치가 반영되는 변수를 선언합니다.
var body: some View {
VStack {
Text("ScrollId wll be changed after 2s")
Text("Current Scroll Id:\(scrolledID ?? -1)")
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrolledID) // 2. ScrollView와 변수를 바인딩으로 연결해줍니다.
.onAppear {
// 3. 화면 진입 후 2초 뒤 스크롤 위치를 가장 마지막 뷰로 이동합니다.
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
scrolledID = SomeItem.previewItems.last?.id
}
}
}
}ScrollView는 스크롤이 끝났을 때 콘텐츠 뷰 어디에 스크롤이 최종적으로 위치할지 계산하기 위해서 기본적인 감속 비율이 정해져 있습니다.
일반적으로는 스크롤 내의 콘텐츠와 무관하게, 계산된 위치에서 부드럽게 멈추게 되죠. 하지만 스크롤이 특정 위치에서 멈추기를 기대한다면 바로 이 수정자를 사용할 수 있습니다.
paging 옵션을 설정하면 특정 페이지 크기에 맞춰서 그 페이지 단위로 스크롤이 일어납니다. 사진 갤러리, 책 뷰어 등의 레이아웃에서 유용하게 사용할 수 있습니다.
viewAligned 옵션을 설정하면 스크롤 내의 콘텐츠 뷰가 ScrollView 가장자리에 맞춰서 정렬되는 상태로 스크롤이 멈추게 됩니다.
이때 대상이 되는 레이아웃 뷰에 .scrollTargetLayout()을 붙여주어야 합니다. safeAreaPadding(_:)을 함께 사용하면 정렬 위치를 세밀하게 조정할 수 있습니다.
기본 옵션 외에 커스텀으로 스크롤 동작을 제어하고 싶다면 ScrollTargetBehavior 프로토콜의 updateTarget(_ target: contex:) 함수를 직접 구현하여 사용할 수도 있습니다.
이 함수는 스크롤이 끝날 때 혹은 ScrollView 크기가 바뀌는 시점에 호출됩니다.
페이지 단위로 스크롤 하는 옵션입니다.
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 10.0) {
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
.frame(width: 100, height: 100)
.background(.orange)
}
}
}
.scrollTargetBehavior(.paging) // 페이지 단위로 스크롤이 일어나게 합니다.
}
이 옵션을 사용하면 뷰가 화면 가장자리에 딱 맞게 멈추는 것을 확인할 수 있습니다.
만약 뷰가 가장자리에 딱 맞게 멈추는 것이 아니라 좀 더 여백을 둔 상태로 멈추게 하고 싶다면 safeAreaPadding을 조정해 볼 수 있습니다.
아래 예제에서는 좌측 가장자리의 패딩을 10씩 늘이고 줄였을 때 뷰의 정렬이 어떻게 변하는지 확인해 볼 수 있습니다.
@State private var padding: CGFloat = 0
var body: some View {
// 버튼 탭시 여백을 10 추가하여 뷰의 정렬 위치를 조정합니다.
Button("safeArea padding + 10") {
padding += 10
}
// 버튼 탭시 여백을 10만큼 감소시켜 뷰의 정렬 위치를 조정합니다.
Button("safeArea padding -10") {
if padding >= 10 {
padding -= 10
}
}
ScrollView(.horizontal) {
LazyHStack(spacing: 10.0) {
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
.frame(width: 200, height: 100)
.background(.yellow)
}
}
.scrollTargetLayout() // 정렬 되어야 할 뷰 레이아웃에 붙여줍니다.
}
.scrollTargetBehavior(.viewAligned) // 스크롤 영역 가장자리에 콘텐츠 뷰가 딱 맞게 정렬됩니다.
.safeAreaPadding(.leading, padding) // 콘텐츠 뷰의 좌측 가장자리(leading)에서 뷰가 정렬되는 위치를 조정할 수 있습니다.
}
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 10.0) {
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
.frame(width: 100, height: 100)
.background(.orange)
}
}
}
.scrollTargetBehavior(CustomScrollTargetBehavior())
}
// 커스텀 방식
struct CustomScrollTargetBehavior: ScrollTargetBehavior {
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// custom logic...
}
} 특정 레이아웃을 스크롤 대상으로 정하는 수정자로, 위에서 소개한 메서드들이 대상 레이아웃의 스크롤을 정밀하게 제어할 수 있도록 해줍니다.
scrollPosition(id:anchor:) 사용 시에는 이 수정자가 붙은 레이아웃 내의 뷰 기준으로 스크롤 위치를 감지하며
scrollTargetBehavior(_:)에서 .viewAligned 옵션 사용 시에도 이 수정자가 붙은 레이아웃내의 뷰가 정렬에 사용됩니다.
아래 예제에서 확인할 수 있듯, ScrollView 내에 동일한 id를 사용하는 LazyVStack 레이아웃이 두 개 있다 하더라도 (물론 실제로는 이렇게 구현하지 않는 게 좋습니다.)
scrollPosition에서는 scrollTargetLayout()이 붙어있는 첫 번째 레이아웃의 id 값을 기준으로 동작합니다.
@State private var scrollId: SomeItem.ID?
@State private var text: String = ""
var body: some View {
controls
ScrollView {
LazyVStack{
ForEach(SomeItem.previewItems) {
SomeItemView($0)
.background(.yellow)
}
}
.scrollTargetLayout() // 이 수정자가 붙은 레이아웃이 스크롤 대상이 됩니다.
LazyVStack{
ForEach(SomeItem.previewItems) {
SomeItemView($0)
.background(.green)
}
}
}
.scrollPosition(id: $scrollId, anchor: .top)
}
private var controls : some View {
VStack(alignment: .leading) {
Text("scrollId: \(scrollId ?? -1)")
HStack{
HStack {
Text("Scroll To Id:")
TextField("1", text:$text)
.frame(width: 20)
}
Button("Go") {
scrollId = Int(text)
}
}
}
}ScrollView에서 스크롤로 인해 뷰가 보이고, 사라질 때 전환 애니메이션을 쉽게 구현할 수 있게 되었습니다.
다양한 VisualEffect를 중첩해 적용할 수 있지만 폰트처럼 콘텐츠 크기의 변경이 있는 수정자들은 사용할 수 없습니다.
var body: some View {
ScrollView(.horizontal) {
LazyHStack {
ForEach(SomeItem.previewItems) {
SomeItemView($0)
.frame(width: 100, height: 100)
.background(.yellow)
.scrollTransition(axis: .horizontal) { content, phase in
content
.scaleEffect(
x: phase.isIdentity ? 1.0 : 0.80,
y: phase.isIdentity ? 1.0 : 0.80)
.rotation3DEffect(.degrees(10 * (phase.value)), axis: (x: 0, y: 0, z: 1))
.offset(y: phase.isIdentity ? 0 : 10)
}
}
}
}
} 그 외에 아래와 같은 비교적 간단한 수정자들도 참고해 볼만합니다. 더욱 더 세밀한 부분까지 신경 쓸 수 있게 다양한 API가 추가되었습니다.
스크롤 콘텐츠 뷰 내에 마진을 추가할 수 있게 되었고, 스크롤 인디케이터가 마진 끝까지 올라가야 하는 상황에 사용할 수 있습니다.
뷰 컨테이너 내에서 동일한 크기의 뷰를 만들어야 할 때 유용하게 사용할 수 있습니다.
ScrollView가 최초 노출될 때 기본 앵커 위치를 설정할 수 있습니다.
스크롤 인디케이터는 기본적으로 스크롤 할 때에만 나타나지만 이 수정자를 이용하면 스크롤 없이도 사용자에게 스크롤 인디케이터를 잠시 표시할 수 있습니다.
사용자가 처음 콘텐츠를 볼 때 스크롤 가능하다는 것을 직관적으로 알기 어려울 때 유용하게 사용할 수 있습니다.
뷰가 처음 표시될 때 나타나게 하거나, 특정 값을 이용해 trigger 할 수 있습니다.
기본적으로는 ScrollView는 콘텐츠 뷰가 정해진 영역을 벗어나면 콘텐츠 뷰를 clip 하여 보이지 않게 해줍니다.
이 수정자를 이용하면 clip이 동작하지 않게 할 수 있습니다. 스크롤 영역을 벗어난 콘텐츠 뷰에도 노출됩니다.
가장 최신 버전에서도 괄목할 만한 업데이트가 많이 있었습니다. 드디어 contentOffset을 알 수 있게 되었고, 특정 좌표 지점으로 스크롤을 이동할 수도 있게 되었습니다.
현재 사용자가 스크롤을 시작했는지, 프로그래매틱 스크롤이 시작되었는지 등의 phase 변화와 특정 뷰가 현재 ScrollView에서 보이는지까지 알 수 있게 되었습니다.
개인적으로는 염원하던 기능들이 대거 제공되기 시작해서 설레는데요. 찬찬히 하나씩 살펴보겠습니다.
ScrollViewReader/scrollTo(_:anchor:) 메서드와, View/scrollPosition(id:anchor:) 수정자를 거쳐, ScrollPosition이라는 타입이 추가되었습니다.
ScrollPosition은 ScrollView의 현재 상태를 추적하고, 특정 위치로 스크롤을 이동하는 데에 사용할 수 있습니다.
ScrollView에 새롭게 제공되는 View/scrollPosition(_:) 수정자를 붙여 ScrollPosition 변수와 바인딩시켜 두면, 이 객체가 제공하는 다양한 scrollTo 메서드를 사용할 수 있습니다.
기존 스크롤 방식처럼 뷰의 id에 기반한 스크롤 이동 메서드 뿐만 아니라 특정 content offset(드디어!)으로 이동하거나, 특정 edge, 그러니까 ScrollView의 상/하/좌/우 위치로 이동할 수도 있게 되었습니다.
새로운 스크롤 이동 메서드들
ScrollPosition/scrollTo(id: anchor:)
ScrollPosition/scrollTo(edge:)
ScrollPosition/scrollTo(point:)
ScrollPosition/scrollTo(x:y:)
ScrollPosition/scrollTo(x:)
ScrollPosition/scrollTo(y:)
ScrollPosition은 뷰의 id 타입을 명시하여 생성합니다.
스크롤 대상이 될 레이아웃에 View/scrollTargetLayout() 수정자를 붙여 표시해 주면, SwiftUI는 ScrollPosition에 명시된 이 id 타입을 이용해 스크롤을 이동하고, ScrollPosition/viewID 값을 업데이트합니다.
viewID에는 현재 보이는 스크롤 영역 내의 콘텐츠 뷰 중 가장 상단에 있는 뷰의 id 값이 실시간으로 반영되어 값을 직접 확인 할 수도 있습니다.
// 1. ScrollPosition 생성
@State private var scrollPosition: ScrollPosition = ScrollPosition(idType: SomeItem.ID.self)
// 2. viewID 확인
private var scrolledViewID: String {
guard let viewID = scrollPosition.viewID(type: SomeItem.ID.self) else {
return "nil"
}
return "\(viewID)"
}
var body: some View {
VStack(alignment: .leading){
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(someItems) { item in
SomeItemView(item)
}
}
.scrollTargetLayout()
}
.scrollPosition($scrollPosition, anchor: .bottom) // 3. 스크롤 위치를 바인딩으로 연동합니다.
.overlay{ Rectangle().stroke() }
Text("scrollViewID: \(scrolledViewID)")
Button("scrollTo targetLayout with id :9999") {
scrollPosition.scrollTo(id: 9999) // 4. 아이디를 입력해 이동합니다.
}
Button("scrollTo targetLayout with edge: bottom") {
scrollPosition.scrollTo(edge: .bottom) // 5. 스크롤 영역의 특정 가장자리로 이동합니다.
}
Button("scrollTo targetLayout with Y: 100") {
scrollPosition.scrollTo(y: 100) // 6. y 방향 content offset을 직접 지정해 이동합니다.
}
}
.padding()
}드디어 ScrollView의 geometry 변화를 감지할 수 있는 수정자도 등장했습니다. 개인적으로는 아주 기다리던 업데이트입니다.
이 수정자를 이용하면 사용자가 현재 스크롤하는 위치를 추적할 수 있어, 스크롤이 특정 위치에 도달하면 콘텐츠를 미리 로드하는 등의 페이징 동작이나 무한 스크롤을 구현하거나,
스크롤 위치를 연동한 시각적 효과를 구현하기에 용이합니다.
ScrollGeometry
이 수정자가 제공하는 ScrollGeometry에는 다음과 같은 정보들이 담겨있습니다.
contentOffset: ScrollView의 전체 contentSize에서 현재 어느 위치에 있는지를 나타내는 값입니다.
contentSize: 스크롤 콘텐츠 뷰의 전체 크기입니다. ScrollView의 컨테이너 크기가 아닌 내부의 콘텐츠 뷰의 크기입니다. 컨테이너 사이즈보다 클 수도, 작을 수도 있습니다.
contentInsets: 콘텐츠 크기에 insets 값을 더하게 되면 스크롤 영역 전체의 크기를 알 수 있게 됩니다.
containerSize: 스크롤 컨테이너의 크기입니다. 이 값과 content offset 값을 더하면 visibleRect 값이 나오게 됩니다.
visibleRect: 현재 보이는 스크롤 컨테이너 영역을 의미합니다.
bounds: visibleRect에서 contentInsets를 제외한 영역입니다.
ScrollView의 geometry는 스크롤 하는 동안 매우 자주 바뀝니다. 그렇기 때문에 이 geometry의 변경에 따라 많은 뷰가 업데이트되게 구성하는 것은 지양해야 합니다.
예를 들어 geometry가 변경될 때마다 그 값 자체를 State 변수로 저장한 뒤 가공하여 사용한다면 불필요한 뷰 업데이트를 굉장히 자주 유발하고, 이는 곧 성능 이슈로 이어질 수 있습니다.
이 수정자가 단순히 geometry 자체를 반환하는 것이 아니라, 아래 두 가지 인터페이스를 제공하는 걸 보면 Apple에서도 이 점을 사용자에게 충분히 인지시킬 목적을 가지고 이 수정자를 설계한 것 같습니다.
transform: geometry 값의 변화가 있을 때 변경된 값을 원하는 데이터 타입으로 변환하는 로직을 작성해야 합니다.
예를 들어 contentOffset의 y좌표가 0보다 작은지를 판단하는 Boolean 값을 반환할 수 있습니다.
action: transform의 결과에 변화가 있을 때, 이전값과 새로운 값을 함께 전달해 주며, 해당 변화에 의해 수행되어야 할 로직을 작성해야 합니다.
Boolean 값이 false에서 true로 변경된 시점, (즉 사용자가 스크롤을 최상단에서 더 위로 올려보는 시점)에 새로고침 로직 등을 넣을 수도 있겠죠.
아래 두 예시 모두 사용자가 스크롤 가장 상단에서 더 위로 스크롤을 시도했는지 확인하는 동작이지만 첫 번째 뷰의 경우,
사용자가 스크롤을 할 때마다 contentOffset이 변경되므로 실제로 감지하길 원하는 시점과 무관할 때도 body가 무수히 많이 그려지게 되지만,
두 번째 뷰의 경우, 정확히 원하는 시점 (isBeyondZero가 변경되는 시점)에 뷰의 body가 다시 그려지고,
그 외의 스크롤에서는 뷰의 상태가 업데이트되지 않기 때문에 불필요하게 body를 다시 그리지 않는 것을 확인할 수 있습니다.
struct ScrollGeometryBadExampleView: View {
// 1. 이 State 변수 값이 변경되면 뷰의 body 계산이 이루어집니다.
@State private var scrollOffset: CGPoint = .zero
// 2. 실제로 뷰를 그릴 때 필요한 값이지만 computed property이므로 State 변수가 변경될때 다시 계산됩니다.
private var isBeyondZero: Bool {
scrollOffset.y < 0.0
}
var body: some View {
let _ = Self._printChanges()
VStack(alignment: .leading){
Text("scroll is beyond zero: \(isBeyondZero)")
ScrollView {
LazyVStack {
ForEach(SomeItem.previewItems) {
SomeItemView($0)
}
}
}
.onScrollGeometryChange(for: CGPoint.self) { geometry in
geometry.contentOffset
} action: { oldValue, newValue in
// 3. 스크롤시 contentOffset은 매우 자주 변경되는 값입니다.
// 이 값을 뷰에 바로 저장하면 값이 변경될 때마다 body가 다시 계산됩니다.
// 실제로 뷰에서 사용하는 값인 isBeyondZero이 false로 같아 뷰를 다시 렌더링 할 필요가 없는데도 불필요하게 body 계산이 일어나므로 성능 저하가 일어날 수 있습니다.
scrollOffset = newValue
}
}
.onChange(of: isBeyondZero) { oldValue, newValue in
if newValue {
print("reload contents")
}
}
}
}
struct ScrollGeometryGoodExampleView: View {
// 1. 이 State 변수 값이 변경되면 뷰의 body 계산이 이루어집니다.
@State private var isBeyondZero: Bool = false
var body: some View {
let _ = Self._printChanges()
VStack(alignment: .leading){
Text("scroll is beyond zero: \(isBeyondZero)")
ScrollView {
LazyVStack {
ForEach(SomeItem.previewItems) {
SomeItemView($0)
}
}
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y < geometry.contentInsets.top
} action: { oldValue, newValue in
// 2. 스크롤로 인해 contentOffset이 아무리 바뀌어도 isBeyondZero 값이 바뀌지 않았다면 body가 계산되지 않으므로 불필요한 body 계산이 일어나지 않습니다.
isBeyondZero = newValue
}
}
.onChange(of: isBeyondZero) { oldValue, newValue in
if newValue {
print("reload contents")
}
}
}
}사용자가 스크롤을 하고 있는지, 사용자가 스크롤을 시작했는지, 멈췄는지 그 시점을 알고 싶었던 적은 없으신가요?
드디어 SwiftUI가 ScrollPhase라는 enum의 형태로 스크롤 단계(phase)를 알려주기 시작했습니다. 역시 개인적으로 정말 기다리고 기다리던 기능인데 드디어 등장했네요.
이제 스크롤이 시작되거나, 스크롤이 멈출 때, 또는 사용자가 스크롤을 중단했을 때 등 스크롤 이벤트의 전/후 상태를 action 클로저를 통해 전달해 주며,
개발자는 이 값을 받아 적절한 처리를 할 수 있게 되었습니다. 이 수정자를 통해 알 수 있는 스크롤 상태의 단계는 다음과 같습니다.
public enum ScrollPhase : Equatable {
case idle
case tracking
case interacting
case decelerating
case animating
public var isScrolling: Bool { get }
}ScrollPhase
idle: 스크롤 관련 동작이 없는 상태입니다.
tracking: 잠재적인 사용자 스크롤을 감지하려고 대기하는 상태입니다.
예를 들어 iOS에서 사용자가 ScrollView 내의 콘텐츠를 터치한 상황에서 사용자가 손가락을 움직이는 경우 ScrollView는 그 손가락의 움직임을 추적해 스크롤이 시작되는지를 감지하는데, 바로 그 단계를 말합니다.
interacting: 사용자가 스크롤을 움직이는 상태입니다.
decelerating: 사용자가 ScrollView와의 상호작용을 멈추어서 스크롤이 최종 타깃으로 이동하며 감속 중인 상태입니다.
animating: ScrollViewReader의 scrollTo 메서드나, scrollPosition(id:anchor:)등의 수정자를 통해 프로그래매틱 스크롤이 일어났을 때, 최종 타깃을 향해 애니메이션 되는 상태입니다.
withAnimation 등과 함께 사용하여 실제로 스크롤 애니메이션이 일어난 경우에만 상태 값 변환이 일어납니다.
isScrolling: ScrollView가 현재 스크롤 중인 상태인지를 확인하는 값으로 phase != .idle과 동일합니다.
수정자가 붙은 레이아웃 내에 ScrollView가 여러 개 있다면, 뷰 계층 구조상 첫 번째 ScrollView의 스크롤 단계를 알려줍니다.
@State var phase: ScrollPhase?
var body: some View {
VStack {
if let phase {
Text("phase: \(phase)")
}
// 이 뷰가 계층 구조상 첫번째 ScrollView이므로 이 뷰의 scroll phase를 알려줍니다.
ScrollView {
LazyVStack{
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
}
}
}
ScrollView {
LazyVStack{
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
}
}
}
}
.onScrollPhaseChange { oldPhase, newPhase in
print("old: \(oldPhase) -> new: \(newPhase)")
phase = newPhase
}
} 이제 스크롤이 시작되었는지, 끝났는지, 현재 사용자가 스크롤하고 있는지, 프로그래매틱 스크롤이 이루어지고 있는지도 구분이 가능하며,
ScrollPhaseChangeContext 값을 통해 geometry, velocity도 함께 받아볼 수 있게 되었습니다.
public struct ScrollPhaseChangeContext {
/// The geometry of the scroll view at the time of the
/// scroll phase change.
public var geometry: ScrollGeometry { get }
/// The velocity of the scroll view at the time of the
/// scroll phase change.
public var velocity: CGVector? { get }
}onScrollPhaseChange(\_:) 수정자는 phase 값이 변경되는 시점에 호출됩니다.
따라서 velocity의 경우 idle 상태로 변하는 시점이나 interacting 상태로 변하는 시점에는 스크롤이 끝났으므로 속도가 0이고, interacting에서 decelerating으로 변경될 때,
감속이 시작될 때의 속도 값을 확인할 수 있습니다. 이 값이 음수인지 양수인지에 따라 스크롤 방향을 짐작해 볼 수도 있을 것 같네요.
@State var phase: ScrollPhase?
@State var lastVelocity: CGVector?
@State var vPhase: (ScrollPhase, ScrollPhase)?
@State private var scrollPosition = ScrollPosition(idType: SomeItem.ID.self)
var body: some View {
VStack {
// 1. 스크롤 phase 정보
if let phase {
Text("phase: \(phase)")
}
if let lastVelocity {
Text("velocity: \(lastVelocity)")
}
if let vPhase {
Text("non-zero velocity phase: \(vPhase)")
}
// 2. 프로그래밍적으로 스크롤을 이동한 경우, 애니메이션이 있는 경우만 phase가 animating으로 변경됩니다.
Button("Programmatic Scroll w/Animation") {
withAnimation {
scrollPosition.scrollTo(y: 2000)
}
}
// 3. 애니메이션 없이 프로그래밍적으로 스크롤 이동시 phase는 변경되지 않습니다.
Button("Programmatic Scroll w/o Animation") {
scrollPosition.scrollTo(y: 0)
}
ScrollView {
LazyVStack{
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
}
}
}
.scrollPosition($scrollPosition)
.onScrollPhaseChange { oldPhase, newPhase, context in
// scroll phase 변경이 있을 때 호출됩니다.
phase = newPhase
if context.velocity != .zero {
lastVelocity = context.velocity
vPhase = (oldPhase, newPhase)
}
}
}
}그동안 어떤 뷰가 스크롤 영역 안에서 보이는지 안 보이는지를 판단하기 위해 가장 흔하게 사용했던 방식은 아마도 하위뷰의 onAppear, onDisappear에서 flag 값을 업데이트하는 방식이었을 겁니다.
이제 onAppear 시점과는 무관하게, ScrollView 내의 특정 뷰가 언제 화면에 보이기 시작하는지, 언제 화면에서 사라지기 시작하는지를 감지할 수 있습니다.
그 뷰의 크기가 최소 몇 % 노출되어야 "보인다"고 할 수 있는지까지도 threshold를 통해 세밀하게 조정할 수 있습니다.
예를 들어 뷰의 높이가 100일 때, threshold가 0.1이라면, 화면상에 이 뷰 높이의 10%인 10만큼만 노출되어도 "보인다"고 판단하게 됩니다.
이 수정자가 붙은 뷰가 화면에 노출된 영역의 크기가 threshold에 도달하면 action이 수행됩니다.
@State private var isNewlyVisibleItemID: SomeItem.ID? // 스크롤 영역에서 "보이기 시작한" 아이템의 id
var body: some View {
ScrollView {
LazyVStack{
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
.background{
// 새롭게 "보이는" 아이템에 빨간 테두리를 표시합니다.
if isNewlyVisibleItemID == item.id {
Rectangle().stroke(lineWidth: 3.0).foregroundColor(.red)
}
}
.onScrollVisibilityChange { isVisible in
print("[\(item.id)] isVisible: \(isVisible) ")
if isVisible {
isNewlyVisibleItemID = item.id
}
}
}
}
}
}visibility를 판단하고 싶은 특정 뷰의 총 크기가 화면 크기보다 클 때는 주의할 필요가 있습니다.
이 수정자의 threshold 값은 기본 0.5로 설정되어 있으므로 특정 뷰의 높이가 20,000인 아주 긴 뷰라면,
그 뷰 높이의 절반인 10,000만큼의 뷰가 화면상에 모두 노출될 수는 없기 때문에, visiblity 값은 항상 false 상태가 됩니다.
따라서 이럴 때, 일부라도 노출되었는지를 판단하기 위해서는 threshold 값을 조정할 수 있겠습니다.
var body: some View {
ScrollView {
LazyVStack{
ForEach(0..<100) { i in
veryLongView
.onScrollVisibilityChange{ isVisible in
print("Very Long View \(i) is visible? \(isVisible) ")
}
.onScrollVisibilityChange(threshold: 0.1) { isVisible in
print("Very Long View \(i) is Visible with threshold 0.1? \(isVisible)")
}
}
}
}
}
private var veryLongView: some View {
Text("VeryLongView needs special care")
.frame(height: 2000, alignment: .top)
.background(.orange)
} onScrollVisibilityChange(_:) 수정자는 스크롤 콘텐츠 내의 개별 뷰에 따로 붙여 동작한다면, 이 수정자는 주로 ScrollView 자체에 붙여 현재 보이는 뷰의 id 리스트를 넘겨받게 됩니다.
scrollTargetLayout() 수정자가 붙은 뷰 컨테이너 내부의 뷰들, 그. 중에서도 수정자에서 명시한 타입의 Id를 가진 뷰들의 visibility를 감지하며, 마찬가지로 threshold를 지정할 수 있습니다.
이 수정자를 이용하면, 현재 보이는 뷰들 중 가운데 뷰를 알아내는 등의 응용이 가능해집니다.
@State private var visibleItemIDs: [SomeItem.ID] = [] // 스크롤 영역 내에 현재 보이고 있는 아이템들의 id 목록
// 현재 보이고 있는 아이템 중 가장 가운데 item의 id
private var middleId: SomeItem.ID? {
guard visibleItemIDs.count > 0 else { return nil }
let index = visibleItemIDs.count / 2
return visibleItemIDs[index]
}
var body: some View {
ScrollView {
LazyVStack{
ForEach(SomeItem.previewItems) { item in
SomeItemView(item)
.background{
if let middleId, middleId == item.id {
Color.yellow
}
}
}
}
.scrollTargetLayout() // 이 수정자를 붙여준 레이아웃 내의 visibility를 알 수 있습니다.
}
.onScrollTargetVisibilityChange(idType: SomeItem.ID.self, threshold: 0.2){ ids in
print("visibleItemIDs: \(ids)")
visibleItemIDs = ids
}
}디바이스에 따라서 터치뿐만 아니라 다른 입력 방식을 통해서도 ScrollView를 조작할 수 있습니다.
예를 들어 애플 워치의 경우, 더블 탭을 하거나, 디지털 크라운을 돌리면 스크롤을 할 수 있죠. 이 수정자로 그런 입력방식을 각각 어떻게 처리할지 결정할 수 있습니다.
아래 예제처럼 watchOS에서 .handGestureShortcut 입력을 비활성화하면, 터치나 디지털 크라운 조작으로만 스크롤을 할 수 있고, 더블 탭으로 스크롤 하는 것은 막을 수 있습니다.
ScrollView(...)
.scrollInputBehavior(.disabled, for: .handGestureShortcut)SwiftUI의 ScrollView가 이렇게 지속적으로 발전된다고 하더라도, 실서비스에서 새로운 기능을 바로 적용한다는 것은 쉽지 않은 일입니다.
앱의 최소 지원 버전을 올려야만 하기 때문입니다. 최신 OS로 업데이트하지 않은 사용자들을 매몰차게 내칠 수 있는 서비스가 아닌 한, 새로운 기능들은 그림의 떡일 뿐입니다.
이럴 때는 역시 구관이 명관이라며 UIKit이 그리워지기도 하고요.
이번 섹션에서는 UIKit의 UIScrollView에서는 제공하지만, SwiftUI의 ScrollView에서는 미흡한 몇 가지 기능들을 살펴보겠습니다.
기존 서비스가 UIKit으로 구현되어 있고, 최소 지원 버전이 iOS 16.0 정도인 상황에서 아래 스크롤 기능들을 사용하고 있다면 SwiftUI로 이관할 때 큰 어려움이 따를 수도 있습니다.
물론 개발자들은 어떻게든 방법을 찾아내려는 사람들이기 때문에 아래와 같은 기능이 필요한 요구사항을 받는다면 다양한 방법으로 결국 구현해 내기도 합니다만,
공식적인 방법들이 아니기 때문에 여기서 따로 언급하지는 않겠습니다.
UIScrollView에서는 UIScrollViewDelegate 프로토콜을 통해 스크롤 동작의 굉장히 세밀한 타이밍까지 처리할 수 있습니다.
SwiftUI에서는 onScrollPhaseChange(_:) 수정자를 이용해 스크롤 상태 값을 받아 사용할 수 있지만
scrollViewDidScroll, scrollViewWillBeginDragging, scrollViewWillEndDragging, scrollViewDidEndDragging 같은 세밀한 시점을 제공해 주지는 않으며,
가장 최신 버전인 SwiftUI 6.0에서 소개된 기능이라 앱 최소 지원 버전을 iOS18.0으로 올려야만 사용할 수 있으므로 현실적으로 반영하기 쉽지 않은 상황입니다.
UIScrollView에서는 setContentOffset(_:animated:) 메서드를 이용해 스크롤 위치를 프로그래밍적으로 정확하게 설정할 수 있습니다.
SwiftUI에서는 ScrollViewReader(iOS 14.0+)와 ScrollPostion(iOS 17.0+)을 통해 특정 뷰로 스크롤 위치를 이동하는 정도의 간접적인 기능만 존재해 왔으며,
SwiftUI 6.0(iOS 18.0+)에 와서야 드디어 ScrollPosition을 통해 특정 좌푯값으로 이동할 수 있게 되었으므로 위 항목과 마찬가지로 당장 사용할 수 없을 확률이 높습니다.
UIScrollView는 기본적으로 줌 기능이 내장되어 있으며, UIScrollViewDelegate를 통해 줌 이벤트도 받아서 처리할 수 있지만, SwiftUI의 ScrollView에는 줌 기능이 없습니다.
UIScrollView에서는 indicatorStyle 속성을 통해 스크롤바의 스타일을 설정할 수 있지만, SwiftUI에서는 제한적입니다.
decelerationRate 속성을 통해 감속 속도를 일부 제어할 수 있지만 ScrollView에서는 제공하지 않습니다.
서두에서 ScrollView를 이용해 많은 양의 콘텐츠를 사용자에게 보여줄 때는, 성능 이슈가 발생하지 않는지 주의 깊게 살펴야 한다고 언급한 바 있습니다.
성능 이슈로 인해 CPU, 메모리 등의 리소스가 부족하면 앱이 일정 시간 이상 응답하지 않는 상태인 Hang이 발생할 수 있기 때문입니다.
Hang이 발생하면 사용자가 인터페이스를 아무리 조작해도 반응이 없고, 애니메이션이 끊기거나, 스크롤이 버벅대는 등의 증상이 나타나며 사용자는 앱이 "멈췄다"라고 느낍니다.
이는 앱의 사용성을 심각하게 저해하기 때문에 ScrollView 사용 시 대량의 데이터에도 이슈가 발생하지 않도록 적절한 최적화가 필요합니다.
이 섹션에서는 ScrollView 사용 시 성능 저하를 일으킬 가능성이 있는 대표적인 케이스들을 몇 가지 확인해 보려 합니다.
ScrollView를 사용하면서 성능 이슈가 발생할 수 있는 첫 번째 케이스는 앞서 언급했던 대량의 데이터를 다루는 스크롤 콘텐츠 뷰에 VStack, HStack 등의 뷰 컨테이너를 사용하는 경우입니다.
위에서도 언급했듯, VStack, HStack 등은 내부의 뷰를 한 번에 렌더링하기 때문에 데이터의 양이 많아질수록 오랜 시간이 걸리게 됩니다.
ScrollView 화면에 진입하는 데 오래 걸릴 때는 적절한 컨테이너 뷰를 사용하고 있는지 확인이 필요합니다.
화면에 보이는 뷰들만 렌더링 될 수 있게 LazyVStack, LazyHStack 등의 컨테이너 뷰를 사용해야 합니다.
두 번째로, 스크롤 내의 콘텐츠 뷰들의 body 계산이 상위 뷰에서 상당히 자주 바뀌는 어떤 값에 의존성을 가지고 있지는 않은지 확인해야 합니다.
SwiftUI에서 뷰는 하나의 구조체입니다. 이 뷰 구조체를 이용해 화면을 그리는 로직은 body 계산에 사용된 값에 의존성을 가집니다.
body를 구성하는 데 사용된 데이터가 변경되면, 이 값을 참고하는 뷰 구조체도 변경이 필요하다고 마킹됩니다.
마킹된 뷰들의 body는 다시 계산되고, 이전 계산값과 비교해(diffing) 달라진 부분이 있다면 화면에 다시 그리는 작업이 수행됩니다.
실제로 body 계산 결과가 달라지지 않아 화면이 다시 그려지지는 않더라도, 뷰 구조체가 참고하고 있는 데이터가 자주 변경되면 diffing을 위해 body 계산이 계속 유발되므로 성능 저하가 있을 수 있습니다.
거기다 diff의 결과로 값이 변경되었다고 판단되어 화면이 갱신되기 시작하면 main thread를 100% 잡아먹으며 높은 확률로 Hang을 만나게 됩니다.
따라서 SwiftUI에서 하위 뷰들이 어떤 값을 들고 있을 때는, 이게 정말 필요한 값인지 자주 되물어야 합니다.
아래 예제와 같이 ScrollView 내에 ForEach로 상당히 많은 수의 하위 뷰가 속해있는 상황에서 상위 뷰가 스크롤 contentOffset 값을 저장하고 있다가,
그 값을 하위 뷰에 Binding으로 전달한 뒤, 하위 뷰의 body에서 contentOffset.y가 음수인지 판단하는 로직이 있다고 생각해 봅시다.
일반적인 스크롤 동작에서 그 결과는 대부분 항상 false이므로 하위뷰에서는 실제로 변경될 것이 없습니다.
그런데도 contentOffset값은 스크롤할 때마다 갱신되므로 스크롤 한 번에 모든 뷰의 body가 수십수백 번 다시 계산되는 참사를 겪게 됩니다.
뷰는 화면을 그리는 데에 꼭 필요한 값들만 가지고 있어야 합니다.
이 예제에서는contentOffset이라는 값을 저장하고 매번 업데이트하는 것 보다 isBeyondZero라는 Boolean값을 가지고 있는 게 훨씬 낫습니다.
(하위 뷰에서 이 값을 수정할 일이 없으니 굳이 바인딩할 필요도 없습니다.)
struct ScrollGeometryBadBadExampleView: View {
@State private var scrollOffset: CGPoint = .zero
var body: some View {
let _ = Self._printChanges()
ScrollView {
LazyVStack {
ForEach(SomeItem.previewItems) {
BadItemView(item: $0, scrollOffset: $scrollOffset)
}
}
}
.onScrollGeometryChange(for: CGPoint.self) { geometry in
geometry.contentOffset
} action: { oldValue, newValue in
scrollOffset = newValue
}
}
}
struct BadItemView: View {
let item: SomeItem
@Binding var scrollOffset: CGPoint
var body: some View {
let _ = Self._printChanges()
let _ = print("item: \(item.id) body")
Text(scrollOffset.y < 0 ? "refreshing" : "\(item.text) \(item.id)")
}
}struct ScrollGeometryGoodGoodExampleView: View {
@State private var isBeyondZero: Bool = false
var body: some View {
let _ = Self._printChanges()
ScrollView {
LazyVStack {
ForEach(SomeItem.previewItems) {
GoodItemView(item: $0, isBeyondZero: isBeyondZero)
}
}
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y < 0
} action: { oldValue, newValue in
isBeyondZero = newValue
}
}
}
struct GoodItemView: View {
let item: SomeItem
let isBeyondZero: Bool
var body: some View {
let _ = Self._printChanges()
let _ = print("item: \(item.id) body")
Text(isBeyondZero ? "refreshing" : "\(item.text) \(item.id)")
}
}가장 많이 할 수 있는 실수로 이런 경우도 있습니다. @StateObject 형태로 뷰 모델 객체를 하나 만들고, 이 뷰 모델에 수많은 @Published 변수를 추가한 상태라고 생각해 봅시다.
이때 가장 쉽게 할 수 있는 실수는 하위 뷰에서 필요한 건 뷰 모델의 특정 변수 값 하나임에도 불구하고 전체 객체 자체를 하위 뷰의 @ObservableObject Property로 저장해놓고 사용하는 것입니다.
이 하위 뷰와는 아무 상관도 없는 Published 변수가 갱신되어도 ObservableObject가 변경되면 하위뷰 구조체가 변경되고, diffing을 위한 body 계산이 시작됩니다.
다시 한번 강조하지만, 뷰는 언제나, 정말 필요한 값만 가지고 있어야 합니다.
마지막으로 데이터의 양이 늘어날수록 그에 따라 계산 시간이 기하급수적으로 늘어나는 메서드를 ForEach 내에서 사용하고 있지는 않은지도 확인이 필요합니다.
예를 들어 특정 id값에 해당하는 데이터를 찾기 위해 ForEach문 안에서 firstIndex(where: )를 사용하게 된다면, 이 메서드는 시간복잡도가 O(n)이므로 ForEach안에서 사용되면 O(n^2) 만큼의 복잡도를 가지게 됩니다.
몇 개 되지 않는 데이터로 화면을 구현해 낼 때에는 성능 저하를 체감할 수 없으나 데이터가 쌓이는 실서비스에서 이슈로 터져 나올 수 있으므로 개발 및 검증 단계에서 대용량 데이터에 대한 테스트가 꼭 필요합니다.
이 글에서 우리는 ScrollView의 기본적인 구조와 사용법, SwiftUI의 버전이 올라감에 따라 새롭게 등장한 API들과 그 예제들을 확인해 보았습니다.
그리고 아직 UIKit에서만 제공하는 기능들은 무엇이 있는지, 어떤 경우에 성능 저하가 발생할 수 있는지까지 알아보았습니다.
글의 제목을 "ScrollView 톺아보기"로 정하고, 그 뜻을 다시 검색해 봤더니 "샅샅이 살핀다"는 뜻이더군요.
시간이 허락하는 한도 내에서 정말 가능한 한 샅샅이 살펴보려고 노력했습니다만 워낙 내용이 많다 보니 충분히 설명하지 못한 부분들도 많이 있을 것 같습니다.
특히 성능 저하 이슈는 실무에서 자주 겪을 수 있는 일이라 상세한 내용을 공유하고 싶었으나 성능 개선이 이 글의 주된 주제는 아니기 때문에 가장 중요하다고 생각되는 몇 가지만 추려서 이야기해 봤습니다.
대신 버전별로 제공되는 전체적인 API의 목록에 대한 소개와 주요 API들의 예제는 공식 예제를 기반으로 하면서도 중요한 기능을 확인할 수 있게 충실히 작성하려고 노력했습니다.
(혹시 읽으면서 오류를 발견하셨다면 너그러운 마음으로 양해 부탁드리며 저에게 알려주시면 감사하겠습니다.)
개인적으로는 ScrollViewReader와 같은 방식에서부터 ScrollPosition 방식까지 살펴보며 SwiftUI의 API 설계가 과도기와 암흑기(...)를 지나 어떻게 더 나은 방향으로 바뀌는지 볼 수 있어 흥미로웠습니다.
독자분들도 비슷하게 느끼셨을까요?
물론 아직은 최소 버전의 허들이 높아 오늘 소개해 드린 많은 신규 API를 사용하기 힘들 수도 있습니다.
2024년 현재 에이닷 iOS 앱도 최소 지원 버전이 iOS 16.0이기 때문에, 앞서 살펴본 소중한 스크롤 메서드 중 대부분이 아직 그림의 떡에 불과합니다.
저도 당분간은 위 기능 중 필요한 것들을 한땀 한땀 구현해 둔 로직들을 계속 유지해야 하겠지만,
사용자들의 OS 업데이트가 비교적 빠른 iOS의 사용자 특성을 감안하면 몇 년 내에 공식 API로 전환할 수 있지 않을까 기대해 봅니다.
그래도 언제 제공될지 모르는 API를 목 빠지게 기다리며 이 메서드들을 따로 구현하던 때보다 훨씬 상황이 나아지고 있다는 것만은 분명해 보이네요.
언젠가 이 API들을 사용해야 할 때가 오면 이 글의 예제를 다시 뒤적여 봐야겠다는 생각이 들었으면 좋겠습니다.
그럼, 최소 지원 버전을 iOS 18.0으로 올릴 그날을 기약하며! 이만 인사드립니다.
긴 글 읽어주셔서 감사합니다.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.