플러터 Navigator, go_router, deep-linking

LeeWonjin·2024년 5월 5일
0

플러터

목록 보기
12/15

https://docs.flutter.dev/ui/navigation

MaterialApp을 쓴다고 가정한다.

import 'package:flutter/material.dart';

개요

Router, Navigator 둘 다 페이지 이동할 때 사용할 수 있다.

  • Router : 선언적 API
  • Navigator : 명령적 API

복잡한 요구사항이 들어가면 Router를 써야 된다.
둘은 배타적이지 않으며 함께 사용하도록 설계되었다.

  • page-backed route : Router 등 선언적 라우팅 패키지로 만들어진 라우트의 경우 page-backed route라고 한다. (Navigator.pages로부터 만들어진 라우트. 언제나 deep-linkable)
  • pageless route : page-backed이 아닌 라우트. Navigator.push, pop으로 만들어진 라우트.

그냥 위젯 인스턴스를 통째로 넘겨줄 수 있다.

// Profile위젯으로 화면을 바꾸는 코드조각
Navigator.of(context).push(MaterialPageRoute(
  builder: (context) => const Profile(),
));

// 만약 뒤로 가고 싶다면 push 말고 pop
Navigator.of(context).pop();

https://docs.flutter.dev/cookbook/navigation/named-routes

아니면 위젯에 문자열 이름 붙인 다음, 문자열을 인스턴스 대신 넘겨줄 수 있다. NamedRoute라고 하는데, 별로 권장하지 않는단다. 이유는

  • 딥링크 쓸 때 항상 같은동작만 해서 커스텀 불가
  • browse forward 미지원
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Nav',
      initialRoute: '/',
      routes: {
        '/': (context) => Home(),
        '/profile': (context) => Profile(),
      },
    );
  }
}

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Flutter Demo'),
      ),
      body: Center(
        child: GestureDetector(
          onTap: () {
            Navigator.of(context).pushNamed('/profile');
          },
          child: Text('go Profile'),
        ),
      ),
    );
  }
}

go_router

Navigator로 하면 딥링크 처리에 골머리를 앓을 수 있다고 한다.
그래서 Router를 쓰고, 날로먹기 위해 공식 라이브러리 go_router를 쓴다. (아니면 Get을 쓴다고 함)

https://pub.dev/packages/go_router
https://jh-industry.tistory.com/123

라우팅 관련 위젯

TabBar, TabBarView

그렇다고 한다.
TabBar.tabs[n]을 누르면 TabBarView.children[n]을 보여준다.

DefaultTabController(
  length: 3,
  child: Scaffold(
    appBar: AppBar(
      bottom: const TabBar(
        tabs: [
          Tab(text: 'first'),
          Tab(icon: Icon(Icons.directions_transit)),
          Tab(text: 'thrid', icon: Icon(Icons.table_bar)),
        ],
      ),
      title: const Text('Tabs Demo'),
    ),
    body: TabBarView(
      children: [
        Text('hi 1st'),
        Icon(Icons.directions_boat),
        Container(color: Colors.amber),
      ],
    ),
  ),
),

Scaffold Drawer

그냥 그렇다.

class _MyHomePageState extends State<MyHomePage> {
  int _selectedIndex = 0;

  // _selectedIndex에 따라 scaffold body에 보여줄 위젯
  static const List<Widget> _widgetOptions = <Widget>[
    Text('selected Index 0'),
    Text('selected Index 1'),
    Text('selected Index 2'),
  ];

  void _onItemTapped(int index, BuildContext context) {
    setState(() {
      _selectedIndex = index;
    });
    Navigator.pop(context); // drawer닫기
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Center(
        child: _widgetOptions[_selectedIndex],
      ),
      drawer: Drawer(
        child: ListView(
          children: [
            const DrawerHeader(
              decoration: BoxDecoration(
                color: Colors.blue,
              ),
              child: Text('Drawer Header'),
            ),
            // 선택가능한 drawer내 버튼 3개
            ListTile(
              title: const Text('index 0'),
              selected: _selectedIndex == 0,
              onTap: () {
                _onItemTapped(0, context);
              },
            ),
            ListTile(
              title: const Text('index 1'),
              selected: _selectedIndex == 1,
              onTap: () {
                _onItemTapped(1, context);
              },
            ),
            ListTile(
              title: const Text('index 2'),
              selected: _selectedIndex == 2,
              onTap: () {
                _onItemTapped(2, context);
              },
            ),
          ],
        ),
      ),
    );
  }
}

