인피니티 포커싱 캐러셀 만들기

2025. 9. 11. 23:14·iOS/UIKit
반응형

👋 들어가기 전

 

종종, 실제 앱에서 구현력을 요구하는 UI를 혼자서 조금씩 연습해보려고한다.

 

대망의 첫번 째 시리즈는 인피니티 포커싱 캐러셀이다.

 

내가 이름을 붙힌거긴하지만, 어떤 구성요소인지는 아래에서 설명하겠다.


🏁 학습할 내용

  • 레퍼런스
  •  요구사항
    • 포커싱
    • 인피니티
  • 구현
  • 전체 코드
  • 컴포지셔널을 이용한 인피니티 스크롤 구현

🤩 레퍼런스

참고 레퍼런스 바로, 현대 백화점 앱이다.

특징을 보면 다음과 같다.

 

  • 항상 중앙에 컨텐츠가 정렬되며,  중앙에 가까워질 록 크기가 커지고, 나머지는 일정 비율만큼 축소되어있다.
  • 현재 10개의 아이템이 보이는데, 9번째를 넘어가면 0번째가 자연스럽게나오고, 0번째에서 9번째로 넘어갈 수 있다.

🎯 요구 사항

 

🧘 포커싱

포커싱은 2개의 동작을 뜻한다.

  • 가운데 정렬
  • 가운데 아이템을 제외한, 아이템은 축소 Scale

 

🔄 인피니티

눈치 챘겠지만, 인피니티는 순환되는 느낌이다.

  • 왼쪽 끝(0번 째) 아이템에서 왼쪽 스크롤 시, 오른쪽 끝(마지막) 아이템이 나온다.
  • 오른쪽 끝(마지막 아이템)에서 오른쪽 스크롤 시, 왼족 끝(0번 째) 아이템이 나온다.

1️⃣ 포커싱

포커싱은 중앙정렬 + Scale 차별화인데, 먼저 중앙정렬부터 알아보자.

 

🅰️ 중앙정렬

 

FlowLayout에서 중앙정렬의 핵심 targetContentOffset이다.

override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint

 

역할과 파라미터를 알아보자.

  • 역할
    • 스크롤이 끝나서 정지할 최종 위치를 결정
  • 파라미터
    • proposedContentOffset: 시스템이 계산한 최종 정지 예상 위치
    • velocity: 스크롤 속도
  • 호출시기
    • 스크롤 시
  • 리턴
    • 정지할 위치
  override func targetContentOffset(
    forProposedContentOffset proposedContentOffset: CGPoint,
    withScrollingVelocity velocity: CGPoint
  ) -> CGPoint {
    guard let collectionView = collectionView
    else {
      return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
    }

    // 왼쪽으로 스크롤 중이면,  왼쪽꺼를, 오른쪽으로 스크롤 중이면, 오른쪽꺼를 우선 중앙에 배치
    let attribute = visibleLayoutAttributesInOffset(at: collectionView.contentOffset).first {
      guard let offset = centeredContentOffset(forRect: $0.frame) else {
        return false
      }
      // velocity.x.sign
      // .plus(오른쪽으로 스크롤)
      // .minus(왼쪽으로 스크롤)
      return (offset.x - collectionView.contentOffset.x).sign == velocity.x.sign
    }

    if let attribute = attribute,
       let offset = centeredContentOffset(forRect: attribute.frame) {
      return offset // 계산된 위치리턴
    } else {
      return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
    }
  }

  /// 현재보이는 Attributes를 contentOfssetX 기준으로 가까운 순으로 정렬
  private func visibleLayoutAttributesInOffset(at offset: CGPoint) -> [UICollectionViewLayoutAttributes] {
    guard let collectionView = collectionView else {
      return []
    }

    return super.layoutAttributesForElements(in: .init(origin: offset, size: collectionView.frame.size))?
      .sorted(by: { lhs, rhs in
        return  lhs.frame.midX - collectionView.contentOffset.x < rhs.frame.midX - collectionView.contentOffset.x
      }) ?? []
  }

  // attribute의 center에 해당하는
  private func centeredContentOffset(forRect rect: CGRect) -> CGPoint? {
    guard let collectionView = collectionView else {
      return nil
    }

    // 왼쪽으로 밀어야, 중앙에 위치하므로 collectionView의 절반 너비 만큼 빼야함
    return .init(x: abs(rect.midX - collectionView.frame.width / 2), y: collectionView.contentOffset.y)
  }

 

 

🅱️ 스케일

 

핵심 메서드는 layoutAttributesForElements이다.

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?

 

역할과 파라미터를 알아보자.

  • 역할
    •  특정 영역(rect)에 표시될 아이템들의 레이아웃 속성을 반환하는 메서드
  • 파라미터
    • rect: 컬렉션 뷰가 현재 화면에 보여주려는 영역
  • 호출시기
    • 스크롤 시
  • 리턴
    • 각 셀/뷰의 레이아웃 속성 객체

 

여기서 핵심은 스케일 비율을 계산할 때, 실제 CollectionView의 center로부터 얼마나 머냐를

