iT邦幫忙

2024 iThome 鐵人賽

DAY 17
0

應用程式畫面的轉場看起來絲滑可以增加使用者體驗,反過來說轉場不流暢也會影響體驗。

如同我們之前見過的, MaterialPageRouteCupertinoPageRoute 可以在 Navigator 中加入路由。在我們的範例中你可能已經注意到在新舊路由交換的過程中會加入轉場效果。一般會使用平台預設的效果,當然我們也可以自訂。

例如在 Android 中切換頁面時由下向上淡入,離開頁面時則採用相反的效果。在 iOS 系統,頁面從右邊滑入,離開頁面則相反。除了預設的效果,Flutter 也支援通過加入頁面之間的轉場自訂效果。為了完成這個功能,我們需要深入解析路由。

PageRouteBuilder

PageRouteBuilder 是一個輔助類別可以用來自訂建立 Route,取代使用內建的路由如 MaterialPageRouteCupertinoPageRoute

PageRouteBuilder 包含了許多 callback 和屬性可以協助定義 PageRoute。下面是一些關鍵的參數:

  • transitionBuilder 這是我們指定轉場動畫的地方,具體來說這也是一個 Builder 函式須回傳組件。
  • pageBuilder 這是指定轉場後新畫面的地方。也是一個 Builder 函式,回傳組件。
  • transitionDuration 轉場的持續時間
  • barrierColorbarrierDismissible 設定局部覆蓋路由。舉例來說點擊按鈕後要彈出對話框,但對話框後面依然是原本畫面且可以看到,這種情況下 barrierColor 設定對話框後面的背景色,barrierDismissible 設定點擊外部區域是否可以關閉視窗。

簡單的說 transitionBuilder 設定轉場動畫,持續時間由 transitionDuration 設定。接著顯示新畫面 pageBuilder ,如果是 Modal 效果則搭配 barrierColorbarrierDismissible 等。

使用 PageRouteBuilder 搭配這些參數可以建立自訂的轉場效果。

實作自訂轉場效果

在開始之前讓我們先回到 Navigator 1.0 的範例:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Navigator 1.0 範例',
      home: MyHomePage(title: '首頁'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    final ScaffoldMessengerState scaffoldMessenger =
        ScaffoldMessenger.of(context);

    return Scaffold(
      appBar: AppBar(title: Text(title)),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () async {
                bool? outcome = await Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) =>
                        const DestinationDetails(destination: '項目 1'),
                  ),
                );
                if (outcome == true) {
                  scaffoldMessenger
                      .showSnackBar(const SnackBar(content: Text("項目 1 加入最愛")));
                }
              },
              child: const Text('項目 1'),
            ),
            ElevatedButton(
              onPressed: () async {
                bool? outcome = await Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) =>
                        const DestinationDetails(destination: '項目 2'),
                  ),
                );
                if (outcome == true) {
                  scaffoldMessenger
                      .showSnackBar(const SnackBar(content: Text("項目 2 加入最愛")));
                }
              },
              child: const Text('項目 2'),
            ),
          ],
        ),
      ),
    );
  }
}

class DestinationDetails extends StatelessWidget {
  const DestinationDetails({super.key, required this.destination});

  final String destination;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(destination)),
      body: Center(
        child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
          Text(destination),
          ElevatedButton(
            child: const Text('加入最愛'),
            onPressed: () {
              Navigator.of(context).pop(true);
            },
          ),
          ElevatedButton(
            child: const Text("返回"),
            onPressed: () {
              Navigator.of(context).pop(false);
            },
          ),
        ]),
      ),
    );
  }
}

首先修改 Navigator 的子組件 ElevatedButton ,現在我們使用 PageRouteBuilder 取代 MaterialPageRoute

原本的程式如下:

ElevatedButton(
  onPressed: () async {
    bool? outcome = await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) =>
            const DestinationDetails(destination: '項目 1'),
      ),
    );
    if (outcome == true) {
      scaffoldMessenger
          .showSnackBar(const SnackBar(content: Text("項目 1 加入最愛")));
    }
  },
  child: const Text('項目 1'),
),

onPressed 參數我們代入匿名函式並使用 MaterialPageRoute 。接著在 MaterialPageRoute 中代入下一個畫面的組件,上面範例是 DestinationDetails 組件。

現在讓我們換成使用 PageRouteBuilder

ElevatedButton(
	child: const Text('項目 1'),
  onPressed: () async {
    Navigator.of(context).push(
    	PageRouteBuilder(
      	pageBuilder: (context, animation, secondaryAnimation) {
          return DestinationDetails(title: "項目 1");
        },
        transitionBuilder: (context, animation, secondaryAnimation, child) {
          return child;
        }
      )
    );
  }
),

比起單純使用 MaterialPageRoute ,現在使用 PageRouteBuilder ,可以看到現在在 pageBuilder 參數回傳我們的 DestinationDetails 組件。

若現在執行程式碼則你會發現沒有任何轉場效果,這是因為我們在 transitionBuilder 沒有做任何操作。假如我們希望加入滑入的效果可以如下調整:

transitionBuilder: (context, animation, secondaryAnimation, child) {
  return SlideTransition(
  	position: Tween<Offset>(
    	begin: const Offset(-1, 0),
      end: Offset.zero,
    ).animate(animation),
    child: child
  );
}

