DAY 18 | Flutter 데모 앱 만들기: 화면 스택을 유지한 채 탭 전환

Pt J·2026년 8월 12일
post-thumbnail

DAY 18 | Flutter 데모 앱 만들기: 화면 스택을 유지한 채 탭 전환

지난 시간에 배운 내용을 가볍게 훑고 중간평가를 했다. 수업을 들었다면 금방 풀 수 있는 정도의 문제였다. 교육과정 절차 상 필요한 과정이라나 뭐라나. 개인적으로는 수업에서 이런 것들을 다뤘었지, 하며 가볍게 리마인드하는 느낌이었다.

bottom navigation 구현하기

지난 시간 끝자락에 이론적으로만 훑어본 StatefulShellRouter 를 사용하여 bottom navigation을 구현할 것이다. 이것이 StatefulShellRouter 의 주요 usecase라고 하더라.

Router 수정하기

기존 우리의 Router는 대략 다음과 같이 구성되어 있었다.

이를 다음과 같이 수정할 것이다. bottom navigation을 통해 다른 탭으로 이동해도 기존 탭의 정보를 유지하도록 할 수 있다.

가령 'workout_home/workout_list/0/workout_guide/2' 에 해당하는 화면에 있다가 setting 탭으로 이동 후 다시 home 탭으로 이동했을 때, branch root인 'workout_home' 으로 돌아가는 게 아니라 'workout_home/workout_list/0/workout_guide/2' 에서 보고 있던 화면으로 돌아올 수 있는 것이다. (물론 원한다면 'workout_home' 으로 돌아가게 구현할 수도 있다.)

StatefulShellRoute 를 통해 navigation을 구현하려면 각 탭을 식별하는 key가 필요하다. root에 key가 하나 있어야 하며 navigator 하나당 key가 하나씩 필요하다. StatefulShellRoutebranches 로 전달되는 각각의 StatefulShellBranch 가 하나의 navigator이므로 우리는 다음과 같은 세 개의 key가 필요하다.

final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>(
  debugLabel: 'root',
);
final GlobalKey<NavigatorState> _homeNavigatorKey = GlobalKey<NavigatorState>(
  debugLabel: 'home',
);
final GlobalKey<NavigatorState> _settingsNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'settings');

이렇게 생성한 key를 navigatorKey 로 전달하지 않으면 제대로 작동하지 않으니 빼먹지 말도록 하자.

bottom navigation으로 사용될 widget은 아직 안 만들었으므로 그것을 Placeholder() 로 둔 채 작성하면 다음과 같다.

lib/workout_router.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'landing_page.dart';
import 'workout_home_page.dart';
import 'workout_list_page.dart';
import 'workout_guide_page.dart';
import 'settings_page.dart';
import 'login_page.dart';

final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>(
  debugLabel: 'root',
);
final GlobalKey<NavigatorState> _homeNavigatorKey = GlobalKey<NavigatorState>(
  debugLabel: 'home',
);
final GlobalKey<NavigatorState> _settingsNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'settings');

