iOS 앱에서 스크롤은 손가락을 움직이면 화면이 부드럽게 따라 움직이죠.
하지만 이 단순한 동작 뒤에는 iOS의 렌더링 시스템과 UIView의 핵심 속성들이 얽혀 있습니다.
이번 글은 UIScrollView가 어떻게 작동하는지, 그 근본적인 원리인 UIView의 렌더링 과정부터 시작해 frame과 bounds의 차이,
그리고 contentOffset의 정체(?)까지 깊이 파헤쳐 보겠습니다
화면 렌더링의 두 단계: Rasterization과 Composition
1단계: Rasterization (래스터화): 각 뷰가 자신의 이미지(콘텐츠)를 그리는 단계
각 뷰가 자신의 콘텐츠를 비트맵 이미지로 변환하는 과정입니다. UILabel은 텍스트를, UIImageView는 이미지를 그리죠.
여기서 중요한 점은 래스터화는 항상 뷰의 bounds를 기준으로 이루어진다는 것입니다.
마치 각 뷰가 자신만의 공간에 그림을 그리는 것(가상 메모리와 같은것 같아요 프로세스가 가상 주소를 받아서 본인이 첫번째지? 착각하는 것과 같은)과 같아요.
이때 bounds 밖으로 그려지는 모든 콘텐츠는 잘려나갑니다.
2단계: Composition (합성): 뷰 이미지들을 합치는 단계
각 뷰의 래스터화된 이미지들을 뷰 계층구조에 따라 하나의 최종 화면으로 합치는 과정입니다.
이 단계에서는 frame이 결정적인 역할을 합니다. frame의 origin이 슈퍼뷰 이미지 위에 서브뷰 이미지가 놓일 위치를 결정하거든요.
결론적으로, 래스터화는 '무엇을' 그릴지(bounds가 결정), 합성은 '어디에' 그릴지(frame이 결정)를 담당합니다.

