[Flutter] Custom Scrollable Tabbar 구현하기

2023. 3. 12. 12:27Developers 공간 [Shorts]/Frontend

728x90
반응형
<분류>
A. 수단
- OS/Platform/Tool : Linux, Kubernetes(k8s), Docker, AWS
- Package Manager : node.js, yarn, brew, 
- Compiler/Transpillar : React, Nvcc, gcc/g++, Babel, Flutter

- Module Bundler  : React, Webpack, Parcel

B. 언어
- C/C++, python, Javacsript, Typescript, Go-Lang, CUDA, Dart, HTML/CSS

C. 라이브러리 및 프레임워크 및 SDK
- OpenCV, OpenCL, FastAPI, PyTorch, Tensorflow, Nsight

 


1. What? (현상)

TabBar를 구현시 보통 아래와 같이 ScaffoldbottomNavigationBar를 활용해 구현하곤 합니다.

// Case1 : Scaffold with PageView(), BottomNavigationBar()+BottomNavigationItem()
Widget _getViewForIndex(int index, MyViewModel model) {
    switch (index) {
      case 0:
        return Page1();
      case 1:
        return Pager2();
      case 2:
        return Page3();
      default:
        return Page4();
    }
  }
Scaffold(
  body: PageTransitionSwitcher(
    duration: const Duration(milliseconds: 500),
    reverse: viewModel.reverse,
    child: _getViewForIndex(viewModel.currentIndex, viewModel),//*********
    transitionBuilder: 
    (Widget child, Animation<double> animation,Animation<double> secondaryAnimation) =>
        SharedAxisTransition(
          fillColor: kMainIvory,
          child: child,
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          transitionType: SharedAxisTransitionType.horizontal,
        ),
  ),
  bottomNavigationBar: BottomNavigationBar(
    type: BottomNavigationBarType.fixed,
    iconSize: 28.0,
    selectedItemColor: kMainDarkGreen,
    unselectedItemColor: Colors.black54,
    selectedFontSize: 12.0,
    showUnselectedLabels: false,
    backgroundColor: kMainIvory,
    currentIndex: viewModel.currentIndex,
    onTap: viewModel.setIndex,
    items: [
      BottomNavigationBarItem(
          icon: Icon(CupertinoIcons.home,),
          label: '홈'),
      BottomNavigationBarItem(
          icon: Icon(CupertinoIcons.chat_bubble_2_fill,),
          label: '채팅'),
      BottomNavigationBarItem(
          icon: Icon(CupertinoIcons.person,),
          label: '프로필')
    ],
  ),
);

// Case2 : TabBarView(), TabBar()+Tab()
Scaffold(
  body: new TabBarView(
    controller: ctr,
    children: <Widget>[
      new p1.Page1(),
      new p2.Page2(),
      new p3.Page3(),
    ],
  ),
  bottomNavigationBar: new Material(
      color: Colors.pinkAccent,
      child: new TabBar(
        controller: ctr,
        tabs: <Tab>[
          new Tab(icon: new Icon(Icons.arrow_forward)),
          new Tab(icon: new Icon(Icons.arrow_downward)),
          new Tab(icon: new Icon(Icons.arrow_back)),
        ],
      )),

);

하지만 때로는 (예를 들어 저의 경우,) Scaffold로 구현하기에 List가 중첩이 되어 관리가 불편하거나, 레이아웃을 여러개 중첩하다 보니 구성하기 불편한 상황이 있기도 했습니다.

 

이런 상황엔 Stateful widget을 활용하고 setState() 함수를  통해 state를 업데이트 해주면 되지만, TabBar의 변화에 따라 이후에 setState()함수를 부르는 만큼 정확하게 async 하게 동작시키기 불편하며,  예를 들어 changeNotifier 등을 상속하는 등 Stateful widget이 아닌 경우, 해당 Life Cycle에 따라 구현하기 쉽지 않습니다.

 

이런 경우, 직접 TabBar를 StreamBuilder을 활용해 구현할 수도 있습니다.

아래는 구현 결과 이며, 스펙은 아래와 같습니다.

  • TabBarView :  Scrollable, PageView,
  • TabBar : Non-Scrollable, ListView & Disappearable, Selectable
  • TabBarView - TabBar : move simultaneously

 

[TabBar View 구현]


2. Why? (원인)

  • X

3. How? (해결책)

  • Controller 및 리스트 선언 : 미리 선언 해주는 것은 아래와 같이 미리 적어두었습니다.
import 'package:flutter/cupertino.dart';
import 'dart:async';

