iyOmSd/Title: SwiftUI

[SwiftUI] CarouselView 근데 이제 UIKit을 곁들인...

냄수 2025. 1. 29. 17:52
반응형

iOS16기준으로 적용된 글입니다

 

CarouselView를 만들면서 고민했던 내용을 정리해보려합니다.

 

이 뷰가 어떤거나면요

배너같은거를 페이징하면 계속 무한으로 보여지는 아래같은 뷰에요

SwiftUI로는 위와같은 뷰를 만드는데 한계가 있었어요

다른 글들을 참고도 해보고 생각도 해봤지만

GeometryReader + offset을 이용해서 한다던가

TabView를 사용한다던가

동작은 그럴싸하게 보일 수 있지만 

 

마지막인덱스 -> 첫인덱스로 다시 돌려야하는 내부로직이 돌아갈때(무한 스크롤을 위해)

자연스럽게 스크롤되지 않는 부분이 존재해요

원하는 시점에 처리를 할 수 없는게 가장 큰 원인이라고 생각해요

 

이 부분은 아마 iOS18에서 onScrollPhaseChange가 추가되서

해결 될수 있을지도 모르지만

대부분 서비스는 타겟이 낮을거라

저와 같은 고민이 있을 수 있을 수 있어요

 

UIKit을 이용하면 스무스하게 넘어갈 수 있습니다

아직 특정한 뷰들은

힘들게 SwiftUI보다 UIKit을 사용하는 방향으로 가는게

좋은것 같아요

 

여기서 이제 또다른 고민이 생겼어요

이 Carousel뷰를 여러화면에서 사용하고싶은데

 

보통 UIKit이라면

1. collectionView를 각 뷰마다 만들어서

보일러 플레이트코드같이

복붙을 사용해서 각 위치마다 구현을 해주거나

또는

2. Cell만 정의하면 되도록 CollectionView 자체를 컴포넌트화

 

이렇게 1번 아님 2번의 방법으로 사용하지 않을까 생각했어요

 

1,2번 모두 SwiftUI기반에서는 불편하다고 생각들었어요

1번은 1번사용할때마다 구현해줘야하고

2번은 생각해보면 제네릭하게 만들어야해서

구현할때 뭔가 복잡할거같고

필요한 cell마다 UIKit으로 작성해야한다는 불편함이 있다고 생각했어요

 

 

위의 고민을 바탕으로

구현을 시작해볼게요

 

 

구현 목표는

Carousel역할을 하는 뷰만 만들고

사용시 Cell에 해당하는 부분을 외부에서 주입할 수 있도록

컴포넌트화를 목표로 했어요

 

 

중요 로직만 간단하게 살펴볼게요

 

UIKit의 CollectionView를 이용했고

동적 사이즈에 잘 대응하도록

Compositional Layout을 사용하려고 했으나

ScrollDelegate를 사용할 수 없는 문제가 있어서

FlowLayout을 사용했습니다.

 

scrollViewDidEndDecelerating 시점에 인덱스를 변경해줍니다. 

 

현 인덱스가 처음일땐 끝으로

끝일땐 처음으로

offset을 조정해서 무한 스크롤이 자연스럽게 이뤄지도록 

정의하는 로직을 포함합니다.

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        if Int(scrollView.contentOffset.x) == 0 {
            scrollView.setContentOffset(
                .init(x: cellWidth * CGFloat(count-2), y: 0),
                animated: false
            )
        } else if Int(scrollView.contentOffset.x) == (count-1) * Int(cellWidth) {
            scrollView.setContentOffset(
                .init(x: cellWidth, y: 0),
                animated: false
            )
        }
        let index = Int(scrollView.contentOffset.x / cellWidth)
        currentIndex = index
    }

왜 이때하나요?

-> 애니메이션 과정이 끝난 시점이기때문에 

페이지를 이동시키는 효과에 이슈가 생기지 않습니다.

다른시점에하면 애니메이션이 중복되서

페이징 혹은 offset변경시 원치않는 위치로 간다던가

멈추는 현상을 볼 수 있어요

 

 

Cell정의는 CellRegistration과 contentConfiguration을 이용했습니다.

UICollectionView.CellRegistration { cell, indexPath, item in ...
  cell.contentConfiguration = UIHostingConfiguration { }
}

CellRegistration을 이용해서 Cell을 등록시키고

 

cell.contentConfiguration = UIHostingConfiguration { }

위 코드를 이용해서

Cell을 외부에서 주입받은 뷰로 구현할수 있도록 했습니다.

 

 

이렇게 구현했을때 생긴 이슈로는

 

1.

순수한 SwiftUI뷰를 cell로 넘겨줫을때는 잘 동작했지만

UIViewControllerRepresentable을 사용해서 랩핑한 뷰는

'UIViewControllerRepresentable is not supported inside of UIHostingConfiguration'

에러 문구를 띄웁니다.

뷰컨트롤러로 만든 뷰를 배너로 사용할때 발생할 수 있습니다.(외부SDK의존 때문에 뷰컨을 사용해야만하는경우)

 

