Programming-[CrossPlatform]/Flutter

[제철음식 알리미] 1. 화면구성: Stack, Navigator, PageView

컴퓨터 탐험가 찰리 2024. 10. 14. 05:56
728x90
반응형

 

제철음식 알리미 앱을 제작하며 학습했던 내용들을 정리한다.

 


 

1. Stack

 

플러터의 화면은 기본적으로 Stack으로 구성되어있다. Stack에 페이지를 한 겹씩 쌓아나가는 구조이다. 화면 전환을 할 때 Stack에 페이지들을 쌓을 수도 있지만 이전 stack을 없애고 새로운 stack만 존재하도록 할 수 있다. 그리고 뒤로가기를 누르면 하나의 Stack을 제거하는 구조이다.

 

1.1 페이지 스택 쌓기 원리 이해

제철음식 알리미에서는 홈, 검색 페이지가 있다. 페이지 전환 시 스택과 관련된 로직은 아래와 같다.

 

  1. 앱에 접속하면 홈 페이지 스택이 쌓인다.
  2. 검색 버튼을 누르면 검색 페이지 스택이 추가로 쌓인다.
  3. 이 상태에서 뒤로 가기를 누르면, 검색 페이지 스택이 제거되면서 이전 홈 페이지 스택으로 돌아간다.
  4. 홈 페이지 스택만 있는 상태에서 뒤로 가기를 누르면 앱이 종료된다.

 

 

1.2 페이지 스택을 미리 생각하자

만약 검색 페이지로 이동하는데 새로운 스택이 쌓이는게 아니라 기존 스택을 없애고 1개 스택만 남긴다면, 사용자가 검색 페이지에서 뒤로가기(Appbar에 있는 뒤로가기가 아닌 시스템 뒤로가기)를 누르면 바로 앱이 종료되어 버릴 것이다. 이 외에 검색 결과를 눌렀을 때 각 음식의 상세 페이지로 이동하는데, 이렇게 이동할 때도 스택을 어떻게 관리할 것인지를 고민해야한다. 이동 후 사용자가 뒤로가기를 눌렀을 때 어떤 동작을 하기를 원하는지를 생각해야한다.

 

 


 

 

2. Navigator

 

2.1 push

Navigator를 이용해서 페이지를 이동할 수 있다. 페이지 이동 관련 제철음식 알리미 프로젝트에서 사용한 메서드들은 다음과 같다.

  • Navigator.push
  • Navigator.pushNamed
  • Navigator.pushReplacementNamed

 

2.1.1 push

push라는 이름에서 알 수 있듯이 Navigator도 1장 스택과 관련되어 있다. push를 사용하여 어떤 페이지를 밀어넣는다는 의미는, Stack에 해당 페이지 스택을 쌓는다는 의미가 된다. 예를 들어 아래와 같이 TextButton안에 onPressed로 버튼이 눌러졌을 때 Navigator.push를 통해 스택을 쌓을 수 있다. 즉 원래 스택에 AdminFoodDetailFind()라는 페이지를 추가하는 것이다.

TextButton(
  onPressed: () {Navigator.push(context, MaterialPageRoute(builder: (context) => AdminFoodDetailFind()));},
  child: const Text("Food Detail 조회", style: TextStyle(fontSize: 24),)
),

 

 

2.1.2 pushNamed

pushNamed는 라우터에 등록된 이름을 기반으로 페이지 스택을 쌓으며 이동하는 메서드이다.

 

main.dart에 아래처럼 routing을 정의해놓았다면,

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: HomeView(),
      ... 중략
      routes: {
        '/home': (context) => HomeView(),
        '/search': (context) => SearchFoodView(),
        '/admin/food' : (context) => AdminHome(),
        '/improve_suggestion': (context) => ImproveSuggestionView()
      },
    );
  }
}

 

아래처럼 /home으로 pushNamed 메서드를 통해 home 페이지로 이동할 수 있는 것이다.

Navigator.pushNamed(context, '/home');

 

 

2.1.3 pushNamedReplacement

이 메서드는 현재 스택을 제거하고 새로운 화면 스택을 추가한다. 이런 메서드들을 통해 화면 스택을 잘 관리해야 뒤로가기 등에서 버그가 안 발생할 것이다.

Navigator.pushReplacementNamed(context, '/login');

 

 

 

2.2 pop

pop은 간단하다. 스택에서 페이지를 제거하는 것이다. 또한 canPop() 메서드를 통해서 스택에 페이지가 남아있는지 확인할 수 있는 메서드도 있다.

 

