반응형

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에서도 자연스럽게 구현할 수 있다.
반응형
Posted by 까칠코더
,