[EasyCloset] ๐ŸŽ  Compositional Layout ์œผ๋กœ Carousel ๋ทฐ ๊ตฌํ˜„

Mason Kimยท2023๋…„ 6์›” 23์ผ
0

EasyCloset ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ…

๋ชฉ๋ก ๋ณด๊ธฐ
1/5
post-thumbnail

CollectionView ๊ฐ€๋กœ ์Šคํฌ๋กค์„ ํ•  ๋•Œ ์‚ฌ์ด์ฆˆ๊ฐ€ ๋™์ ์œผ๋กœ ์›€์ง์ด๊ฒŒ ๊ตฌํ˜„

๋ฐฐ๊ฒฝ

  • ์ด๋Ÿฐ ํ˜•ํƒœ์˜ Carousel View๋ฅผ ๊ตฌํ˜„ํ•ด์•ผ ํ–ˆ์Œ

  • ์ดˆ๊ธฐ์—” ์ปฌ๋ ‰์…˜๋ทฐ์˜ ์ปดํฌ์ง€์…”๋„ ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ๊ตฌ์„ฑํ•˜๋ ค๊ณ  ์‹œ๋„
  • orthogonal ๋ฐฉ์‹์œผ๋กœ ํˆญํˆญ ๋‹ค์Œ์œผ๋กœ ๋„˜์–ด๊ฐ€๊ฒŒ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์€ ์‰ฌ์› ์ง€๋งŒ, ์‚ฌ์šฉ์ž์˜ ์Šคํฌ๋กค์— ๋”ฐ๋ผ ์‚ฌ์ด์ฆˆ๋ฅผ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ‚ค์šฐ๊ณ  ์ค„์ด๋Š” ํ˜•ํƒœ์˜ ๊ตฌํ˜„์—์„œ ๋ง‰ํž˜.
  • CompositionalLayout์—์„œ ์„ธ๋กœ ์Šคํฌ๋กค ๋‚ด๋ถ€์— ์ค‘์ฒฉ ํ˜•ํƒœ๋กœ ๊ฐ€๋กœ ์Šคํฌ๋กค์„ ๊ตฌํ˜„ ํ•  ๊ฒฝ์šฐ, contentOffset ์„ ์ถœ๋ ฅํ•˜๋ฉด x์ขŒํ‘œ์˜ ๋ณ€๊ฒฝ์€ ์ถœ๋ ฅ๋˜์ง€ ์•Š์•˜์Œ.

    (๊ฐ ์„น์…˜์ด ๊ฐ€๋กœ ์Šคํฌ๋กค์„ ํ•˜๋Š” ๊ฒƒ์ด์ง€, ์ „์ฒด ์ปฌ๋ ‰์…˜๋ทฐ์˜ ์Šคํฌ๋กค๋ทฐ๊ฐ€ ๊ฐ€๋กœ ์Šคํฌ๋กค์„ ํ•˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๊ธฐ ๋•Œ๋ฌธ)

1์ฐจ ๊ตฌํ˜„ - FlowLayout ์ปค์Šคํ…€

  • UPCarouselFlowLayout ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ฐธ๊ณ ํ•ด, ๋œฏ์–ด๋ณด๋ฉด์„œ ํ•„์š”ํ•œ ๋ถ€๋ถ„๋งŒ ๋ฝ‘์•„์„œ ๋”ฐ๋กœ FlowLayout์„ ์ƒ์†ํ•œ Custom Class ๊ตฌํ˜„
  • ๊ฐ ์ƒ์˜/ํ•˜์˜/โ€ฆ์˜ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ปฌ๋ ‰์…˜๋ทฐ์˜ ์…€๋กœ ๊ตฌ์„ฑํ•˜๊ณ  ํ•ด๋‹น ์…€ ๋‚ด๋ถ€์— ๊ฐ€๋กœ๋กœ ์Šคํฌ๋กค ํ•˜๋Š” Carousel ๋ ˆ์ด์•„์›ƒ์˜ ์ปฌ๋ ‰์…˜๋ทฐ๊ฐ€ ๋“ค์–ด๊ฐ€ ์žˆ๋Š” ์ค‘์ฒฉ ์ปฌ๋ ‰์…˜๋ทฐ์˜ ํ˜•ํƒœ๋กœ ๊ตฌํ˜„

1์ฐจ ๊ตฌํ˜„ํ•œ CaroselFlowLayout ์ฝ”๋“œ

๋งค์šฐ.. ๋ณต์žกํ•œ ์ฝ”๋“œโ€ฆ

final class CarouselFlowLayout: UICollectionViewFlowLayout {

  private var sideItemScale: CGFloat = 0.6
  private var spacing: CGFloat = 40

  // collectionView์˜ ํฌ๊ธฐ
  private var size: CGSize = .zero

