[Flutter] Nested Navigator 위젯으로 마치 Fragment처럼 화면 일부만 이동하기 (multi, 중첩 navigation)

Hans Park·2022년 9월 28일
4

Flutter

목록 보기
13/14
post-thumbnail

우리가 화면 이동 시 늘상 쓰던 Navigator.push()와 같은 방법이 아니다.

개요😃

프로젝트 도중 화면의 일부만 이동해야하는 화면이 있었다.

AppBar는 가만히 있고, 아래의 화면만 마치 화면이 이동하듯 움직여야 했다.
특히 AppBar의 데이터는 실시간으로 유지되어야 했는데,
당시 상태관리 패키지를 사용하지 않은 상태였다.

예를 들어보자.

아래 메인 클래스와 세 클래스가 있다.


class _MyHomePageState extends State<MyHomePage> {

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Expanded(flex: 1, child: Fix()),
            Expanded(flex: 1, child: A()),
          ],
        ),
      ),
    );
  }
}

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

  
  Widget build(BuildContext context) {
    return Container(color: Colors.red);
  }
}

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

  
  Widget build(BuildContext context) {
    return Container(color: Colors.blue);
  }
}

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

  
  Widget build(BuildContext context) {
    return Container(color: Colors.yellow);
  }
}

아래 보라색 화면 (위 파란색)만 다른 화면으로 화면을 이동시켜보자.


(누가 gif 이쁘게 올리는 법좀 알려주세요 😭)

단지 위젯을 바꾸면서 애니메이션 변화를 주어도 될 것이다. (안해봄)
하지만 이미 구현되어 있는 위젯을 활용하여, UI만 만들어 사용해보고자 한다.




사전 학습

우리가 화면 이동 시 사용하는 그 위젯이다.

Flutter에서 화면 이동을 구현해보았다면 Stack 구조의 라우트저장소에 Pop과 Push를 통해 화면이 중첩됨을 알고 있을 것이다.

MaterialApp이 생성될 때 최상위 Navigator를 구성한다.
우리가 화면 이동 시 호출하는 Navigator.***는 MaterialApp에서 구성한 Route등을 참고한다.

return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: FlatButton(
          child: Text('View Details'),
          onPressed: () {
            Navigator.push(
              context,
              MaterialPageRoute(builder: (context) {
                return DetailScreen();
              }),
            );
          },
        ),
      ),
    );
return MaterialApp(
      routes: {
        '/': (context) => HomeScreen(),
        '/details': (context) => DetailScreen(),
      },
    );
    
...........
    
 return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: FlatButton(
          child: Text('View Details'),
          onPressed: () {
            Navigator.pushNamed(
              context,
              '/details',
            );
          },
        ),
      ),
    );

위와 같이 처음 화면 이동과 관련하여 배우는 내용들은 Navigator1.0이다.
하지만 위와 같은 방법은 MaterialApp의 하나의 라우트 스택으로만 관리된다.

위 예제같이 우리가 원하는 화면 일부분만 화면을 이동시키기 위해 새로운 그 화면만의 Route스택을 가져야 할 것이다.

플러터 공식 블로그의 글을 살펴보면 우리가 원하는 기능을 내포하고 있는 Navigator 2.0을 확인할 수 있다.
(https://medium.com/flutter/learning-flutters-new-navigation-and-routing-system-7c9068155ade)

Page, MaterialPage, 멀티 Pop 등의 새로운 개념들이 나오나,
이 중 몇 가지만 가져와 우리가 원하는 기능을 구현해보려 한다.

공식 블로그를 잘 살펴보면 Navigator를 아래와 같이 사용하는 것을 확인할 수 있다.

return MaterialApp(
      title: 'Books App',
      home: Navigator(
        pages: [
          MaterialPage(
            key: ValueKey('BooksListPage'),
            child: Scaffold(),
          )
        ],
        onPopPage: (route, result) => route.didPop(result),
      ),
    );

이 Navigator 위젯을 잘 활용하면 화면 일부만 수정이 가능하지 않을까 생각했다.

onGenerateRoute

onGenerateRoute는 Navigator 1.0에서도 사용되는 방법으로, 아직 Page위젯을 배우지 않은 우리가 사용해야 할 방법이다.

Route<dynamic> generateRoute(RouteSettings settings) {
  switch (settings.name) {
    case HomeViewRoute:
      return MaterialPageRoute(builder: (context) => HomeView());
    case LoginViewRoute:
      return MaterialPageRoute(builder: (context) => LoginView());
    default:
      return MaterialPageRoute(builder: (context) => HomeView());
  }
}

GlobalKey<NavigatorState>()

Navigator위젯 내부에서 Navigator.push 등을 사용하면 우리가 호출한 위젯이 아닌 MaterialApp에서 만든 최상위 Navigator가 호출된다.

key를 통해 우리가 호출한 Navigator를 불러야 한다.




구현하기🧑🏻‍💻

아래는 화면이동을 구현 하기 전 작성해본 맨 처음 코드이다.


class _MyHomePageState extends State<MyHomePage> {
  final _navigatorKey = GlobalKey<NavigatorState>();

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Expanded(flex: 1, child: Fix()),
            Expanded(flex: 1, child: A()),
          ],
        ),
      ),
    );
  }
}

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

  
  Widget build(BuildContext context) {
    return Container(color: Colors.red);
  }
}

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

  
  Widget build(BuildContext context) {
    return Container(color: Colors.blue);
  }
}

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

  
  Widget build(BuildContext context) {
    return Container(color: Colors.yellow);
  }
}