잘 계산해야한다.

 override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    guard let attributes = super.layoutAttributesForElements(in: rect)
    else { return nil }

    return attributes.compactMap { transform(attributes: $0) }
  }

  private func transform(attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    guard let collectionView = collectionView
    else { return attributes }

    let collectionViewCenter = collectionView.frame.size.width / 2
    let contentOffsetX = collectionView.contentOffset.x
    let center = attributes.center.x - contentOffsetX

    let maxDistance = self.itemSize.width + self.minimumLineSpacing // 중앙으로부터, maxDistance 넘어가면, 모두 minimumItemScale
    let distance = min(abs(collectionViewCenter - center), maxDistance)

    let ratio = (maxDistance - distance) / maxDistance
    let scale = ratio * (1 - UI.minimumItemScale) + UI.minimumItemScale // 최종 sacle
    let baseAlphaRatio = 0.5

    let transform = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)

    attributes.transform3D = transform
    attributes.alpha = max(ratio, baseAlphaRatio)

    return attributes
  }

 

 

여기서 하나 더, 사이 간격을 한번 주목해보자.

 

나는 분명 12로 짧게 줬는데, 엄청 벌어져있다. 

이유가 뭘까??

 

정답은 scale하면 그 만큼 작아지니깐, 12 + 스케일로 작아진 너비 만큼 간격이 더 벌어져있다.

 

그러므로 우리는 12에서 스케일로 줄어드는 간격을 뺴줘야한다.

    let itemWidth = self.itemSize.width

    let scaledItemOffset: CGFloat = (itemWidth - UI.minimumItemScale * itemWidth) / 2 // 축소 시, 차감된 간격 = 2A , 2A/2 = A(열간격에서 차감해야될 )

    self.scrollDirection = .horizontal

    // 스케일로 줄어드는 만큼, spacing을 축소
    self.minimumLineSpacing = UI.itemSpacing - scaledItemOffset

 


♻️ 무한스크롤

 

무한 스크롤의 핵심 로직은 2가지다.

 

첫번 째는, 원래 아이템보다 3배수로 만들기, 아이템이 3개만, cell이 9개 필요 (따로 코드 설명 X)

 

두번 째는, 끝 지점이 보일라고 할 때, 다시 중앙 근처로 위치시키는 것

 

세번 쨰는, 처음 뷰가 보일 때 조차, 중앙으로 보여주기

 

🅰️ 중앙 근처로 이동 시키기

 

 func scrollTo(page: Int) {
    // scrollViewDidScroll 호출되지 않음
    guard let layout = collectionViewLayout as? UICollectionViewFlowLayout
    else { return }

    let pagingSize = layout.itemSize.width + layout.minimumLineSpacing
    let offset = CGPoint(
      x: contentOffset.x.truncatingRemainder(dividingBy: pagingSize) + CGFloat(page) * pagingSize,
      y: 0
    )

    self.contentOffset = offset
  }

 func scrollViewDidScroll(_ scrollView: UIScrollView) {
    forceScrollToTargetPage(scrollView: scrollView)
 }

 private func forceScrollToTargetPage(scrollView: UIScrollView) {
    guard let layout = collectionViewLayout as? UICollectionViewFlowLayout,
          let originalItemCount = self.originalItemCount,
          originalItemCount != 0
    else { return }

    let pageWidth = layout.itemSize.width + layout.minimumLineSpacing
    let page = Int(round(scrollView.contentOffset.x / pageWidth))
    /// 여기서 scrollTo에 전달될 page는 왼쪽에 위치될 page다.
    /// 예를들어 중앙에 4page가 와야하면, 스크롤을 3page까지 해주면, CenterScaleSnapFlowLayout 내부에서 중앙을 잡읍
    if page == originalItemCount * 2 + 1 {
      scrollTo(page: originalItemCount)

    } else if page == originalItemCount {
      scrollTo(page: originalItemCount * 2)
    }
 }

 

🅱️ 처음 스크롤 위치 중앙으로 위치

최초 뷰가 보일 떄만, 중앙으로 가야하므로 플래그 변수와 함께, viewDidLayoutSubviews 시점에 호출한다.

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    collectionView.scrollToCenter()
}
  
func scrollToCenter() {
    guard let originalItemCount = self.originalItemCount,
          isInitalizedScrolled == false
    else { return }

    //scrollToItem으로 하는 이유는 UICollectionViewDelegate의 scrollViewDidScroll를 호출하기위해
    scrollToItem(at: [0, originalItemCount * 2], at: .centeredHorizontally, animated: false)
    isInitalizedScrolled = true
}

😂 전체코드

 

CenterFocusedCarouselLayout.swift

protocol CenterFocusedCarouselLayoutDelegate: AnyObject {
    func didUpdateCenterIndexPath(_ indexPath: IndexPath?)
}

final class CenterFocusedCarouselLayout: UICollectionViewFlowLayout {
    enum ItemSizeStrategy {
        /// 절대 고정 크기
        case fixed(CGSize)
        /// 너비 비율(portrait, landscape)
        case widthFraction(CGFloat, CGFloat)
    }
    
