← 홈으로
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 | 줄바꿈 그리드만. 아이템 높이 제각각이면 빈 공간 발생(폭포수 불가) |
| Compositional | Item→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 / size | frame 대신 중심+크기로 지정 |
transform | 2D 회전·확대·이동 (CGAffineTransform) |
transform3D | 3D 회전·원근 (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() 캐싱 전략
prepare()에서 모든 attributes를 미리 계산해 캐싱 → layoutAttributesForElements는 필터링만
- 데이터 변경 시
invalidateLayout() → 다음 레이아웃 패스에서 prepare() 재호출
- 부분 무효화가 필요하면
invalidationContext로 변경 영역만 지정
- 아이템이 1만 개 이상이면 visible rect 근처만 계산하는 incremental 방식 고려
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
| ContentConfiguration | Manual Setup |
| 방식 | 셀에 configuration 객체 할당 | 셀 직접 프로퍼티 설정 |
| 장점 | 상태(normal/selected/disabled) 자동 대응, 시스템 셀과 통일 | 완전한 제어, 복잡한 커스텀에 유리 |
| iOS | 14+ | 모든 버전 |
// 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 이해
panGestureRecognizer: 스크롤 드래그
pinchGestureRecognizer: 줌 (zoomScale)
- 다른 제스처와 충돌하면
gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)로 조정
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년차 체크리스트
UICollectionViewLayout 서브클래싱의 4가지 필수 오버라이드(prepare, collectionViewContentSize, layoutAttributesForElements, layoutAttributesForItem)를 설명할 수 있다
- Waterfall(폭포수) 레이아웃의 원리("가장 짧은 열에 배치")와 구현 흐름을 안다
layoutAttributesForElements에서 copy() 후 반환하는 이유를 안다
shouldInvalidateLayout(forBoundsChange:)로 스크롤 기반 transform 애니메이션을 구현할 수 있다
- Compositional Layout의
orthogonalScrollingBehavior로 가로 스크롤 섹션을 만들 수 있다
visibleItemsInvalidationHandler로 커스텀 레이아웃 없이 스크롤 애니메이션을 적용할 수 있다
setCollectionViewLayout(_:animated:)로 그리드↔리스트 전환을 구현할 수 있다
beginInteractiveMovementForItem으로 드래그 재정렬을 구현할 수 있다
prepareForReuse에서 제약 재구성을 피해야 하는 이유를 안다