iT邦幫忙

2022 iThome 鐵人賽

DAY 13
2
Mobile Development

Flutter 30: from start to store系列 第 13

Flutter介紹:在App內導頁 - navigation

  • 分享至 

  • xImage
  •  

今天一起來看看flutter的導頁,在此分為以下幾個基本面向說明:

  • 導到新創建頁面
  • 導到已命名頁面
  • 返回上一頁
  • 帶著參數進入/退出頁面
  • 導頁並移除所有的路徑紀錄

好的,那我們就開始吧!


route stack

  • 頁面的路徑是一個stack的結構,由Navigator組件管理。一般來說,隨著進入新的頁面,頁面所在的路徑會依序被加入(push)到route stack,退出時也會從「最後進入的頁面」開始退出(pop),從route stack刪除。

  • flutter 也提供其他導頁方式,比如說跳到特定頁面時重設路徑的pushReplacement, pushReplacementNamed等等方式


導到新創建頁面

  1. 建立一個頁面的widget class,定義新頁面要長什麼樣子。比如說FirstScreen:
    class FirstScreen extends StatelessWidget {
      const FirstScreen({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('First Screen'),
          ),
          body: Center(
            child: ElevatedButton(
              onPressed: () {
                // Navigate to the second screen when tapped.
              },
              child: const Text('Launch screen'),
            ),
          ),
        );
      }
    }
    
  2. 使用Navigator.push導頁到FirstScreen
    Navigator.push(
        context,
        MaterialPageRoute(builder:(context)=> const FirstScreen());
    )
    
    其中MateriaPageRoutewidget 負責頁面的轉換。在畫面上,新的頁面實例化之後會覆蓋原本的頁面;但此時原本的頁面還不會被刪除,其路徑也仍還留在路徑堆疊之中。

導到已命名頁面

  1. 建立一個頁面,例如FirstScreen
  2. 在設定 MaterialAppCupertinoApp的class之中,指定路徑名稱和對應的頁面
    MaterialApp(
      title: 'Named Routes Demo',
      initialRoute: '/',
      routes: {
        '/first': (context) => const FirstScreen(),
      },
    )
    
    如上例,Navigator即會在導頁到/first路徑時創建FirstScreen頁面
  3. 使用Navigator.pushNamed 導頁到FirstScreen
    onPressed: () {
      Navigator.pushNamed(context, '/first');
    }
    
  • 使用push和pushNamed在運作原理上沒有太大的差別,但是在大型專案時,每次導頁到某個頁面都使用push的方式會造成代碼大量重複的情況,使用pushNamed可以解決這個問題。此外,要限定特定頁面的使用權限時,使用named route也會比較好管理。

    However, if you need to navigate to the same screen in many parts of your app, this approach can result in code duplication. The solution is to define a named route, and use the named route for navigation.


返回上一頁

  • 在要返回的頁面設置Navigation.pop()
    onPressed: () {
      Navigator.pop(context);
    }
    
    即可退出當前頁面,並將當前路徑從route stack中刪除,退回上一個路徑。

帶著參數進入/退出頁面

  • 有時候,導向某個頁面時需要帶著一些參數,方便我們在新的頁面做一些事情。

    例如我要查看點餐單中的某個項目,點選該項目後會帶著項目的id到細節頁面,在細節頁面call api取得該餐點的詳細資料。

push with argument

  1. 在指定頁面定義要傳進來的參數,可以使用任何object,官網範例是新建一個ScreenArguments class:

    class ScreenArguments {
      final String title;
      final String message;
    
      ScreenArguments(this.title, this.message);
    }
    
  2. 在「要接受參數」的widget先挖好要填入參數的空缺,並透過ModalRoute.of(context).settings.arguments取得參數

    class ExtractArgumentsScreen extends StatelessWidget {
      const ExtractArgumentsScreen({super.key});
      // 預先寫好此頁面的路徑名稱並設為static,可直接從類別取用
      static const routeName = '/extractArguments';
    
      @override
      Widget build(BuildContext context) {
        // 從settings.argument中取出物件並告訴flutter其型別為ScreenArguments
        final args = ModalRoute.of(context)!.settings.arguments as ScreenArguments; 
    
        return Scaffold(
          appBar: AppBar(
            title: Text(args.title),
          ),
          body: Center(
            child: Text(args.message),
          ),
        );
      }
    }
    
  3. 設置好之後,將該頁面註冊為特定路徑

    MaterialApp(
      routes: { // 從ExtractArgumentsScreen.routeName中取得路徑名稱
        ExtractArgumentsScreen.routeName: (context) =>
            const ExtractArgumentsScreen(),
      },
    )
    
  4. 如此導頁的時候只需要在Navigator.pushNamed中再指定arguments即可送參數到對應的頁面

    Navigator.pushNamed(
          context,
          ExtractArgumentsScreen.routeName,
          arguments: ScreenArguments(
            'Extract Arguments Screen',
            'This message is extracted in the build method.',
          ), // 指定要傳輸的參數
    );
    