上面範例我們不再單純的回傳 child 組件,我們使用了 SlideTransition 組件封裝動畫邏輯;轉場效果由左而右直到完整呈現畫面。其子組件 DestinationDetails 置放於 SlideTransition 裡,因此新畫面可以覆蓋之前的頁面。接著當我們返回上頁時動畫則是運行相反的效果。

這個範例中有個小複雜的地方就是 TweenOffset 類別我們還未詳細探討關於動畫的部分,後續章節將深入說明。

現在,如果你預計在每個頁面使用相同的效果,那麼可以繼承 PageRouteBuilder 類別來建立可重用的轉場,後續你就可以像使用 MaterialPageRouteCupertinoPageRoute 一樣。如此可以避免重複程式碼也可以輕鬆的調整轉場效果。

例如,如果你希望整個應用程式都使用 SlideTransition 的效果。那麼你可以如下自訂 MyRoute 類別:

class MyRoute extends PageRouteBuilder {
  final Widget target;
  
  MyRoute({ required this.target }): super(
  	pageBuilder: (
    	BuildContext context,
      Animation<double> animation,
      Animation<double> secondaryAnimation,
    ) => target,
    transitionsBuilder: (
    	BuildContext context,
      Animation<double> animation,
      Animation<double> secondaryAnimation,
      Widget child,
    ) => SlideTransition(
    	position: Tween<Offset>(
      	begin: const Offset(-1, 0),
        end: Offset.zero,
      ).animate(animation),
      child: child
    ),
  );
}

在這個例子,我們使用單一的參數 target 就是我們要轉場的組件。作為建構子的一部分,類別設定了父類別 PageRouteBuilder 的參數,pageBuilder 為匿名函式並接受對應參數,最後單純的回傳 target 。而 transitionsBuilder 也一樣傳入一些參數,並回傳我們之前使用過的 SildeTransition

乍看之下好像很複雜,但其實如果認真解析會發現,其實只是在建構子的地方傳遞兩個匿名函式的參數到父類別。

因此當 MyRoute 物件被建立時,會自動呼叫 PageRouteBuilder 的建構子並代入兩個參數。也就是包了一層的 PageRouteBuilder 而已。

現在在 ElevatedButton 中,我們可以如之前一樣在 onPressed 函式使用:

ElevatedButton(
	child: Text("點擊"),
  onPressed: () {
    Navigator.of(context).push(
    	MyRoute(
      	transitionPage: DestinationDetails(
        	title: "項目"
        ),
      ),
    );
  },
)

我們只需要加入自訂的路由組件到導航堆疊,其就會自動具備 SlideTransition 的效果。

內建還有很多不同的轉場效果可以使用

  • ScaleTransition 逐漸放大效果
  • RotationTransition 旋轉效果
  • FadeTransition 淡入

只需要簡單的替換類別就可以取得需要的效果。

現在我們已經學習了如何切換畫面,接下來我們將進一步探索如何傳遞狀態。

頁面之間傳遞狀態

幾乎所有的應用程式都有狀態的概念。這裡的狀態比起單一組件中的狀態會包含更多東西,因為使用者在使用整個應用程式的流程都需要存取。如過你曾經使用過其他框架,你可能見識過各種不同處理狀態的方式。

在 Flutter 中並沒有唯一處理共享狀態的方式。

在後續介紹套件的段落,我們會介紹持久性的儲存方式例如:雲端資料庫。不過當我們從資料庫讀取資料到狀態中又該如何在各個頁面中使用?

值得一提的是,關於狀態管理並沒有絕對的正確方式,任何一種方式都有它們的優缺點,而我們需要從可維護性、程式碼的可讀性,和應用程式情境的角度來決定使用何種方式。

通過參數傳遞狀態

在應用程式中共享狀態最簡單的方式,也是大部分開發者在 Flutter 中管理狀態的方式就是通過建構子參數的方式在頁面之間傳遞狀態。在 DestinationDetails 組件我們已經看到了這種用法,通過 title 傳遞使用者查看的狀態。另一個常見的情境,假設有一個使用者登入應用程式,此時我們會建立關於使用者資料的物件以存取相關資訊。當使用者瀏覽其他頁面的時候我們可以將這個物件傳遞到任意組件。這個方式的優點是非常簡單,但缺點也非常明顯:

  • 如果某一個畫面需要一些現在沒有的狀態,那麼所有中間傳遞的組件都需要更新才能將狀態傳達。
  • 無法監聽狀態的變化並隨著狀態自動更新
  • 長時間執行的非同步操作例如監聽資料庫數據更新,和這種傳達的方式無法很好的整合使用。

但在現階段我們依舊會使用這種方式,來保持程式清晰易懂,後續當你學習更多 Flutter 和 Dart 的使用,你可以進一步使用一些進階的狀態管理。

總結

到此我們探索了應用程式中畫面的概念,了解如何切換。首先是使用 Navigator 組件。然後搭配使用 Route 使用了具名路由以及轉換的過程如何傳遞參數。接著了解了如何自訂轉場效果,上面最後我們提及了關於「狀態」的事情,後續我們將繼續介紹狀態管理。


上一篇
Day 16 路由與 Navigator 1.0 vs 2.0
下一篇
Day 18 狀態管理
系列文
Flutter 開發實戰 - 30 天逃離新手村38
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言