UIKit · Advanced · Internal

ScrollView 내부 동작 원리 심화

bounds.origin 이동, 터치 처리, 관성/바운스, Layout Invalidation까지 — UIScrollView가 실제로 어떻게 동작하는지 깊이 파헤친다.

Core Animation Layout Cycle Performance
학습 날짜

기록 없음

1. 스크롤의 본질: bounds.origin 이동

핵심 원리

스크롤은 자식 뷰를 움직이는 게 아니다. 부모(ScrollView)의 좌표계 원점(bounds.origin)을 이동시키는 것이다.

contentOffset = bounds.origin
이 둘은 완전히 동일하다. contentOffset은 bounds.origin의 편의 프로퍼티일 뿐.
// 이 두 줄은 완전히 같은 동작
scrollView.contentOffset = CGPoint(x: 0, y: 100)
scrollView.bounds.origin = CGPoint(x: 0, y: 100)

frame vs bounds

속성 좌표계 스크롤 시 변화
frame superview 좌표계 ❌ 불변
bounds.origin 자신의 좌표계 ✅ 변함 (= 스크롤)
bounds.size 자신의 좌표계 ❌ 불변 (줌 제외)

시각화: 스크롤 전후 비교

스크롤 전 (contentOffset.y = 0) 스크롤 후 (contentOffset.y = 100) ┌──────────────┐ │ ┌──────────┐ │ subview ┌──────────┐ ← subview (y=0) │ │ subview │ │ frame.y = 0 └──────────┘ 화면 밖으로 밀려남 │ │ y = 0 │ │ ┌──────────────┐ │ └──────────┘ │ │ │ │ │ │ │ ← 보이는 영역 │ │ │ │ y=100~700 └──────────────┘ └──────────────┘ bounds.origin = (0,0) bounds.origin = (0,100)
⚠️ 흔한 오해
"스크롤하면 subview의 frame이 바뀐다" → ❌ 틀림
subview.frame은 절대 변하지 않는다. 부모의 bounds.origin만 변한다.

검증 코드

let scrollView = UIScrollView(frame: CGRect(x: 0, y: 100, width: 375, height: 600))
scrollView.contentSize = CGSize(width: 375, height: 2000)

let label = UILabel(frame: CGRect(x: 50, y: 500, width: 200, height: 50))
scrollView.addSubview(label)

// 스크롤 전
print(scrollView.frame)       // (0, 100, 375, 600)
print(scrollView.bounds)      // (0, 0, 375, 600)
print(label.frame)            // (50, 500, 200, 50)

// 스크롤 후
scrollView.contentOffset.y = 300

print(scrollView.frame)       // (0, 100, 375, 600) ← 동일!
print(scrollView.bounds)      // (0, 300, 375, 600) ← origin.y만 변경
print(label.frame)            // (50, 500, 200, 50) ← 동일!

// 하지만 label은 화면상 y = 200 위치에 보임 (500 - 300)

2. 스크롤 범위와 바운스

정상 스크롤 범위

minOffset = CGPoint(x: 0, y: 0)
maxOffset = CGPoint(
    x: contentSize.width - bounds.width,
    y: contentSize.height - bounds.height
)

// 예: contentSize = (375, 2000), bounds.size = (375, 600)
// maxOffset.y = 2000 - 600 = 1400
// 스크롤 범위: 0 ≤ contentOffset.y ≤ 1400

바운스 (bounces = true)

정상 범위를 벗어나도 스크롤이 가능. 손 떼면 스프링처럼 튕겨서 돌아옴.

contentOffset.y = -50 (바운스 중, 맨 위에서 더 아래로 당김) ┌──────────────┐ │ (빈 공간) │ ← 원래 없던 영역이 보임 │ ┌──────────┐ │ │ │ 첫 번째 │ │ │ │ 콘텐츠 │ │ └──────────────┘ contentOffset.y = 1450 (바운스 중, 맨 아래에서 더 위로 당김) ┌──────────────┐ │ │ 마지막 │ │ │ │ 콘텐츠 │ │ │ └──────────┘ │ │ (빈 공간) │ ← 원래 없던 영역이 보임 └──────────────┘

바운스 관련 프로퍼티

프로퍼티 설명
bounces 바운스 활성화 여부 (기본 true)
alwaysBounceVertical contentSize < bounds여도 세로 바운스
alwaysBounceHorizontal contentSize < bounds여도 가로 바운스

3. Layout Invalidation과 ScrollView

🔥 핵심 포인트
bounds.origin 변경(스크롤)은 setNeedsLayout을 트리거하지 않는다!
bounds.size 변경은 트리거한다.

왜 이게 중요한가?