우선 화면이동이 필요한 부분에 Navigator함수와 key, 라우트를 할당하자.

const routeA = "/";
const routeB = "/B";
const routeC = "/C";

class _MyHomePageState extends State<MyHomePage> {
  final _navigatorKey = GlobalKey<NavigatorState>();

...

      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Expanded(flex: 1, child: Fix()),
            Expanded(
              flex: 1,
              child: Navigator(
                key: _navigatorKey,
                initialRoute: routeA,
                onGenerateRoute: _onGenerateRoute,
              ),
            ),
          ],
        ),
      ),
  ...
}

onGenerateRoute함수를 만들어, route에 따른 화면을 return해준다.
(Navigator property 중 onUnknownRoute를 통해 정의되있지 않은 route가 들어올 경우를 처리할 수 있다.)

MaterialPageRoute _onGenerateRoute(RouteSettings setting) {
    if (setting.name == routeA) {
      return MaterialPageRoute<dynamic>(
          builder: (context) => A(), settings: setting);
    } else if (setting.name == routeB) {
      return MaterialPageRoute<dynamic>(
          builder: (context) => B(), settings: setting);
    } else if (setting.name == routeC) {
      return MaterialPageRoute<dynamic>(
          builder: (context) => C(), settings: setting);
    } else {
      throw Exception('Unknown route: ${setting.name}');
    }
  }

이제 A, B, C 클래스들에서 각각 화면이동을 하면 된다.
다만 아까 작성했다시피, 우리가 보통 사용하는 Navigator.* 방법이 아닌 key를 활용하여 이동해야 한다.

key를 클래스에 넘겨주거나 버튼의 function을 넘겨주는 방법 등 방법은 다양하지만, 이번에는 function(VoidCallback)을 넘겨줘보자.

각 클래스에 화면이동 시 누를 버튼을 만들고, 버튼을 눌렀을 때 실행할 함수를 건네받자.

class A extends StatelessWidget {
  const A({Key? key, required this.onPress}) : super(key: key);

  final onPress;

  
  Widget build(BuildContext context) {
    return Container(
      color: Colors.purple,
      child: Center(
        child: OutlinedButton(
          child: Text("go to B"),
          onPressed: onPress,
        ),
      ),
    );
  }
}

class B extends StatelessWidget{...}
class C extends StatelessWidget{...}

화면을 이동하는 방법은 currentState를 불러오고, 우리가 아는 push등을 사용한다.
이 때, currentState는 NavigatorState?형식으로 null값이 들어올 수 도 있으니, null 확인 등을 통해 적절히 관리해주어야 한다.

A(onPress: () => _navigatorKey.currentState?.pushNamed(routeB));
B(
   onPressC: () => _navigatorKey.currentState?.pushNamed(routeC),
   onPressHome: () => _navigatorKey.currentState?.pop(),
);
C(...);

pushNamed와 같은 함수가 아닌 처음 보는 함수들이 있다.

  • canPop
    현재 화면에서 pop을 할 수 있는지 확인하는 메소드이다.
    현재 Route스택의 첫번째 화면이라면 pop 실행 시 검은 화면이 나오게 된다.
    AppBar의 뒤로가기 화면에서 등 현재가 pop을 할 수 있는 화면인지 아닌지 확인해야 할 경우 필요하다.
  • maybePop
    현재 화면에서 pop을 진행하는데, 첫 화면 등 pop을 하지 못하는 환경이라면 pop을 하지 않는다.
    mayPop을 사용하면 화면이동이 되지 않을 때의 적절한 대처를 하기 힘들기 때문에 canPop을 통해 확인하는 것이 바람직해 보인다.

출처 및 도움

profile
장안동 개발새발

0개의 댓글