[Flutter] BottomNavigationBar 사용하기

BottomNavigationBar 사용하기

네비게이션 바와 탭바 UI 예시 1네비게이션 바와 탭바 UI 예시 2
네비게이션 바와 탭바 UI 예시

 

위와 같은 화면을 구성할 때 BottomNavigationBar를 많이 사용하게 됩니다. BottomNavigationBarTabBarView와 같이 사용하게 되는데, 사용자의 입력에 따라서 두 위젯의 index가 잘 맞아야 하기 때문에 은근히 구현하기 어려운 부분입니다.

이 글에서 TabBarViewBottomNavigationBar를 이용해서 위와 같은 UI를 구현해보겠습니다.

BottomNavigationBar와 TabBarView 이용해서 화면 전환 구현하기

StatefulWidget 만들기

우선, BottomNavigationBar를 담는 View부터 만들어야 하는데, 이를 TabView라고 하겠습니다. 당연히 사용자의 입력에 따라서 UI가 달라져야 하므로, StatefulWidget으로 선언합니다.

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

  @override
  State<TabView> createState() => _TabViewState();
}

class _TabViewState extends State<TabView> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
            bottomNavigationBar: // 밑에서 구현할 예정
        );
  }
}

여기서 index는, BottomNavigationBar에서 나타낼 아이템 인덱스입니다. 위 그림에서 [’홈’, ‘음식’, ‘주문’, ‘프로필’] 아이템들의 인덱스를 말합니다.

BottomNavigationBar

Scaffold(
  bottomNavigationBar: BottomNavigationBar(
    onTap: (int index) {
      _tabController.animateTo(index); // TabController의 animateTo 함수로, index 위치로 화면 전환
    },
    currentIndex: _index, // 인덱스는 위에서 정의하였음
    items: const [
      BottomNavigationBarItem(
        icon: Icon(Icons.home_outlined),
        label: '홈',
      ),
      BottomNavigationBarItem(
        icon: Icon(Icons.fastfood_outlined),
        label: '음식',
      ),
      BottomNavigationBarItem(
        icon: Icon(Icons.receipt_long_outlined),
        label: '주문',
      ),
      BottomNavigationBarItem(
        icon: Icon(Icons.person_outlined),
        label: '프로필',
      ),
    ],
  ), // BottomNavigationBar
    child: // ...
)

BottomNavigationBar 자체는 위와 같이 구현합니다.

  • currentIndex: 현재 선택된 인덱스
  • onTap: 눌렀을 때 동작, 콜백 함수는 네비게이션 바의 n번 째 아이템을 눌렀을 때, 해당 인덱스를 반환합니다
  • items: 네이게이션 바의 아이템, Widget 중 BottomNavigationBarItem 위젯이 가장 많이 사용됩니다

TabView

Scaffold(
    bottomNavigationBar: BottomNavigationBar(
        // 위에서 구현한 부분과 동일
    ),
    child: TabBarView(
      physics: const NeverScrollableScrollPhysics(), // 스와이핑으로 화면 전환 방지
      controller: _tabController, // BottomNavigationBar의 controller와 동일한 컨트롤러 사용
      children: const [
        RestaurantPage(),
        ProductPage(),
        OrderPage(),
        ProfilePage(),
      ],
    ),
)

Scaffold의 child에는, TabBarView를 넣어주겠습니다.

 

TabBarViewTabBarBottomNavigationBar에서 선택한 index에 맞추어서 메인 화면에 띄울 페이지를 선택합니다.

 

TabBarView에 대한 자세한 내용은 공식 문서공식 소개 비디오를 참고하세요.

 

TabBarView class - material library - Dart API

A page view that displays the widget which corresponds to the currently selected tab. This widget is typically used in conjunction with a TabBar. If a TabController is not provided, then there must be a DefaultTabController ancestor. The tab controller's T