    var spacing: CGFloat = 10
    var sideItemScale: CGFloat = 1.0
    var heightRatio: CGFloat = 0.75
    var isRotating = false
    var threshold: CGFloat = 0.5
    /// 중앙인덱스가 바뀔 때만 호출
    var shouldNotifyOnlyWhenCenterIndexPathChanges = true
    
    weak var delegate: CenterFocusedCarouselLayoutDelegate?
    
    private var currentWidth: CGFloat?
    private var currentCenteredIndexPath: IndexPath?
    private var isInitialLayout: Bool = true
    private let itemSizeStrategy: ItemSizeStrategy
    
    init(itemSizeStrategy: ItemSizeStrategy) {
        self.itemSizeStrategy = itemSizeStrategy
        super.init()
        scrollDirection = .horizontal
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepare() {
        super.prepare()
        guard let collectionView = collectionView else { return }
        
        // 첫 prepare 또는 회전 시 configure
        if isInitialLayout || isRotating {
            configure(bounds: collectionView.bounds)
            isInitialLayout = false
            
            if isRotating {
                restoreCenter()
            }
        }
        
        isRotating = false
    }
    
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        // 이전 너비와 새로운 너비가 다를 경우에만 isRotating을 true로 설정
        if let currentWidth = self.currentWidth, currentWidth != newBounds.width {
            isRotating = true
        }
        
        self.currentWidth = newBounds.width
        return true
    }
    
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let attrs = super.layoutAttributesForElements(in: rect),
              let collectionView = collectionView else { return nil }
        
        let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2
        
        for attr in attrs {
            let distance = abs(attr.center.x - centerX)
            let maxDistance = itemSize.width + minimumLineSpacing
            let ratio = min(distance / maxDistance, 1)
            
            let scale = sideItemScale + (1 - sideItemScale) * (1 - ratio)
            attr.transform = CGAffineTransform(scaleX: scale, y: scale)
        }
        
        return attrs
    }
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint,
                                      withScrollingVelocity velocity: CGPoint) -> CGPoint {
        guard let collectionView else { return proposedContentOffset }
        
        // 1. 페이지 단위 폭 (셀 너비 + 최소 간격)
        let pageWidth = itemSize.width + minimumLineSpacing
        
        // 2. 현재 스크롤 위치 기반의 대략적인 인덱스 계산
        let approximateScrollingIndex = collectionView.contentOffset.x / pageWidth
        
        // 3. 속도(velocity)와 이동 거리(delta)를 기반으로 타겟 인덱스 결정
        var targetIndex: CGFloat
        
        if velocity.x > threshold {
            // 빠르게 오른쪽으로 스와이프 -> 다음 페이지
            targetIndex = ceil(approximateScrollingIndex)
        } else if velocity.x < -threshold {
            // 빠르게 왼쪽으로 스와이프 -> 이전 페이지
            targetIndex = floor(approximateScrollingIndex)
        } else {
            // 속도가 느릴 때는 가장 가까운 페이지로 반올림
            targetIndex = round(proposedContentOffset.x / pageWidth)
        }
        
        // 4. 최소/최대 인덱스 제한
        let maxIndex = CGFloat(collectionView.numberOfItems(inSection: 0) - 1)
        targetIndex = max(0, min(targetIndex, maxIndex))
        
        // 5. 최종 X 좌표 계산
        // (인덱스 * 너비)를 하면 정확히 중앙에 스냅됩니다. (sectionInset이 양 옆 동일할 때)
        let targetX = targetIndex * pageWidth
        
        // 인덱스 업데이트 알림
        let newIndexPath = IndexPath(item: Int(targetIndex), section: 0)
        
        let isChanged = (currentCenteredIndexPath != newIndexPath)
        
        // 인덱스가 바뀌었거나, 바뀌지 않았어도 '매번 알림' 모드일 때 호출
        if isChanged || !shouldNotifyOnlyWhenCenterIndexPathChanges {
            delegate?.didUpdateCenterIndexPath(newIndexPath)
        }
        
        currentCenteredIndexPath = newIndexPath
        
        return CGPoint(x: targetX, y: proposedContentOffset.y)
    }
    
    func initCenterIndexPath(indexPath: IndexPath) {
        self.currentCenteredIndexPath = indexPath
    }
    
    func restoreCenter() {
        guard let targetPoint = centerPoint(),
              let collectionView = collectionView
        else { return }
        collectionView.contentOffset = targetPoint
    }
    
    private func centerPoint() -> CGPoint? {
        guard let indexPath = currentCenteredIndexPath,
              let collectionView = collectionView
        else { return nil }
        
        // 1. 현재 인덱스의 X 좌표 계산
        // (인덱스 * 전체 한 페이지 너비) + 좌측 인셋
        let pageWidth = itemSize.width + minimumLineSpacing
        let xPosition = CGFloat(indexPath.item) * pageWidth + sectionInset.left
        
        // 2. 셀의 중앙값
        let cellCenterX = xPosition + (itemSize.width / 2)
        
        // 3. 컬렉션뷰 중앙에 맞추기 위한 오프셋
        let targetX = cellCenterX - (collectionView.bounds.width / 2)
        
        return CGPoint(x: targetX, y: 0)
    }
    
    private func configure(bounds: CGRect) {
        itemSize = calcItemSize(bounds: bounds)
        
        let horizontalInset = (bounds.width - itemSize.width) / 2
        
        self.sectionInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset)
        
        let ItemWidth = itemSize.width
        
        let scaledItemOffset = (ItemWidth - ItemWidth * sideItemScale) / 2
        self.minimumLineSpacing = spacing - scaledItemOffset
    }
    
    private func calcItemSize(bounds: CGRect) -> CGSize {
        switch itemSizeStrategy {
        case let .fixed(fixiedSize):
            return fixiedSize
        case let .widthFraction(portraitRatio, landSacpeRatio):
            let width = bounds.width * (UIDevice.current.orientation == .portrait ? portraitRatio : landSacpeRatio)
            let height = width * heightRatio
            return CGSize(width: width, height: height)
        }
    }
}

 

