Q: 스크롤할 때마다 layoutSubviews가 호출되나?
A: 아니다. bounds.origin 변경(스크롤)은 setNeedsLayout을 트리거하지 않는다. bounds.size 변경(리사이즈)만 트리거한다. 이게 60fps 유지의 핵심.
bounds.origin 이동, 터치 처리, 관성/바운스, Layout Invalidation까지 — UIScrollView가 실제로 어떻게 동작하는지 깊이 파헤친다.
기록 없음
스크롤은 자식 뷰를 움직이는 게 아니다. 부모(ScrollView)의 좌표계 원점(bounds.origin)을 이동시키는 것이다.
// 이 두 줄은 완전히 같은 동작 scrollView.contentOffset = CGPoint(x: 0, y: 100) scrollView.bounds.origin = CGPoint(x: 0, y: 100)
| 속성 | 좌표계 | 스크롤 시 변화 |
|---|---|---|
frame |
superview 좌표계 | ❌ 불변 |
bounds.origin |
자신의 좌표계 | ✅ 변함 (= 스크롤) |
bounds.size |
자신의 좌표계 | ❌ 불변 (줌 제외) |
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)
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) |
alwaysBounceVertical |
contentSize < bounds여도 세로 바운스 |
alwaysBounceHorizontal |
contentSize < bounds여도 가로 바운스 |
setNeedsLayout을 트리거하지 않는다!만약 스크롤할 때마다 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)"
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를 반환하면 스크롤할 때마다 전체 레이아웃을 재계산 → 성능 저하
| 메서드 | 대상 | 용도 |
|---|---|---|
setNeedsLayout() |
UIView | 다음 런루프에서 layoutSubviews 호출 |
invalidateLayout() |
UICollectionViewLayout | 레이아웃 정보 무효화 → prepare() 재호출 |
invalidateLayout(with:) |
UICollectionViewLayout | 부분 무효화 (특정 아이템/섹션만) |
// 전체 무효화 (느림) 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)
// FlowLayout의 shouldInvalidateLayout 기본 구현
override func shouldInvalidateLayout(
forBoundsChange newBounds: CGRect
) -> Bool {
// bounds.size가 바뀔 때만 true
// 스크롤(bounds.origin 변경)에는 false
return collectionView?.bounds.size != newBounds.size
}
// 즉, 일반 스크롤 시에는 레이아웃 재계산 안함
// → 셀 재사용(dequeue)만으로 처리
UIScrollView는 내부적으로 UIPanGestureRecognizer와 UIPinchGestureRecognizer를 가지고 있다.
// 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
}
}
}
// 기본값: true scrollView.delaysContentTouches = true // true일 때 동작: // 1. 터치 시작 → 150ms 대기 // 2. 150ms 내에 드래그 감지 → 스크롤로 처리, 터치 이벤트 취소 // 3. 150ms 내에 움직임 없음 → 자식 뷰에 터치 전달 // false일 때 동작: // 터치 즉시 자식 뷰에 전달 + 동시에 스크롤도 가능
// 기본값: true scrollView.canCancelContentTouches = true // true: 스크롤 시작되면 자식 뷰의 터치 취소 (touchesCancelled 호출) // false: 이미 시작된 터치는 취소하지 않음
// 미리 정의된 값 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("스크롤 완전히 멈춤")
}
| 프로퍼티 | 설명 | 설정 주체 |
|---|---|---|
contentInset |
개발자가 명시적으로 설정 | 개발자 |
safeAreaInsets |
노치, 홈 인디케이터 등 | 시스템 (자동) |
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
}
// 기본 스크롤 범위 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
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
}
shouldRasterize = true// ❌ 느림 - 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()
}
}
// 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)
}
}
}
A: 아니다. bounds.origin 변경(스크롤)은 setNeedsLayout을 트리거하지 않는다. bounds.size 변경(리사이즈)만 트리거한다. 이게 60fps 유지의 핵심.
A: 없다. 완전히 같은 값이다. contentOffset은 bounds.origin의 편의 프로퍼티.
A: 아니다. subview.frame은 절대 변하지 않는다. 부모의 좌표계(bounds.origin)가 이동하는 것이고, 상대적으로 움직여 보이는 것.
A: setNeedsLayout은 UIView의 layoutSubviews 호출용. invalidateLayout은 UICollectionViewLayout의 prepare() 재호출용. 대상이 다르다.
A: 커스텀 CollectionViewLayout에서 스크롤 시 레이아웃을 재계산할지 결정. Sticky Header, Parallax 효과 등에서 true 반환. 단, 성능 영향 주의.
A: 정상 범위를 벗어난다. 맨 위에서 더 당기면 음수, 맨 아래에서 더 당기면 최대값 초과. 손 떼면 스프링 애니메이션으로 정상 범위로 돌아옴.