Navigator

생성자에 주는 것 말고, 네비게이팅하는 행위 자체에 데이터(arguments)를 넘겨줄 수도 있다.

출발지에서 대상지로 데이터를 보내려면 아래처럼 하면 된다.

// 주는쪽에서
Navigator.of(context).push(
  MaterialPageRoute(
    builder: (context) => const DetailScreen(),
    settings: RouteSettings(
      arguments: 42, // 넘길 데이터
    ),
  ),
);

// 받는 쪽에서
final n = ModalRoute.of(context)!.settings.arguments as int;
print(n);

아래는 동작하는 예제

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      title: 'Passing Data',
      home: ListScreen(),
    ),
  );
}

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

  final List<int> nums = List.generate(
    5,
    (i) => i,
  );

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Todos'),
      ),
      body: ListView.builder(
        itemCount: nums.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('${nums[index]}'),
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => const DetailScreen(),
                  settings: RouteSettings(
                    arguments: nums[index],
                  ),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

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

  
  Widget build(BuildContext context) {
    final num = ModalRoute.of(context)!.settings.arguments as int;

    // Use the Todo to create the UI.
    return Scaffold(
      appBar: AppBar(
        title: Text('$num'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Text('You tapped on number $num'),
      ),
    );
  }
}

Navigator.push로 다른 화면을 보여 준 뒤, 액션에 의해 pop될 것이 예상 되는 경우
--> pop에 파라미터를 실어 보내고, push로 리턴받을 수 있다.

// push하는 쪽 (데이터 돌려 받을 곳)
final result = Navigator.of(context).push(
  MaterialPageRoute(builder: (context) => Somewhere()),
);
if (!context.mounted) return;
print(result); // 'I_am_result'

// pop 하는 쪽 (데이터 돌려 보낼 곳)
Navigator.pop(context, 'I_am_result');

go_router

https://pub.dev/documentation/go_router/latest/topics/Configuration-topic.html

GoRoute와 GoRouter가 다르다는 (당연한)사실에 주의

go 메소드에 대해 먼저 일러두기

context.go를 쓰자.

_router.go(PATH)context.go(PATH)는 같은 동작을 한다.
다만 context.go()는 빌드 컨텍스트에서 알아서 라우터를 찾는다.
특별한 제어가 필요한게 아니면 컨텍스트를 통해서 이동하는게 바람직하다고 한다.

GoRouter 생성자

routerConfig를 리턴한다.
MaterialApp.router생성자의 routerConfig값으로 주면 된다.

final _router = GoRouter(
  routes : [
    GoRoute(
      path : '/',
      builder : (context, state) => HomeScreen(),
    ),
    GoRoute(
      path : '/profile',
      builder : (context, state) => ProfileScreen(),
    ),
  ]
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  
  Widget build(BuildContext context) {
    return MaterialApp(
      routerConfig: _router,
    );
  }
}

참고로

  • 초기 위치를 지정할 수도 있다. initialLocation
  • 로그 출력 기능도 활성화 할 수 있다. debugLogDiagnostics
  • 에러 핸들링도 할 수 있다. errorBuilder
final _router = GoRouter(
  initialLocation : '/어딘가',
  debugLogDiagnostics: true,
  errorBuilder : (context, state) => ErrorScreen(state.error),
  
  routes : [ ... ],
);

GoRoute 생성자

path와 builder 두 개가 필수 파라미터
builder에는 builder 또는 pageBuilder가 들어갈 수 있음

Dynamic RoutingConfig

GoRouter 생성자가 호출된 뒤에도 라우트의 목록과 내용을 바꿀 수 있다.

  1. RoutingConfig를 만든다. (Route r Config가 아니다)
  2. ValueNotifier<RoutingConfig>로 1을 감싼다
  3. GoRouter 인스턴스는 GoRouter.routingConfig생성자로 만든다. 파라미터 routingConfig의 값으로 2를 준다
  4. 훗날 라우트들을 바꾸고 싶어질 때면, 2번_객체.value에 새로운 RoutingConfig를 만들어 대입해주면 된다.

코드로 정리하면 아래와 같다.

// 1~2
final ValueNotifier<RoutingConfig> rcf = ValueNotifier<RoutingConfig>(
  RoutingConfig(
    routes : <RouteBase>[
      GoRoute(path: 경로, builder: 빌더),
      GoRoute(path: 경로, builder: 빌더),
    ]
  )
)

// 3
final GoRouter _router = GoRouter.routingConfig(
  routingConfig: rcf,
);

// 4
rcf.value = RoutingConfig(
  routes: <RouteBase>[ // 맘대로 바꾸면 됨
    GoRoute(path: 새_경로, builder: 원래_빌더), 
    GoRoute(path: 원래_경로, builder: 새_빌더),
  ]
)

4번 과정(새 설정 대입)까지 끝나면 GoRouter는 곧바로!
변경된 RoutingConfig로 현재 라우트를 재분석한다.

path parameters

path template에 경로 파라미터를 넘길 수 있음(e.g., userId).
이름은 고유해야 함.

참고 : state.pathParameters[something]String?타입이다.

GoRoute(
  path : '/profile/:userId',
  builder : (context, state) => ProfileScreen(
    userId : state.pathParameters['userId'],
  ),
)

// 이동하는 메소드는
_router.go('/profile/123'); // 123은 userId

path query string (query parameters)

query string도 받을 수 있음. (path template에 미리 정의되지는 않았지만)

GoRoute(
  path : '/profile',
  builder : (context, state) => ProfileScreen(
    userName : state.uri.queryParameters['userName'],
  ),
)

// 쿼리스트링을 넣어서 이동하는 메소드는
_router.go('/profile?userName=wjlee'); // wjlee는 userName

child route

아래 두 개 path에 대해 각각의 GoRoute가 설정되었다고 하자.

  • /profile
  • /profile/detail

아래 코드는 두 GoRoute가 같은 level에 각각 있다.
이 상태에서 _router.go('/profile/detail')을 하면,

  • Navigator는 현재 스택을 /profile/detail로 대체한다.
  • 즉, pop해서 갈 데가 없다.
final _router = GoRouter(routes: [
  GoRoute(
    path: '/',
    builder: (context, state) => HomeScreen(),
  ),
  GoRoute(
    path: '/profile',
    builder: (context, state) => ProfileScreen(
      userName: state.uri.queryParameters['userName'],
    ),
  ),
  GoRoute(
    path: '/profile/detail',
    builder: (context, state) => ProfileDetailScreen(),
  ),
]);

아래 코드는 /profile/detailprofile라우트의 자식으로 넣어버렸다.
이 상태에서 _router.go('profile/detail')을 한 경우,

  • 마치 /profile에서 /detailNavigator.push()하는 것과 같다. (Navigator.of(context).widget.pages를 찍어보면 알 수 있다.)
  • /profile, /detail 두 개가 스택에 들어가는 것.
  • 다시 말하면, /profile/detail에서 pop하면 /profile로 간다.

조금 다르게 _router.push('/profile/detail')을 하면 스택에 찍히는게 /profile/detail하나밖에 없는 재미있는 상황을 볼 수 있다. 마치 child routes가 아닌 것 처럼 행동한다.

이렇듯 어떤 GoRoute의 하위 라우트로 GoRoute를 넣은걸 Child routes라고 한다.

final _router = GoRouter(routes: [
  GoRoute(
    path: '/',
    builder: (context, state) => HomeScreen(),
  ),
  GoRoute(
    path: '/profile',
    builder: (context, state) => ProfileScreen(
      userName: state.uri.queryParameters['userName'],
    ),
    routes: [
      GoRoute(
        path: 'detail', // '/profile/detail'이 여기로 왔다.
        builder: (context, state) => ProfileDetailScreen(),
      ),
    ],
  ),
]);

Nested Navigation

https://pub.dev/documentation/go_router/latest/go_router/ShellRoute-class.html

지금껏 살펴본 것은 path에 따라 스크린을 통째로 갈아치워버렸다.
그러지 않고 화면의 일부만 바꿀 수도 있다.
(마치 머티리얼 bottom navigation bar가 항상 그 자리에 있는 것 처럼)

그걸 하고싶다면 ShellRoute를 쓴다.

ShellRoute의 builder함수 인수에 child가 있다.
이걸 빌더가 리턴하는 위젯에 넘겨서 잘 써먹으면 된다.
ShellRoute.routes의 path가 바뀌면 child만 다시 빌드한다.

final _router = GoRouter(routes: [
  GoRoute(
    path: '/',
    builder: (context, state) => HomeScreen(),
  ),
  ShellRoute(
      builder: (context, state, child) => ProfileScreen(
            child: child,
          ),
      routes: [
        GoRoute(
          path: '/profile/summary',
          builder: (context, state) => Text('Profile Summary'),
        ),
        GoRoute(
          path: '/profile/detail',
          builder: (context, state) => Text('Profile Detail'),
        ),
      ]),
]);

네비게이팅

go 메소드

가장 간단한 사용법은 .go()메소드의 파라미터로 path를 넣어주는 것.
path parameter도(:id), query parameter도 path string에 다 때려박을 수 있다.
e.g., /somewhere/123?name=wjlee

GoRouter.of(context).go(경로);
context.go(경로);
_router.go(경로); // 고라우터 인스턴스가 _router에 저장됐다면 이것도 된다.

스트링 하나에 모든걸 때려박는게 정 싫다면 Uri().toString()을 넣어도 된다.

context.go(
  Uri(
    path : '/somewhere/123',
    queryParameters : {
      'name' : 'wjlee',
    },
  ).toString()
);

추가 데이터를 extra에 실어 보낼 수도 있다.

// 보내기
context.go(경로, extra: 'I_AM_EXTRA_DATA');

// 받기
final String str = GoRouterState.of(context).extra! as String;

Router의 명령적 네비게이트. push() vs go()

context.push(), context.pop()이 가능하긴 한데 별로 권장되지 않는다고 한다.
(이름에서 예상되는대로, Navigator 스택에 쌓고 빼는 메소드다.)

브라우저 히스토리에 이슈가 좀 있다는게 권장되지 않는 이유.
해보니까 push, pop은 Router API가 의도한바를 명확하게 반영하지 못한다.

child routes가 있는 경우 자식 라우트에서 pop했을 때 부모 라우트로 돌아갈 것이 예상된다.
그러나 push를 하면 path전체를 하나로 인식해서 스택에 쌓기 때문에 push()가 호출되었던 그 라우트로 돌아간다.

  • /a의 자식 라우트로 /a/b가 있다면
  • go('/a/b') 를 하면 현재 스택 요소를 /a로 대체한 뒤, /b가 쌓인다. (pop하면 a로 간다)
  • 근데 push('/a/b')를 하면 스택에 /a/b 하나가 쌓인다. (pop하면 원래있던 그곳으로 돌아감)

쓰면 머리 아프니까, 꼭 써야겠으면 미리미리 예상 동작을 잘 생각해서 쓰도록 하자.

참고로 push()의 좋은점은, push호출하고 기다렸다가 pop할때 값을 받을 수 있다.

// push해주는 쪽
final int? result = await context.push<int>(경로);
print(result); // 123

// pop해주는 쪽
context.pop(123);

포스트의 초반부에 밝혔듯 go_router와 함께 Navigator를 사용할 수 있다.
주의해야 하는 점은

  • Navigator push, pop으로 표시된 페이지는 deep-linkable하지 않다.
  • GoRoute아래에서 Navigator를 쓰고 있을 때, GoRoute가 바뀐다면? 사라진다면? (e.g., context.go(다른경로)
    • 새로운 GoRoute로 대체됨.
    • Navigator는 영문도 모르고 go_router동작을 따라간다

동작은 GoRoute냐 ShellRoute냐에 따라 좀 다르다.

  • If pushing a new screen without any shell route onto the current screen with shell route, the new screen is placed entirely on top of the current screen.
  • If pushing a new screen with the same shell route as the current screen, the new screen is placed inside of the shell.
  • If pushing a new screen with the different shell route as the current screen, the new screen along with the shell is placed entirely on top of the current screen.

https://pub.dev/documentation/url_launcher/latest/link/Link-class.html

웹상의 실제 링크를 렌더링할 수 있다.
네이티브에서 링크를 열어 보여주려면 WebViews를 쓰면 된다.

Redirection

현재 앱 상태에 기반해 incoming location을 변경한다.
e.g., 유저 인증정보 없는데 마이페이지에 접근했다? 넌 로그인 페이지 행이다.

GoRouterRedirect타입 콜백을 GoRouter나 GoRoute의 redirect값에 넣어주면 된다.

  • GoRouter에 넣었다면 : Top-level 리디렉션 이라고 한다. 네비게이션 이벤트 발생 전에 콜백 호출
  • GoRoute에 넣었다면 : Route-level 리디렉션 이라고 한다. 네비게이션 이벤트가 발생해서 라우트를 표시하려고 할 때 콜백 호출
// Top-level redirection
final _router = GoRouter(
    redirect: (BuildContext context, GoRouterState state) {
      return '/profile/summary';
    },
    routes : [ ... ],
    ...
);

// Route-level redirection
final _router = GoRouter(routes: [
  GoRoute(
    path: '/somewhere',
    builder: (context, state) => HomeScreen(),
    redirect: (BuildContext context, GoRouterState state) {
      if (대충 무슨 조건) {
        return '/profile/summary';
      }
    },
  ),
);

최대 리디렉션 횟수 제한

GoRouter - redirectLimit로 최대 리디렉션 횟수를 지정해야 한다.
기본값은 5.

만약 이 숫자 넘어서 리디렉션 하려고 하면 에러화면이 표시된다.

go_router의 딥링크 처리

플랫폼에서 딥링크를 수신하면 알아서 path처리해서 알맞은 화면 보여준다.
따로 go_router에서 처리할 거리는 없다.

go_router 애니메이션

GoRoute - pageBuilder를 사용하면 트랜지션 애니메이션을 줄 수 있다.
기존 builder함수의 리턴값을 pageBuilder - CustomTransitionPage - child로 주면 된다.

그냥 원래 알던 그 플러터 애니메이션 쓰는 것 맞다.

// 기존 builder
GoRoute(
    path: '/profile/summary',
    builder: (context, state) => ProfileScreen(child: Text('Profile Summary')),
  ),

// pageBuilder로 변경
GoRoute(
    path: '/profile/summary',
    pageBuilder: (context, state) {
      return CustomTransitionPage(
        child: ProfileScreen(child: Text('Profile Summary')),
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return SlideTransition(
            position: Tween<Offset>(
              begin: const Offset(1.0, 0.0),
              end: Offset.zero,
            ).animate(animation),
            child: child,
          );
        },
      );
    },
  ),

Type-safe routes

https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html

URL 스트링 때려넣어서 네비게이팅 할 수도 있는데, 그건 너무 무식하니까 아래 세 개 패키지를 사용한다.
또 코드 자동생성이다.

flutter pub add dev:go_router_builder
flutter pub add dev:build_runner
flutter pub add dev:build_verify

GoRouteData를 상속한 Route를 만들고, build메소드에서 실제로 보여줄 위젯을 리턴하도록 한다.
이후 아래 커멘드로 자동생성 코드를 빌드한다.

flutter pub global activate build_runner
flutter pub run build_runner build

go_router Named Routes

도 있다.

// GoRoute 정의
GoRoute(
    name: 'summary',
    path: '/profile/summary',
    builder: (context, state) => ProfileScreen(child: Text('Profile Summary')),
  ),
  
// ---------------------------------

// 네비게이팅 방법 1
context.goNamed('summary');

// 네비게이팅 방법 2
final String location = context.namedLocation('summary');
context.go(location);

// 리디렉션도 가능
redirect: (_, __) {
  if (로그인 안됐다) {
    return context.namedLocation('mypage');
  } else { 
    return null; // 로그인 된 경우 리디렉션 안함.
  }
}

딥링크 일반

Android, iOS

https://docs.flutter.dev/ui/navigation/deep-linking

profile
노는게 제일 좋습니다.

0개의 댓글

관련 채용 정보