← 홈으로
UIKit · Advanced Layout

커스텀 CollectionView/TableView 설계 & 구현

기존 스크롤뷰 원리 문서가 다루지 않는 커스텀 레이아웃 서브클래싱, 고급 스크롤 애니메이션, 레이아웃 전환, Compositional Layout 고급 패턴을 정리한다. (2026-06-22)

Custom Layout Animation Performance

1. Custom UICollectionViewLayout 서브클래싱

FlowLayout이나 Compositional Layout으로 해결 못 하는 배치(Pinterest 폭포수, 원형 캐러셀 등)가 필요할 때 UICollectionViewLayout을 직접 서브클래싱한다.

1.1 왜 커스텀 레이아웃?

기존 레이아웃한계
FlowLayout줄바꿈 그리드만. 아이템 높이 제각각이면 빈 공간 발생(폭포수 불가)
CompositionalItem→Group→Section 구조. 자유로운 절대 좌표 배치·곡선 경로는 어려움
언제 커스텀? ① 아이템이 자유 좌표(그리드 아님) ② 3D 변환·회전 기반 ③ 스크롤 방향에 따른 동적 transform ④ FlowLayout/Compositional로 "억지로 비틀면" 코드가 더 복잡해질 때.

1.2 필수 오버라이드 메서드 4가지

// 1. 전체 콘텐츠 크기 (스크롤 범위 결정) override var collectionViewContentSize: CGSize { get } // 2. 사전 계산 — 모든 아이템의 attributes를 여기서 캐싱 override func prepare() // 3. 해당 rect에 보이는 요소들의 attributes 배열 반환 (재사용 판단 근거) override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? // 4. 특정 아이템의 attributes (선택적으로 더 정밀 제어) override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?

prepare()에서 모든 아이템의 frame/transform/zIndex를 미리 계산해 캐싱 → layoutAttributesForElements는 rect와 교차하는 것만 필터링해 반환.

1.3 UICollectionViewLayoutAttributes 심화

프로퍼티용도
frame아이템 위치·크기 (기본)
center / sizeframe 대신 중심+크기로 지정
transform2D 회전·확대·이동 (CGAffineTransform)
transform3D3D 회전·원근 (CATransform3D)
alpha투명도 (0~1)
zIndex겹침 순서 (클수록 위)
isHidden숨김 여부
중요: layoutAttributesForElements에서 캐시된 attributes를 직접 수정하지 말 것. 반드시 .copy()해서 반환해야 예기치 않은 상태 공유를 막는다.

1.4 Pinterest/Waterfall Layout 전체 구현

아이템 높이가 제각각이고, 가장 짧은 열에 다음 아이템을 배치해 빈 공간을 최소화하는 레이아웃.

