SwiftUI Study – ScrollViewReader와 ScrollProxy를 활용한 고급 스크롤 제어: 자동 스크롤·특정 위치 이동·채팅 UI 구현 패턴
Dev Study/SwiftUI 2025. 12. 9. 10:59반응형
SwiftUI Study – ScrollViewReader와 ScrollProxy를 활용한 고급 스크롤 제어: 자동 스크롤·특정 위치 이동·채팅 UI 구현 패턴
1. 왜 중요한가 (문제 배경)
SwiftUI의 기본 ScrollView는 단순히 “스크롤 가능한 영역” 제공에 그친다.
하지만 실제 앱에서는 다음과 같은 요구가 매우 자주 발생한다.
- 채팅 UI에서 새 메시지가 오면 자동 아래로 스크롤
- 특정 항목(날짜, 알림, 유저 액션 등)으로 부드럽게 이동
- 긴 리스트에서 또는 특정 인덱스를 즉시 스크롤
- 화면 전환 후 특정 위치를 복원
- 스크롤 위치 기반 UI 업데이트 (예: 상단 숨김/노출)
이런 요구는 ScrollView 단독으로는 불가능하며,
ScrollViewReader + proxy.scrollTo 를 정확히 이해하고 설계해야 한다.
또한 잘못 쓰면 스크롤이 튀거나, 애니메이션이 반복되거나,
뷰가 렌더링되기 전에 scrollTo가 호출되어 무시되는 문제가 발생한다.
본 팁은 기존 1~32번 내용과 주제가 전혀 겹치지 않는
“고급 스크롤 제어 패턴”만을 다룬다.
2. 잘못된 패턴 예시
❌ 예시 1: 뷰가 아직 렌더링되지 않았는데 scrollTo 호출
ScrollViewReader { proxy in
ScrollView {
ForEach(items) { item in
Text(item.title)
.id(item.id)
}
}
.onAppear {
proxy.scrollTo(items.last?.id) // ❌ 렌더 단계 이전에 호출됨 → 무시됨
}
}
문제점
- ScrollView 내부의 ID가 렌더되기 전에 scrollTo 실행
- 아무 효과 없음
- 특히 비동기 로딩 UI에서 자주 나타나는 버그
❌ 예시 2: 메시지가 추가될 때마다 강제 scrollTo → 스크롤 튐
.onChange(of: messages) { _ in
proxy.scrollTo(messages.last?.id) // ❌ 유저가 스크롤 중이어도 계속 아래로 끌어내림
}
문제점
- 사용자가 위쪽 대화를 읽고 있을 때도 강제로 스크롤
- 채팅 앱에서 매우 나쁜 UX
- “유저가 스크롤 중인지” 상태 체크 필요
❌ 예시 3: id 누락 또는 잘못된 id로 scrollTo가 작동하지 않음
Text("메시지")
// ❌ id가 없으면 scrollTo로 이동 불가
문제점
- scrollTo는 반드시 고유 id가 필요
- id가 없거나 중복되면 작동하지 않음
❌ 예시 4: 스크롤 애니메이션이 반복 실행되는 문제
.onChange(of: isNewMessage) { _ in
withAnimation {
proxy.scrollTo(bottomID)
}
}
문제점
- SwiftUI의 diffing 타이밍에 따라 애니메이션이 여러 번 실행될 수 있음
- 메시지 수천 개일 경우 끊김 발생
3. 올바른 패턴 예시
✅ 예시 1: 렌더 완료 후 scrollTo 호출 (DispatchQueue or Task)
.onAppear {
DispatchQueue.main.async {
proxy.scrollTo(messages.last?.id, anchor: .bottom)
}
}
또는:
.task {
try? await Task.sleep(nanoseconds: 50_000_000)
proxy.scrollTo(messages.last?.id, anchor: .bottom)
}
장점
- ScrollView가 실제로 렌더링된 뒤에 실행
- scrollTo가 항상 정상 반응
✅ 예시 2: “사용자가 현재 하단에 있을 때만” 자동 스크롤
@State private var isUserAtBottom = true
스크롤 위치 감지:
.onChange(of: scrollOffset) { offset in
isUserAtBottom = offset.isNearBottom
}
메시지 추가 시:
.onChange(of: messages) { _ in
if isUserAtBottom {
withAnimation(.easeOut) {
proxy.scrollTo(messages.last?.id, anchor: .bottom)
}
}
}
장점
- 유저 경험 완벽
- 사용자가 위쪽을 보고 있을 때는 자동 스크롤 방지
✅ 예시 3: id 기반 정밀 제어 – 인덱스 이동, 특정 날짜 이동
proxy.scrollTo(targetID, anchor: .center)
필수 조건:
Text(item.title)
.id(item.id) // 반드시 필요!
✅ 예시 4: 채팅 UI에서 “맨 아래 고정” 구현
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(messages) { message in
ChatBubbleView(message)
.id(message.id)
}
Color.clear.frame(height: 1).id("BOTTOM")
}
}
.onChange(of: messages) { _ in
withAnimation {
proxy.scrollTo("BOTTOM", anchor: .bottom)
}
}
}
장점
- 메시지를 반복해서 렌더하더라도
항상 정확한 최하단 위치로 스크롤 이동 - 성능이 안정적
✅ 예시 5: 화면 전환 후 “이전 위치로 복원”
@State private var lastViewedID: UUID?
.onDisappear {
lastViewedID = currentVisibleID
}
.onAppear {
if let id = lastViewedID {
proxy.scrollTo(id, anchor: .top)
}
}
장점
- 뉴스 앱, SNS, 쇼핑 목록 등에서 유용
- 사용자 경험 대폭 향상
4. 실전 적용 팁
✔ 팁 1 – scrollTo는 반드시 “id 렌더 이후” 호출해야 작동한다
DispatchQueue.main.async 또는 Task.sleep 활용 필수.
✔ 팁 2 – 채팅 UI는 “유저가 하단에 있을 때만 자동 스크롤” 규칙이 중요
사용자 스크롤을 절대로 방해하지 말 것.
✔ 팁 3 – LazyVStack + ScrollViewReader 조합이 성능 최고
대량 메시지에서도 안정적.
✔ 팁 4 – scrollTo 대상은 고유한 id 또는 anchor point
예: BOTTOM 같은 sentinel id도 유용함.
✔ 팁 5 – 스크롤 위치 기반 상단/하단 UI 제어 가능
툴바 숨김, 빠른 스크롤 버튼 노출 등에 활용.
5. 정리
- ScrollViewReader는 SwiftUI에서 고급 스크롤 제어를 담당하는 핵심 도구이다.
- scrollTo는 렌더 타이밍에 따라 무시될 수 있으므로 호출 시점을 정확히 관리해야 한다.
- 채팅 UI나 SNS처럼 동적 리스트에서는
자동 스크롤·위치 복원·하단 고정 패턴이 필수적이다. - 올바르게 사용하면 UIKit 수준의 스크롤 제어를 SwiftUI에서도 자연스럽게 구현할 수 있다.
반응형

