SwiftUI 화면에서 본문 헤더가 스크롤로 가려질 때 같은 제목을 네비게이션 중앙 타이틀로 자연스럽게 띄우고 싶었다. 단순한 토글이 아니라 헤더가 가려지는 정도에 비례해서 opacity가 점점 진해지는 동작이 필요했다. 별것 아닌 줄 알았는데… 한참 헤맸다.
배경
DocumentDetailView라는 노트 상세 화면이 있고, 본문 상단에 큼지막한 제목 헤더가 있다. 요구사항은 이렇다.
| |
첫 접근 — GeometryReader + PreferenceKey
SwiftUI에서 흔히 쓰는 방식대로 GeometryReader와 PreferenceKey 조합으로 시작했다. 본문 헤더의 위치를 ScrollView named coordinate space 기준으로 측정하고, 헤더가 위로 사라지는 정도를 부모 뷰에 전달하는 방식.
| |
근데 이건 boolean 전환만 가능해서 “가려지는 정도에 따른 점진 노출"에는 안 맞았다. 그래서 다음 단계로 스크롤 오프셋과 헤더 높이를 직접 계산해서 opacity를 만들었다.
| |
증상
View Hierarchy를 열어보면 네비게이션 중앙에 Text - 노트 A 같은 식으로 텍스트가 실제로 존재했다. 레이아웃 자리도 잡혀 있었다.
즉 문제는 다음이 아니었다.
- 데이터가 없는 문제 아님
- 네비게이션 중앙 뷰가 생성되지 않은 문제 아님
- 텍스트가 잘못 바인딩된 문제 아님
텍스트는 존재하는데 화면에서만 안 보이는 상태. 이건 SwiftUI에서 텍스트의 opacity가 0이거나 색상 alpha가 0일 때 나오는 증상이다.
로그로 원인 좁히기
추측으로 계속 고치면 안 되는 상황이라, 먼저 값 흐름을 확인하는 로그를 깔았다.
| |
처음 PreferenceKey 기반 로그는 앱 진입 시 한 번만 찍히고, 스크롤 중에는 찍히지 않았다.
이 시점에 확인된 사실은 다음.
| |
원인 분리를 위해 임시로 UIScrollView.contentOffset을 KVO로 관찰했다. 이 코드는 최종 구현에 남기지 않았고, 순수하게 디버깅 목적이었다.
결과적으로 스크롤 오프셋은 정상적으로 변하고 있었다.
| |
여기서 원인이 더 좁혀졌다.
| |
즉 최종 원인은 스크롤 추적 자체가 아니라, 헤더 높이 측정값이 유효하게 들어오지 않는 것이었다.
UIKit 브리지를 최종 구현에서 제외
임시로 UIScrollView.contentOffset KVO를 붙였을 때 스크롤 값은 정확하게 들어왔다. 하지만 이 화면은 SwiftUI 전환 중인 화면이고, UIKit 의존성을 줄이는 방향이 더 맞다.
UIViewRepresentable로 내부 UIScrollView를 찾아 KVO를 거는 방식엔 이런 문제가 있다.
- SwiftUI 내부 구현 구조에 의존함
- UIKit 의존성이 화면 코드에 다시 생김
- 나중에 SwiftUI ScrollView 구현이 바뀌면 깨질 수 있음
- 화면 전환 목표인 SwiftUI + MVI 구조와 맞지 않음
그래서 KVO 브리지는 원인 확인용으로만 쓰고 제거했다.
공식 API 확인
Apple 문서를 다시 보니 SwiftUI에는 스크롤/geometry 변화 관찰용 API가 이미 있다.
onScrollGeometryChange: ScrollView의ScrollGeometry변화를 관찰 (iOS 18+)ScrollGeometry.contentOffset: ScrollView content offset 제공onGeometryChange: 일반 View의 geometry 변화를 관찰 (iOS 16+)
참고 문서:
이 프로젝트의 최소 iOS 타깃은 17.0이라 onScrollGeometryChange(iOS 18+)는 못 써도, onGeometryChange(iOS 16+)는 쓸 수 있다. UIKit 브리지 대신 이걸로 충분히 풀린다.
최종 해결 — onGeometryChange 기반 순수 SwiftUI
최종 구현은 두 값을 분리해서 추적한다.
scrollOffsetY: ScrollView 상단 sentinel의 위치 변화로 계산headerHeight: 헤더 뷰 자신의 실제 높이로 계산
스크롤 오프셋 추적
ScrollView content 최상단에 0pt 높이의 sentinel을 둔다. 이 sentinel의 minY가 스크롤에 따라 음수로 이동하므로, -minY를 offset으로 쓴다.
| |
헤더 높이 추적
헤더 뷰에는 onGeometryChange를 직접 붙여 실제 높이를 읽는다.
| |
opacity 계산
이제 opacity는 단순해진다.
| |
스크롤 오프셋이 헤더 높이만큼 이동하면 opacity는 1이 된다.
네비게이션 타이틀 렌더링
처음에는 UIColor.withAlphaComponent(...)로 타이틀 색상 alpha를 바꾸려고 했다.
| |
근데 디버깅 과정에서 View Hierarchy에는 텍스트가 있는데 화면에 안 보이는 상황이 있었기 때문에, 색상 alpha 경로보다 SwiftUI Text.opacity(...)를 직접 쓰는 쪽으로 바꿨다.
공용 NavigationTitleConfig에 titleOpacity를 추가하고, 실제 SwiftUI Text에 적용했다.
| |
이렇게 하면 데이터/색상/투명도 역할이 분리된다.
- 텍스트 내용:
center.title.text - 기본 색상:
center.title.color - 스크롤 연동 노출 정도:
center.titleOpacity
본문 헤더는 반대로 fade-out
네비게이션 타이틀이 점점 나타나면, 본문 헤더는 같은 속도로 흐려지게 만들 수 있다. navigationTitleOpacity가 0 -> 1로 증가하니, 본문 헤더는 그 반대값을 쓰면 된다.
| |
이 값을 본문 헤더에 적용한다.
| |
주의할 점은 배경까지 같이 투명하게 만들지 않는 것. 배경이 같이 흐려지면 아래 색상이나 구분선이 비쳐서 의도와 다르게 보일 수 있다. 그래서 흰 배경은 유지하고, 헤더 텍스트 영역만 흐려지도록 modifier 순서를 조정했다.
최종 로그
정상 동작 시 로그는 이렇게 나와야 한다.
| |
결론
이번 문제의 핵심은 “네비게이션 타이틀이 안 보인다"가 아니었다. 실제 문제는 다음 흐름.
| |
최종 해결은 SwiftUI의 onGeometryChange로 스크롤 sentinel과 헤더 높이를 직접 추적하는 방식이었다.
이번 디버깅에서 얻은 교훈은 명확하다.
- View Hierarchy에 Text가 보이면 데이터/레이아웃 문제부터 의심하면 안 됨
- “안 보임"은 색상/opacity/rendering 문제일 가능성이 큼
- 스크롤 연동 UI는 offset, 기준 높이, 최종 opacity를 각각 로그로 분리해야 함
- UIKit 브리지는 원인 확인용으로는 쓸 수 있지만, SwiftUI 화면의 최종 구현에 남길지는 별도로 판단해야 함
- 최신 SwiftUI에서는
PreferenceKey대신onGeometryChange/onScrollGeometryChange를 먼저 검토할 만함