본문 바로가기

일상

Flutter 고정 헤더 아래로 특정 섹션까지 스크롤이 안 될 때 (getOffsetToReveal 이 안쪽 ListView 를 잡는 문제와 화면 좌표로 animateTo)

반응형

플러터(Flutter)에서 SliverPersistentHeader(pinned: true) 로 고정한 헤더 아래에 긴 목록을 두고, 헤더의 버튼을 누르면 목록 속 특정 섹션까지 내려가게 하려는데 엉뚱한 곳으로 가거나 아예 안 움직일 때 해결하는 방법입니다.


개인 게임 앱의 장비 화면은 위에 전투 화면과 "지금 장착한 무기, 탈것, 방패" 줄이 고정돼 있고, 그 아래로 무기, 탈것, 방패 묶음이 길게 이어집니다. 장착 줄의 칸을 누르면 아래 그 묶음이 장착 줄 바로 밑에 오도록 내려가게 하고 싶었는데, 처음 짠 코드는 위치 계산이 맞지 않았습니다.


- 원인은 스크롤이 두 겹인데 위치를 재는 쪽과 움직이는 쪽이 서로 다른 뷰포트를 잡은 것이었다

- RenderAbstractViewport.of 는 가장 가까운, 곧 안쪽 shrinkWrap ListView 의 뷰포트를 돌려준다

- 두 위젯의 화면 좌표 차이를 재서 바깥 스크롤을 animateTo 로 움직이자 해결됐다


1. 화면 구조: 바깥 CustomScrollView 안에 shrinkWrap 목록

전투 화면과 목록을 한 번에 스크롤하려고 바깥에 CustomScrollView 를 두고, 고정 헤더 두 개와 목록 패널을 슬리버로 넣었습니다. 목록이 짧을 때 아래가 비지 않게 하려고 NestedScrollView 대신 이 구조를 골랐고, 그래서 안쪽 목록은 ListView(shrinkWrap: true, physics: NeverScrollableScrollPhysics()) 로 만들어 스크롤은 바깥이 맡게 했습니다.



겉으로는 스크롤이 하나뿐이지만, 위젯 트리 안에는 바깥 CustomScrollView 의 뷰포트와 안쪽 ListView 의 뷰포트가 둘 다 있습니다. 안쪽은 스스로 움직이지 않을 뿐 뷰포트로서는 엄연히 존재한다는 점이 문제의 시작입니다.


2. getOffsetToReveal 이 엉뚱한 값을 주는 이유

처음에는 흔히 쓰는 방법대로, 대상 섹션의 RenderObject 로 RenderAbstractViewport.of(ro) 를 구해 getOffsetToReveal(ro, 0) 으로 목표 오프셋을 받고, 움직이는 건 Scrollable.of(context).position 에 맡겼습니다.


그런데 RenderAbstractViewport.of 는 대상에서 위로 올라가며 가장 가까운 뷰포트를 찾기 때문에 안쪽 ListView 의 뷰포트를 돌려줍니다. 반면 헤더 쪽 context 로 부른 Scrollable.of 는 바깥 스크롤을 돌려주니, 안쪽 목록 기준으로 잰 오프셋을 바깥 스크롤에 넣는 셈이 됩니다.


실제로 찍어 보니 헤더 아래 끝과 뷰포트 위 끝의 차이가 0.0 으로 나왔고, 목표 값은 바깥 스크롤의 최대치로 잘려 있었습니다. 안쪽 뷰포트가 헤더 바로 밑에서 시작하니 그 기준으로는 차이가 없는 게 당연했습니다.


Scrollable.ensureVisible 을 써도 고정 헤더가 있으면 대상의 위 끝이 뷰포트 맨 위, 곧 헤더 뒤에 맞춰지기 쉬워서 원하는 "헤더 바로 아래"에 오지 않습니다.


3. 화면 좌표 차이로 바깥 스크롤을 움직이기

뷰포트를 따지지 않고, 대상 섹션의 위 끝과 고정 헤더의 아래 끝을 둘 다 화면 좌표(localToGlobal) 로 재서 그 차이만큼 바깥 스크롤을 움직이면 됩니다. 화면 좌표는 어느 스크롤 안에 있든 같은 기준이라 두 겹 구조에 흔들리지 않습니다.



섹션이 접혀 있으면 먼저 펴고, 다음 프레임(addPostFrameCallback)에서 위치를 재야 펼쳐진 높이가 반영됩니다. 목표 값은 minScrollExtent 와 maxScrollExtent 사이로 잘라 두는데, 목록 맨 아래 섹션은 끝까지 내려도 헤더 밑까지 못 올라오는 게 정상이라 거기까지만 갑니다.


4. 위젯 테스트에서 안 움직이는 것처럼 보였던 함정

고친 코드를 위젯 테스트로 캡처해 확인하는데, 탭한 뒤 이미지 디코딩을 기다리는 도우미(tester.runAsync 로 실제 시간을 기다린 뒤 pump() 한 번)를 부르면 화면이 거의 안 내려가 있었습니다. 코드가 또 틀린 줄 알았는데, 문제는 테스트 쪽이었습니다.



위젯 테스트에서 애니메이션은 가짜 시계로 돌아서, pump(Duration) 으로 시간을 넘겨 줘야만 진행됩니다. runAsync 안에서 흐른 실제 시간은 가짜 시계를 움직이지 않으니, 320ms 짜리 animateTo 가 시작만 하고 멈춘 상태로 캡처된 것이었습니다. 50ms 씩 여러 번 pump 해서 애니메이션 길이보다 길게 시간을 넘겨 주자 의도한 자리까지 내려간 화면이 찍혔습니다.


5. 정리

고정 헤더 아래로 특정 섹션까지 스크롤이 안 된다면, 위치를 재는 뷰포트와 움직이는 스크롤이 같은지부터 확인해 볼 만합니다. 안쪽에 shrinkWrap ListView 가 있으면 RenderAbstractViewport.of 와 getOffsetToReveal 은 안쪽 기준 값을 주니, 대상과 헤더의 화면 좌표 차이를 재서 바깥 ScrollPosition.animateTo 로 움직이는 편이 단순하고 확실했습니다.


테스트로 확인할 때는 애니메이션 길이만큼 pump 로 시간을 넘겨 줘야 한다는 점도 같이 챙기면, 맞는 코드를 틀렸다고 오해하는 일을 줄일 수 있습니다.


여기까지 플러터 고정 헤더 아래로 섹션까지 스크롤하는 방법에 대해서 작성해봤습니다. 여기까지 읽어주셔서 감사합니다!

반응형