개발노트

35. [Flutter] NestedScrollView 로 여러개 스크롤 하나로 제어하기(with Sliver) 본문

앱 개발/Flutter

35. [Flutter] NestedScrollView 로 여러개 스크롤 하나로 제어하기(with Sliver)

mroh1226 2023. 10. 11. 16:27
반응형

NestedScrollView 

개발하다보면 각각의 위젯마다 스크롤이 따로되는 위젯들을 마주하게됩니다. 이를 하나의 스크롤로 묶어서 제어하고 싶을 때 NestedScrollView를 사용하면됩니다.

  • NestedScrollView는 Flutter에서 사용되는 스크롤 가능한 위젯의 중첩된 구조를 만들기 위한 유용한 위젯입니다. 이것은 여러 슬리버 (Sliver) 위젯과 스크롤 가능한 컨텐츠를 함께 사용하여, 복잡한 스크롤 레이아웃을 만들 수 있게 해줍니다. 주로 스크롤 가능한 헤더와 본문을 함께 사용하여 구현됩니다.
  • NestedScrollView는 머티리얼 디자인 앱에서 많이 사용되며, 다음과 같은 주요 구성 요소로 구성됩니다:
  • Header Slivers: 이 부분에는 앱의 헤더 또는 헤더와 관련된 위젯이 포함됩니다. 주로 SliverAppBar와 같은 슬리버 위젯을 포함합니다. 헤더는 스크롤 가능한 컨텐츠 위에 놓이며, 일반적으로 pinned 속성을 사용하여 상단에 고정할 수 있습니다.
  • Body: 이 부분에는 본문 컨텐츠가 포함됩니다. 본문은 스크롤 가능한 목록 또는 컨텐츠가 될 수 있으며, 주로 TabBarView와 같은 슬리버 위젯을 포함합니다.
  • Custom Slivers: NestedScrollView는 헤더와 본문 사이에 사용자 지정 슬리버 위젯을 추가하는 것도 허용합니다. 이를 통해 복잡한 레이아웃을 만들 수 있습니다.
  • NestedScrollView를 사용하면 앱에서 다양한 레이아웃을 구성할 수 있으며, 본문 스크롤과 헤더 고정을 조합하여 멋진 사용자 경험을 제공할 수 있습니다. 이것은 특히 탭 바와 함께 사용할 때 유용하며, 각 탭에 대한 서로 다른 슬리버와 본문을 만들 수 있습니다.

    NestedScrollView를 구현할 때 주의할 점은 스크롤에 대한 동작 및 레이아웃을 정확하게 구성해야 한다는 것입니다. 위젯의 중첩 및 각 슬리버와 본문의 크기 및 위치를 조절해야 합니다.

예시.

NestedScrollView를 이용하여 SliverAppBar, SliverPersistentHeader, SliverToBoxAdapter 와 함께 GridView 스크롤을 하나로 통제하는 기능을 만들어보겠습니다.

(2개의 스크롤을 1개의 스크롤 행위로 통합하기)

import 'package:flutter/material.dart';

class TestScreen extends StatefulWidget {
  const TestScreen({super.key});

  @override
  State<TestScreen> createState() => _TestScreenState();
}

class _TestScreenState extends State<TestScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: DefaultTabController(
          length: 2,
          child: NestedScrollView(
              headerSliverBuilder: (context, innerBoxIsScrolled) {
                return [
                  const SliverAppBar(
                    elevation: 0,
                    centerTitle: true,
                    title: Text(
                      'SliverAppBar',
                      style: TextStyle(color: Colors.black),
                    ),
                  ),
                  const SliverToBoxAdapter(
                    child: Column(
                      children: [
                        CircleAvatar(
                          radius: 60,
                        ),
                        Text(
                          'Avatar',
                          style: TextStyle(
                              fontSize: 28, fontWeight: FontWeight.bold),
                        ),
                      ],
                    ),
                  ),
                  SliverPersistentHeader(
                      pinned: true, delegate: SliverDelegate())
                ];
              },
              body: TabBarView(
                children: [
                  GridView.builder(
                    itemCount: 12,
                    padding: EdgeInsets.zero,
                    gridDelegate:
                        const SliverGridDelegateWithFixedCrossAxisCount(
                            crossAxisSpacing: 1,
                            mainAxisSpacing: 1,
                            childAspectRatio: 9 / 16,
                            crossAxisCount: 3),
                    itemBuilder: (context, index) => Container(
                      alignment: Alignment.center,
                      color: Colors.pink.shade200,
                      child: Text('첫번째 Tab $index'),
                    ),
                  ),
                  GridView.builder(
                    itemCount: 12,
                    padding: EdgeInsets.zero,
                    gridDelegate:
                        const SliverGridDelegateWithFixedCrossAxisCount(
                            crossAxisSpacing: 1,
                            mainAxisSpacing: 1,
                            childAspectRatio: 9 / 16,
                            crossAxisCount: 3),
                    itemBuilder: (context, index) => Container(
                      alignment: Alignment.center,
                      color: Colors.green.shade300,
                      child: Text('두번째 Tab $index'),
                    ),
                  ),
                ],
              )),
        ),
      ),
    );
  }
}