스크롤의 핵심: frame과 bounds
- UIScrollView의 frame: 사용자가 스크롤을 해도 크기와 위치는 변하지 않습니다.
- UIScrollView의 bounds: 이 안에서 움직이는 콘텐츠의 좌표계입니다. 스크롤이 발생할 때, bounds의 원점(origin)이 계속해서 변하며 콘텐츠가 움직이는 것처럼 보이게 합니다.
뷰가 슈퍼뷰 위에 그려질 때(합성될 때)의 위치는 다음 공식으로 계산됩니다
CompositedPosition.x = View.frame.origin.x - Superview.bounds.origin.x;
CompositedPosition.y = View.frame.origin.y - Superview.bounds.origin.y;
일반적인 뷰에서는 슈퍼뷰의 bounds.origin이 {0, 0}이므로, CompositedPosition은 뷰의 frame.origin과 같아집니다.
하지만 UIScrollView는 이 공식을 역으로 이용합니다.
frame을 고정하고 SuperView.bounds.origin, 즉 스크롤 뷰의 bounds.origin을 변경함으로써 모든 서브뷰의 CompositedPosition을 한꺼번에 이동시킵니다
contentOffset == bounds.origin?
UIScrollView를 다루다 보면 contentOffset이라는 프로퍼티를 자주 사용하게 됩니다.
이 값은 스크롤 뷰가 현재 얼마나 스크롤되었는지를 알려주는 핵심 지표죠.
예를 들어 tableView.contentOffset.y는 수직 스크롤 거리를 의미합니다
사실........contentOffset은 별도의 프로퍼티가 아니라, bounds.origin의 편의성을 위한 별칭(alias) 입니다!
즉, contentOffset을 변경하면 내부적으로 bounds.origin이 변경되고, 그 반대도 마찬가지입니다.
이 사실을 확인하기 위해 간단한 테스트 코드를 작성해볼 수 있습니다.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("contentOffset: \(scrollView.contentOffset)")
print("bounds.origin: \(scrollView.bounds.origin)")
}
UIScrollViewDelegate 메서드인 scrollViewDidScroll에서 두 값을 모두 출력해보면, 항상 같은 값이 출력되는 것을 확인할 수 있습니다.
콘텐츠의 크기와 범위: contentSize & contentInset
contentSize: 스크롤 가능한 영역
자 저기서 어두운 영역 모두가 contentSize로 스크롤이 가능한 것을 의미합니다
UIScrollView는 그 중에 저 흰색 영역을 보고 있는거죠
그러다보니 만약 contentSize < ScrollView ? 스크롤이 애초에 불가능한거겟죠
contentInset: 스크롤 영역에 여백
contentInset은 contentSize는 그대로 둔 채, contentOffset의 최소값과 최대값을 변경하여 스크롤 범위를 확장하는 데 사용됩니다.
UIEdgeInsets 타입으로 top, left, bottom, right 네 가지 값을 가져요.
언제쓰일지 볼께요:)
- '당겨서 새로고침' 기능:
- 리프레시 컨트롤을 스크롤 영역 바깥(위쪽)에 배치
- 평소에는 contentInset이 0이라서 안 보임
- 사용자가 위로 당기면 contentOffset이 음수가 되면서 리프레시 컨트롤 노출
- 리프레시 시작하면 contentInset.top을 조정해서 리프레시 컨트롤이 보이도록 유지
- 완료되면 contentInset을 원래대로
- 키보드가 나타날 때도 마찬가지:
// 키보드 높이만큼 bottom inset 조정
scrollView.contentInset.bottom = keyboardHeight
Content Layout Guide & Frame Layout Guide
iOS 11부터 도입된 이 두 Layout Guide는 UIScrollView와 Auto Layout의 충돌을 해결해줍니다.
Content Layout Guide
UIScrollView 내부의 전체 콘텐츠 영역을 나타냅니다.
- 역할: contentSize를 Auto Layout 방식으로 정의
- 작동 원리: 하위 뷰들이 Content Layout Guide의 상하좌우에 닿도록 제약 조건을 설정하면, UIScrollView가 그 제약 조건들을 바탕으로 contentSize를 자동 계산
- 핵심: '콘텐츠가 얼만큼의 공간을 필요로 하는지'를 알려주는 역할
Frame Layout Guide
UIScrollView 자체의 크기와 위치를 나타냅니다.
- 역할: UIScrollView 자체의 frame을 정의
- 작동 원리: Frame Layout Guide는 UIScrollView의 superview와 제약 조건을 설정할 때 사용
- 핵심: '스크롤 뷰가 화면의 어디에 위치하고 얼마나 커야 하는지'를 알려주는 역할
실제 예시:)(
// 콘텐츠 뷰를 Content Layout Guide에 연결 (contentSize 결정)
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor)
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor)
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor)
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor)
// 수직 스크롤을 위해 콘텐츠 뷰의 너비를 Frame Layout Guide와 동일하게 설정
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor)
스크롤이 일어나는 전 과정
이제 사용자가 화면을 스크롤할 때 실제로 어떤 일이 일어나는지 단계별로 살펴보겠습니다.
iOS는 이 모든 과정이 16.67ms(60FPS) 또는 8.33ms(120FPS) 내에 완료되어야 부드러운 애니메이션을 제공할 수 있어요.
Phase 1: Touch Event & Gesture Recognition
사용자의 손가락이 화면에 닿고 움직이는 순간, UIApplication이 터치 이벤트를 감지합니다.
이 이벤트는 MainRunLoop에 전달되어 UIScrollView 내부의 UIPanGestureRecognizer가 받아 스크롤로 해석하죠.
이동거리와 속도를 계산하여 사용자의 의도를 ....Phase2로 이동->
Phase 2: contentOffset 업데이트
UIScrollView는 translation과 velocity를 바탕으로 새로운 contentOffset 값을 계산합니다.
앞서 설명했듯이, contentOffset을 변경하는 것은 UIScrollView 자체의 bounds.origin을 변경하는 것과 동일해요.
Phase 3: 볼수 있는 영역 및 셀 관리
contentOffset이 변경되면 UIScrollView (또는 UITableView, UICollectionView)는 현재 bounds 내부에 어떤 콘텐츠가 보이는지를 파악합니다.
UITableView의 경우:
- 화면 밖으로 나간 셀 처리: tableView(_:didEndDisplaying:forRowAt:) 델리게이트 메서드를 호출하고, 재사용 큐로 이동
- 새로 나타날 셀 처리:
- dequeueReusableCell(withIdentifier:for:)를 호출하여 재사용 가능한 셀 확보
- tableView(_:cellForRowAt:) 델리게이트 메서드가 호출되어 새로운 데이터 바인딩
- prepareForReuse()를 통해 이전 상태 초기화
- tableView(_:willDisplay:forRowAt:)가 호출되어 최종 UI 업데이트 기회 제공
Phase 4: 레이아웃 계산
contentOffset의 변화 그 자체는 뷰들의 상대적인 위치를 직접 바꾸지는 않지만, UIScrollView의 bounds가 변경되면서 뷰 계층 구조 내에서 레이아웃 재계산이 필요할 수 있음을 시스템에게 알립니다.
setNeedsLayout() 호출: UIScrollView의 bounds가 변경될 때, 시스템은 해당 뷰와 그 서브뷰들에게 setNeedsLayout()을 전달
Phase 5: Draw
frame이 확정된 각 뷰는 이제 자신의 draw(_:) 메서드를 호출하여 내부 콘텐츠를 다시 그립니다.
Core Graphics 사용: 이 메서드 안에서 Core Graphics 프레임워크를 사용하여 뷰의 bounds 좌표계에 맞춰 텍스트, 이미지, 도형 등을 픽셀 데이터로 변환하여 그립니다
Phase 6: 합성 (Composition)
이제 Core Animation 프레임워크가 이들을 모아 하나의 최종 화면으로 합성합니다.
UIScrollView의 bounds.origin이 변경된 상태이므로, Core Animation은 다음 공식에 따라 모든 서브뷰의 래스터화된 이미지를 이동시켜 합칩니다:
CompositedPosition = View.frame.origin - Superview.bounds.origin
모든 서브뷰가 동일한 방향으로 이동하기 때문에, 마치 스크롤 뷰의 콘텐츠 전체가 움직이는 것처럼 보이게 되는 거죠!
이 합성 과정은 주로 GPU가 담당하며, 매우 빠른 속도로 여러 뷰 레이어를 하나의 최종 이미지로 병합합니다.
Phase 7: 화면 표시
Core Animation이 완성한 최종 그림(합성된 이미지)은 이제 실제로 우리 눈에 보이도록 화면에 뿌려져야 합니다.
- 렌더링 서버로 전달: 합성된 최종 이미지는 iOS의 렌더링 서버로 전달됩니다.
- 픽셀 변환: GPU는 이 이미지를 받아서, 우리 아이폰/아이패드 화면의 아주 작은 점들(픽셀)로 변환하여 뿌려줍니다.
- 이 모든 과정(터치부터 화면 표시까지)이 16.67ms (60FPS) 또는 8.33ms (120FPS) 내에 완료되어야 합니다
. 이 시간을 지키면 화면이 끊김 없이 부드럽게 움직이고, 만약 시간을 초과하면 화면이 '버벅거린다'고 느끼게 되는 거죠.
'iOS' 카테고리의 다른 글
HTTP 캐싱(Etag & max-age) 그리고 iOS에서는? (0) | 2025.10.01 |
---|---|
URLSession에 대한 에브리띵 (0) | 2025.09.27 |
데이터 보관은 어떻게이루어질까(직렬화: NSCoding부터 Codable까지) (0) | 2025.09.09 |
객체 간 통신(delegate, Closure, NotificationCenter, KVO) (0) | 2025.09.04 |
PHPickerController의 UTI 활용법 (4) | 2025.08.29 |