🐦 Flutter

[Flutter] Sliver TabBarView hide 이슈

ji-hyun 2024. 3. 31. 20:33

위에 컨텐츠들이 있고 하단에 탭이 있다고 가정하자. 스크롤 시에는 탭이 상단에 고정되어 있어야 한다.

이 경우, 위젯을 어떻게 구성할 수 있을까?

 

 

Flutter 공식문서에서는 위와 같은 뷰는 NestedScrollView 클래스 를 통해 구성하라고 한다.

 

 

 

NestedScrollView 클래스

The most common use case for this widget is a scrollable view with a flexible SliverAppBar containing a TabBar in the header (built by headerSliverBuilder, and with a TabBarView in the body, such that the scrollable view's contents vary based on which tab is visible.

 

해석하면 다음과 같다.

-> 이 위젯의 가장 일반적인 사용 사례는 헤더에 TabBar 를 포함하고 body 에는 TabBarView 가 있는, 유연한 SliverAppBar 있는 스크롤이 있을 때 사용하는 Class 이다.

 

 

 

 

 

따라서 아래와 같이 작성해주면 된다.

 

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: DefaultTabController(
      length: 2,
      child: NestedScrollView(
        headerSliverBuilder: (context, value) {
          return [
          	SliverList(
                        delegate: SliverChildListDelegate([ 
                        위젯들.. 
            	]),
            SliverAppBar(
              pinned: true, // 스크롤 시, 상단에 고정
              bottom: TabBar(
                tabs: [
                  Tab(icon: Icon(Icons.call), text: "Call"),
                  Tab(icon: Icon(Icons.message), text: "Message"),
                ],
              ),
            ),
          ];
        },
        body: TabBarView(
          children: [
            CallPage(),
            MessagePage(),
          ],
        ),
      ),
    ),
  );
}

 

 

여기서 headerSliverbuilder 는 뭘까?

headerSliverbuilder 는 NestedScrollView 와 같은 위젯에서 헤더 영역을 말한다.

이 콜백은 스크롤 가능한 영역의 상단에 위치하는 하나 이상의 Sliver 위젯(여기선 SliverList, SliverAppBar 가 해당)을 생성한다.

 

 

 

그런데 위와 같이 작성해줬을 때 아래 화면과 같은 문제가 발견되었다.

 

 

 

영상에서 볼 수 있듯이

스크롤하게 되면 아래 tab 의 contents 가 tab 밑으로 가려진다는 것이다.

 

 

 

 

 

찾아보니 stackoverflow 에 나와 비슷한 이슈를 겪은 분이 계셨고 (아래 링크 참고 ↓)

https://stackoverflow.com/questions/55187332/flutter-tabbar-and-sliverappbar-that-hides-when-you-scroll-down

이에 대한 해결책으로는 SliverOverlapAbsorberSliverOverlopInstpector 를 쓰면 된다고 했다.

 

 

 

 

 

근데 왜 이와 같은 현상이 일어난걸까?

아래는 Flutter 의 NestedScrollView 에 관한 설명인데 여기서 어느 정도 유추가 가능했다. 

 

In a normal ScrollView, there is one set of slivers (the components of the scrolling view). If one of those slivers hosted a TabBarView which scrolls in the opposite direction (e.g. allowing the user to swipe horizontally between the pages represented by the tabs, while the list scrolls vertically), then any list inside that TabBarView would not interact with the outer ScrollView. For example, flinging the inner list to scroll to the top would not cause a collapsed SliverAppBar in the outer ScrollView to expand.
NestedScrollView solves this problem by providing custom ScrollControllers for the outer ScrollView and the inner ScrollViews (those inside the TabBarView, hooking them together so that they appear, to the user, as one coherent scroll view.

 

 

위를 바탕으로 간단히 해석을 하자면..

ScrollView 에서는 스크롤 뷰를 구성하는 여러 개의 Sliver 가 있다.

이 중 하나의 Sliver 가 TabBarView 를 호스트하고, 그것이 반대방향 (= 사용자가 탭으로 표시된 페이지를 수평으로 스와이프할 수 있게 하면서, 리스트는 수직으로 스크롤) 으로 스크롤되면, 그 TabBarView 내의 어떤 리스트도 바깥쪽 ScrollView 와 상호작용하지 않게 된다.

 

 

다시 말해 (ScrollView 내에 있는 TabBarView 와 같은 내부 스크롤 영역)(바깥쪽 ScrollView)독립적으로 동작한다는 것이다.

NestedScrollView 는 위 문제를 해결할 수 있게 하고 위 설명에서 잘렸지만 SliverOverlapAbsorber / SliverOverlapInjector 쌍을 사용하여 내부 목록을 올바르게 정렬할 것을 강조한다.

 

 

 

 

 

 

 

SliverOverlapAbsorber / SliverOverlapInjector 란?

 

SliverOverlapAbsorber SliverAppBar 가 차지하는 공간을 "흡수" 하여, 스크롤하는 내용이 SliverAppBar 아래로 적절히 배치될 수 있도록 한다. 이 작업을 하지 않으면, 내부 스크롤 뷰(예: 리스트나 그리드)가 SliverAppBar 아래로 올바르게 배치되지 않아, 사용자가 스크롤을 하지 않았음에도 불구하고 내용 일부가 SliverAppBar 에 의해 가려질 수 있다.

 


SliverOverlapAbsorber 가 이 겹침을 "흡수"한 후에는, SliverOverlapInjector 를 사용하여 이 공간을 스크롤 뷰 내부의 적절한 위치에 "주입"한다.

 

 

이렇게 함으로써, 스크롤 뷰의 내용이 스크롤할 때마다 SliverAppBar 아래로 자연스럽게 배치되며, 겹치는 부분 없이 모든 내용을 볼 수 있게 된다.

 

 

 

 

예시 코드)

 