CenterScaleSnapCollectionView.swift

final class CenterScaleSnapCollectionView: UICollectionView {
  private lazy var diffableDataSource: DataSource = makeDataSource()
  private var originalItemCount: Int?
  private var isInitalizedScrolled: Bool = false // 처음에 center 정렬 이후, 막기위해

  override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
    super.init(frame: frame, collectionViewLayout: layout)

    self.collectionViewLayout = makeLayout()
    self.delegate = self
    self.isPagingEnabled = false
    self.decelerationRate = .fast
    self.showsHorizontalScrollIndicator = false
    self.contentInsetAdjustmentBehavior = .never
    self.backgroundColor = .clear
  }

  @available(*, unavailable)
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

private extension CenterScaleSnapCollectionView {
  typealias DataSource = UICollectionViewDiffableDataSource<Section, Item>
  typealias CellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Item>

  enum Constant {
    static let contentsCount: Int = 3
  }

  func makeCellRegistration() -> CellRegistration {
    return CellRegistration { cell, indexPath, itemIdentifier in
      guard case let .main(model) = itemIdentifier
      else { return }

      cell.indentationLevel = .zero
      cell.backgroundConfiguration = UIBackgroundConfiguration.clear()
      let configuration = ContentConfiguration(modle: model)
      cell.contentConfiguration = configuration
      cell.contentView.frame = cell.bounds
    }
  }

  func makeLayout() -> UICollectionViewFlowLayout {
    let screenWidth = UIScreen.main.bounds.width
    let itemWidth = screenWidth * 0.8
    let itemHeight = 200.0

    let layout = CenterScaleSnapFlowLayout()
    layout.itemSize = CGSize(width: itemWidth, height: itemHeight)
    layout.minimumInteritemSpacing = .zero

    return layout
  }

  func makeDataSource() -> DataSource {
    let cellRegistration = makeCellRegistration()
    return DataSource(collectionView: self) {
      collectionView,
        indexPath,
        itemIdentifier in
      switch itemIdentifier {
      case .main:
        return collectionView.dequeueConfiguredReusableCell(
          using: cellRegistration,
          for: indexPath,
          item: itemIdentifier
        )
      }
    }
  }

  func scrollTo(page: Int) {
    // scrollViewDidScroll 호출되지 않음
    guard let layout = collectionViewLayout as? UICollectionViewFlowLayout
    else { return }

    let pagingSize = layout.itemSize.width + layout.minimumLineSpacing
    let offset = CGPoint(
      x: contentOffset.x.truncatingRemainder(dividingBy: pagingSize) + CGFloat(page) * pagingSize,
      y: 0
    )

    self.contentOffset = offset
  }
}

extension CenterScaleSnapCollectionView {
  typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>

  enum Section: Hashable {
    case main
  }

  enum Item: Hashable {
    case main(Model)
  }

  func apply(_ snapShot: Snapshot, originalItemCount: Int) {
    self.originalItemCount = originalItemCount
    diffableDataSource.apply(snapShot, animatingDifferences: false)
  }

  func scrollToCenter() {
    guard let originalItemCount = self.originalItemCount,
          isInitalizedScrolled == false
    else { return }

    //scrollToItem으로 하는 이유는 UICollectionViewDelegate의 scrollViewDidScroll를 호출하기위해
    scrollToItem(at: [0, originalItemCount * 2], at: .centeredHorizontally, animated: false)
    isInitalizedScrolled = true
  }
}