PageController pageController = PageController(initialPage: 0);
StreamController<int> streamController= StreamController<int>()..add(0);
ScrollController scrollController = new ScrollController();

List<Widget> tabPages = <Widget>[Page1(), Page2(), Page3(), Page4()];
List<String> tabNames = const<String>['ColorA', 'ColorB', 'ColorC', 'ColorD'];
  • TabBarView : PageView로 구현했으며(pageController활용), page가 바뀔 때마다 streamController()로 async하게 해당 페이지 번호를 넣어줍니다.
    또한 scrollController를 활용해 해당 위치로 animateTo()하는 과정이 있는데, 움직이는 위치의 position값은 TabBar의 Item의 Width가 200.0일 때의 예시입니다.
Container(
  height: MediaQuery.of(context).size.width
  width : MediaQuery.of(context).size.width,
  color : Colors.green.withOpacity(0),
  child: PageView(
    scrollDirection: Axis.horizontal,
    controller: viewModel.pageController,
    children: [...tabPages],
    onPageChanged: (value) {
      viewModel.streamController.add(value);
      viewModel.scrollController.animateTo(200.0*(value)-MediaQuery.of(context).size.width/4, duration: const Duration(milliseconds: 300), curve: Curves.ease);
    },
  ),
),
  • TabBar : StreamBuilder를 활용해 위에서 입력한 stream에 async하게 들어오는 데이터를 받아 동작하도록 작동합니다.
    • ListView로 구현했으며, Non-Scrollable하므로 NeverScrollableScrollPhysics()를 넣어 줍니다.
    • Selectable하므로, GestureDetector()를 활용해 선택되었을 때 TabBar에서 위치이동하던 것과 동일한 방법으로 이동하고, pageController를 활용해 page도 이동시켜줍니다.
    • snapshot.data == index : snapshot.data는 stream으로 들어온 데이터이며, index를 현재 렌더링 하고 있는 item의 인덱스입니다. 즉, 현재 선택된 것들에 대한 condition 을 의미합니다.
    • 위에서 말한 바와 같이, TabBar의 Item들은 Width를 200.0으로 BoxDecoration방법을 활용해 구현해줍니다.
Align(
  alignment: Alignment.bottomCenter,
  child: SizedBox(
      height: p2_1_Size_Tagbar,
      width : double.infinity,
      child: StreamBuilder<int>(
          stream: viewModel.streamController.stream,
          builder: (context, snapshot) {
            if (snapshot.hasError) return new Text('Error: ${snapshot.error}');
            if (!snapshot.hasData) return Center(child: CircularProgressIndicator());
            if (snapshot.hasData) {
              return
                ListView.builder(
                    physics: const NeverScrollableScrollPhysics(),
                    scrollDirection: Axis.horizontal,
                    controller: viewModel.scrollController,
                    itemCount: tabNames.length,
                    itemBuilder: (context, index) {
                      return
                        GestureDetector(
                          onTap: () {
                            viewModel.pageController.animateToPage(index, duration: const Duration(milliseconds: 300), curve: Curves.ease);
                            viewModel.streamController.add(index);
                            viewModel.scrollController.animateTo(200.0*(index)-MediaQuery.of(context).size.width/4, duration: const Duration(milliseconds: 300), curve: Curves.ease);
                          },
                          child:AnimatedContainer(
                            duration: const Duration(milliseconds: 300),
                            margin: const EdgeInsets.all(5),
                            width: 200,
                            height: 45,
                            decoration: BoxDecoration(
                              color: snapshot.data == index? Colors.white70: Colors.white54,
                              borderRadius: snapshot.data == index? BorderRadius.circular(15): BorderRadius.circular(10),
                              border: snapshot.data == index? Border.all(color: Colors.deepPurpleAccent, width: 2): null,
                            ),
                            child: Center(
                              child: Text(
                                tabNames[index],
                                style: GoogleFonts.laila(
                                    fontSize: 30,
                                    fontWeight: FontWeight.w500,
                                    color: snapshot.data == index ? Colors.black : Colors.grey),
                              ),
                            ),
                          ),
                        );
                    }
                  );
            }
            return Container();
          })
  ),
),

https://youtu.be/XOQ5kHyzwCk

https://github.com/AmirBayat0/Flutter-Custom-TabBar/tree/main/custom_tebbar

https://stackoverflow.com/questions/44121912/how-to-implements-a-scrollable-tab-on-the-bottom-screen

https://devmemory.tistory.com/39

728x90
반응형