[Flutter] MaterialApp, 그게 뭔데?

Cody Yun
Flutter Seoul
Published in
20 min readJun 15, 2024

지난 포스팅에서 Navigator 클래스를 사용자 중심으로 살펴봤습니다.

지난 포스팅에서는 MaterialApp을 생성하면 Navigator가 함께 생성된다고 했는데, 어떤 과정을 거쳐 생성되는지 살펴보며 플러터 네비게이션의 동작 원리를 이해하기 위한 준비를 하겠습니다.

MaterialApp의 routes

앞선 포스팅에서 설정 화면으로 이동하기 위해 Navigator의 pushNamed를 호출하며 setting 이라는 이름을 전달했습니다. setting 이라는 이름을 사용해 SettingPage로 이동할 수 있었던 이유는 MaterialApp의 routes에 setting 이라는 key에 context를 전달받아 SettingPage 위젯을 반환하는 함수를 value로 지정했기 때문입니다.

import 'package:flutter/material.dart';
import 'package:navigation_example/setting_page.dart';
import 'package:navigation_example/tab_page.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
/// 중략
home: const TabPage(),
routes: {
'setting': (context) => const SettingPage(),
},
);
}
}

routes 인자는 string타입의 key를 사용하고, WidgetBuilder를 value로 사용하는 map 타입입니다. Navigator 위젯의 pushNamed로 ‘setting’이 routes에서 ‘setting’을 key로 하는 WidgetBuilder를 찾고 실행해 SettingPage를 생성합니다. 이제 MaterialApp은 routes로 전달한 속성을 어떻게 사용하고 있는지 내부 코드를 살펴보며 플러터에 대한 이해를 높여봅시다.

StatefulWidget인 MaterialApp

MaterialApp은 StatefulWidget입니다. MaterialAppState의 build 메소드에서 앱의 일반적인 동작을 처리하는 다양한 위젯을 생성해 복잡한 구조의 위젯 트리를 생성합니다.

class MaterialApp extends StatefulWidget {
/// 중략
final Map<String, WidgetBuilder>? routes;
/// 중략
@override
State<MaterialApp> createState() => _MaterialAppState();
}

class _MaterialAppState extends State<MaterialApp> {
Widget _materialBuilder(BuildContext context, Widget? child) {
/// 중략
Widget childWidget = child ?? const SizedBox.shrink();

if (widget.themeAnimationStyle != AnimationStyle.noAnimation) {
if (widget.builder != null) {
childWidget = Builder(
builder: (BuildContext context) {
return widget.builder!(context, child);
},
);
}
childWidget = AnimatedTheme(
data: theme,
duration: widget.themeAnimationStyle?.duration ?? widget.themeAnimationDuration,
curve: widget.themeAnimationStyle?.curve ?? widget.themeAnimationCurve,
child: childWidget,
);
} else {
childWidget = Theme(
data: theme,
child: childWidget,
);
}

return ScaffoldMessenger(
key: widget.scaffoldMessengerKey,
child: DefaultSelectionStyle(
selectionColor: effectiveSelectionColor,
cursorColor: effectiveCursorColor,
child: childWidget, /// Theme or AnimatedTheme Widget
),
);
}
Widget _buildWidgetApp(BuildContext context) {
/// 중략
return WidgetsApp(
key: GlobalObjectKey(this),
navigatorKey: widget.navigatorKey,
navigatorObservers: widget.navigatorObservers!,
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) {
return MaterialPageRoute<T>(settings: settings, builder: builder);
},
home: widget.home,
routes: widget.routes!,
initialRoute: widget.initialRoute,
onGenerateRoute: widget.onGenerateRoute,
onGenerateInitialRoutes: widget.onGenerateInitialRoutes,
onUnknownRoute: widget.onUnknownRoute,
onNavigationNotification: widget.onNavigationNotification,
builder: _materialBuilder,
title: widget.title,
onGenerateTitle: widget.onGenerateTitle,
textStyle: _errorTextStyle,
color: materialColor,
locale: widget.locale,
localizationsDelegates: _localizationsDelegates,
localeResolutionCallback: widget.localeResolutionCallback,
localeListResolutionCallback: widget.localeListResolutionCallback,
supportedLocales: widget.supportedLocales,
showPerformanceOverlay: widget.showPerformanceOverlay,
checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages,
checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers,
showSemanticsDebugger: widget.showSemanticsDebugger,
debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner,
inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder,
shortcuts: widget.shortcuts,
actions: widget.actions,
restorationScopeId: widget.restorationScopeId,
);
}