pop with argument

  • 「回到上一頁時」傳送「使用者在本頁送出的結果」,利用的是Navigator.push預設會回傳一個非同步的Future物件

  • 假設今天從頁面FirstScreen導頁到SecondScreen:

  1. 在FirstScreen啟動Navigator.push的地方,建立一個function 準備接收回傳的訊息:
    Future<void> getMessageFromSecondScreen(BuildContext context) async {
        final message = await Navigator.push(
            context,
            MaterialPageRoute(builder: (context) => const SecondScreen()),
        );
        if (!mounted) return; // 若是這個頁面在取得非同步訊息結果時,頁面已經不在了,直接取消function結束後續行為
    
        return message;
    }
    
  2. 在FirstScreen觸發getMessageFromSecondScreen並導頁到SecondScreen後,只要在pop中加入要回傳的訊息並在按鍵時觸發:
    ElevatedButton(
      onPressed: () {
        Navigator.pop(context, 'Hi There!');
      },
      child: const Text('Nope.'),
    )
    
    如此在回到上一頁時,FirstScreen就會在getMessageFromSecondScreen中收到message = 'Hi There!'

導頁並移除所有的路徑紀錄

  • 導向特定頁面後清空所有路徑,只留下當前路徑。

  • 應用情境如:登出app時,由於無法確認接下來的使用者,應該使用pushReplacementNamed導向「登入頁」並清空所有路徑,確保使用者沒辦法再回到Navigator紀錄的「上一個頁面」。

  • 寫法:導向路徑為'/login'的登入頁時可以寫成

    Navigator.pushReplacementNamed('/login');
    

專案實作

