본문 바로가기

프로젝트/Film-in

CarouselView - DragGesture -> ScrollView -> UICollectionView

현재 내 프로젝트의
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를 사용하는 경우
NavigationLinkTapGestureDragGesture우선순위 충돌로 인해
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()
    }
}

 

하지만 이 구현에도 치명적인 문제가 있었는데
DragGestureonEnded가 호출이 되지 않는다는 것이다.

이것이 왜 문제냐 한다면,
드래그종료되는 시점에 스크롤 위치가
threshold값에 도달하지 못하는 경우 전 인덱스스냅되도록
스크롤 애니메이션을 적용해줘야 하는데 그러지 못해
스냅되지 못하고 멈춰버리는 현상이 있다는 문제가 생기며
현재 나의 지식과 구글링의 수준에서는 해결하지 못하였다.

 

3️⃣ 3차 구현 -> UICollectionView

결국 돌고돌아 마지막 보류의 선택지로 생각해뒀던

UIKit - UICollectionView(Compositional Layout)으로 코드를 작성 후,
UIViewRepresentable로 래핑하여 사용하는 방법으로 구현하게되었다.

 

코드를 나열한 후 설명한다면

음...설명할 부분이 많진 않다.

동일하게 제네릭 형태의 Item과 View를 받아 사용하며

  • UICollectionView를 활용
    • Compositional Layout
      • section.orthogonalScrollingBehavior = .groupPagingCentered
        을 통해 Paging되는 Snap CarouselView를 구현
    • DiffableDataSource
  • 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를 전달해주지만
CoordinatorReference 타입이라도 PagingViewValue 타입이므로
복사 연산이 일어나기 때문에 내부 값이 업데이트되지 않는 것이다.

결론적으로는 updateUIView() 메서드에서 self를 다시
복사하도록 해줘 해결하였다.

func updateUIView(_ uiView: UICollectionView, context: Context) {
    context.coordinator.parent = self
    context.coordinator.applySnapshot()
}