extension CenterScaleSnapCollectionView: UICollectionViewDelegateFlowLayout {
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
    forceScrollToTargetPage(scrollView: scrollView)
  }

  private func forceScrollToTargetPage(scrollView: UIScrollView) {
    guard let layout = collectionViewLayout as? UICollectionViewFlowLayout,
          let originalItemCount = self.originalItemCount,
          originalItemCount != 0
    else { return }

    let pageWidth = layout.itemSize.width + layout.minimumLineSpacing
    let page = Int(round(scrollView.contentOffset.x / pageWidth))
    /// 여기서 scrollTo에 전달될 page는 왼쪽에 위치될 page다.
    /// 예를들어 중앙에 4page가 와야하면, 스크롤을 3page까지 해주면, CenterScaleSnapFlowLayout 내부에서 중앙을 잡읍
    if page == originalItemCount * 2 + 1 {
      scrollTo(page: originalItemCount)

    } else if page == originalItemCount {
      scrollTo(page: originalItemCount * 2)
    }
  }
}

 

ViewController.swift

class ViewController: UIViewController {
  private var expandedItems: [Model] = [
    .init(text: "🍎", color: .white),
    .init(text: "🍌", color: .systemBlue),
    .init(text: "🍊", color: .systemGreen),
    .init(text: "🍎", color: .white),
    .init(text: "🍌", color: .systemBlue),
    .init(text: "🍊", color: .systemGreen),
    .init(text: "🍎", color: .white),
    .init(text: "🍌", color: .systemBlue),
    .init(text: "🍊", color: .systemGreen),
  ]

  private let collectionView = CenterScaleSnapCollectionView(frame: .zero, collectionViewLayout: .init())

  override func viewDidLoad() {
    super.viewDidLoad()

    view.addSubview(collectionView)
    collectionView.snp.makeConstraints {
      $0.horizontalEdges.equalToSuperview()
      $0.height.equalTo(300)
      $0.centerY.equalToSuperview()
    }

    apply()
  }

  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    collectionView.scrollToCenter()
  }

  func apply() {
    var snapShot = CenterScaleSnapCollectionView.Snapshot()
    snapShot.appendSections([.main])
    snapShot.appendItems(expandedItems.map{.main($0)}, toSection: .main)
    collectionView.apply(snapShot, originalItemCount: 3)
  }
}

 

ContentView.swift

struct ContentConfiguration: UIContentConfiguration {
  let modle: Model

  init(modle: Model) {
    self.modle = modle
  }

  func makeContentView() -> any UIView & UIContentView {
    ContentView(configuration: self)
  }

  func updated(for state: any UIConfigurationState) -> ContentConfiguration {
    self
  }
}

final class ContentView: UIView, UIContentView {
  var configuration: any UIContentConfiguration {
    didSet {
      update()
    }
  }

  let title = UILabel()

  init(configuration: any UIContentConfiguration) {
    self.configuration = configuration
    super.init(frame: .zero)

    setupUI()
  }

  @available(*, unavailable)
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  func setupUI() {
    self.addSubview(title)
    title.translatesAutoresizingMaskIntoConstraints = false

    NSLayoutConstraint.activate([
      title.centerXAnchor.constraint(equalTo: centerXAnchor),
      title.centerYAnchor.constraint(equalTo: centerYAnchor)
    ])
  }

  func update() {
    guard let configuration = configuration as? ContentConfiguration else { return }

    title.text = configuration.modle.text
    self.backgroundColor = configuration.modle.color
  }
}

🎉 최종결과


🎉  컴포지션널을 이용한 무한 중앙정렬 스크롤 (추가 2026.1.17)

 

컴포지셔널로는 스케일과 opacity는 적용하지 않고, 중앙정렬과 무한 스크롤만 구현 해 봤다.

 

주요 내용은 다음과 같다.

 

  • 1Group, 1Item: 한그룹에 아이템 한개만 둠
    • orthogonalScrollingBehavior = .groupPagingCentered를 이용하기 위해
  • 3배수가 아닌, 약 300개정도로 넣어 무한한 듯한 느낌을 줌
  • group의 width를 0.85 ~ 0.95 fraction으로 설정
  • 회전 할 떄 부자연스러운 스크롤 애니메이션 처리가 있어, performWithoutAnimation 을 이용하여 스크롤
  • 또한 화면 회전 시, 스크롤 될 때 원치 않은 이벤트를 받을 수 있어 isRotating 변수로 체크
import UIKit

// MARK: - Model
struct CarouselModel: Hashable {
  let id = UUID()
  let text: String
  let color: UIColor
}

// MARK: - Configuration & View (ContentConfiguration 방식)
struct CarouselContentConfiguration: UIContentConfiguration {
  let model: CarouselModel

  func makeContentView() -> any UIView & UIContentView {
    CarouselContentView(configuration: self)
  }

  func updated(for state: any UIConfigurationState) -> CarouselContentConfiguration { self }
}

final class CarouselContentView: UIView, UIContentView {
  var configuration: any UIContentConfiguration {
    didSet { updateContents() }
  }

  private let titleLabel: UILabel = {
    let label = UILabel()
    label.font = .systemFont(ofSize: 40)
    return label
  }()

  init(configuration: any UIContentConfiguration) {
    self.configuration = configuration
    super.init(frame: .zero)
    setupLayout()
  }

