flutter

Flutter Navigator 사용법

PJH 2024. 12. 11. 17:40

플러터에서는 뭘로 화면 전환하지?

Navigator를 사용하여 화면전환이 가능하다.

화면 전환 코드에 대해 한 번 확인해보자.

 

 

 

사전 준비

별도의 사전 준비는 필요 없다.
다만, 코드 변경이 조금 필요하다.

void main() => runApp(
  MaterialApp(
    home: MyApp(),
  )
);

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...
    );
  }
}

MaterialApp이 뭐지?

플러터에서 Material Design을 적용하기 위한 최상위 위젯이다.

앱의 전반적인 구조와 동작을 설정하며 여러 기능들을 담당하는데,

특히 여기를 주목

 

"ㆍ라우팅 관리 (routes, onGenerateRoute, onUnknownRoute): 앱 내에서 화면 간의 네비게이션을 관리"

 

 

이 말은, 화면 전환은 MaterialApp 위젯이 필수이며,

MaterialApp으로 앱 내 모든 위젯을 포함해야 한다.

 

 

때문에, 아래처럼 선언해야 Navigator를 쓸 수 있다.

void main() => runApp(
  MaterialApp(
    home: MyApp(),
  )
);

 

 

 

그럼 저렇게 안하면 어떤 일이 발생할까?

Navigator operation requested with a context that does not include a Navigator.

Android Studio 사용 중이면 이런 문구가 빨간색으로 표시되고,

 

화면 전환이 일절 발생하지 않는다.

 

 

 

 

전체 코드

main.dart

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: FirstScreen(),
    );
  }
}

class FirstScreen extends StatelessWidget {
  const FirstScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('첫 번째 화면'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => const SecondScreen()),
            );
          },
          child: const Text('두 번째 화면으로 이동'),
        ),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  const SecondScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('두 번째 화면'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.pop(context);
          },
          child: const Text('이전 화면으로 돌아가기'),
        ),
      ),
    );
  }
}

 

 

 

실행 결과

 

 

 

 

좀 더 효율적으로 쓰려면?

 

당연 문제 없다.

 

오히려 라우터 분리를 권장한다.

 

매 번 Navigator.push가 호출된다고 생각해봐라.

그 때마다 고통스럽다.

 

코드 변경에 분노하고,

유지보수 하기도 힘들어서 분노한다.

 

 


먼저 파일을 세 가지로 나눈다.

lib/
└ main.dart
└ utils/
  └ route_manager.dart
└ features/
  └ my_app.dart
  └ count/
    └ count_screen.dart
    └ count_provider.dart
    └ count_provider.g.dart
  └ dialog/
    └ dialog_screen.dart

 

 

 

main.dart

import 'package:flutter/material.dart';
import './features/my_app.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(ProviderScope(
    child: MaterialApp(
      home: MyApp(),
      debugShowCheckedModeBanner: false,
    ),
  ));
}

상태관리 도구로 Riverpod를 사용하기로 했다.

 

Riverpod로 상태관리를 하려면 전역을 ProviderScope로 감싸야 한다.

 

그래야 사용 가능하다.

 

 

 

왜 RiverPod인가?

플러터 상태관리는 C언어의 동적 메모리 할당과 같다.

 

 

C언어에서 특정 메모리 공간을 할당할 때,

원하는 시점에 할당 하고, 사용 후, 반납하는 코드가 있다.

 

 

똑같이, 플러터도 상태를 할당했으면,

안 쓸 땐 해제해야 한다.

 

 

 

그럼 해제하는 코드 dispose 쓰면 되는 거 아님?

상태가 한 두개면 상관없다.

 

하지만 앱이 커지면서 상태를 많이 쓰면?

이 때 부터 하나 둘씩 놓치게 돼, 메모리 누수가 발생한다.

 

 

 

Riverpod는 자동 해제를 지원한다.

때문에, 나는 Riverpod를 쓴다.

 

 

 

실수없이 잘 관리하면 되잖아??

뭐, 그렇게 말하면 할 말 없다.

그땐 자유롭게 쓰면 되니까.

 

 

 

my_app.dart

// lib/my_app.dart
import 'package:flutter/material.dart';
import './count/count_screen.dart';
import './dialog_example/dialog_example.dart';
import '../utils/route_manager.dart';

class MyApp extends StatelessWidget{