透過BottomNavigationBar切換頁面

  1. lib新增pages資料夾
  2. pages新增calendar_page.dart, main_page.dart, favorite_page.dart 三個頁面
  3. 將原本在MyHomePageState內部的主要頁面內容移到lib/screens/main_page.dart
    // main_page.dart
    import 'package:flutter/material.dart';
    
    class MainPage extends StatelessWidget {
      const MainPage({super.key});
    
      @override
      Widget build(BuildContext context) {
        double deviceWidth = MediaQuery.of(context).size.width;
    
        return SingleChildScrollView(
          child: Column(
            children: <Widget>[
              Stack(
                children: [
                  Container(
                    constraints: BoxConstraints(minHeight: deviceWidth),
                    width: deviceWidth,
                    child: Image.network(
                        'https://apod.nasa.gov/apod/image/2209/WaterlessEarth2_woodshole_2520.jpg',
                        loadingBuilder: (BuildContext context, Widget child,
                            ImageChunkEvent? loadingProgress) {
                      if (loadingProgress == null) {
                        return child;
                      }
                      return Center(
                        child: CircularProgressIndicator(
                          value: loadingProgress.expectedTotalBytes != null
                              ? loadingProgress.cumulativeBytesLoaded /
                                  loadingProgress.expectedTotalBytes!
                              : null,
                        ),
                      );
                    }),
                  ),
                  Positioned(
                    top: 10.0,
                    right: 10.0,
                    child: ElevatedButton(
                        onPressed: () {
                          print('add to favorite');
                        },
                        child: const Text('favorite')),
                  ),
                ],
              ),
              const Text(
                "How much of planet Earth is made of water? Very little, actually. Although oceans of water cover about 70 percent of Earth's surface, these oceans are shallow compared to the Earth's radius. The featured illustration shows what would happen if all of the water on or near the surface of the Earth were bunched up into a ball. The radius of this ball would be only about 700 kilometers, less than half the radius of the Earth's Moon, but slightly larger than Saturn's moon Rhea which, like many moons in our outer Solar System, is mostly water ice. The next smallest ball depicts all of Earth's liquid fresh water, while the tiniest ball shows the volume of all of Earth's fresh-water lakes and rivers. How any of this water came to be on the Earth and whether any significant amount is trapped far beneath Earth's surface remain topics of research.",
                style: TextStyle(fontSize: 12, color: Colors.blueGrey),
              ),
            ],
          ),
        );
      }
    }
    
    
  4. main.dart中加入三個頁面,並使用BottomNavigationBaronTap切換頁面
    class MyHomePage extends StatefulWidget {
      const MyHomePage({super.key, required this.title});
    
      final String title;
    
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      int _selectedIndex = 0;
    
      final List<Widget> _pageList = [
        const CalendarPage(),
        const MainPage(),
        const FavoritePage()
      ]; // 加入三個頁面
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: _pageList[_selectedIndex], // 會因為_selectedIndex的改變而切換頁面
          bottomNavigationBar: BottomNavigationBar(
            items: const <BottomNavigationBarItem>[
              BottomNavigationBarItem(
                icon: Icon(Icons.calendar_month),
                label: '月曆',
              ),
              BottomNavigationBarItem(
                icon: Icon(Icons.home),
                label: '主頁',
              ),
              BottomNavigationBarItem(
                icon: Icon(Icons.settings),
                label: '設定',
              ),
            ],
            currentIndex: _selectedIndex,
            onTap: (int index) {
              // 透過點選時切換選取的index來選取頁面
              setState(() {
                _selectedIndex = index;
              });
            },
            selectedItemColor: Colors.blue,
            selectedFontSize: 14,
            unselectedFontSize: 14,
            unselectedItemColor: Colors.grey,
            type: BottomNavigationBarType.fixed,
          ),
        );
      }
    }
    
  5. 在MyHomePage移除title,改用各頁面的title

透過 Navigator.push 導頁

  1. 在FavoritePage新加入一個按鈕,並導頁到MainPage
    class FavoritePage extends StatefulWidget {
      const FavoritePage({super.key});
    
      @override
      State<FavoritePage> createState() => _FavoritePageState();
    }
    
    class _FavoritePageState extends State<FavoritePage> {
      @override
      Widget build(BuildContext context) {
        return Center(
          child: TextButton(
              onPressed: () {
                Navigator.push(context, MaterialPageRoute(builder: (context) {
                  return Scaffold(
                      appBar: AppBar(
                        title: const Text('Lunation Matrix'),
                      ),
                      body: const MainPage());
                }));
              },
              child: const Text('Navigate to MyHome Page')),
        );
      }
    }
    
    就這樣,我們完成了第一個導頁體驗!試著按左上角的箭頭回上一頁,並比較用BottomNavigator切換頁面和Navigator.push的行為有什麼不同。
  • 本次改動的相關程式碼放在我的github,見Day13相關commit

Recap

今天大致瞭解了各種導頁方式:

  • 導向新創建頁面:Navigator.push
  • 導到已命名頁面: Navigator.pushNamed
  • 回到上一頁: Navigator.pop
  • 帶參數導頁: Navigator.push/pushNamed/pop加入argument參數
  • 導頁並移除所有的路徑紀錄: Navigator.pushReplacement/pushReplacementName

明天一起來看看在flutter如何進行Networking,從網路取得資料吧~


上一篇
Flutter介紹:頁面的排版 - layout
下一篇
Flutter介紹:取得外部資料 - networking
系列文
Flutter 30: from start to store30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Christine
iT邦新手 5 級 ‧ 2022-12-26 13:48:27

『在畫面上,新的頁面實例化之後c會覆蓋原本的頁面』
這句中間是不是多打一個c?
另外謝謝大大的分享:)

Ray Chang iT邦新手 5 級 ‧ 2022-12-27 13:32:07 檢舉

感謝大大指正!
已更正錯誤
發文時校閱不力,敬請見諒 @@

我要留言

立即登入留言