@override
Widget build(BuildContext context) {
Widget result = _buildWidgetApp(context);
result = Focus(
canRequestFocus: false,
onKeyEvent: (FocusNode node, KeyEvent event) {
if ((event is! KeyDownEvent && event is! KeyRepeatEvent) ||
event.logicalKey != LogicalKeyboardKey.escape) {
return KeyEventResult.ignored;
}
return Tooltip.dismissAllToolTips() ? KeyEventResult.handled : KeyEventResult.ignored;
},
child: result,
);
/// 중략
return ScrollConfiguration(
behavior: widget.scrollBehavior ?? const MaterialScrollBehavior(),
child: HeroControllerScope(
controller: _heroController,
child: result,
),
);
}
}

비교적 덜 중요한 세부 구현을 주석처리한 _MaterialAppState를 살펴보면 아래와 같이 다양한 역할을 위젯을 구성하는걸 확인할 수 있습니다.

  • Focus : 하위 위젯을 포함해 키보드의 포커스를 관리하는 위젯
  • ScrollConfiguration : 하위 위젯 중 스크롤되는 위젯을 제어하는 위젯
  • ScaffoldMessenger : 스낵바와 머터리얼 배너를 제어하는 위
  • AnimatedTheme or Theme : 하위 위젯에 테마를 적용하는 위젯. Theme.of를 사용해 하위 위젯에서 접근 가능하며 Theme이 변경되면 자동으로 리빌드. AnimatedTheme는 Theme 변경 시 변경된 값으로 애니메이션을 통해 변경되도록 해주는 위젯
  • HeroControllerScope : 하위의 네비게이터에 의해 화면이 전환될 때 Hero 로 감싼 위젯이 어떻게 애니메이션될지 제어하는 위젯
  • WidgetsApp : 앱에 필요한 여러 위젯을 감싸고 있는 위젯. 대표적으로 디버그 배너를 표시하는 CheckedModeBanner, 텍스트에 기본 스타일을 적용하는 DefaultTextStyle, 하위 위젯에 미디어쿼리를 제공하는 MediaQuery, 로케일 정보를 제공하는 Localizations, 운영체제에 앱의 타이틀을 제공하는 Title, 다양한 위젯을 스택으로 관리하며 네비게이션 기능을 제공하는 Navigator, 최상단에서 위젯의 렌더링 순서를 제어하는데 사용되는 Overlay, 최상단에서 접근성 관련 기능을 제공하는 SemanticsDebugger 등이 있음

WidgetApp으로 전달된 routes

MaterialApp의 routes로 전달된 파라미터는 _MaterialAppState에서 WidgetApp의 routes로 전달됩니다. WidgetApp은 StatefulWidget인데, WidgetApp의 State인 _WidgetAppState의 build 메소드에서 Navigator 위젯을 생성합니다. Navigator 위젯은 WidgetApp이 생성하는 다양한 위젯과 함께 위젯트리를 구성하게 됩니다.

class WidgetsApp extends StatefulWidget {
/// 중략
final Map<String, WidgetBuilder>? routes;
@override
State<WidgetsApp> createState() => _WidgetsAppState();
}