->ViewController.view를 UIViewRepresentable로 사용하여 해결할 수 있습니다.

 

2.

같은 컬랙션뷰안에 구성이 다른 뷰가 들어갈 경우 + page컨트롤이 가능해야합니다.

 

-> View 배열을 사용해서 해결했습니다.

 

3.

CollectionViewCell에 minimumLineSpacing, minimumInteritemSpacing값이 0임에도 패딩이 존재

UICollectionView.CellRegistration { cell, indexPath, item in ...
  cell.contentConfiguration = UIHostingConfiguration { 
  }
  .margins(.all, 0) <<<
}

-> margins(.all, 0) 추가로 해결

 

구현후 사용법은 아래와같아요

#Preview {
    CarouselView2(viewArray: [Color.red, Color.blue, Color.green, Color.yellow])
        .frame(height: 300)
}

4개의 뷰를 전달했다면

1234, 1234, 1234 3세트에

앞뒤로 처음과 마지막을 자연스럽게 연결시켜서 사용할 수 있도록 로직을 구현했습니다.

-> 4 1 2 3 4 1 2 3 4 1 2 3 4 1

 

3세트를 사용한 이유는 빠르게 스크롤시

맨 마지막 인덱스에서 처음인덱스를 불러와야하는데 동작하지 않는경우가 있어서

어색해서 여유분을 추가한거라

이부분이 없어도된다면

제거해도 큰 지장은 없습니다!

 

 

쉽고 간단하게 사용할 수 있고

원하는 뷰만 넣으면

무한 페이징 배너가 될 수 있게 구현을 완료했습니다. 👏

 

 

아래는 전체코드입니다

final class TimerManager: ObservableObject {
    @Published var event: Void = ()
    private var timer: Timer?
    var isRunning: Bool { timer != nil }
    
    func start(interval: TimeInterval) {
        guard timer == nil else { return }
        
        timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
            guard let self else { return }
            event = ()
        }
    }
    
    func stop() {
        timer?.invalidate()
        timer = nil
    }
}

/**
 배너 1세트를 앞뒤로 복사하여 3세트를 만들고
 인덱스 0과, 마지막 배너를 하나씩 양끝에 추가하여
 자연스러운 스크롤을 대응하도록 구현
 [1,2,3]배너일때 -> [3,1,2,3,1,2,3,1,2,3,1]로 변경됨
 
 - note: 배너갯수 2개 이상부터 페이징 타이머 시작 및 인덱스 노출
 */
struct CarouselView2<Content: View>: UIViewControllerRepresentable {
    private let originCount: Int
    private let count: Int
    private let interval: TimeInterval
    private let pageLabelBottomPadding: CGFloat
    @State private var viewArray: [Content]
    
    init(
        viewArray: [Content],
        interval: TimeInterval = 3,
        pageLabelBottomPadding: CGFloat = 16
    ) {
        self.originCount = viewArray.count
        var viewArray = viewArray
        /// 3세트 가공하는 부분
        if viewArray.count > 1 {
            viewArray = viewArray + viewArray + viewArray
            let firstData: Content = viewArray.first!
            let lastData: Content = viewArray.last!
            viewArray.insert(lastData, at: 0)
            viewArray.append(firstData)
        }
        self._viewArray = State(initialValue: viewArray)
        self.count = viewArray.count
        self.interval = interval
        self.pageLabelBottomPadding = -pageLabelBottomPadding
    }
    
    func makeUIViewController(context: Context) -> UIViewController {
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, Int> { cell, indexPath, item in
            cell.contentConfiguration = UIHostingConfiguration {
                viewArray[indexPath.item]
            }
            .margins(.all, 0)
        }
        return CarouselViewController(
            originCount: originCount,
            count: count,
            interval: interval,
            pageLabelBottomPadding: pageLabelBottomPadding,
            cellRegistration: cellRegistration
        )
    }
    
    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

final class CarouselViewController: UIViewController {
    typealias CellModel = Int
    /// 진짜배너 갯수
    private let originCount: Int
    /// 페이징할 배너갯수
    private let count: Int
    /// 외부로부터 특정타입에 매칭되지않고 뷰의 역할만한기위해 데이터는 외부에서 정의해서 받아옵니다.
    /// nil정의를 할 수 없기때문에 사실상 CellModel타입은 내부에서 쓰이지않는 타입입니다.
    private let cellRegistration: UICollectionView.CellRegistration<UICollectionViewCell, CellModel>
    private lazy var cellWidth: CGFloat = view.frame.width
    private var currentIndex: Int = 1 {
        didSet {
            let displayIndex = (currentIndex-1) % originCount + 1
            pageLabel.text = "\(displayIndex) / \(originCount)"
        }
    }
    private lazy var collectionView: UICollectionView = .init(
        frame: .zero,
        collectionViewLayout: collectionViewLayout
    )
    private var collectionViewLayout: UICollectionViewFlowLayout {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        layout.minimumLineSpacing = 0
        layout.minimumInteritemSpacing = 0
        return layout
    }
    private var isDoneScrollSetting: Bool = false
    private let timer: TimerManager = TimerManager()
    private let interval: TimeInterval
    