  @available(*, unavailable)
  required init?(coder: NSCoder) { fatalError() }

  private func setupLayout() {
    addSubview(titleLabel)
    titleLabel.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
      titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
      titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
    ])
  }

  private func updateContents() {
    guard let config = configuration as? CarouselContentConfiguration else { return }
    titleLabel.text = config.model.text
    backgroundColor = config.model.color
  }
}

// MARK: - ViewController
final class TestViewController: UIViewController {
  /// UI Elements
  private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())

  // Data & State
  private let items: [CarouselModel]
  private var isRotating: Bool = false // 회전 중, visibleItemsInvalidationHandler에서 로직을 끊기 위해서

  private var currentIndexPath: IndexPath? {
    didSet {
      guard let row = currentIndexPath?.row, oldValue?.row != row else { return }
      handleIndexPathChange(to: row)
    }
  }

  /// Cell Registration
  private let carouselCellRegistration = UICollectionView
    .CellRegistration<UICollectionViewListCell, CarouselModel> { cell, indexPath, model in
      cell.backgroundConfiguration = .clear()
      cell.contentConfiguration = CarouselContentConfiguration(model: model)
    }

  // MARK: - Initializer
  init() {
    // 데이터 생성 로직 분리 가능
    self.items = (0 ..< 100).flatMap { _ in
      [
        CarouselModel(text: "🍎", color: .systemOrange),
        CarouselModel(text: "🍌", color: .systemBlue),
        CarouselModel(text: "🍊", color: .systemGreen)
      ]
    }
    super.init(nibName: nil, bundle: nil)
  }

  @available(*, unavailable)
  required init?(coder: NSCoder) { fatalError() }

  // MARK: - Life Cycle
  override func viewDidLoad() {
    super.viewDidLoad()
    setupUI()
    prepareCollectionView()
  }

  private func setupUI() {
    view.backgroundColor = .systemBackground
    view.addSubview(collectionView)
    collectionView.translatesAutoresizingMaskIntoConstraints = false

    NSLayoutConstraint.activate([
      collectionView.heightAnchor.constraint(equalToConstant: 300),
      collectionView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
      collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
      collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
    ])
  }

  private func prepareCollectionView() {
    collectionView.backgroundColor = .clear
    collectionView.dataSource = self
    collectionView.reloadData()

    DispatchQueue.main.async { [weak self] in
      guard let self else { return }
      // 초기 중앙 위치 설정
      let initialPath = IndexPath(row: items.count / 2, section: 0)
      collectionView.scrollToItem(at: initialPath, at: .centeredHorizontally, animated: false)
      self.currentIndexPath = initialPath
    }
  }

  // MARK: - Rotation Handling
  override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    isRotating = true
    let savedIndex = currentIndexPath

    super.viewWillTransition(to: size, with: coordinator)

    coordinator.animate(alongsideTransition: { _ in
      self.collectionView.collectionViewLayout.invalidateLayout()

      // 회전 시, 스크롤될 때 발생하는 애니메이션 방지
      if let index = savedIndex {
        UIView.performWithoutAnimation {
          self.collectionView.scrollToItem(at: index, at: .centeredHorizontally, animated: false)
        }
      }
    }) { [weak self] _ in
      self?.isRotating = false
    }
  }

  // MARK: - Logic
  private func handleIndexPathChange(to index: Int) {
    print("🎯 중앙 아이템 안착: \(index)")
    // 페이지 컨트롤 업데이트 등
  }
}

// MARK: - Layout Factory
extension TestViewController {
  private func createLayout() -> UICollectionViewLayout {
    let widthFraction: CGFloat = 0.95

    return UICollectionViewCompositionalLayout { [weak self] _, env in
      guard let self = self else { return nil }

      // 1. 1Group & 1Item
      let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1.0),
        heightDimension: .fractionalHeight(1.0)
      )
      let item = NSCollectionLayoutItem(layoutSize: itemSize)

      // 2. Group
      let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(widthFraction),
        heightDimension: .absolute(300)
      )
      let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

      // 3. Section
      let section = NSCollectionLayoutSection(group: group)
      section.interGroupSpacing = 10
      section.orthogonalScrollingBehavior = .groupPagingCentered

      // 4. Invalidation Handler (핵심 로직)
      section.visibleItemsInvalidationHandler = { [weak self] visibleItems, offset, env in
        guard let self = self,
              !self.isRotating
        else { return }

        let containerWidth = env.container.contentSize.width
        if containerWidth == 0 { return }

        let itemWidth = containerWidth * widthFraction
        let groupFullWidth = itemWidth + 10

        // 2. 산술 계산으로 인덱스 찾기 (O(1))
        // offset.x가 중앙 정렬 기준이므로 소수점 반올림(rounded)을 통해 가장 가까운 인덱스 산출
        let targetIndex = Int((offset.x / groupFullWidth).rounded())
        let safeIndex = max(0, min(targetIndex, self.items.count - 1))

        // 4. 값이 변경되었을 때만 처리
        if let currentIndexPath = self.currentIndexPath,
           currentIndexPath.row != safeIndex {
          self.currentIndexPath = IndexPath(item: safeIndex, section: 0)
        }
      }
      return section
    }
  }
}