final router = GoRouter(
  navigatorKey: _rootNavigatorKey,
  routes: [
    GoRoute(path: '/', builder: (context, state) => LandingPage()),
    StatefulShellRoute.indexedStack(
      parentNavigatorKey: _rootNavigatorKey,
      builder: (context, state, navigationShell) => Placeholder(), // (임시)
      branches: [
        StatefulShellBranch(
          navigatorKey: _homeNavigatorKey,
          routes: [
            GoRoute(
              path: '/workout_home',
              builder: (context, state) => WorkoutHomePage(),
              routes: [
                GoRoute(
                  path: 'workout_list/:group_index',
                  builder: (context, state) {
                    String? groupIndexStream =
                        state.pathParameters['group_index'];
                    final int groupIndex = int.parse(groupIndexStream!);
                    return WorkoutListPage(groupIndex: groupIndex);
                  },
                  routes: [
                    GoRoute(
                      path: 'workout_guide/:workout_index',
                      builder: (context, state) {
                        String? workoutIndexStream =
                            state.pathParameters['workout_index'];
                        final int workoutIndex = int.parse(workoutIndexStream!);
                        String? groupIndexStream =
                            state.pathParameters['group_index'];
                        final int groupIndex = int.parse(groupIndexStream!);
                        return WorkoutGuidePage(
                          workoutIndex: workoutIndex,
                          groupIndex: groupIndex,
                        );
                      },
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
        StatefulShellBranch(
          navigatorKey: _settingsNavigatorKey,
          routes: [
            GoRoute(
              path: '/settings',
              builder: (context, state) => SettingsPage(),
              routes: [
                GoRoute(
                  path: 'login',
                  builder: (context, state) => LoginPage(),
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  ],
);

설정 페이지와 로그인 페이지는 일단 단순한 형태로 만들어 넣어둔다.

shell 생성하기

StaetfulShellRoutebuilderPlaceholder() 를 담아두었던 부분을 구현해 보자. 이것은 navigationShell 을 주입받는 widget이며 Scaffold 를 사용해야 한다.

constructor로 전달받은 navigationShell 은 이 widget의 body 로 사용된다. 그리고 bottomNavigationBar 에 적절한 widget을 만들어 넣어 bottom navigation을 구현할 것이다.

여기에는 BottomNavigationBar 를 사용할 수도 있고 NavigationBar 를 사용할 수도 있지만 우리는 Material 3에서 권장하는 것에 따라 NavigationBar 를 사용할 것이다.

NavigationBardestinations 는 Router에 작성한 StatefulShellRoutebranches 와 index를 공유하기 때문에 순서를 맞춰 주어야 한다. StatefulShellRoute 로부터 전달받는 navigationShell object에 그 index를 포함한 메타데이터들이 들어 있다.

클릭했을 때 원하는 탭 화면으로 넘어가는 코드는 onDestinationSelectedFunction(int) 형태의 callback function으로 전달한다. 이 때, parameter로 전달되는 int 값은 선택된 탭의 index다. goBranch() method에 대상 index를 전달하여 탭을 이동하며, initialLocationtruefalse 냐에 따라 보던 화면을 띄울지 해당 탭의 루트 페이지를 띄울지가 결정된다.

우리는 다른 탭으로 이동 시 보던 화면이 뜨고, 현재 탭과 같은 탭 버튼을 누를 경우 해당 탭의 루트로 돌아가도록 구현할 것이다.

lib/workout_shell.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class WorkoutShell extends StatelessWidget {
  final StatefulNavigationShell _navigationShell;
  const WorkoutShell({super.key, required this._navigationShell});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: _navigationShell,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _navigationShell.currentIndex,
        destinations: [
          NavigationDestination(
            icon: Icon(Icons.home_outlined),
            selectedIcon: Icon(Icons.home),
            label: 'home',
          ),
          NavigationDestination(
            icon: Icon(Icons.settings_outlined),
            selectedIcon: Icon(Icons.settings),
            label: 'settings',
          ),
        ],
        onDestinationSelected: (index) {
          _navigationShell.goBranch(
            index,
            initialLocation: index == _navigationShell.currentIndex,
          );
        },
      ),
    );
  }
}

이제 Placeholder() 로 임시 작성했던 부분을 bottom navigation으로 갈아끼우면 다음과 같다.

lib/workout_router.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'workout_shell.dart';
import 'landing_page.dart';
import 'workout_home_page.dart';
import 'workout_list_page.dart';
import 'workout_guide_page.dart';
import 'settings_page.dart';
import 'login_page.dart';

final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>(
  debugLabel: 'root',
);
final GlobalKey<NavigatorState> _homeNavigatorKey = GlobalKey<NavigatorState>(
  debugLabel: 'home',
);
final GlobalKey<NavigatorState> _settingsNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'settings');

final router = GoRouter(
  navigatorKey: _rootNavigatorKey,
  routes: [
    GoRoute(path: '/', builder: (context, state) => LandingPage()),
    StatefulShellRoute.indexedStack(
      parentNavigatorKey: _rootNavigatorKey,
      builder: (context, state, navigationShell) =>
          WorkoutShell(navigationShell: navigationShell),
      branches: [
        // 생략
      ],
    ),
  ],
);

Persist Data

우리가 만들고 있는 앱의 메인 화면에 위치한 대시보드에는 오늘의 운동 시간이 있다. 그런데 데이터가 휘발성을 가진 변수에 담겨 있어 앱을 종료하면 데이터가 날아간다. 이를 방지하기 위해서는 비휘발 데이터를 사용해야 한다.

key-value store, file storage, local database, remote datastore 등 비휘발 데이터를 사용할 수 있는 방식은 다양하지만 우리는 shared_properties를 통한 key-value store를 사용할 것이다. 데이터의 성격과 요구사항을 고려하여 가장 적합한 방식을 사용하면 된다.

shared_properties는 int, bool, string 세 가지 type만을 다룬다. set 으로 쓰고 get 으로 읽고 remove 로 제거하는 단순한 로직이라 데이터베이스에 비해 가볍다.

sync, async, cache 세 가지 방식을 사용할 수 있지만 공식 문서는 async 로 할 것을 권장하기에 우리는 async 로 할 것이다.

늘 그렇듯 pubspec.yaml 에 작성하고 [Pub Get] 하는 걸로 시작한다.

pubspec.yaml

# 앞 부분 생략
dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8
  audioplayers: ^6.8.1
  flex_color_scheme: ^8.4.0
  intl: ^0.20.3
  go_router: ^17.4.0
  shared_preferences: ^2.5.5
# 뒷 부분 생략

우리에게는 UI가 아닌 데이터를 다루는 파일이 몇 개 있다. Model의 역할을 하는 lib/workout.dartlib/workout_group.dart, 그리고 Controller의 역할을 하는 lib/workout_manager.dart 가 이에 해당한다.

비휘발 데이터를 다루는 function은 WorkoutManager 의 static method로 작성하도록 하겠다.

lib/workout_manager.dart

import 'package:shared_preferences/shared_preferences.dart';
import 'workout.dart';
import 'workout_group.dart';

// 생략

  static final asyncPrefs = SharedPreferencesAsync();

  static Future<int> getTodayWorkoutMinutes() async {
    int todayMinutes = await asyncPrefs.getInt('todayMinutes') ?? 0;
    return todayMinutes;
  }

  static Future<void> increaseTodayWorkoutMinutes(int minutes) async {
    int todayMinutes = await asyncPrefs.getInt('todayMinutes') ?? 0;
    await asyncPrefs.setInt('todayMinutes', todayMinutes + minutes);
  }
}

비휘발 데이터를 다루는 것이 실습의 핵심이므로 비즈니스 로직의 적합성은 배제하고 생각하겠다. 엄밀하게 구현하고자 한다면 WorkoutGuidePage 에서 재생 버튼을 눌러 시간이 흐른 만큼만 운동 시간으로 더해주어야겠지만 우리는 WorkoutGuidePage 에 들어가면 아무튼 그 운동을 그 시간만큼 했다고 치고 넘어가겠다.

lib/workout_guide_page.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'workout_manager.dart';
import 'workout.dart';

// 생략

class _WorkoutGuidePageState extends State<WorkoutGuidePage> {
  final _player = AudioPlayer();
  Timer? _timer;
  bool _isPlaying = false;

  late List<Workout> workouts;

  late Workout _currentWorkout;
  late int _remainSeconds;
  int _workoutIndex = 0;

  
  void initState() {
    super.initState();
    _player.setReleaseMode(ReleaseMode.loop);
    workouts = WorkoutManager.groups[widget.groupIndex].workouts;
    _workoutIndex = widget.workoutIndex;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;

    WorkoutManager.increaseTodayWorkoutMinutes(_currentWorkout.minutes);
  }

  // 생략

  void _next() {
    _workoutIndex = (_workoutIndex + 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
    WorkoutManager.increaseTodayWorkoutMinutes(_currentWorkout.minutes);
  }

  void _prev() {
    _workoutIndex = (_workoutIndex - 1) % workouts.length;
    _currentWorkout = workouts[_workoutIndex];
    _remainSeconds = _currentWorkout.minutes * 60;
    WorkoutManager.increaseTodayWorkoutMinutes(_currentWorkout.minutes);
  }

  // 생략
}

변화한 오늘의 운동 시간 데이터를 메인 화면 대시보드에 반영하는 작업은 다음 시간에 마저 하도록 하겠다.

>>> GitHub Repository at this point (4889180)

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글