@override
  Widget build(BuildContext context) {
    return Material(
      child: Scaffold(
        body: DefaultTabController(
          length: _tabs.length, 
          child: NestedScrollView(
            headerSliverBuilder:
                (BuildContext context, bool innerBoxIsScrolled) {
              return <Widget>[
                SliverOverlapAbsorber(
                  handle:
                      NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                  sliver: SliverSafeArea(
                    top: false,
                    sliver: SliverAppBar(
                      title: const Text('Books'),
                      floating: true,
                      pinned: true,
                      snap: false,
                      primary: true,
                      forceElevated: innerBoxIsScrolled,
                      bottom: TabBar(
                        tabs: _tabs.map((String name) => Tab(text: name)).toList(),
                      ),
                    ),
                  ),
                ),
              ];
            },
            body: TabBarView(
              children: _tabs.map((String name) {
                return SafeArea(
                  top: false,
                  bottom: false,
                  child: Builder(
                    builder: (BuildContext context) {
                      return CustomScrollView(
                        key: PageStorageKey<String>(name),
                        slivers: <Widget>[
                          SliverOverlapInjector(
                            handle:
                                NestedScrollView.sliverOverlapAbsorberHandleFor(
                                    context),
                          ),
                          SliverPadding(
                            padding: const EdgeInsets.all(8.0),
                            sliver: SliverFixedExtentList(
                              itemExtent: 60.0,
                              delegate: SliverChildBuilderDelegate(
                                (BuildContext context, int index) {
                                  return Container(
                                      color: Color((math.Random().nextDouble() *
                                                      0xFFFFFF)
                                                  .toInt() <<
                                              0)
                                          .withOpacity(1.0));
                                },
                                childCount: 30,
                              ),
                            ),
                          ),
                        ],
                      );
                    },
                  ),
                );
              }).toList(),
            ),
          ),
        ),
      ),
    );
  }

 

 

위와 같이 코드를 작성하면 스크롤 시, tab 밑에 contents 가 가려지는 현상을 피할 수 있게 된다.

 

 

 

 

 

 

PageStorageKey

위 현상을 해결하다 보니, pageStorageKey 를 발견하게 되었는데 이 key 또한 TabBar 구현 시, 필요한 존재이다.

우선 아래에서 pageStorageKey 가 없을 때와 있을 때를 한 번 살펴보자.

 

 

pageKey 를 설정해주지 않았을 때

 

pageKey 를 설정해주었을 때

 

두 차이점이 보이는가?

바로 pageStorageKey 를 설정해주지 않았을때, tabBar 를 이동하고 오게 되면 초기화가 되는 모습을 볼 수 있다.

하지만 pageStorageKey 를 설정해주면 tabBar 를 이동하고 와도 그대로 page 가 유지된다.

 

 

 



스크롤 구현 코드가 처음엔 복잡해보였는데 한 번 이해하고 정리해두니 별로 복잡하지 않은 것 같다.

Flutter 가 두 스크롤의 이슈를 흡수, 주입 으로 다루는 것이 흥미로웠다

앞으로도 다양한 스크롤 UI 를 만나더라도 당황하지 않고 멋지게 구현하고 싶다!