// MARK: - DataSource
extension TestViewController: UICollectionViewDataSource {
  func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    items.count
  }

  func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    collectionView.dequeueConfiguredReusableCell(
      using: carouselCellRegistration,
      for: indexPath,
      item: items[indexPath.row]
    )
  }
}

 

아래는 중앙정렬, 스케일까지 적용한건데,  회전 판단 중앙인덱스를 조금 다른 방식으로 했다

visibleItemsInvalidationHandler안에서 모두 해결함

 

import UIKit

// MARK: - Model
struct CarouselModel: Hashable {
    let id = UUID()
    let text: String
    let color: UIColor
}

// MARK: - Configuration & View (ContentConfiguration 방식)
struct CarouselContentConfiguration: UIContentConfiguration {
    let model: CarouselModel
    
    func makeContentView() -> any UIView & UIContentView {
        CarouselContentView(configuration: self)
    }
    
    func updated(for state: any UIConfigurationState) -> CarouselContentConfiguration { self }
}

final class CarouselContentView: UIView, UIContentView {
    var configuration: any UIContentConfiguration {
        didSet { updateContents() }
    }
    
    private let titleLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 40)
        return label
    }()
    
    init(configuration: any UIContentConfiguration) {
        self.configuration = configuration
        super.init(frame: .zero)
        setupLayout()
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError() }
    
    private func setupLayout() {
        addSubview(titleLabel)
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
            titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
        ])
    }
    
    private func updateContents() {
        guard let config = configuration as? CarouselContentConfiguration else { return }
        titleLabel.text = config.model.text
        backgroundColor = config.model.color
    }
}

// MARK: - ViewController
final class ViewController: UIViewController {
    /// UI Elements
    private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
    
    // Data & State
    private let items: [CarouselModel]
    private var isRotating: Bool = false // 회전 중, visibleItemsInvalidationHandler에서 로직을 끊기 위해서
    
    private var focusedIndexPath: IndexPath? {
        didSet {
            if let focusedIndexPath {
                handleIndexPathChange(to: focusedIndexPath.row)
            }
        }
    }
    
    /// 회전 여부를 Width 변경으로 감지
    private var containerWidth: CGFloat? = nil {
        didSet {
            guard let _ = oldValue else { return }
            resetFocus()
        }
    }
    
    private func resetFocus() {
        if let indexPath = focusedIndexPath {
            collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
        }
    }
    
    /// Cell Registration
    private let carouselCellRegistration = UICollectionView
        .CellRegistration<UICollectionViewListCell, CarouselModel> { cell, indexPath, model in
            cell.backgroundConfiguration = .clear()
            cell.contentConfiguration = CarouselContentConfiguration(model: model)
        }
    
    // MARK: - Initializer
    init() {
        // 데이터 생성 로직 분리 가능
        self.items = (0 ..< 100).flatMap { _ in
            [
                CarouselModel(text: "🍎", color: .systemOrange),
                CarouselModel(text: "🍌", color: .systemBlue),
                CarouselModel(text: "🍊", color: .systemGreen)
            ]
        }
        super.init(nibName: nil, bundle: nil)
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError() }
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        prepareCollectionView()
    }
    
    private func setupUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(collectionView)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            collectionView.heightAnchor.constraint(equalToConstant: 300),
            collectionView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
    }
    
    private func prepareCollectionView() {
        collectionView.backgroundColor = .clear
        collectionView.dataSource = self
        collectionView.reloadData()
        
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            // 초기 중앙 위치 설정
            let initialPath = IndexPath(row: items.count / 2, section: 0)
            collectionView.scrollToItem(at: initialPath, at: .centeredHorizontally, animated: false)
            self.focusedIndexPath = initialPath
        }
    }
    
    // MARK: - Rotation Handling
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        isRotating = true
        
        super.viewWillTransition(to: size, with: coordinator)
        
        coordinator.animate(alongsideTransition: { _ in
            self.collectionView.collectionViewLayout.invalidateLayout()
        }) { [weak self] _ in
            self?.isRotating = false
        }
    }
    
    // MARK: - Logic
    private func handleIndexPathChange(to index: Int) {
        print("🎯 중앙 아이템 안착: \(index)")
        // 페이지 컨트롤 업데이트 등
    }
}

// MARK: - Layout Factory
extension ViewController {
    
    // 스케일 이팩트
    private func calcScaleRaito(
        containerWidth: CGFloat,
        distance: CGFloat
    ) -> CGFloat {
        let maxDistance = containerWidth / 2.0
        let minScale: CGFloat = 0.9
        let maxScale: CGFloat = 1.0
        let normalized = min(distance / maxDistance, 1.0)
        
        return  maxScale - (maxScale - minScale) * normalized
    }
    