만약 스크롤할 때마다 layoutSubviews가 호출되면 60fps 유지가 불가능하다. Apple은 이를 최적화했다.

변경 setNeedsLayout 호출? 이유
bounds.origin (스크롤) ❌ No 자식 frame 변경 없음, 좌표계만 이동
bounds.size (리사이즈) ✅ Yes 자식 레이아웃 재계산 필요
frame.size ✅ Yes bounds.size도 함께 변경됨
contentSize ❌ No 스크롤 범위만 변경, 레이아웃 무관

검증 실험

class TestScrollView: UIScrollView {
    override func layoutSubviews() {
        super.layoutSubviews()
        print("layoutSubviews called - bounds: \(bounds)")
    }
}

// 테스트
let sv = TestScrollView(frame: CGRect(x: 0, y: 0, width: 375, height: 600))
sv.contentSize = CGSize(width: 375, height: 2000)

// 초기 레이아웃
sv.layoutIfNeeded()  // "layoutSubviews called - bounds: (0, 0, 375, 600)"

// 스크롤 (bounds.origin 변경)
sv.contentOffset.y = 100  // ← layoutSubviews 호출 안됨!
sv.contentOffset.y = 200  // ← layoutSubviews 호출 안됨!

// 리사이즈 (bounds.size 변경)
sv.frame.size.height = 500  // "layoutSubviews called - bounds: (0, 200, 375, 500)"

UICollectionView/UITableView의 invalidateLayout

CollectionView/TableView는 스크롤 시에도 셀을 재배치해야 하므로 다른 메커니즘을 사용한다.

// UICollectionViewLayout의 핵심 메서드
class UICollectionViewLayout {

    // 1. 스크롤할 때마다 호출 여부 결정
    func shouldInvalidateLayout(
        forBoundsChange newBounds: CGRect
    ) -> Bool {
        // 기본 구현: bounds.size가 바뀔 때만 true
        // UICollectionViewFlowLayout: bounds.origin 변경에도 false
        return oldBounds.size != newBounds.size
    }

    // 2. true 반환 시 → invalidateLayout() 호출됨
    // 3. 다음 레이아웃 패스에서 prepare() 재호출
}
⚠️ 커스텀 레이아웃 주의점
shouldInvalidateLayout(forBoundsChange:)에서 항상 true를 반환하면 스크롤할 때마다 전체 레이아웃을 재계산 → 성능 저하

invalidateLayout vs setNeedsLayout

메서드 대상 용도
setNeedsLayout() UIView 다음 런루프에서 layoutSubviews 호출
invalidateLayout() UICollectionViewLayout 레이아웃 정보 무효화 → prepare() 재호출
invalidateLayout(with:) UICollectionViewLayout 부분 무효화 (특정 아이템/섹션만)

Layout Invalidation Context (부분 무효화)

// 전체 무효화 (느림)
collectionView.collectionViewLayout.invalidateLayout()

// 부분 무효화 (빠름) - 특정 아이템만
let context = UICollectionViewLayoutInvalidationContext()
context.invalidateItems(at: [IndexPath(item: 5, section: 0)])
collectionView.collectionViewLayout.invalidateLayout(with: context)

// 콘텐츠 크기만 무효화
let context = UICollectionViewLayoutInvalidationContext()
context.contentSizeAdjustment = CGSize(width: 0, height: 100)
collectionView.collectionViewLayout.invalidateLayout(with: context)

4. 스크롤 시 레이아웃 사이클

UIScrollView 스크롤 시

사용자 드래그 ↓ UIPanGestureRecognizer 인식 ↓ bounds.origin 변경 (= contentOffset) ↓ CALayer.bounds 변경 → Core Animation이 즉시 렌더링 ↓ scrollViewDidScroll(_:) 델리게이트 호출 ↓ ❌ layoutSubviews 호출 안됨 (성능 최적화)

UICollectionView 스크롤 시

사용자 드래그 ↓ bounds.origin 변경 ↓ shouldInvalidateLayout(forBoundsChange:) 호출 ↓ ├─ false 반환 → 레이아웃 유지, 기존 셀 재사용 │ └─ true 반환 → invalidateLayout() ↓ prepare() 재호출 ↓ layoutAttributesForElements(in:) 재계산 ↓ 셀 배치 업데이트

UICollectionViewFlowLayout의 기본 동작

// FlowLayout의 shouldInvalidateLayout 기본 구현
override func shouldInvalidateLayout(
    forBoundsChange newBounds: CGRect
) -> Bool {
    // bounds.size가 바뀔 때만 true
    // 스크롤(bounds.origin 변경)에는 false
    return collectionView?.bounds.size != newBounds.size
}