protocol WaterfallLayoutDelegate: AnyObject { func collectionView(_ cv: UICollectionView, heightForItemAt indexPath: IndexPath) -> CGFloat } final class WaterfallLayout: UICollectionViewLayout { weak var delegate: WaterfallLayoutDelegate? var numberOfColumns = 2 var cellPadding: CGFloat = 8 // ① 캐시 — prepare()에서 채움 private var cache: [UICollectionViewLayoutAttributes] = [] private var contentHeight: CGFloat = 0 private var contentWidth: CGFloat { guard let cv = collectionView else { return 0 } let insets = cv.contentInset return cv.bounds.width - insets.left - insets.right } // ② 전체 콘텐츠 크기 override var collectionViewContentSize: CGSize { CGSize(width: contentWidth, height: contentHeight) } // ③ 사전 계산 — 열별 y 추적하며 가장 짧은 열에 배치 override func prepare() { guard cache.isEmpty, let cv = collectionView else { return } let columnWidth = contentWidth / CGFloat(numberOfColumns) var xOffsets: [CGFloat] = (0..<numberOfColumns).map { CGFloat($0) * columnWidth } var yOffsets: [CGFloat] = Array(repeating: 0, count: numberOfColumns) for item in 0..<cv.numberOfItems(inSection: 0) { let indexPath = IndexPath(item: item, section: 0) // 가장 짧은 열 찾기 let column = yOffsets.enumerated().min(by: { $0.element < $1.element })!.offset let height = delegate?.collectionView(cv, heightForItemAt: indexPath) ?? 180 let frame = CGRect( x: xOffsets[column] + cellPadding, y: yOffsets[column] + cellPadding, width: columnWidth - cellPadding * 2, height: height ) let attrs = UICollectionViewLayoutAttributes(forCellWith: indexPath) attrs.frame = frame cache.append(attrs) yOffsets[column] = frame.maxY } contentHeight = yOffsets.max() ?? 0 } // ④ rect 교차 필터링 override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { cache.filter { $0.frame.intersects(rect) } } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { cache[indexPath.item] } // ⑤ 레이아웃 무효화 시 캐시 초기화 override func invalidateLayout() { super.invalidateLayout() cache.removeAll() contentHeight = 0 } }
핵심: yOffsets 배열로 각 열의 현재 높이를 추적 → 가장 짧은 열에 다음 아이템 배치. invalidateLayout()에서 캐시 초기화 필수.

1.5 Circular Layout 구현

아이템을 원 둘레에 배치하는 레이아웃. 캐러셀·다이얼 UI에 사용.

final class CircularLayout: UICollectionViewLayout { var itemSize = CGSize(width: 80, height: 80) var radius: CGFloat = 150 private var cache: [UICollectionViewLayoutAttributes] = [] private var center: CGPoint = .zero override var collectionViewContentSize: CGSize { collectionView?.bounds.size ?? .zero } override func prepare() { guard cache.isEmpty, let cv = collectionView else { return } center = CGPoint(x: cv.bounds.midX, y: cv.bounds.midY) let count = cv.numberOfItems(inSection: 0) let angleStep = (2 * .pi) / CGFloat(count) for item in 0..<count { let angle = CGFloat(item) * angleStep - .pi / 2 // 12시 방향 시작 let x = center.x + radius * cos(angle) - itemSize.width / 2 let y = center.y + radius * sin(angle) - itemSize.height / 2 let attrs = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: item, section: 0)) attrs.frame = CGRect(origin: CGPoint(x: x, y: y), size: itemSize) cache.append(attrs) } } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { cache.filter { $0.frame.intersects(rect) } } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { cache[indexPath.item] } override func invalidateLayout() { super.invalidateLayout() cache.removeAll() } }

1.6 성능: prepare() 캐싱 전략

2. 커스텀 셀 설계 패턴

2.1 재사용 가능한 셀 컴포넌트 설계

셀 내부를 configure 가능한 작은 컴포넌트로 분리 → 여러 화면에서 재사용.

// 재사용 가능한 "프로필 뱃지" 컴포넌트 final class ProfileBadgeView: UIView { private let imageView = UIImageView() private let nameLabel = UILabel() func configure(image: UIImage?, name: String) { imageView.image = image nameLabel.text = name } } // 셀은 컴포넌트를 조립만 final class FeedCell: UICollectionViewCell { private let profileBadge = ProfileBadgeView() private let contentImageView = UIImageView() func configure(with model: FeedItem) { profileBadge.configure(image: model.authorImage, name: model.authorName) contentImageView.image = model.contentImage } }

2.2 셀 변형(Variants) 패턴

같은 데이터지만 표시 형태가 다른 경우(그리드 vs 리스트). 하나의 셀에 variant 프로퍼티로 분기하거나, 별도 셀로 분리.

enum CellVariant { case grid, list } final class ProductCell: UICollectionViewCell { var variant: CellVariant = .grid { didSet { applyVariant() } } private func applyVariant() { switch variant { case .grid: stackView.axis = .vertical imageView.snp.remakeConstraints { $0.height.equalTo(120) } case .list: stackView.axis = .horizontal imageView.snp.remakeConstraints { $0.width.equalTo(80) } } } }

2.3 ContentConfiguration (iOS 14+) vs Manual Setup

ContentConfigurationManual Setup
방식셀에 configuration 객체 할당셀 직접 프로퍼티 설정
장점상태(normal/selected/disabled) 자동 대응, 시스템 셀과 통일완전한 제어, 복잡한 커스텀에 유리
iOS14+모든 버전
// ContentConfiguration 방식 (iOS 14+) var config = cell.defaultContentConfiguration() config.text = item.title config.secondaryText = item.subtitle config.image = item.icon cell.contentConfiguration = config

2.4 prepareForReuse 최적화

override func prepareForReuse() { super.prepareForReuse() // ✅ 잔상 제거 imageView.image = nil titleLabel.text = nil // ✅ 비동기 작업 취소 imageTask?.cancel() imageTask = nil // ✅ 상태 초기화 (선택/하이라이트 등) isHighlighted = false // ❌ 하지 말 것: 무거운 작업 (뷰 제거/재생성, 제약 재구성) }
팁: prepareForReuse에서 subview를 add/remove하거나 Auto Layout 제약을 재구성하면 재사용 성능이 떨어진다. 숨기기(isHidden)상태 변경으로 대체.

3. 고급 스크롤 애니메이션

3.1 Parallax 효과 — layoutAttributesForElements 활용

스크롤 시 이미지가 셀 프레임보다 느리게 움직여 깊이감을 주는 효과.

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let attrs = super.layoutAttributesForElements(in: rect) else { return nil } guard let cv = collectionView else { return attrs } let visibleRect = CGRect(origin: cv.contentOffset, size: cv.bounds.size) return attrs.compactMap { original -> UICollectionViewLayoutAttributes? in let attr = original.copy() as! UICollectionViewLayoutAttributes // 셀 중심과 화면 중심의 거리 let distanceFromCenter = attr.center.y - visibleRect.midY // parallax: 이미지 오프셋 (셀 이동의 30%만 따라감) let parallaxOffset = distanceFromCenter * 0.3 // 커스텀 키로 셀에 전달 (셀에서 imageView.transform 적용) attr.transform = CGAffineTransform(translationX: 0, y: parallaxOffset) return attr } }
셀에서 적용: apply(_ layoutAttributes:)를 오버라이드하거나, 커스텀 attributes 서브클래스로 parallaxOffset을 전달.

3.2 Transform 효과 — 3D Flip, Scale, Rotation

// 화면 중심에서 멀어질수록 작아지고 기울어지는 효과 func applyTransform(to attr: UICollectionViewLayoutAttributes, visibleRect: CGRect) { let distanceFromCenter = attr.center.x - visibleRect.midX let normalizedDistance = distanceFromCenter / visibleRect.width // Scale: 중앙 1.0 → 가장자리 0.8 let scale = 1 - abs(normalizedDistance) * 0.2 // 3D Rotation: y축 회전 var transform3D = CATransform3DIdentity transform3D.m34 = -1.0 / 500 // 원근감 transform3D = CATransform3DRotate(transform3D, normalizedDistance * .pi / 4, 0, 1, 0) transform3D = CATransform3DScale(transform3D, scale, scale, 1) attr.transform3D = transform3D attr.zIndex = Int(scale * 100) // 큰 셀이 위로 }

3.3 scrollViewDidScroll 실시간 변환

스크롤할 때마다 레이아웃을 무효화해 transform을 갱신.

// 레이아웃에서 override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true // bounds(=스크롤 위치) 바뀔 때마다 무효화 → prepare/layoutAttributes 재호출 }
주의: shouldInvalidateLayout(forBoundsChange:)가 항상 true면 매 스크롤 프레임마다 레이아웃 재계산. 가벼운 transform만 적용하고, 무거운 계산은 캐싱.

4. 레이아웃 전환

4.1 setCollectionViewLayout(_:animated:)

런타임에 레이아웃을 바꾸면서 셀 위치가 부드럽게 애니메이션된다.

collectionView.setCollectionViewLayout(newLayout, animated: true) { finished in print("전환 완료") }

4.2 그리드 ↔ 리스트 전환 실전 예제

final class LayoutSwitchViewController: UIViewController { private var isGridMode = true private lazy var gridLayout: UICollectionViewFlowLayout = { let layout = UICollectionViewFlowLayout() layout.itemSize = CGSize(width: 100, height: 100) layout.minimumInteritemSpacing = 8 return layout }() private lazy var listLayout: UICollectionViewFlowLayout = { let layout = UICollectionViewFlowLayout() layout.itemSize = CGSize(width: view.bounds.width - 32, height: 80) layout.minimumLineSpacing = 8 return layout }() @objc func toggleLayout() { isGridMode.toggle() let newLayout = isGridMode ? gridLayout : listLayout collectionView.setCollectionViewLayout(newLayout, animated: true) } }

4.3 Interactive Transition

제스처에 따라 진행률을 직접 제어하는 전환. startInteractiveTransition 사용.

var transitionLayout: UICollectionViewTransitionLayout? func handlePinch(_ gesture: UIPinchGestureRecognizer) { switch gesture.state { case .began: let targetLayout = gesture.scale > 1 ? gridLayout : listLayout transitionLayout = collectionView.startInteractiveTransition(to: targetLayout) { _, _ in self.transitionLayout = nil } case .changed: // progress: 0 (시작) ~ 1 (완료) let progress = min(max(abs(gesture.scale - 1), 0), 1) transitionLayout?.transitionProgress = progress transitionLayout?.invalidateLayout() case .ended: if (transitionLayout?.transitionProgress ?? 0) > 0.5 { collectionView.finishInteractiveTransition() } else { collectionView.cancelInteractiveTransition() } default: break } }

5. Compositional Layout 고급 패턴 iOS 13+

5.1 Orthogonal Scrolling (가로 스크롤 섹션)

전체는 세로 스크롤이지만 특정 섹션만 가로 스크롤되는 패턴(넷플릭스·앱스토어).

func createCarouselSection() -> NSCollectionLayoutSection { let itemSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .absolute(200), heightDimension: .absolute(150) ) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 12 section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) // ★ 핵심: orthogonalScrollingBehavior section.orthogonalScrollingBehavior = .continuous // 자유 스크롤 // .paging: 페이지 단위 / .groupPaging: 그룹 단위 / .groupPagingCentered: 그룹 중앙 정렬 return section }

5.2 Mixed Layouts (섹션별 다른 레이아웃)

let layout = UICollectionViewCompositionalLayout { sectionIndex, env -> NSCollectionLayoutSection? in switch sectionIndex { case 0: return createBannerSection() // 풀 폭 배너 case 1: return createCarouselSection() // 가로 스크롤 캐러셀 case 2: return createGridSection() // 2열 그리드 default: return createListSection() // 리스트 } }

5.3 Supplementary Items (배지, 데코레이션)

섹션 헤더/푸터 외에 아이템 위에 겹치는 배지섹션 배경을 추가.

// 아이템에 배지 추가 let badgeAnchor = NSCollectionLayoutAnchor(edges: [.top, .trailing], fractionalOffset: CGPoint(x: 0.3, y: -0.3)) let badgeSize = NSCollectionLayoutSize(widthDimension: .absolute(20), heightDimension: .absolute(20)) let badge = NSCollectionLayoutSupplementaryItem(layoutSize: badgeSize, elementKind: "badge", containerAnchor: badgeAnchor) let item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [badge]) // 섹션 배경 데코레이션 let backgroundDecoration = NSCollectionLayoutDecorationItem.background(elementKind: "sectionBackground") section.decorationItems = [backgroundDecoration] // 레이아웃에 데코레이션 뷰 클래스 등록 layout.register(SectionBackgroundView.self, forDecorationViewOfKind: "sectionBackground")

5.4 visibleItemsInvalidationHandler

스크롤 중 보이는 아이템에 실시간 transform 적용 (커스텀 레이아웃 없이 애니메이션 효과).

section.visibleItemsInvalidationHandler = { items, offset, env in let centerX = offset.x + env.container.contentSize.width / 2 for item in items { let distanceFromCenter = abs(item.frame.midX - centerX) let scale = max(1 - distanceFromCenter / 500, 0.8) item.transform = CGAffineTransform(scaleX: scale, y: scale) } }
장점: 커스텀 UICollectionViewLayout 서브클래싱 없이 Compositional Layout 안에서 스크롤 기반 애니메이션 구현 가능.

6. 커스텀 제스처 처리

6.1 UIScrollView gesture recognizer 이해

6.2 Long Press + Drag Reordering

// 1. gesture 추가 let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) collectionView.addGestureRecognizer(longPress) // 2. 처리 @objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) { let location = gesture.location(in: collectionView) switch gesture.state { case .began: guard let indexPath = collectionView.indexPathForItem(at: location) else { return } collectionView.beginInteractiveMovementForItem(at: indexPath) case .changed: collectionView.updateInteractiveMovementTargetPosition(location) case .ended: collectionView.endInteractiveMovement() default: collectionView.cancelInteractiveMovement() } } // 3. DataSource에서 실제 데이터 이동 func collectionView(_ cv: UICollectionView, moveItemAt source: IndexPath, to dest: IndexPath) { let item = items.remove(at: source.item) items.insert(item, at: dest.item) }

6.3 Swipe Actions (UITableView / iOS 11+ UICollectionView)

// TableView: trailingSwipeActionsConfigurationForRowAt func tableView(_ tv: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let delete = UIContextualAction(style: .destructive, title: "삭제") { _, _, completion in self.items.remove(at: indexPath.row) tv.deleteRows(at: [indexPath], with: .automatic) completion(true) } return UISwipeActionsConfiguration(actions: [delete]) }

7. Q&A — 설계 결정 · 구현 디버깅 · 성능

Q. 커스텀 레이아웃 vs Compositional — 언제 뭘?

Compositional 먼저. Item→Group→Section 조합과 orthogonal로 대부분 해결. 커스텀은: ① 자유 좌표(원형·곡선) ② 스크롤 위치 기반 3D transform ③ Compositional로 구현하면 더 복잡해질 때.

Q. layoutAttributesForElements에서 copy() 왜?

prepare()에서 캐싱한 attributes를 직접 수정하면 다음 호출 때 오염된 상태로 시작. .copy()해서 수정 후 반환하면 원본 캐시는 깨끗하게 유지.

Q. shouldInvalidateLayout(forBoundsChange:) 항상 true의 대가?

매 스크롤 프레임마다 레이아웃 패스가 돈다. transform 적용만 하면 괜찮지만, prepare()에서 무거운 계산을 하면 프레임 드롭. frame 캐싱 + transform만 갱신하는 패턴 권장.

Q. visibleItemsInvalidationHandler의 items는 layoutAttributes?

맞다. [NSCollectionLayoutVisibleItem]인데 사실상 attributes처럼 frame, transform 등을 읽고 쓸 수 있다. 스크롤 중 실시간 갱신.

Q. 레이아웃 전환 시 셀 indexPath가 바뀌면?

같은 dataSource 상태에서 레이아웃만 바꾸면 indexPath 유지. 데이터까지 바꾸려면 snapshot apply → setCollectionViewLayout 순서로 분리해서 처리.

Q. prepareForReuse에서 제약 재구성하면?

재사용 때마다 Auto Layout 엔진이 풀고 다시 계산 → 셀당 수십 ms 추가. 제약은 init에서 한 번만 세팅하고, isHidden·constant 변경으로 전환.

8. 5년차 체크리스트

관련 문서