class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
/// 중략
@override
Widget build(BuildContext context) {
Widget? routing;
if (_usesRouterWithDelegates) {
routing = Router<Object>(
restorationScopeId: 'router',
routeInformationProvider: _effectiveRouteInformationProvider,
routeInformationParser: widget.routeInformationParser,
routerDelegate: widget.routerDelegate!,
backButtonDispatcher: _effectiveBackButtonDispatcher,
);
} else if (_usesNavigator) {
assert(_navigator != null);
routing = FocusScope(
debugLabel: 'Navigator Scope',
autofocus: true,
child: Navigator(
clipBehavior: Clip.none,
restorationScopeId: 'nav',
key: _navigator,
initialRoute: _initialRouteName,
onGenerateRoute: _onGenerateRoute,
onGenerateInitialRoutes: widget.onGenerateInitialRoutes == null
? Navigator.defaultGenerateInitialRoutes
: (NavigatorState navigator, String initialRouteName) {
return widget.onGenerateInitialRoutes!(initialRouteName);
},
onUnknownRoute: _onUnknownRoute,
observers: widget.navigatorObservers!,
routeTraversalEdgeBehavior: kIsWeb ? TraversalEdgeBehavior.leaveFlutterView : TraversalEdgeBehavior.parentScope,
reportsRouteUpdateToEngine: true,
),
);
} else if (_usesRouterWithConfig) {
routing = Router<Object>.withConfig(
restorationScopeId: 'router',
config: widget.routerConfig!,
);
}

Widget result;
if (widget.builder != null) {
result = Builder(
builder: (BuildContext context) {
return widget.builder!(context, routing);
},
);
} else {
assert(routing != null);
result = routing!;
}
/// 중략
return RootRestorationScope(
restorationId: widget.restorationScopeId,
child: SharedAppData(
child: NotificationListener<NavigationNotification>(
onNotification: widget.onNavigationNotification ?? _defaultOnNavigationNotification,
child: Shortcuts(
debugLabel: '<Default WidgetsApp Shortcuts>',
shortcuts: widget.shortcuts ?? WidgetsApp.defaultShortcuts,
// DefaultTextEditingShortcuts is nested inside Shortcuts so that it can
// fall through to the defaultShortcuts.
child: DefaultTextEditingShortcuts(
child: Actions(
actions: widget.actions ?? <Type, Action<Intent>>{
...WidgetsApp.defaultActions,
ScrollIntent: Action<ScrollIntent>.overridable(context: context, defaultAction: ScrollAction()),
},
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: TapRegionSurface(
child: ShortcutRegistrar(
child: Localizations(
locale: appLocale,
delegates: _localizationsDelegates.toList(),
child: title,
),
),
),
),
),
),
),
),
),
);
}
}

WidgetApp의 State에서 생성되는 Navigator 위젯

WidgetApp의 State인 _WidgetAppState build 메소드에서는 다양한 앱의 전반적인 동작에 필요한 다양한 위젯을 생성합니다. MaterialApp과 WidgetApp을 플러터의 네비게이션 딥다이브 과정에서 살펴보기 시작했기 때문에 Navigator 위젯을 생성하는 부분을 주의깊게 살펴봅시다.

class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
Widget? routing;
if (_usesRouterWithDelegates) {
routing = Router<Object>(
restorationScopeId: 'router',
routeInformationProvider: _effectiveRouteInformationProvider,
routeInformationParser: widget.routeInformationParser,
routerDelegate: widget.routerDelegate!,
backButtonDispatcher: _effectiveBackButtonDispatcher,
);
} else if (_usesNavigator) {
assert(_navigator != null);
routing = FocusScope(
debugLabel: 'Navigator Scope',
autofocus: true,
child: Navigator(
clipBehavior: Clip.none,
restorationScopeId: 'nav',
key: _navigator,
initialRoute: _initialRouteName,
onGenerateRoute: _onGenerateRoute,
onGenerateInitialRoutes: widget.onGenerateInitialRoutes == null
? Navigator.defaultGenerateInitialRoutes
: (NavigatorState navigator, String initialRouteName) {
return widget.onGenerateInitialRoutes!(initialRouteName);
},
onUnknownRoute: _onUnknownRoute,
observers: widget.navigatorObservers!,
routeTraversalEdgeBehavior: kIsWeb ? TraversalEdgeBehavior.leaveFlutterView : TraversalEdgeBehavior.parentScope,
reportsRouteUpdateToEngine: true,
),
);
} else if (_usesRouterWithConfig) {
routing = Router<Object>.withConfig(
restorationScopeId: 'router',
config: widget.routerConfig!,
);
}
/// 생략
}
}

끝으로

MaterialApp과 WidgetApp을 거쳐 Navigator가 생성되는 과정을 살펴봤습니다. 플러터에서 네비게이션을 구현하는 다양한 방법이 있습니다.

  1. Navigator
  2. RouterConfig
  3. RouterDelegate

RouterConfig나 RouterDelegate 방식은 2020년에 Navigator 2.0으로 공개되었습니다.

플러터 프로젝트에서 많이 사용되는 go_router 패키지가 Navigator 2.0인 RouterConfig를 사용해 구현되었습니다. go_router 패키지의 GoRouter 객체가 RouterConfig를 구현하고 있는 객체입니다.

Navigator 2.0을 이해하기 위해서는 Navigator 1.0 방식인 Navigator를 완전히 이해할 필요가 있습니다. 다음 포스팅에서는 Navigator 1.0 방식에 사용되는 Navigator 위젯을 자세히 살펴보며 네비게이션의 핵심 동작 원리를 살펴보겠습니다.

언제나 그렇듯 Happy Coding 👨‍💻

--

--

Cody Yun
Flutter Seoul

I wanna be a full stack software engineer at the side of user-facing application.