void _handleBackButton() {
  if (Navigator.canPop(context)) {
    Navigator.pop(context);
  } else {
    Navigator.pushReplacementNamed(context, '/home', arguments: {'selectedIndex': 0});
  }
}

 

 

 

 

 

2.3 context

Navigator로 전달되는 context 인자에 대해서도 알 필요가 있다. 이 context는 BuildContext 클래스인데, 이를 통해 상위 위젯의 데이터에 접근하거나 위젯간의 계층 관계(위치)를 파악할 수 있기 때문이다. 가장 대표적인 예로 부모 위젯으로부터 데이터를 받아와서 자식 위젯에서 표현할 때 이 context가 사용된다.

 

 

2.3.1 context의 전달과 MaterialApp, BuildContext

단순히 상위에 context가 있어야 하는게 아니라, 항상 최상위에 context가 있거나 MaterialApp이 있어야 스택에 쌓인 화면이 자신의 위치를 정확히 파악할 수 있다.

 

보통 main에 생성하는 MaterialApp(또는 CupertinoApp)에 Navigator가 위치하며 여기서 각 하위 위젯들의 BuildContext를 관리한다.

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
    // 이하 중략...
    routes: {
        '/home': (context) => HomeView(),
        '/search': (context) => SearchFoodView(),
        '/admin/food' : (context) => AdminHome(),
        '/improve_suggestion': (context) => ImproveSuggestionView()
      },
      
    // 이하 생략...

 

즉 최상위에 Navigator가 생성되며 그 하위 컴포넌트들로 context가 전달되면서 앱이 시작되는 것이다. 그리고 하위 페이지들에서는 최상위의 Navigator를 통해 앱의 계층적인 위치를 파악한다.

 

위에서 언급한 Navigator용 메서드(push 등)들은 위젯 트리에서 특정 위치를 나타내는 BuildContext를 통해서 가장 가까운 Navigator의 위치를 찾는다. 다시 말해 main에서 하나의 Navigator만 사용해서 앱의 계층 구조를 트리로 그렸다면 context는 1개만 갖고 있고, 이를 기반으로 페이지 탐색을 진행할 것이다. 그러나 MaterialApp이 여러 개거나 Navigator가 여러 개인 경우 context를 알고 있어야 어떤 Navigator를 사용하여 페이지를 탐색할 것인지를 알게 되는 것이다.

 

 

만약 아래 코드처럼 MyWidget 위에 아무런 MaterialApp 또는 CupertinoApp이 없어서 Navigator가 없는 상태라면, MyWidget의 BuildContext를 Navigator에 전달하는 경우 에러가 난다. Navigator 자체가 없으니 페이지들이 어떤 트리 구조로 이루어졌는지, 현재 어떤 위치인지를 알 수가 없기 때문이다.

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 이 위치의 context는 MyWidget의 context입니다.
    // 하지만 MyWidget 위에 Navigator가 없다면 오류가 발생할 수 있습니다.
    return ElevatedButton(
      onPressed: () {
        Navigator.pushNamed(context, '/detail');
      },
      child: Text('Go to Detail'),
    );
  }
}

 

따라서 이런 경우 MaterialApp또는 CupertinoApp을 상위에 두어 Navigator를 만들거나, 아래처럼 최상위 context를 만들어서 위치를 인식하게할 수도 있다.

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Builder(
      builder: (BuildContext newContext) {
        return ElevatedButton(
          onPressed: () {
            Navigator.pushNamed(newContext, '/detail');
          },
          child: Text('Go to Detail'),
        );
      },
    );
  }
}

 

 

 


 

3. PageView

 

이 부분은 그냥 경험적인 내용이다. 아래와 같이 페이지 전체를 좌, 우로 스와이프할 때 부드럽게 넘어가도록 애니메이션을 적용해야했다. 그런데 음식들의 목록이라고 생각해서 ListView로 작성해놓았었다.

 

그랬더니 애니메이션을 적용하기가 너무 어려웠다. ListView는 좌우로 넘기는 듯한 PageView를 구성하는 것이 아니라 단순히 어떤 컴포넌트들을 나열하는 방식으로 사용된다고 한다. 또한 PageView는 페이지들을 보는 것이 목적이라 위 화면처럼 자연스레 애니메이션이 적용된다.

 

그래서 PageView를 적용하고, PageView의 itemBuilder에 ListView를 두었다. 그리고 controller를 두어 좌우 애니메이션을 구현했다. 대략적인 내용은 아래와 같다.

 

return PageView.builder(
  controller: _pageController,
  onPageChanged: (page) {
    setState(() {
      currentMonth = getMonth(page);
      widget.selectedMonth.value = currentMonth;
    });
  },
  itemBuilder: (context, index) {
    return ListView(

 

728x90
반응형