api.flutter.dev

  • physics: const NeverScrollableScrollPhysics(): TabBarView에서 스와이핑을 통해서 화면이 전환되는 것을 방지합니다. 우리는 BottomNavigationBar의 아이템을 클릭했을 때 화면이 전환되는 것을 원하지, 스와이핑을 통해서 전환되는 것을 원치 않습니다.
  • controller: BottomNavigationBar와 동일한 TabController를 넣어줍니다.
  • children: 각 아이템마다 화면에 보여줄 페이지를 제공합니다.

TabController

가장 핵심이 되는 것은 TabController입니다.

 

우선, TabController 선언 시 late로 선언해야 합니다. 왜냐하면 TabControllerinitState 함수 안에서 초기화할 것이기 때문입니다.

 

그리고 TabController를 이용하기 위해서는, 우선 _TabViewState 클래스에 SingleTickerProviderStateMixin을 붙여줘야 합니다.

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

  @override
  State<TabView> createState() => _TabViewState();
}

// SingleTickerProviderStateMixin 추가
class _TabViewState extends State<TabView> with SingleTickerProviderStateMixin {
  int _index = 0;
    late TabController _tabController; // TabController 추가

    // ...
}

그리고 이를 initState에서 초기화합니다. 그리고 TabController에 추가한 리스너는 반드시 이 위젯이 dispose될 때 detach되어야 합니다. 그래서 dispose 함수에서 리스너를 지워줍니다.

class _RootTabState extends State<RootTab> with SingleTickerProviderStateMixin {
  late TabController _tabController;
  int _index = 0;

    /// initState에서 tabController 초기화
  @override
  void initState() {
    super.initState();

        /// length 4는 BottomNavigationBar의 item 개수
        /// `vsync: this`를 사용하기 위해서 SingleTickerProviderStateMixin를 사용함
    _tabController = TabController(length: 4, vsync: this);

        /// tabListener는 밑에서 구현할 예정
    _tabController.addListener(tabListener);
  }

  @override
  void dispose() {
        /// tabListener 지우기
    _tabController.removeListener(tabListener);

    super.dispose();
  }

    void tabListener() {
        // ...
    }
}

tabListener 함수는 다음과 같이 정의합니다.

void tabListener() {
  setState(() {
    _index = _tabController.index;
  });
}

여기서, 사용자의 클릭에 의해서 _tabController의 index 속성이 바뀔 때, 현재 인덱스 정보를 나타내는 _index 를 변경해줍니다. _index가 변경이 되고 있으니깐, setState를 불러서 화면을 다시 그리게끔 요청합니다.

코드 리팩토링

몇 가지 불만사항이 있습니다.

 

우선, _tabController = TabController(length: 4, vsync: this); 이 부분에서, 길이를 4로 하드코딩하여 지정하고 있습니다. 그러나 만약에 BottomNavigationBar의 item 개수가 바뀌게 되면 어떨까요?

 

한 가지 예시로, 최근 카카오톡도 UI에서 하단 네비게이션 바의 아이템 개수가 5개로 바뀌었습니다. 변경 사항이 있을 때마다 하드코딩된 숫자를 바꾸는 건 그리 좋은 방법은 아닙니다.

items: const [
  BottomNavigationBarItem(
    icon: Icon(Icons.home),
    label: '홈',
  ),
  BottomNavigationBarItem(
    icon: Icon(Icons.calendar_today),
    label: '스케줄',
  ),
  BottomNavigationBarItem(
    icon: Icon(Icons.chat),
    label: '채팅',
  ),
  BottomNavigationBarItem(
    icon: Icon(Icons.person),
    label: '프로필',
  ),
],

이 리스트 부분을 따로 분리해줍시다.

/// NavItem은 아이콘, label을 담고 있는 모델 클래스로써
/// BottomNavigationBarItem에 필요한 정보를 제공합니다
class NavItem {
  final IconData activeIcon;
  final String label;