class SliverDelegate extends SliverPersistentHeaderDelegate {
  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
      //Container 안에 decoration을 정해줘야 pinned = true로 해도 오류가 안남
      decoration: BoxDecoration(
        color: Colors.white,
        border: Border.symmetric(
          horizontal: BorderSide(
            color: Colors.grey.shade200,
            width: 0.5,
          ),
        ),
      ),
      child: const TabBar(
        indicatorSize: TabBarIndicatorSize.tab,
        indicatorColor: Colors.black,
        labelPadding: EdgeInsets.symmetric(
          vertical: 10,
        ),
        labelColor: Colors.black,
        tabs: [
          Padding(
            padding: EdgeInsets.symmetric(
              horizontal: 20,
            ),
            child: Icon(Icons.grid_4x4_rounded),
          ),
          Padding(
            padding: EdgeInsets.symmetric(
              horizontal: 20,
            ),
            child: Icon(Icons.favorite_rounded),
          ),
        ],
      ),
    );
  }

  @override
  double get maxExtent => 47;

  @override
  double get minExtent => 47;

  @override
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }
}

설명.

화면 부분

  • Scaffold 및 SafeArea:
    Scaffold 위젯은 기본 앱 스켈레톤을 제공합니다.
    SafeArea는 화면의 안전한 영역에 컨텐츠를 배치하며, 일반적으로 노치 또는 상단 바와 같은 장치의 경계를 고려합니다.
  • DefaultTabController:
    DefaultTabController는 탭 뷰를 관리하며, length 속성에 2가 설정되어 두 개의 탭을 가집니다.
  • NestedScrollView:
    NestedScrollView는 스크롤 가능한 헤더와 본문을 포함하는 중첩된 스크롤 레이아웃을 생성합니다.
    headerSliverBuilder 콜백에서 슬리버 위젯 및 헤더를 정의합니다.
    body: 에는 또 다른 스크롤 이 필요한 GridView를 자식으로 갖는 TabBarView와 탭에 해당하는 본문이 정의됩니다.
  • SliverAppBar:
    SliverAppBar는 헤더 부분을 나타냅니다.
    title에 'SliverAppBar' 텍스트가 표시되며, centerTitle 및 elevation 설정을 사용하여 디자인을 조정합니다.
  • SliverToBoxAdapter:
    SliverToBoxAdapter는 헤더 아래에 있는 추가 정보를 표시하며, CircleAvatar와 'Avatar' 텍스트가 포함됩니다.
  • SliverPersistentHeader:
    SliverPersistentHeader는 탭 바를 pinned = true로 설정하여 고정된 헤더로 표시합니다.
    delegate로 따로 생성한 SliverDelegate 클래스가 사용됩니다.
  • TabBarView:
    TabBarView는 탭 바와 연결된 본문을 표시합니다.
    두 개의 탭이 있으며, 각 탭에는 GridView.builder가 포함되어 각각 '첫번째 Tab' 및 '두번째 Tab'을 가진 그리드가 표시됩니다.

SliverDelegate()

  • SliverPersistentHeader 에 사용될 SliverPersistentHeaderDelegate를 따로 생성해줍니다.
  • 이때 maxExtent에는 Header가 확장되었을 때 크기를 minExtent에는 축소되었을 때 크기를 입력해줍니다.


위와 같이 NestedScrollView를 사용하면 헤더와 본문을 함께 스크롤할 수 있으며, 헤더를 고정하고 탭 바를 표시하는 등 다양한 스크롤 레이아웃을 구현할 수 있습니다.


위 예시를 만들면서 NestedScrollView 위젯 적용에 아래와 같은 어려움이 있었으며, 여러 방법으로 시도한 끝에 아래와 같은 해결 방법을 알아냈습니다.

 

문제

  1. TabBar가 들어있는 SliverPersistenHeader에  pinned = true로 설정하여 스크롤시 고정되는 기능을 만들려고 했으나..
  2. 레이아웃 오류가 뜸

원인

  1. SliverPersistentHeader 안에 있는 TabBar를 그대로 자식으로 넣으니, TabBar 쪽에서 오류가 남
  2. delegate로 사용된 SliverPersistentHeaderDelegate의 maxExtent, minExtent 수치를 정확하게 입력하지않음

해결 방법

  1. TabBar를 Container() 위젯으로 감싸줌
  2. decoration: 속성에  BoxDecoration()을 넣어 줌
    *decoration을 설정하지 않으면 오류남
  3. Delegate로 사용한 SliverPersistentHeaderDelegate의 maxExtent, minExtent를 47로 설정함(오류 메시지에서 수치 확인)

빌드된 모습.

반응형
Comments