-
Beyond Scroll Views (feat. WWDC 2023)SwiftUI 2023. 6. 15. 11:05
안녕하세요. 그린입니다🍏
이번 포스팅에서는 SwiftUI의 API를 사용해 스크롤 뷰를 더 잘 활용하는 방법을 보겠습니다🙋🏻
특히 이전과는 방식으로 다른 스크롤 뷰를 커스텀하게 정의하는걸 보려해요.
우리가 처리하는 많은 컨텐츠는 고정된 화면에 다 담을 수 없어 스크롤링을 많이 이용합니다.
이를 통해 화면에 많은것을 담을 수 있죠.
SwiftUI는 스크롤을 앱에 통합할 수 있는 몇가지 다른 컴포넌트들을 제공합니다🙃
이번 포스팅에서는 그 중 하나인 ScrollView를 보겠습니다!
ScrollView에 대한 간략 정리
스크롤 뷰는 컨텐츠를 스크롤할 수 있는 building block입니다.
스크롤 뷰는 스크롤 방향을 설정하고 내부 컨텐츠를 담아주죠.
해당 컨텐츠가 스크롤 뷰의 크기를 초과하면 해당 컨텐츠 중 일부가 잘리고 사람들이 컨텐츠를 표시하려면 스크롤해야 하죠.
ScrollView(.vertical) { VStack { ForEach(items) { item in ItemView(item: item) } } }
기본적으로 사용은 이러한데 만약 스택에 많은 아이템이 쌓이게 되면 보이지 않는 아이템들도 현재는 VStack으로 감싸져 다 받아오고 뷰를 그립니다.
즉 데이터가 많을 수록 메모리 낭비가 심하겠죠?
그래서 우리는 최소한의 뷰만 화면에 노출하고 스크롤 시 이후 나타나는 필요한 데이터들의 아이템은 그때 가져오고 싶습니다.
그렇기 위해 아래와 같이 LazyVStack을 ScrollView로 구성 시 많이 이용하죠!
ScrollView(.vertical) { LazyVStack { ForEach(items) { item in ItemView(item: item) } } }
컨텐츠 내에서 스크롤 뷰가 스크롤되는 위치를 content offset이라고 부릅니다.
SwiftUI는 이러한 컨텐츠 오프셋을 제어하기 위해 ScrollViewReader API를 제공합니다.
WWDC 2023에서는 SwiftUI에서 스크롤 뷰가 관리하는 컨텐츠 오프셋에 영향을 미치고 반응하는 더 많은 방법을 소개했습니다!
그럼 본격적으로 그 주제로 넘어가보시죠🕺🏻
Margins and safe area
스크롤 뷰의 여백에 영향을 미치는 방법과 safe area의 관계에 대해 알아보시죠!
우선 기본적으로 이러한 수평 스크롤 뷰가 있다고 가정해볼까요?
import SwiftUI struct ContentView: View { var colors: [Color] = [.red, .green, .blue, .yellow] var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: 5) { ForEach(colors, id: \.self) { color in ChildView(color: color) } } } } } private struct ChildView: View { fileprivate var color: Color fileprivate init(color: Color) { self.color = color } fileprivate var body: some View { Rectangle() .fill(color) .frame(width: 200, height: 100) } }
지극히 평범한 수평 스크롤 뷰 이며 아래와 같이 단순히 나열됩니다.
여기서 ScrollView에 horizontal padding을 줄 수 있는건 아실거에요.
그럼 좌 우측에 지정된 값만큼 패딩이 들어갑니다.
그런데 아래와 같이 패딩으로 준 뷰는 스크롤 시 좌 우측 패딩이 여전히 잡혀있어요.
요렇게 말이에요!
safeAreaPadding
우리는 스크롤 시에는 safearea까지 확장하고 싶고 이 스크롤 뷰 컨텐츠 양 끝에서만 패딩을 주고 싶습니다.
그럴때는 이번 SwiftUI에서 새로 나온 safeAreaPadding 모디파이어를 사용할 수 있습니다.
ScrollView(.horizontal) { LazyHStack(spacing: 5) { ForEach(colors, id: \.self) { color in ChildView(color: color) } } } .safeAreaPadding(.horizontal, 5)
스크롤 뷰는 컨텐츠에 적용되는 여백으로 safe area를 확인합니다.
contentMargins
스크롤 뷰에는 스크롤 인디케이터와 같이 추가 컨텐츠도 포함될 수 있죠.
만약 다른 insets들을 적용하고 싶다면 contentMargins API를 이용할 수 있습니다.
보시면 Safe Area로부터 Content Margin을 설정할 수 있죠.
.contentMargins(.vertical, 50, for: .scrollContent) .contentMargins(.vertical, 50, for: .scrollIndicators)
스크롤 컨텐츠와 인디케이터를 별도로 제어할 수 있습니다.
만약 아래 scrollIndicators의 컨텐츠 마진을 많이 주면 이렇게 보일 수 있죠.
인디케이터가 safe area로 부터 위로 올라옵니다.
이렇게 스크롤 뷰에 여백을 적용할 수 있습니다.
뭐 정리해보자면 safeAreaPadding과 어떻게 보면 동작이 유사하다고 볼 수 있습니다.
자 그럼 이제 스크롤 뷰의 컨텐츠 오프셋을 제어해볼까요?
scrollTargetBehavior
기본적으로 스크롤 뷰는 스크롤 속도와 감속률을 사용해 스크롤이 끝나야 하는 대상 컨텐츠 오프셋을 계산합니다.
스크롤 뷰나 해당 컨텐츠의 크기와 같은 사항은 고려하지 않죠.
SwiftUI의 새로운 기능으로 scrollTargetBehavior 모디파이어를 이용해 대상 컨텐츠 오프셋을 계산하는 방법을 변경할 수 있습니다.
.scrollTargetBehavior(.paging)
이렇게 만약 paging으로 스크롤 뷰를 처리하면 한번에 한 페이지씩 스와이프 됩니다.
이렇게 말이죠!
그런데 저것도 보시면 왼쪽 오른쪽에 약간씩 이전 뷰가 나오는게 보이시죠!?
뷰가 정렬되지 않고 그냥 페이징 처리만 되어서 그렇습니다.
저 구현을 의도할 수도 있지만 만약 아이패드와 같이 기기 사이즈가 커진다면 하나씩 보여야될 것이 뷰가 두개씩 보일 수 있습니다.
만약 뷰를 정렬하고 하나씩만 딱 보이고 싶다면 아래와 같이 구현할 수 있습니다🙋🏻
ScrollView(.horizontal) { LazyHStack(spacing: 15) { ForEach(colors, id: \.self) { color in ChildView(color: color) } } .scrollTargetLayout() } .contentMargins(.horizontal, 10) .scrollTargetBehavior(.viewAligned) }
scrollTargetBehavior를 viewAligned으로 주고 해당 감싸진 컨텐츠에 scrollTargetLayout 모디파이어를 사용해줍니다.
즉 스택 내 뷰가 스크롤 대상으로 간주되도록 해주죠.
자 이제 더 깔끔해졌죠😃
Lazy 스택을 사용해야 하는 경우면 scrollTargetLayout 모디파이어를 사용하는것이 중요합니다.
보이지 않는 영역의 뷰는 아직 생성되지 않죠.
그러나 레이아웃은 생성할 뷰에 대해 알고 있기에 스크롤 뷰가 올바른 위치로 스크롤되도록 할 수 있습니다!
커스텀한 ScrollTargetBehavior
위에서 봤던 페이징이나 뷰 얼라인 속성들은 ScrollTargetBehavior 프로토콜을 따르는데 이 프로토콜을 채택해 커스텀하게도 만들 수 있습니다.
struct GalleryScrollTargetBehavior: ScrollTargetBehavior { func updateTarget(_ target: inout ScrollTarget, context: TargetContext) { if target.rect.minY < (context.containerSize.height / 3.0), context.velocity.dy < 0.0 { target.rect.origin.y = 0.0 } } }
위 코드와 같이 필수 메서드인 updateTarget 메서드를 구현해줘야 합니다.
해당 메서드는 스크롤이 끝나는 위치를 계산할 때 뿐만 아니라 스크롤 뷰가 크기를 변경할 때와 같은 다른 컨텍스트에서도 해당 메서드를 호출합니다😊
원하는 코드를 넣어 스크롤 뷰의 행동에 영향을 줄 수 있습니다.
우리는 SwiftUI를 사용하면서 GeometryReader의 기능을 익숙하게 사용했을거에요.
뷰의 크기를 측정하여 의미있는 코드를 구현해줬습니다.
그런데 이제 SwiftUI에서는 좀 더 편리한 새로운 API를 제공해줍니다.
containerRelativeFrame
해당 뷰의 컨테이너 넓이가 변경되어도 뷰의 크기가 자동으로 업데이트 됩니다.
ChildView(color: color) .frame(height: 300) .containerRelativeFrame(.horizontal)
요렇게 height는 고정이지만 horizontal을 넣어 해당 모디파이어를 사용하면 기기 혹은 뷰의 사이즈가 늘어나더라도 그에 맞춰 해당 뷰의 넓이가 늘어납니다.
또한 이러한 조건을 통해 그리드 뷰 처럼 만들 수도 있습니다.
.containerRelativeFrame( .horizontal, count: 2, spacing: 10 )
2개의 뷰를 보여주고 간격은 10으로 주는 등 그리드 구현을 충분히 해줄 수 있죠.
추가로 아이패드인지 아이폰인지에 따라 sizeClass 환경 변수를 통해 조건을 둘 수 도 있습니다.
import SwiftUI struct ContentView: View { @Environment(\.horizontalSizeClass) private var sizeClass ... var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: 15) { ForEach(colors, id: \.self) { color in ChildView(color: color) .containerRelativeFrame( .horizontal, count: sizeClass == .regular ? 2 : 1, spacing: 10 ) } } .scrollTargetLayout() } .contentMargins(.horizontal, 10) .scrollTargetBehavior(.viewAligned) } }
이제는 모든 플랫폼에서 sizeClass라는 환경 변수를 이용할 수 있기에 OS 조건식을 제거하고 사용할 수 있다는 점도 하나의 포인트😃
aspectRatio
해당 모디파이어를 이용해 굳이 프레임을 고정시키지 않아도 비율에 맞게 상대적인 높이를 가지도록 할 수 있습니다.
ChildView(color: color) .aspectRatio(16.0 / 9.0, contentMode: .fit)
scrollIndicators
해당 모디파이어를 이용해 스크롤 인디케이터를 숨겨줄 수 있습니다.
.scrollIndicators(.hidden)
다만 hidden의 기본 구현은 트랙패드 및 사람의 손과 같은 유연한 입력 장치를 사용할 때는 숨기지만 마우스가 연결되어 마우스로 컨트롤을 해야한다면 인디케이터가 표시됩니다.
마우스로는 인디케이터가 없으면 스크롤하기가 더 어려워서 사용성을 위함입니다.
만약 마우스고 뭐고 다 숨기고 싶다! 하면 never값으로 해당 모디파이어를 사용하면 됩니다🫠
이렇게 스크롤 뷰의 레이아웃과 스크롤 동작을 살펴보았습니다🏄🏻♂️
그럼 이제 버튼등이나 어떠한 액션을 이용해 특정 뷰의 오프셋으로 이동을 시켜볼까요?Targets and positions
스크롤 대상 및 스크롤 위치를 통해 스크롤 뷰의 컨텐츠 오프셋을 관리하는 방법에 대해 보시죠!
섹션의 예시로 이러한 ID값 바인딩을 통해 해당 ID가 있는 뷰로 스크롤 할 수 있습니다.
scrollPosition 모디파이어는 ScrollTargetBehavior와 마찬가지로 ID 값을 쿼리하기 위한 뷰를 파악합니다.
물론 스크롤을 해줘도 ID 바인딩 값은 자동으로 업데이트 됩니다.
더 나아가서 스크롤 뷰의 위치에 따라 뷰를 시각적으로 변경하고 싶다면 어떻게 할까요?
Scroll transitions
스크롤 전환을 사용해 앱에 실제 기능을 추가하는 방법을 보겠습니다!
이제 SwiftUI에서는 ScrollTransitions API를 제공해줍니다.
일반적인 transition과 비슷하게 뷰가 나타나거나 사라질때 변경 사항을 넣어줄 수 있죠.
ScrollTransition을 통해 뷰가 스크롤 뷰의 가시 영역에 들어가거나 가시 영역을 떠날 때 적용할 수 있습니다.
.scrollTransition(axis: .horizontal) { content, phase in content.scaleEffect( x: phase.isIdentity ? 1.0 : 0.8, y: phase.isIdentity ? 1.0 : 0.8 ) .rotationEffect(.degrees(phase.isIdentity ? 0.0 : 10.0)) .offset( x: phase.isIdentity ? 0 : 20, y: phase.isIdentity ? 0 : 20 )
scrollTransition 모디파이어를 통해 content에 scaleEffect, rotationEffect, offset 등을 컨트롤 해줄 수 있죠.
단 font 모디파이어는 지원되지 않으면 스크롤 뷰의 전체 컨텐츠 크기를 변경하는 속성들도 scrollTransition 모디파이어에선 사용할 수 없습니다.
마무리
음 스크롤 뷰를 좀 더 편리하게 사용할 수 있다 정도!?
해당 예제 코드들은 아래 깃헙 레포에서 확인할 수 있습니다🙋🏻
https://github.com/GREENOVER/playground/tree/main/SwiftUIScrollView
참고 자료
'SwiftUI' 카테고리의 다른 글
SwiftUI로 캘린더 직접 구현하기 (3탄 - 보완된 캘린더) (6) 2023.06.29 SwiftUI에서 Tooltip 구현하기 (9) 2023.06.22 Explore SwiftUI Animation (WWDC 2023) (3) 2023.06.08 Advanced animations in SwiftUI (feat. WWDC 2023) (12) 2023.06.08 Discover Observation in SwiftUI (feat. WWDC 2023) (6) 2023.06.08