  const NavItem({
    required this.activeIcon,
    required this.label,
  });
}

/// 여기서는 그냥 전역적으로 선언해주었습니다
/// [_navItems]는 [_TabViewState]의 지역 변수로 관리해주어도 되고, provider에 넣어도 되지만
/// 여기선 그냥 제일 간단하게 전역에서 관리할 예정입니다
const _navItems = [
  NavItem(
    activeIcon: Icons.home,
    label: '홈',
  ),
  NavItem(
    activeIcon: Icons.calendar_today,
    label: '스케줄',
  ),
  NavItem(
    activeIcon: Icons.chat,
    label: '채팅',
  ),
  NavItem(
    activeIcon: Icons.person,
    label: '프로필',
  ),
];

그리고 items를 다음과 같이 리팩토링합니다.

items: _navItems.map((item) {
  return BottomNavigationBarItem(
    icon: Icon(item.activeIcon),
    label: item.label,
  );
}).toList(),

그리고 하드 코딩되어 있는 부분을 개선합니다.

@override
void initState() {
  super.initState();

    /// length를 `_navItems.length` 로 교체
  _tabController = TabController(length: _navItems.length, vsync: this);
  _tabController.addListener(tabListener);
}

UI 개선하기

선택된 아이템의 크기와 텍스트가 변경됨
선택된 아이템의 크기와 텍스트가 변경됨

한 가지 불만이 있습니다. BottomNavigationBar에서, 선택된 item만 강조되는 효과가 있습니다. 텍스트도 선택된 아이템만 나오고 있습니다.

이를 원하지 않는다면, 몇 가지 옵션을 넣어주면 됩니다.

BottomNavigationBar(
  selectedItemColor: kPrimaryDarkColor, // 선택된 아이템의 색
  unselectedItemColor: kPrimaryDarkColor, // 선택되지 않은 아이템의 색 (여기서는 둘이 같게 함)
  type: BottomNavigationBarType.fixed, // 아이콘과 텍스트가 항상 함께 보임
  onTap: // 나머지는 동일
)

type: BottomNavigationBarType.fixed 를 옵션으로 넣어주면, 텍스트가 모두 보이게 됩니다.

고정된 아이콘 사이즈&#44; 그러나 텍스트가 변경됨
고정된 아이콘 사이즈, 그러나 텍스트가 변경됨

위에 그림에서 선택된 아이템의 사이즈가 바뀌는 것을 확인할 수 있는데, 이 역시 옵션으로 조절 가능합니다.

BottomNavigationBar(
  selectedItemColor: kPrimaryDarkColor,
  unselectedItemColor: kPrimaryDarkColor,
  type: BottomNavigationBarType.fixed,
    selectedFontSize: 10, // 선택된 아이템과 비선택된 아이템의 텍스트 크기를 같게
  unselectedFontSize: 10,
    // ...
)

텍스트 통일된 결과
텍스트 사이즈를 모두 통일한 결과

단순히 폰트 사이즈 뿐만 아니라, 다양한 텍스트 옵션을 조절하고 싶다면, selectedFontSize 등이 아닌 selectedTextStyle 속성을 조절합니다.

BottomNavigationBar(
  selectedItemColor: kPrimaryDarkColor,
  unselectedItemColor: kPrimaryDarkColor,
  selectedLabelStyle: const TextStyle(
    fontWeight: FontWeight.bold, // 선택된 아이템의 글자 크기를 굵게
    fontSize: 10,
  ),
  unselectedLabelStyle: const TextStyle(
    fontSize: 10, // 둘다 폰트 사이즈는 10으로 고정
  ),
  type: BottomNavigationBarType.fixed,
    // ...
)

선택된 아이템의 텍스트 스타일 변경
선택된 아이템의 텍스트 스타일 변경

선택된 ‘홈’ 아이템의 텍스트가 bold로 적용된 것을 확인할 수 있습니다.