  // ์ฒ˜์Œ ์ปฌ๋ ‰์…˜ ๋ทฐ๊ฐ€ ๋‚˜ํƒ€๋‚  ๋•Œ ํ˜ธ์ถœ๋˜๊ฑฐ๋‚˜ ๋ ˆ์ด์•„์›ƒ์„ ๋ช…์‹œ์  ํ˜น์€ ์•”๋ฌต์ ์œผ๋กœ ๋ฌดํšจํ™”ํ–ˆ์„ ๋•Œ ํ˜ธ์ถœ
  override func prepare() {
    super.prepare()

    let currentSize = collectionView?.bounds.size ?? .zero
    if currentSize != size {
      self.setupCollectionView()
      self.updateLayout()
      self.size = currentSize
    }
  }

  private func setupCollectionView() {
    guard let collectionView = collectionView else { return }
    // ์ปฌ๋ ‰์…˜๋ทฐ์˜ ์Šคํฌ๋กค ๊ฐ์† ์†๋„๋ฅผ ์„ค์ •
    if collectionView.decelerationRate != .fast {
      collectionView.decelerationRate = .fast
    }
  }

  private func updateLayout() {
    guard let collectionView = collectionView else { return }

    let collectionSize = collectionView.bounds.size

    let horizontalInset = (collectionSize.width - self.itemSize.width) / 2
    self.sectionInset = UIEdgeInsets.init(top: 0, left: horizontalInset,
                                          bottom: 0, right: horizontalInset)

    let scaledItemOffset = (self.itemSize.width - self.itemSize.width * self.sideItemScale) / 2
    self.minimumLineSpacing = self.spacing - scaledItemOffset
  }

  // ๋งค๋ฒˆ ๋ ˆ์ด์•„์›ƒ์„ ์—…๋ฐ์ดํŠธ ํ•˜๋„๋ก ์„ค์ • (๊ธฐ๋ณธ๊ฐ’์€ false)
  override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
    return true
  }

  // ๊ฐ ์•„์ดํ…œ์˜ ๋ ˆ์ด์•„์›ƒ ์†์„ฑ
  override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return super.layoutAttributesForElements(in: rect)?.compactMap { self.transform($0) }
  }

  // ๊ฐ ์•„์ดํ…œ์˜ ๋ ˆ์ด์•„์›ƒ ์†์„ฑ ๋ณ€ํ™˜
  private func transform(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
    guard let collectionView = self.collectionView else { return attributes }

    let contentOffsetX = collectionView.contentOffset.x
    let normalizedCenter = attributes.center.x - contentOffsetX

    let maxDistance = self.itemSize.width + self.minimumLineSpacing
    // ์•„์ดํ…œ์˜ ์ค‘์•™๊ณผ ์ปฌ๋ ‰์…˜ ๋ทฐ์˜ ์ค‘์•™ ์‚ฌ์ด์˜ ๊ฑฐ๋ฆฌ๋ฅผ ๊ณ„์‚ฐ
    let distance = min(abs(collectionView.center.x - normalizedCenter), maxDistance)
    let ratio = (maxDistance - distance) / maxDistance // ๊ฑฐ๋ฆฌ์— ๋”ฐ๋ฅธ scale ๋น„์œจ

    // ๊ฑฐ๋ฆฌ์— ๋”ฐ๋ผ ์•„์ดํ…œ์˜ ์Šค์ผ€์ผ(๋น„์œจ๋กœ) ๊ณ„์‚ฐ
    let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale
    attributes.alpha = 1
    // ์•„์ดํ…œ์˜ ํฌ๊ธฐ์™€ ์œ„์น˜ ๋ณ€ํ˜•
    attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
    attributes.zIndex = Int(scale * 10)

    return attributes
  }

  // ์Šคํฌ๋กค์ด ๋๋‚˜๋ ค๋Š” ์‹œ์ ์— ํ˜ธ์ถœ, ์Šคํฌ๋กค์ด ๋ฉˆ์ถœ ์œ„์น˜๋ฅผ ์ œ์–ด
  override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint,
                                    withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView,
          collectionView.isPagingEnabled == false,
          let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds) else {
      return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
    }

    let midSide = collectionView.bounds.size.width / 2
    // ์ปฌ๋ ‰์…˜๋ทฐ์˜ ์ค‘์•™์ง€์ ์— ์ œ์•ˆ๋œ ์˜คํ”„์…‹์˜ ์ค‘์•™ ์ง€์ ์„ ๋”ํ•ด ์ƒˆ๋กœ์šด ์ค‘์•™ ์ง€์ ์„ ๊ณ„์‚ฐ
    let proposedContentOffsetCenter = proposedContentOffset.x + midSide

    let closest = layoutAttributes.min {
      abs($0.center.x - proposedContentOffsetCenter) < abs($1.center.x - proposedContentOffsetCenter)
    } ?? UICollectionViewLayoutAttributes()

    let targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y)

    return targetContentOffset
  }
}