    private func createLayout() -> UICollectionViewLayout {
        let itemWidth: CGFloat = 435
        
        return UICollectionViewCompositionalLayout { [weak self] _, env in
            guard let self = self else { return nil }
            
            // 1. 1Group & 1Item
            let itemSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalHeight(1.0)
            )
            let item = NSCollectionLayoutItem(layoutSize: itemSize)
            
            // 2. Group
            let groupSize = NSCollectionLayoutSize(
                widthDimension: .absolute(itemWidth),
                heightDimension: .absolute(244)
            )
            let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
            
            // 3. Section
            let section = NSCollectionLayoutSection(group: group)
            section.interGroupSpacing = 10
            section.orthogonalScrollingBehavior = .groupPagingCentered
            
            // 4. Invalidation Handler (핵심 로직)
            section.visibleItemsInvalidationHandler = { [weak self] visibleItems, offset, env in
                guard let self = self else { return }
                
                let containerWidth = env.container.contentSize.width
                let containerCenterX = offset.x + containerWidth / 2.0
                
                var closestItem: NSCollectionLayoutVisibleItem?
                var closestDistance: CGFloat = .greatestFiniteMagnitude
                
                // 회전 감지
                if self.containerWidth != containerWidth {
                    self.containerWidth = containerWidth
                }
                
                visibleItems.forEach { [weak self] item in
                    guard let self = self,
                          !isRotating
                    else { return }
                    
                    let itemCenterX = item.frame.midX
                    let distance = abs(itemCenterX - containerCenterX)
                    
                    // 중앙 item 추적
                    if distance < closestDistance {
                        closestDistance = distance
                        closestItem = item
                    }
                    
                    let scale = calcScaleRaito(
                        containerWidth: containerWidth,
                        distance: distance
                    )
                    
                    let scaledWidth = item.frame.width * scale
                    let deltaX = (item.frame.width - scaledWidth) / 2
                    
                    let direction: CGFloat = itemCenterX < containerCenterX ? 1 : -1
                    let translationX = deltaX * direction
                    
                    item.transform = CGAffineTransform.identity
                        .translatedBy(x: translationX, y: 0)
                        .scaledBy(x: scale, y: scale)
                }
                
                // 중앙 아이템 인덱스 변경
                if let indexPath = closestItem?.indexPath,
                   focusedIndexPath != indexPath {
                    focusedIndexPath = indexPath
                }
            }
            return section
        }
    }
}

// MARK: - DataSource
extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        items.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        collectionView.dequeueConfiguredReusableCell(
            using: carouselCellRegistration,
            for: indexPath,
            item: items[indexPath.row]
        )
    }
}
반응형

'iOS > UIKit' 카테고리의 다른 글

preferredMaxLayoutWidth  (0) 2025.08.26
CALayer란  (1) 2025.08.24
margin  (1) 2025.08.03
NSTextAttachment  (2) 2025.07.23
[Text 시리즈 3] TextKit1  (5) 2025.07.21
'iOS/UIKit' 카테고리의 다른 글
  • preferredMaxLayoutWidth
  • CALayer란
  • margin
  • NSTextAttachment
Hamp
Hamp
남들에게 보여주기 부끄러운 잡다한 글을 적어 나가는 자칭 기술 블로그입니다.
  • Hamp
    Hamp의 분리수거함
    Hamp
  • 전체
    오늘
    어제
    • 분류 전체보기 (340)
      • CS (30)
        • 객체지향 (2)
        • Network (7)
        • OS (6)
        • 자료구조 (1)
        • LiveStreaming (3)
        • 이미지 (1)
        • 잡다한 질문 정리 (0)
        • Hardware (2)
        • 이론 (6)
        • 컴퓨터 그래픽스 (0)
      • Firebase (3)
      • Programing Langauge (41)
        • swift (34)
        • python (6)
        • Kotlin (1)
      • iOS (135)
        • UIKit (37)
        • Combine (1)
        • SwiftUI (34)
        • Framework (7)
        • Swift Concurrency (22)
        • Tuist (6)
        • Setting (11)
        • Modularization (2)
        • Instruments (6)
      • PS (59)
        • 프로그래머스 (24)
        • 백준 (13)
        • LeetCode (19)
        • 알고리즘 (3)
      • Git (18)
        • 명령어 (4)
        • 이론 (2)
        • hooks (1)
        • config (2)
        • action (7)
      • Shell Script (2)
      • Linux (6)
        • 명령어 (5)
      • Spring (21)
        • 어노테이션 (6)
        • 튜토리얼 (14)
      • CI-CD (4)
      • Android (0)
        • Jetpack Compose (0)
      • AI (21)
        • 이론 (10)
        • MCP (1)
        • LangGraph (10)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    dp
    dfs
    투포인터
    GIT
    concurrency
    Spring
    UIKit
    프로그래머스
    lifecycle
    property
    protocol
    dispatch
    IOS
    AVFoundation
    Tuist
    CS
    SwiftUI
    Swift
    백준
    boostcamp
  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.0
Hamp
인피니티 포커싱 캐러셀 만들기
상단으로

티스토리툴바