선택된 아이템의 아이콘을 바꾸고 싶다면?

그런데 위의 그림을 보면, 단순히 아이콘의 색이 바뀌는 게 아니라, 아예 아이콘 자체가 바뀌고 있습니다.

 

이건 어떻게 한 걸까요? 선택된 index와 해당 아이템의 인덱스를 비교하면 됩니다.

 

정말 다행히도, 이미 NavItem 모델과 item 리스트를 따로 분리해놓았기 때문에, 약간의 수정만 거치면 됩니다.

 

NavItem 모델을 다음과 같이 변경합니다.

class NavItem {
    /// 현재 item의 인덱스
  final int index;
  final IconData activeIcon;

    /// 비활성화된 item에서 어떤 아이콘을 표시할 지
  final IconData inactiveIcon;
  final String label;

  const NavItem({
    required this.index,
    required this.activeIcon,
    required this.inactiveIcon,
    required this.label,
  });
}

그리고 indexinactiveIcon 정보를 리스트에 추가합니다.

const _navItems = [
  NavItem(
    index: 0, // index 추가
    activeIcon: Icons.home,
    inactiveIcon: Icons.home_outlined, // icon 추가
    label: '홈',
  ),
  NavItem(
    index: 1,
    activeIcon: Icons.calendar_today,
    inactiveIcon: Icons.calendar_today_outlined,
    label: '스케줄',
  ),
  NavItem(
    index: 2,
    activeIcon: Icons.chat,
    inactiveIcon: Icons.chat_outlined,
    label: '채팅',
  ),
  NavItem(
    index: 3,
    activeIcon: Icons.person,
    inactiveIcon: Icons.person_outline,
    label: 'My',
  ),
];

그리고 BottomNavigationBarItem 부분을 다음과 같이 변경합니다.

BottomNavigationBar(
    items: _navItems.map((item) {
      return BottomNavigationBarItem(
        icon: Icon(
                // 현재 index와 item.index가 같은 지 비교해서 서로 다른 icon을 내보낸다
          _index == item.index ? item.activeIcon : item.inactiveIcon,
        ),
        label: item.label,
      );
    }).toList(),
)

아이콘이 변경되는 효과 구현
선택된 아이콘이 달라짐

이제 아이콘을 누를 때마다, 서로 다른 아이콘이 보여지는 효과를 구현할 수 있습니다.

 

아이콘 변경이 되지 않는 UI는 아래와 같습니다.

아이콘이 변경되지 않는 UI

이때에는 색을 통해서 선택된 아이콘을 표시하게 됩니다.

BottomNavigationBar(
    selectedItemColor: primaryColor, // 선택된 아이템 컬러와
    unselectedItemColor: bodyTextColor, // 선택되지 않은 아이템 컬러를 다르게 하여 표시
    selectedFontSize: 10,
    unselectedFontSize: 10,
    type: BottomNavigationBarType.fixed,
)

전체 코드

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

  @override
  State<TabView> createState() => _TabViewState();
}

class _TabViewState extends State<TabView> with TickerProviderStateMixin {
  late TabController _tabController;
  int _index = 0;

  @override
  void initState() {
    super.initState();

    _tabController = TabController(length: _navItems.length, vsync: this);
    _tabController.addListener(tabListener);
  }

  @override
  void dispose() {
    _tabController.removeListener(tabListener);
    super.dispose();
  }