1์ฐจ ๊ตฌํ˜„ ๋ฐฉ์‹์˜ ๋ฌธ์ œ์ 

  • ์ค‘์ฒฉ ์ปฌ๋ ‰์…˜๋ทฐ์˜ ํŠน์„ฑ ์ƒ "์ปฌ๋ ‰์…˜๋ทฐ -> ์…€ -> ์ปฌ๋ ‰์…˜๋ทฐ -> ์…€" ์˜ ํ˜•ํƒœ์˜€๊ธฐ ๋•Œ๋ฌธ์— ์ฝ”๋“œ์˜ ํ๋ฆ„, ๋ฐ์ดํ„ฐ์˜ ํ๋ฆ„์ด ์•Œ๊ธฐ ์–ด๋ ค์›Œ์ง€๋Š” ํ˜„์‚ฌ ๋ฐœ์ƒ
  • ์…€์ด ์žฌ์‚ฌ์šฉ๋˜๊ธฐ ๋•Œ๋ฌธ์— ๋ฐœ์ƒ๋˜๋Š” ์ž์ž˜ํ•œ ์ด์Šˆ๋„ ๊ฒช์Œ

์ตœ์ข… ๊ตฌํ˜„ - CompositionalLayout์— ์ ์šฉ

  • ๊ทธ๋Ÿฌ๋˜ ์ค‘, Compositional Layout์˜ ๊ณต์‹ ๋ฌธ์„œ์—์„œ visibleItemsInvalidationHandler๋ฅผ ๋ฐœ๊ฒฌํ•จ.
  • ํ•ด๋‹น ์„น์…˜์˜ scroll Offset๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ์—ˆ๊ณ , ํ™”๋ฉด์— ํ‘œ์‹œ๋˜๊ณ  ์žˆ๋Š” visibleItems์˜ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์™€์„œ ์ค‘์‹ฌ๋ถ€๋กœ๋ถ€ํ„ฐ์˜ ๊ฑฐ๋ฆฌ๋ฅผ ๊ณ„์‚ฐ ํ•ด tranformํ•ด์ค„ ์ˆ˜ ์žˆ์—ˆ์Œ
  • ์ด๋ ‡๊ฒŒ ๋‹จ์ผ ์ปฌ๋ ‰์…˜๋ทฐ๋กœ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ์—ˆ๊ณ  ์ฝ”๋“œ์˜ ํ๋ฆ„๊ณผ ๋ฐ์ดํ„ฐ์˜ ํ๋ฆ„์ด ํ›จ์”ฌ ์ง๊ด€์ ์ด์–ด์ง
 /// Carousel ์„ ์ ์šฉํ•˜๊ธฐ ์œ„ํ•ด ์…€ ์•„์ดํ…œ์— ์ค‘์‹ฌ๋ถ€ ๋ถ€ํ„ฐ์˜ ๊ฑฐ๋ฆฌ๋ฅผ ๊ณ„์‚ฐ ํ•ด transform ์„ ์ ์šฉ
private func setupCollectionViewCarousel(to section: NSCollectionLayoutSection) {
  section.visibleItemsInvalidationHandler = { visibleItems, offset, environment in

    // ํ—ค๋”๊ฐ€ ์•„๋‹Œ ์…€ ์•„์ดํ…œ๋“ค
    let cellItems = visibleItems.filter {
      $0.representedElementKind != UICollectionView.elementKindSectionHeader
    }
    let containerWidth = environment.container.contentSize.width

    cellItems.forEach { item in
      let itemCenterRelativeToOffset = item.frame.midX - offset.x

      // ์…€์ด ์ปฌ๋ ‰์…˜ ๋ทฐ์˜ ์ค‘์•™์—์„œ ์–ผ๋งˆ๋‚˜ ๋–จ์–ด์ ธ ์žˆ๋Š”์ง€
      let distanceFromCenter = abs(itemCenterRelativeToOffset - containerWidth / 2.0)

      // ์…€์ด ์ปค์ง€๊ณ  ์ž‘์•„์งˆ ๋•Œ์˜ ์ตœ๋Œ€ ์Šค์ผ€์ผ, ์ตœ์†Œ ์Šค์ผ€์ผ
      let minScale: CGFloat = 0.7
      let maxScale: CGFloat = 1.0
      let scale = max(maxScale - (distanceFromCenter / containerWidth), minScale)

      item.transform = CGAffineTransform(scaleX: scale, y: scale)
    }
  }
}
profile
iOS developer

0๊ฐœ์˜ ๋Œ“๊ธ€