// 즉, 일반 스크롤 시에는 레이아웃 재계산 안함
// → 셀 재사용(dequeue)만으로 처리

5. 터치 → 스크롤 변환 과정

UIScrollView 내부 제스처

UIScrollView는 내부적으로 UIPanGestureRecognizerUIPinchGestureRecognizer를 가지고 있다.

// UIScrollView의 내부 구조 (개념적)
class UIScrollView: UIView {

    // 내부 제스처 (readonly로 노출)
    var panGestureRecognizer: UIPanGestureRecognizer { get }
    var pinchGestureRecognizer: UIPinchGestureRecognizer? { get }

    private func handlePan(_ gesture: UIPanGestureRecognizer) {
        switch gesture.state {
        case .began:
            // 기존 감속 애니메이션 중지
            stopDecelerating()

        case .changed:
            let translation = gesture.translation(in: self)

            // bounds.origin 업데이트
            var newOffset = contentOffset
            newOffset.x -= translation.x
            newOffset.y -= translation.y

            // 범위 제한 (바운스 고려)
            newOffset = constrainedOffset(newOffset)

            contentOffset = newOffset
            gesture.setTranslation(.zero, in: self)

        case .ended:
            let velocity = gesture.velocity(in: self)
            // 관성 스크롤 시작
            startDecelerating(with: velocity)

        default:
            break
        }
    }
}

터치 딜레이 (delaysContentTouches)

// 기본값: true
scrollView.delaysContentTouches = true

// true일 때 동작:
// 1. 터치 시작 → 150ms 대기
// 2. 150ms 내에 드래그 감지 → 스크롤로 처리, 터치 이벤트 취소
// 3. 150ms 내에 움직임 없음 → 자식 뷰에 터치 전달

// false일 때 동작:
// 터치 즉시 자식 뷰에 전달 + 동시에 스크롤도 가능

터치 취소 (canCancelContentTouches)

// 기본값: true
scrollView.canCancelContentTouches = true

// true: 스크롤 시작되면 자식 뷰의 터치 취소 (touchesCancelled 호출)
// false: 이미 시작된 터치는 취소하지 않음

6. 관성 스크롤 (Deceleration)

감속률 (decelerationRate)

// 미리 정의된 값
UIScrollView.DecelerationRate.normal  // 0.998
UIScrollView.DecelerationRate.fast    // 0.99

// 숫자가 1에 가까울수록 천천히 멈춤 (더 멀리 스크롤)
// 숫자가 작을수록 빨리 멈춤
scrollView.decelerationRate = .normal

감속 물리 공식

// 매 프레임마다:
velocity = velocity * decelerationRate

// 예: 초기 속도 1000pt/s, decelerationRate = 0.998
// 1초 후 (60fps = 60프레임):
// velocity = 1000 * (0.998 ^ 60) ≈ 886 pt/s

// 정지 조건: velocity < threshold (약 0.1 pt/s)

관성 스크롤 관련 델리게이트

protocol UIScrollViewDelegate {

    // 드래그 끝남 (손 뗌)
    func scrollViewDidEndDragging(
        _ scrollView: UIScrollView,
        willDecelerate decelerate: Bool  // 관성 스크롤 시작 여부
    )

    // 관성 스크롤 중
    func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView)

    // 관성 스크롤 완전히 멈춤
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView)
}

스크롤 완전 정지 감지

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate: Bool) {
    if !willDecelerate {
        // 관성 없이 바로 정지 → 여기서 처리
        handleScrollingStopped()
    }
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    // 관성 스크롤 후 정지 → 여기서 처리
    handleScrollingStopped()
}

private func handleScrollingStopped() {
    print("스크롤 완전히 멈춤")
}

7. contentInset 심화

세 가지 Inset

프로퍼티 설명 설정 주체
contentInset 개발자가 명시적으로 설정 개발자
safeAreaInsets 노치, 홈 인디케이터 등 시스템 (자동)
adjustedContentInset 최종 적용값 (읽기 전용) 계산됨

adjustedContentInset 계산

// contentInsetAdjustmentBehavior에 따라 다름

switch contentInsetAdjustmentBehavior {
case .automatic:
    // 상황에 따라 자동 결정
    adjustedContentInset = contentInset + safeAreaInsets (상황별)

case .scrollableAxes:
    // 스크롤 가능한 축에만 safeArea 적용
    adjustedContentInset = contentInset + safeAreaInsets (스크롤 축만)

case .never:
    // safeArea 무시
    adjustedContentInset = contentInset

case .always:
    // 항상 safeArea 적용
    adjustedContentInset = contentInset + safeAreaInsets
}

contentInset이 스크롤 범위에 미치는 영향

// 기본 스크롤 범위
minY = 0
maxY = contentSize.height - bounds.height