  void tabListener() {
    setState(() {
      _index = _tabController.index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: BottomNavigationBar(
        selectedItemColor: kPrimaryDarkColor,
        unselectedItemColor: kPrimaryDarkColor,
        selectedLabelStyle: const TextStyle(
          fontWeight: FontWeight.bold,
          fontSize: 10,
        ),
        unselectedLabelStyle: const TextStyle(
          fontSize: 10,
        ),
        type: BottomNavigationBarType.fixed,
        onTap: (int index) {
          _tabController.animateTo(index);
        },
        currentIndex: _index,
        items: _navItems.map((item) {
          return BottomNavigationBarItem(
            icon: Icon(
              _index == item.index ? item.activeIcon : item.inactiveIcon,
            ),
            label: item.label,
          );
        }).toList(),
      ),
      body: TabBarView(
        physics: const NeverScrollableScrollPhysics(),
        controller: _tabController,
        children: const [
          HomePage(),
          SchedulePage(),
          Center(child: Text('채팅')),
          Center(child: Text('My')),
        ],
      ),
    );
  }
}

class NavItem {
  final int index;
  final IconData activeIcon;
  final IconData inactiveIcon;
  final String label;

  const NavItem({
    required this.index,
    required this.activeIcon,
    required this.inactiveIcon,
    required this.label,
  });
}

const _navItems = [
  NavItem(
    index: 0,
    activeIcon: Icons.home,
    inactiveIcon: Icons.home_outlined,
    label: '홈',
  ),
  NavItem(
    index: 1,
    activeIcon: Icons.calendar_today,
    inactiveIcon: Icons.calendar_today_outlined,
    label: '스케줄',
  ),
  NavItem(
    index: 2,
    activeIcon: Icons.chat,
    inactiveIcon: Icons.chat_outlined,
    label: '채팅',
  ),
  NavItem(
    index: 3,
    activeIcon: Icons.person,
    inactiveIcon: Icons.person_outline,
    label: 'My',
  ),
];

예전에 사용했던 방법

저도 위의 방법을 몰랐을 때는, 아래와 같이 구현을 했었습니다.

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

  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  int _selectedIndex = 0;

  static List<Widget> pages = <Widget>[
    const MapPage(),
    const PostsPage(),
    const ProfilePage(),
  ];

  void _onItemTapped(int index) {
    if (mounted) {
      setState(() {
        _selectedIndex = index;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
          child: pages.elementAt(_selectedIndex),
        ),
        bottomNavigationBar: BottomNavigationBar(
          items: const <BottomNavigationBarItem>[
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              label: 'Home',
              backgroundColor: Colors.blue,
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.list),
              label: 'Posts',
              backgroundColor: Colors.green,
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.person),
              label: 'Profile',
              backgroundColor: Colors.pink,
            ),
          ],
          currentIndex: _selectedIndex,
          onTap: _onItemTapped,
        ));
  }
}

Flutter를 배운 지 한 달도 안 된 시기에 구현했던 BottomNavigationBar를 이용한 TabBarView인데, 정말 아무것도 모르는 상태에서 꾸역꾸역 만들었답니다.

class _MainPageState extends State<MainPage> {
    /// 현재 선택된 인덱스
  int _selectedIndex = 0;

    /// 페이지 리스트들
    /// 왜 굳이 static으로 선언했는지는 모르겠습니다
  static List<Widget> pages = <Widget>[
    const MapPage(),
    const PostsPage(),
    const ProfilePage(),
  ];

    /// 페이지 인덱스를 변경시키는 `setState` 함수
  void _onItemTapped(int index) {
    if (mounted) {
      setState(() {
        _selectedIndex = index;
      });
    }
  }

    // ...
}

그때 당시에는 TickerProviderStateMixinTabController의 존재를 몰라서, _onItemTappedsetState 로만 인덱스 변경을 시도했습니다.

body: Center(
  child: pages.elementAt(_selectedIndex),
),

body에는 pages[_selectedIndex]Center로 감싸서 화면을 보여주고 있습니다.

 

그래서 onTap 시에 해당 인덱스로 page 전환이 이루어지는 구조입니다.

 

생각보다 잘 만들었네요.

 

이처럼 탭바 페이지를 구현하는 방법은 매우 다양하고 취향입니다. 여기서는 SingleTickerProviderStateMixin을 사용하는 한 가지 방법을 소개했습니다.

참고 자료