현재 내 프로젝트의
iOS Target Version은 16.4이며
프레임워크는 SwiftUI를 사용중이다.
Horizontal 방향으로 Snap을 스크롤을 넘기는
CarouselView 구현을 진행했다.
Target Version을 17.0으로 잡았다면
17.0에 새로 나왔던 기능중 하나인
ScrollTargetBehavior를 사용하여
간단하게 구혔했을 것이다.
https://developer.apple.com/documentation/swiftui/scrolltargetbehavior
ScrollTargetBehavior | Apple Developer Documentation
A type that defines the scroll behavior of a scrollable view.
developer.apple.com
1️⃣ 1차 구현 -> DragGesture
여러 Blog와 Youtube 영상을 참고하며
DragGesture를 활용하여 Custom Carousel View를 구현
코드를 먼저 나열해놓은 후 설명한다면
- 제네릭 형태의 View와 View에 사용될 Item을 전달
- .offset() - 드래그된 offset과 index 등 포함하여 계산된 값으로 Snap되도록 content의 offset 조정
- DragGesture
- .updating() - 드래그 발생 시, offset을 저장
- .onEnded() - 드래그 종료 시, 이동 방향과 거리를 계산하여 index를 계산 및 저장
- .onChanged() - 드래그 중 , 이동 방향과 거리를 계산하여 Index를 계산 및 저장
import SwiftUI
struct SnapCarousel<Content: View, Item: Identifiable>: View {
private let content: (Item) -> Content
private let list: [Item]
private let spacing: CGFloat
private let trailingSpace: CGFloat
@Binding private var index: Int
// offset
@GestureState private var offset: CGFloat = 0
init(spacing: CGFloat = 15, trailingSpace: CGFloat = 100, index: Binding<Int>, items: [Item], @ViewBuilder content: @escaping (Item) -> Content) {
self.list = items
self.spacing = spacing
self.trailingSpace = trailingSpace
self._index = index
self.content = content
}
var body: some View {
GeometryReader { proxy in
let width = proxy.size.width - (trailingSpace - spacing)
let adjustMentWidth = (trailingSpace / 2) - spacing
HStack(spacing: spacing) {
ForEach(list, id: \.id) { item in
content(item)
.frame(width: proxy.size.width - trailingSpace)
}
}
.padding(.horizontal, spacing)
.offset(x: (CGFloat(index) * -width) + (index != 0 ? adjustMentWidth : 0) + offset)
.gesture(
DragGesture()
.updating($offset, body: { value, state, _ in
state = (value.translation.width / 1.5)
})
.onEnded({ value in
let offsetX = value.translation.width
let progress = -offsetX / width
let roundIndex = progress.rounded()
index = max(min(index + Int(roundIndex), list.count - 1), 0)
})
.onChanged({ value in
let offsetX = value.translation.width
let progress = -offsetX / width
let roundIndex = progress.rounded()
index = max(min(index + Int(roundIndex), list.count - 1), 0)
})
)
}
.animation(.easeInOut, value: offset == 0)
}
}
하지만 위 코드 호출 부에서 NavigationLink
를 사용하는 경우NavigationLink
의 TapGesture
와 DragGesture
의 우선순위 충돌로 인해TapGesture
가 우선적으로 호출이 되어 DragGesture
가 호출되지 않는
현상이 발견되었다.
.gesture()
메서드를 .highPriorityGesture()
로 변경하여DragGesture
를 더 우선적으로 변경하여 해결하였다.
GeometryReader { proxy in
let width = proxy.size.width - (trailingSpace - spacing)
let adjustMentWidth = (trailingSpace / 2) - spacing
HStack(spacing: spacing) {
ForEach(list, id: \.id) { item in
content(item)
.frame(width: proxy.size.width - trailingSpace)
}
}
.padding(.horizontal, spacing)
.offset(x: (CGFloat(index) * -width) + (index != 0 ? adjustMentWidth : 0) + offset)
// .gesture(DragGesture())
.highPriorityGesture(DragGesture())
}
.animation(.easeInOut, value: offset == 0)
하지만 스크롤 애니메이션이 너무 딱딱하다는 유저의 리뷰가 있었다.
2️⃣ 2차 구현 -> ScrollView
부드러운 스크롤 애니메이션을 위해
ScrollView와 ScrollViewReader를 활용하여 구현하려했다.
코드를 나열하여 설명해보자면
- scrollObserver() 뷰빌더 메서드와 PreferenceKey를 채택한 ScrollOffsetKey
를 활용하여 스크롤에 대한 offset을 전달받음 - valueChanged() 내부에서 offset이 수정될 경우 offsetX 값을 기준으로
인덱스를 계산하며 스크롤 위치가 threshold 값에 도달하게 되면 해당 인덱스로 스냅
되도록 스크롤 애니메이션
struct CustomPagingView: View {
@State private var offsetX: CGFloat = .zero
@State private var index = 0
var body: some View {
NavigationStack {
ScrollViewReader { proxy in
ScrollView(.horizontal) {
scrollObserver(offsetX: $offsetX)
HStack(spacing: 12) {
ForEach(0..<10) { item in
NavigationLink {
VStack {
Image(systemName: "square.and.arrow.up")
}
} label: {
Rectangle()
.frame(width: item == index ? 300 : 300 * 0.9,
height: item == index ? 400 : 400 * 0.9)
.foregroundStyle(.black)
.id(item)
}
}
}
.padding(.horizontal)
}
.scrollIndicators(.hidden)
.highPriorityGesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
print("onChanged, \(value.velocity)")
}
.onEnded { _ in
// TODO: scrollTo 메서드 호출하여 content 이동
}
)
.valueChanged(value: offsetX) { value in
let threshold: CGFloat = 50
let cellWidth: CGFloat = 300 * 0.9
let index = Int(round(-value / cellWidth))
if abs(value + CGFloat(index) * cellWidth) < threshold {
if self.index != index {
withAnimation(.easeInOut) {
proxy.scrollTo(index, anchor: .center)
self.index = index
}
}
}
}
}
}
}
@ViewBuilder
private func scrollObserver(offsetX: Binding<CGFloat>) -> some View {
GeometryReader { proxy in
let offsetX = proxy.frame(in: .global).origin.x
Color.clear
.preference(
key: ScrollOffsetKey.self,
value: offsetX
)
}
.onPreferenceChange(ScrollOffsetKey.self) { offset in
offsetX.wrappedValue = offset
}
.frame(height: 0)
}
}
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}
하지만 이 구현에도 치명적인 문제가 있었는데DragGesture
의 onEnded
가 호출이 되지 않는다는 것이다.
이것이 왜 문제냐 한다면,
드래그가 종료되는 시점에 스크롤 위치가
threshold값에 도달하지 못하는 경우 전 인덱스로 스냅되도록
스크롤 애니메이션을 적용해줘야 하는데 그러지 못해
스냅되지 못하고 멈춰버리는 현상이 있다는 문제가 생기며
현재 나의 지식과 구글링의 수준에서는 해결하지 못하였다.
3️⃣ 3차 구현 -> UICollectionView
결국 돌고돌아 마지막 보류의 선택지로 생각해뒀던
UIKit - UICollectionView(Compositional Layout)으로 코드를 작성 후,
UIViewRepresentable로 래핑하여 사용하는 방법으로 구현하게되었다.
코드를 나열한 후 설명한다면
음...설명할 부분이 많진 않다.
동일하게 제네릭 형태의 Item과 View를 받아 사용하며
- UICollectionView를 활용
- Compositional Layout
section.orthogonalScrollingBehavior = .groupPagingCentered
을 통해 Paging되는 Snap CarouselView를 구현
- DiffableDataSource
- Compositional Layout
- ViewBuilder를 활용하여 호출부에서 Item을 전달받아
Content를 그려주는 형태
import SwiftUI
struct PagingView<Item: Hashable, Content: View>: UIViewRepresentable {
private let items: [Item]
private let spacing: CGFloat?
private var content: (Item) -> Content
init(
items: [Item],
spacing: CGFloat? = 0,
@ViewBuilder content: @escaping (Item) -> Content
) {
self.items = items
self.spacing = spacing
self.content = content
}
func createLayout() -> UICollectionViewLayout {
let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: spacing ?? 0, bottom: 0, trailing: spacing ?? 0)
let containerGroup = NSCollectionLayoutGroup.horizontal(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.80),
heightDimension: .fractionalHeight(1.0)),
subitems: [item])
let section = NSCollectionLayoutSection(group: containerGroup)
section.orthogonalScrollingBehavior = .groupPagingCentered
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
func makeUIView(context: Context) -> UICollectionView {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
collectionView.isPagingEnabled = true
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceVertical = false
collectionView.delegate = context.coordinator
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
context.coordinator.configureDataSource(for: collectionView)
return collectionView
}
func updateUIView(_ uiView: UICollectionView, context: Context) {
context.coordinator.applySnapshot()
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UICollectionViewDelegateFlowLayout {
var parent: PagingView
var dataSource: UICollectionViewDiffableDataSource<Int, Item>!
init(_ parent: PagingView) {
self.parent = parent
}
func configureDataSource(for collectionView: UICollectionView) {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, Item> { cell, indexPath, identifier in
cell.contentConfiguration = UIHostingConfiguration { [weak self] in
self?.parent.content(identifier)
}
}
dataSource = UICollectionViewDiffableDataSource<Int, Item>(collectionView: collectionView) { collectionView, indexPath, item in
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
}
applySnapshot()
}
func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
snapshot.appendSections([0])
snapshot.appendItems(parent.items)
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
물론 이 구현 코드에서도 문제가 하나있었다.
API를 통해 데이터를 비동기적으로 Fetch하여 받아와
PagingView의 Item으로 데이터를 전달해줬지만
View에서는 그려지지 않는 현상이 발견되었다.
정확한 이유가 맞는가? 라고 물어본다면
그것은 확실하진 않지만 이 부분에서 문제가 발생하는 건
확실하다 라고 말할 수 있는 부분이 보였다.
Value 타입과 Reference 타입의 차이와 SwiftUI의 뷰 렌더링 작동방식에서
발생하는 문제라고 생각이 되는 부분이 보였는데
API 요청을 통해 비동기적으로 데이터를 받아오게되면서
Item의 변동이 생겨 PagingView를 재렌더링 하게될 것이고
그로인해 updateUIView()
메서드가 호출이 될 것이다.
이 부분이 문제인 것이다.makeCoordinator()
메서드를 통해 PagingView인 self를 전달해주지만
Coordinator가 Reference 타입이라도 PagingView가 Value 타입이므로
복사 연산이 일어나기 때문에 내부 값이 업데이트되지 않는 것이다.
결론적으로는 updateUIView()
메서드에서 self를 다시
복사하도록 해줘 해결하였다.
func updateUIView(_ uiView: UICollectionView, context: Context) {
context.coordinator.parent = self
context.coordinator.applySnapshot()
}
'프로젝트 > Film-in' 카테고리의 다른 글
SwiftUI에서 DIContainer를 활용한 의존성 주입(Dependency Injection) (1) | 2025.01.21 |
---|---|
메모리 사용량을 줄이기 위한 노력 - 이미지 다운샘플링 (0) | 2024.12.23 |
Memory Leak - Combine Sink, Swift Concurrency (1) | 2024.12.23 |
메모리 사용량을 줄이기 위한 노력 - Kingfisher 사용 중 일어난 일 (2) | 2024.12.23 |