    private var cancellable: Set<AnyCancellable> = []
    
    private let pageLabel: UILabel = UILabel()
    private let pageLabelBackgroundView: UIView = UIView()
    private let pageLabelBottomPadding: CGFloat
    private let isHiddenPageLabel: Bool
    
    init(
        originCount: Int,
        count: Int,
        interval: TimeInterval,
        pageLabelBottomPadding: CGFloat,
        cellRegistration: UICollectionView.CellRegistration<UICollectionViewCell, CellModel>
    ) {
        self.originCount = originCount
        self.count = count
        self.interval = interval
        self.pageLabelBottomPadding = pageLabelBottomPadding
        self.isHiddenPageLabel = originCount < 2
        self.cellRegistration = cellRegistration
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
   
    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        setLayout()
        setCollectionView()
        setTimer()
    }
    
    override func viewIsAppearing(_ animated: Bool) {
        super.viewIsAppearing(animated)
        guard count > 1 else { return }
        if !timer.isRunning {
            timer.start(interval: interval)
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        timer.stop()
    }
    
    /// 자동 페이징
    private func setTimer() {
        timer.$event
            .dropFirst()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in
                guard let self else { return }
                let maxIndex = count - 1
                if currentIndex == maxIndex {
                    currentIndex = 1
                    let firstOffset: CGPoint = .init(x: cellWidth , y: 0)
                    collectionView.setContentOffset(firstOffset, animated: false)
                }
                
                currentIndex += 1
                let offset: CGPoint = .init(x: cellWidth * CGFloat(currentIndex), y: 0)
                collectionView.setContentOffset(offset, animated: true)
            }
            .store(in: &cancellable)
    }
   
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        /// 최초 1회만 실행하며 이 시점보다 빠른경우 하위뷰 레이아웃이 잡혀잇지않아서 제대로 동작안함
        /// 현재 바라보고있는 0 인덱스는 마지막 뷰이므로 1 인덱스(cellWidth만큼)로 초기화 해야함
        if !isDoneScrollSetting {
            collectionView.setContentOffset(
                .init(x: cellWidth, y: 0),
                animated: false
            )
            isDoneScrollSetting = true
        }
    }
    
    private func setLayout() {
        view.addSubview(collectionView)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(pageLabel)
        view.addSubview(pageLabelBackgroundView)
        pageLabel.translatesAutoresizingMaskIntoConstraints = false
        pageLabelBackgroundView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            
            pageLabel.heightAnchor.constraint(equalToConstant: 20),
            pageLabel.centerXAnchor.constraint(equalTo: pageLabelBackgroundView.centerXAnchor),
            pageLabel.centerYAnchor.constraint(equalTo: pageLabelBackgroundView.centerYAnchor),
            
            pageLabelBackgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
            pageLabelBackgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: pageLabelBottomPadding),
            pageLabelBackgroundView.widthAnchor.constraint(equalTo: pageLabel.widthAnchor, constant: 16),
            pageLabelBackgroundView.heightAnchor.constraint(equalToConstant: 20)
        ])
    }
    
    private func setUI() {
        pageLabel.isHidden = isHiddenPageLabel
        pageLabelBackgroundView.isHidden = isHiddenPageLabel
        pageLabelBackgroundView.backgroundColor = .black.withAlphaComponent(0.16)
        pageLabelBackgroundView.layer.cornerRadius = 10
        pageLabel.textColor = .white
        pageLabel.font = .systemFont(ofSize: 14, weight: .semibold)
        pageLabel.text = "\(currentIndex) / \(originCount)"
    }
    
    private func setCollectionView() {
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.alwaysBounceVertical = false
        collectionView.alwaysBounceHorizontal = false
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.contentInsetAdjustmentBehavior = .never
        collectionView.isPagingEnabled = true
        collectionView.decelerationRate = .fast
    }
}

extension CarouselViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueConfiguredReusableCell(
            using: cellRegistration,
            for: indexPath,
            item: indexPath.item
        )
        return cell
    }
}

extension CarouselViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return .init(width: cellWidth, height: collectionView.frame.height)
    }
    
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        if Int(scrollView.contentOffset.x) == 0 {
            scrollView.setContentOffset(
                .init(x: cellWidth * CGFloat(count-2), y: 0),
                animated: false
            )
        } else if Int(scrollView.contentOffset.x) == (count-1) * Int(cellWidth) {
            scrollView.setContentOffset(
                .init(x: cellWidth, y: 0),
                animated: false
            )
        }
        let index = Int(scrollView.contentOffset.x / cellWidth)
        currentIndex = index
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let index = Int(scrollView.contentOffset.x / cellWidth)
        currentIndex = max(index, 1)
    }
    
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        timer.stop()
    }
    
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        timer.start(interval: interval)
    }
}

 

반응형