  MyApp({
    super.key
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: buildColumn(context),
      ),
    );
  }

  // 위젯을 빌드에 필요한 context를
  Widget buildColumn(BuildContext context){
    return Column(
      crossAxisAlignment: CrossAxisAlignment.center,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        buildElevatedButton(context, CountScreen(number: 5), "count example"),
        buildElevatedButton(context, DialogExample(), "dialog example"),
      ],
    );
  }

  Widget buildElevatedButton(BuildContext context, Widget target, String buttonName){
    return Container(
      margin: EdgeInsets.all(15),
      child: ElevatedButton(
        onPressed: () {
          RouteManager.moveTo(context, () => target);
        },
        child: Text("${buttonName}")
      ),
    );  
  }
}

 

my_app에는 Navigator를 직접 쓰지 않는다.

 

 

route_manager.dart

// lib/utils/route_manager.dart
import 'package:flutter/material.dart';

class RouteManager {
  static void moveTo(
    BuildContext context, 
    Widget Function() targetBuilder, {
      bool fullscreenDialog = false,
    }) {
    // future: 조건 확인, 로그, 애니메이션 등 가능
    Navigator.push(
      context, 
      MaterialPageRoute(
        builder: (_) => targetBuilder(),
        fullscreenDialog: fullscreenDialog
      )
    );
  }
}

 

static으로 두고, 언제든 사용할 수 있는 형태로 정의한다.

저렇게 정의하면 뭐가 좋냐?

 

 

1. 위젯마다 파라미터 유연한 처리 가능

2. 해당 함수만 호출하면 화면전환이 자유로움

 

 

 

 

count_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'count_provider.dart'; // 위에서 만든 상태관리 코드

class CountScreen extends ConsumerWidget {
  final number;

  CountScreen({
    super.key, 
    this.number,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(countProvider);
    return Scaffold(
      body: Center(
        child: Text('Count $number : $count', style: TextStyle(fontSize: 40)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(countProvider.notifier).increment();
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

 

 

count_provider.dart

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'count_provider.g.dart'; // 이거 빌드하면 생성됨

@riverpod
class Count extends _$Count {
  @override
  int build() {
    return 0; // 초기값 0
  }

  void increment() {
    state++;
  }

  void decrement() {
    state--;
  }
}

 

 

count_provider.g.dart

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'count_provider.dart';

// **************************************************************************
// RiverpodGenerator
// **************************************************************************

String _$countHash() => r'9d2b44a8c99f10f03e7255c3bfd4c417ce9a6000';

/// See also [Count].
@ProviderFor(Count)
final countProvider = AutoDisposeNotifierProvider<Count, int>.internal(
  Count.new,
  name: r'countProvider',
  debugGetCreateSourceHash:
      const bool.fromEnvironment('dart.vm.product') ? null : _$countHash,
  dependencies: null,
  allTransitiveDependencies: null,
);

typedef _$Count = AutoDisposeNotifier<int>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

 

위 코드는 build runner라는 명령어로 상태 관리도구를 자동생성한다.

 

이번 예제에서만 원활한 상태관리를 위해

자동 생성 도구를 썼지만,

 

실제로 Dart 지원 범위의 한계와

아직 적용 안 되는 상태관리 도구가 있어서 그런지

이후 작성할 땐 자동생성 도구를 쓰지 않을 예정이다.

 

 

 

dialog_screen.dart

import 'package:flutter/material.dart';

class DialogScreen extends StatelessWidget{
  DialogScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.center,
      child: ElevatedButton(
        onPressed: (){
          print("눌렀습니다");
        }, 
        child: Text("dialog 예제")),
    );
  }
}

 

 

 

실행결과

 

 

잘 실행된다.

Count에서는 생성자로 number를 받았지만

Navigator로 자동 전달 돼,

화면전환 도구의 구현체를 전혀 신경 쓸 필요 없다.

 

dialog 버튼도 누름면 눌렀습니다 라는 문구를 잘 표시한다.

 

 

결론

오늘은 Flutter의 화면전환에 대해 간단히 알아봤다.

이번 포스팅의 예제 외 다른 방식도 존재한다.
만약, 이 포스팅의 화면 전환 방식이 마음에 안든다면

다른 방법을 검색하여 찾아보면 되겠다.

플러터를 시작한지 얼마 안 돼, 아직 알아봐야 할 게 많다.

아마 Navigator 만으로는 안되는 무언가가 있지 않을까 생각한다.