// contentInset 적용 후
minY = -adjustedContentInset.top
maxY = contentSize.height - bounds.height + adjustedContentInset.bottom

// 예: contentInset.top = 100, contentSize = 2000, bounds.height = 600
// minY = -100  (위로 100pt 더 스크롤 가능)
// maxY = 1400 + 0 = 1400
adjustedContentInset.top = 100 ← contentOffset.y = -100 (최상단) ┌──────────────┐ │ (여백) │ ← contentInset.top 영역 │──────────────│ │ │ │ 콘텐츠 │ ← contentOffset.y = 0 (콘텐츠 시작) │ │ └──────────────┘

키보드 대응 예제

NotificationCenter.default.addObserver(
    self,
    selector: #selector(keyboardWillShow),
    name: UIResponder.keyboardWillShowNotification,
    object: nil
)

@objc func keyboardWillShow(_ notification: Notification) {
    guard let keyboardFrame = notification.userInfo?[
        UIResponder.keyboardFrameEndUserInfoKey
    ] as? CGRect else { return }

    let keyboardHeight = keyboardFrame.height

    // contentInset.bottom 조정
    scrollView.contentInset.bottom = keyboardHeight
    scrollView.verticalScrollIndicatorInsets.bottom = keyboardHeight
}

@objc func keyboardWillHide(_ notification: Notification) {
    scrollView.contentInset.bottom = 0
    scrollView.verticalScrollIndicatorInsets.bottom = 0
}

8. 성능 최적화

스크롤 성능 체크리스트

Offscreen Rendering 피하기

// ❌ 느림 - offscreen rendering 발생
cell.layer.cornerRadius = 12
cell.layer.masksToBounds = true

// ✅ 빠름 - cornerRadius만 (마스킹 없이)
cell.layer.cornerRadius = 12
// masksToBounds = false (기본값)

// ✅ 또는 래스터화
cell.layer.cornerRadius = 12
cell.layer.masksToBounds = true
cell.layer.shouldRasterize = true
cell.layer.rasterizationScale = UIScreen.main.scale

스크롤 상태에 따른 최적화

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    // 스크롤 시작 → 무거운 작업 중지
    imageLoadingQueue.isSuspended = true
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    // 스크롤 완전 정지 → 이미지 로딩 재개
    imageLoadingQueue.isSuspended = false
    loadImagesForVisibleCells()
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate: Bool) {
    if !willDecelerate {
        imageLoadingQueue.isSuspended = false
        loadImagesForVisibleCells()
    }
}

prefetchDataSource (iOS 10+)

// UITableViewDataSourcePrefetching
extension ViewController: UITableViewDataSourcePrefetching {

    func tableView(_ tableView: UITableView,
                   prefetchRowsAt indexPaths: [IndexPath]) {
        // 곧 보일 셀의 데이터 미리 로드
        for indexPath in indexPaths {
            loadData(for: indexPath)
        }
    }

    func tableView(_ tableView: UITableView,
                   cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
        // 스크롤 방향 바뀜 → 프리페치 취소
        for indexPath in indexPaths {
            cancelLoading(for: indexPath)
        }
    }
}

Q&A

Q: 스크롤할 때마다 layoutSubviews가 호출되나?

A: 아니다. bounds.origin 변경(스크롤)은 setNeedsLayout을 트리거하지 않는다. bounds.size 변경(리사이즈)만 트리거한다. 이게 60fps 유지의 핵심.

Q: contentOffset과 bounds.origin의 차이는?

A: 없다. 완전히 같은 값이다. contentOffset은 bounds.origin의 편의 프로퍼티.

Q: 스크롤하면 subview의 frame이 바뀌나?

A: 아니다. subview.frame은 절대 변하지 않는다. 부모의 좌표계(bounds.origin)가 이동하는 것이고, 상대적으로 움직여 보이는 것.

Q: invalidateLayout vs setNeedsLayout?

A: setNeedsLayout은 UIView의 layoutSubviews 호출용. invalidateLayout은 UICollectionViewLayout의 prepare() 재호출용. 대상이 다르다.

Q: shouldInvalidateLayout(forBoundsChange:)는 언제 쓰나?

A: 커스텀 CollectionViewLayout에서 스크롤 시 레이아웃을 재계산할지 결정. Sticky Header, Parallax 효과 등에서 true 반환. 단, 성능 영향 주의.

Q: 바운스 중 contentOffset 값은?

A: 정상 범위를 벗어난다. 맨 위에서 더 당기면 음수, 맨 아래에서 더 당기면 최대값 초과. 손 떼면 스프링 애니메이션으로 정상 범위로 돌아옴.

📚 관련 문서