Skip to main content

新的按鈕與按鈕主題

基本的 Material 按鈕類別已被取代。

摘要

#

Flutter 新增了一組全新的基本 Material 按鈕元件 (Widget) 與主題。原有的類別已被棄用,未來將會移除。這次更新的整體目標,是讓按鈕更具彈性,並且更容易透過建構函式參數或主題(theme)進行設定。

FlatButton、RaisedButton 與 OutlineButton 元件已分別被 TextButton、ElevatedButton 和 OutlinedButton 取代。每個新的按鈕類別都有對應的主題:TextButtonTheme、ElevatedButtonTheme 和 OutlinedButtonTheme。原本的 ButtonTheme 類別已不再使用。按鈕的外觀現在是由 ButtonStyle 物件來指定,而不再是透過大量的元件參數與屬性。這種設計大致上類似於用 TextStyle 物件來定義文字外觀。新的按鈕主題同樣是透過 ButtonStyle 物件來設定。ButtonStyle 本身就是一組視覺屬性的集合。其中許多屬性是用 MaterialStateProperty 定義的,這代表它們的值可以根據按鈕的狀態而變化。

背景說明

#

我們選擇不直接在原有的按鈕類別及其主題上進行演進,而是推出全新的替代按鈕元件與主題。這樣除了可以避免因相容性導致的複雜問題外,新的命名也讓 Flutter 再次與 Material Design 規範同步,因為規範中對按鈕元件已採用這些新名稱。

舊元件(Widget)舊主題(Theme)新元件(Widget)新主題(Theme)
FlatButton ButtonTheme TextButton TextButtonTheme
RaisedButton ButtonTheme ElevatedButton ElevatedButtonTheme
OutlineButton ButtonTheme OutlinedButton OutlinedButtonTheme

新的主題遵循 Flutter 約一年前針對新 Material 元件所採用的「標準化」模式。主題屬性與元件建構函式參數預設為 null。若主題屬性或元件參數非 null,則代表要覆寫元件的預設值。實作與說明預設值,是按鈕元件本身的唯一責任。這些預設值主要根據整體主題(Theme)的 colorScheme 與 textTheme 來決定。

在視覺上,新的按鈕會有些微不同,因為它們符合最新的 Material Design 規範,並且顏色設定是以整體主題的 ColorScheme 為基礎。此外,內距(padding)、圓角半徑,以及 hover/focus/pressed 等回饋效果也有些細微差異。

許多應用程式只需將舊的類別名稱直接替換為新的即可。若您的應用程式有 golden image 測試,或是按鈕外觀是透過建構函式參數或原本的 ButtonTheme 進行設定,則可能需要參考遷移指南與下方的說明內容。

API 變更:以 ButtonStyle 取代個別樣式屬性

#

除了簡單的使用情境外,新的按鈕類別 API 與舊類別並不相容。新的按鈕與主題的視覺屬性,都是透過單一的 ButtonStyle 物件來設定,這類似於如何用 TextField 或 Text 元件搭配 TextStyle 物件進行設定。大多數 ButtonStyle 屬性都是用 MaterialStateProperty 定義,因此單一屬性可以根據按鈕的 pressed/focused/hovered 等不同狀態,對應不同的值。

按鈕的 ButtonStyle 並不會定義按鈕的視覺屬性,而是用來覆寫按鈕預設的視覺屬性,這些預設屬性是由按鈕元件本身計算得出。例如,若要覆寫 TextButton 在所有狀態下的預設前景(文字/圖示)顏色,可以這麼寫:

dart
TextButton(
  style: ButtonStyle(
    foregroundColor: MaterialStateProperty.all<Color>(Colors.blue),
  ),
  onPressed: () { },
  child: Text('TextButton'),
)

這類覆寫(override)很常見;然而,在許多情況下,還需要覆寫文字按鈕(text button)用來表示其滑鼠懸停、聚焦(focus)或按下(pressed)狀態時所使用的覆蓋色(overlay colors)。這可以透過在 ButtonStyle 中新增 overlayColor 屬性來達成。

dart
TextButton(
  style: ButtonStyle(
    foregroundColor: MaterialStateProperty.all<Color>(Colors.blue),
    overlayColor: MaterialStateProperty.resolveWith<Color?>(
      (Set<MaterialState> states) {
        if (states.contains(MaterialState.hovered))
          return Colors.blue.withOpacity(0.04);
        if (states.contains(MaterialState.focused) ||
            states.contains(MaterialState.pressed))
          return Colors.blue.withOpacity(0.12);
        return null; // Defer to the widget's default.
      },
    ),
  ),
  onPressed: () { },
  child: Text('TextButton')
)

一個色彩 MaterialStateProperty 只需要針對預設值需要被覆寫的顏色回傳一個值即可。如果回傳 null,則會使用元件的預設值。例如,若只想覆寫文字按鈕的 focus overlay 顏色:

dart
TextButton(
  style: ButtonStyle(
    overlayColor: MaterialStateProperty.resolveWith<Color?>(
      (Set<MaterialState> states) {
        if (states.contains(MaterialState.focused))
          return Colors.red;
        return null; // Defer to the widget's default.
      }
    ),
  ),
  onPressed: () { },
  child: Text('TextButton'),
)

styleFrom() ButtonStyle 工具方法

#

Material Design 規範以色彩方案(color scheme)的主色(primary color)來定義按鈕的前景色(foreground)和覆蓋色(overlay)。主色會根據按鈕的不同狀態,以不同的不透明度來呈現。為了簡化建立包含所有依賴色彩方案顏色屬性的按鈕樣式,每個按鈕類別都包含一個靜態的 styleFrom() 方法,可以根據一組簡單的值(包括其所依賴的 ColorScheme 顏色)來建構 ButtonStyle。

以下範例建立了一個按鈕,並使用指定的主色來覆寫其前景色以及覆蓋色,同時套用 Material Design 規範中定義的不透明度。

dart
TextButton(
  style: TextButton.styleFrom(
    foregroundColor: Colors.blue,
  ),
  onPressed: () { },
  child: Text('TextButton'),
)

TextButton 文件指出,當按鈕處於停用狀態時,其前景顏色會根據配色方案(color scheme)的 disabledForegroundColor 顏色來設定。若要一併覆寫這個設定,可以使用 styleFrom():

dart
TextButton(
  style: TextButton.styleFrom(
    foregroundColor: Colors.blue,
    disabledForegroundColor: Colors.red,
  ),
  onPressed: null,
  child: Text('TextButton'),
)

使用 styleFrom() 方法是建立 ButtonStyle 的首選方式,特別是當你想要創建一個 Material Design 變體時。最具彈性的做法是直接定義一個 ButtonStyle,並針對你想要覆蓋外觀的狀態,為其指定 MaterialStateProperty 值。

ButtonStyle 預設值

#

像新按鈕類別這樣的元件會根據整體主題的 colorScheme 和 textTheme,以及按鈕目前的狀態,來「計算」它們的預設值。在某些情況下,它們也會考慮整體主題的色彩方案是明亮還是深色。每個按鈕都有一個受保護的方法,會在需要時計算其預設樣式。雖然應用程式不會直接呼叫這個方法,但其 API 文件會說明所有預設值。當按鈕或按鈕主題指定 ButtonStyle 時,只有按鈕樣式中非 null 的屬性會覆蓋計算出的預設值。按鈕的 style 參數會覆蓋對應按鈕主題中指定的非 null 屬性。例如,如果 TextButton 的樣式中的 foregroundColor 屬性非 null,則會覆蓋 TextButtonTheme 樣式中的同一屬性。

如前所述,每個按鈕類別都包含一個名為 styleFrom 的靜態方法,該方法會從一組簡單的值(包括它所依賴的 ColorScheme 顏色)建立 ButtonStyle。在許多常見情境下,使用 styleFrom 來建立一個臨時的 ButtonStyle 以覆蓋預設值,是最簡單的做法。這在自訂樣式的目的是要覆蓋預設樣式所依賴的某個色彩方案顏色(如 primary 或 onPrimary)時尤其適用。對於其他情況,你可以直接建立 ButtonStyle 物件。這樣做可以讓你控制所有按鈕可能狀態(如按下、懸停、停用、聚焦)下的視覺屬性值,例如顏色。

遷移指南

#

請參考以下資訊,將你的按鈕遷移到新 API。

還原原始按鈕的視覺效果

#

在許多情況下,只需將舊的按鈕類別切換為新的即可。這假設你不在意尺寸/形狀上的微小變化,以及顏色上可能較大的變動。

若要在這些情況下保留原始按鈕的外觀,可以定義與原始按鈕外觀盡可能相符的按鈕樣式。例如,以下樣式會讓 TextButton 看起來像預設的 FlatButton:

dart
final ButtonStyle flatButtonStyle = TextButton.styleFrom(
  foregroundColor: Colors.black87,
  minimumSize: Size(88, 36),
  padding: EdgeInsets.symmetric(horizontal: 16),
  shape: const RoundedRectangleBorder(
    borderRadius: BorderRadius.all(Radius.circular(2)),
  ),
);
​
TextButton(
  style: flatButtonStyle,
  onPressed: () { },
  child: Text('Looks like a FlatButton'),
)

同樣地,若要讓 ElevatedButton 看起來像預設的 RaisedButton:

dart
final ButtonStyle raisedButtonStyle = ElevatedButton.styleFrom(
  foregroundColor: Colors.black87,
  backgroundColor: Colors.grey[300],
  minimumSize: Size(88, 36),
  padding: EdgeInsets.symmetric(horizontal: 16),
  shape: const RoundedRectangleBorder(
    borderRadius: BorderRadius.all(Radius.circular(2)),
  ),
);
ElevatedButton(
  style: raisedButtonStyle,
  onPressed: () { },
  child: Text('Looks like a RaisedButton'),
)

OutlinedButton 的 OutlineButton 樣式會稍微複雜一些,因為當按鈕被按下時,外框(outline)的顏色會變為主色(primary color)。外框的外觀是由 BorderSide 定義的,而你會使用 MaterialStateProperty 來定義按下時的外框顏色:

dart
final ButtonStyle outlineButtonStyle = OutlinedButton.styleFrom(
  foregroundColor: Colors.black87,
  minimumSize: Size(88, 36),
  padding: EdgeInsets.symmetric(horizontal: 16),
  shape: const RoundedRectangleBorder(
    borderRadius: BorderRadius.all(Radius.circular(2)),
  ),
).copyWith(
  side: MaterialStateProperty.resolveWith<BorderSide?>(
    (Set<MaterialState> states) {
      if (states.contains(MaterialState.pressed)) {
        return BorderSide(
          color: Theme.of(context).colorScheme.primary,
          width: 1,
        );
      }
      return null;
    },
  ),
);
​
OutlinedButton(
  style: outlineButtonStyle,
  onPressed: () { },
  child: Text('Looks like an OutlineButton'),
)

若要在整個應用程式中還原按鈕的預設外觀,您可以在應用程式的主題(theme)中設定新的按鈕主題(button themes):

dart
MaterialApp(
  theme: ThemeData.from(colorScheme: ColorScheme.light()).copyWith(
    textButtonTheme: TextButtonThemeData(style: flatButtonStyle),
    elevatedButtonTheme: ElevatedButtonThemeData(style: raisedButtonStyle),
    outlinedButtonTheme: OutlinedButtonThemeData(style: outlineButtonStyle),
  ),
)

若要在應用程式的某一部分恢復按鈕的預設外觀,您可以將元件(Widget)子樹包裹在 TextButtonTheme、ElevatedButtonTheme 或 OutlinedButtonTheme 中。例如:

dart
TextButtonTheme(
  data: TextButtonThemeData(style: flatButtonStyle),
  child: myWidgetSubtree,
)

遷移具有自訂顏色的按鈕

#

以下章節將說明如何使用 FlatButton、RaisedButton 和 OutlineButton 這些顏色參數:

dart
textColor
disabledTextColor
color
disabledColor
focusColor
hoverColor
highlightColor*
splashColor

新的按鈕類別(button classes)不再支援獨立的高亮顏色(highlight color),因為這已經不再是 Material Design 的一部分。

遷移具有自訂前景色與背景色的按鈕

#

在原有的按鈕類別中,兩個常見的自訂方式分別是為 FlatButton 設定自訂前景色,或是為 RaisedButton 設定自訂前景色與背景色。要在新的按鈕類別中達到相同的效果,其實很簡單:

dart
FlatButton(
  textColor: Colors.red, // foreground
  onPressed: () { },
  child: Text('FlatButton with custom foreground/background'),
)
​
TextButton(
  style: TextButton.styleFrom(
    foregroundColor Colors.red,
  ),
  onPressed: () { },
  child: Text('TextButton with custom foreground'),
)

在此情境下,TextButton 的前景(文字/圖示)顏色,以及其滑鼠懸停/獲得焦點/按下時的覆蓋顏色,將會根據 Colors.red 設定。預設情況下,TextButton 的背景填充顏色為透明。

遷移具有自訂前景與背景顏色的 RaisedButton:

dart
RaisedButton(
  color: Colors.red, // background
  textColor: Colors.white, // foreground
  onPressed: () { },
  child: Text('RaisedButton with custom foreground/background'),
)
​
ElevatedButton(
  style: ElevatedButton.styleFrom(
    backgroundColor: Colors.red,
    foregroundColor: Colors.white,
  ),
  onPressed: () { },
  child: Text('ElevatedButton with custom foreground/background'),
)

在這個情境下,按鈕對於配色方案中的 primary 色彩的使用方式,與 TextButton 相較是相反的:primary 成為按鈕的背景填充顏色,而 onPrimary 則作為前景(文字/圖示)顏色。

遷移具有自訂疊加色的按鈕

#

覆寫按鈕預設的聚焦(focused)、懸停(hovered)、高亮(highlighted)或波紋(splash)顏色的情況較為少見。FlatButton、RaisedButton 和 OutlineButton 這些類別,各自有對應這些狀態相關顏色的獨立參數。新的 TextButton、ElevatedButton 和 OutlinedButton 類別則改為僅使用單一的 MaterialStateProperty<Color> 參數。新的按鈕允許你為所有顏色指定狀態相關的值,而原本的按鈕僅支援指定現在稱為 "overlayColor" 的部分。

dart
FlatButton(
  focusColor: Colors.red,
  hoverColor: Colors.green,
  splashColor: Colors.blue,
  onPressed: () { },
  child: Text('FlatButton with custom overlay colors'),
)
​
TextButton(
  style: ButtonStyle(
    overlayColor: MaterialStateProperty.resolveWith<Color?>(
      (Set<MaterialState> states) {
        if (states.contains(MaterialState.focused))
          return Colors.red;
        if (states.contains(MaterialState.hovered))
            return Colors.green;
        if (states.contains(MaterialState.pressed))
            return Colors.blue;
        return null; // Defer to the widget's default.
    }),
  ),
  onPressed: () { },
  child: Text('TextButton with custom overlay colors'),
)

新版本雖然不如舊版精簡,但更加靈活。在原本的版本中,不同狀態的優先順序是隱含(且未記錄)且固定的;而在新版本中,這些優先順序是明確的。對於經常需要自訂這些顏色的應用程式來說,最簡單的遷移方式是定義一個或多個 ButtonStyles,使其符合上述範例,然後直接使用 style 參數,或者定義一個無狀態包裝元件(stateless wrapper widget),將這三個顏色參數封裝起來。

遷移具有自訂停用顏色的按鈕

#

這是一種相對少見的自訂需求。FlatButton、RaisedButton 和 OutlineButton 類別都擁有 disabledTextColor 與 disabledColor 參數,用來定義當按鈕的 onPressed 回呼(callback)為 null 時的背景與前景顏色。

預設情況下,所有按鈕都會使用色彩方案(color scheme)中的 disabledForegroundColor 顏色,並將停用狀態下的前景顏色透明度設為 0.38。只有 ElevatedButton 具有非透明的背景顏色,其預設值為 disabledForegroundColor 顏色並設置透明度為 0.12。因此,在許多情況下,你只需要使用 styleFrom 方法來覆寫停用狀態下的顏色即可:

dart
RaisedButton(
  disabledColor: Colors.red.withOpacity(0.12),
  disabledTextColor: Colors.red.withOpacity(0.38),
  onPressed: null,
  child: Text('RaisedButton with custom disabled colors'),
),
​
ElevatedButton(
  style: ElevatedButton.styleFrom(disabledForegroundColor: Colors.red),
  onPressed: null,
  child: Text('ElevatedButton with custom disabled colors'),
)

若要完全控制 disabled 狀態下的顏色,必須明確地以 MaterialStateProperties 的方式定義 ElevatedButton 的樣式:

dart
RaisedButton(
  disabledColor: Colors.red,
  disabledTextColor: Colors.blue,
  onPressed: null,
  child: Text('RaisedButton with custom disabled colors'),
)
​
ElevatedButton(
  style: ButtonStyle(
    backgroundColor: MaterialStateProperty.resolveWith<Color?>(
      (Set<MaterialState> states) {
        if (states.contains(MaterialState.disabled))
          return Colors.red;
        return null; // Defer to the widget's default.
    }),
    foregroundColor: MaterialStateProperty.resolveWith<Color?>(
      (Set<MaterialState> states) {
        if (states.contains(MaterialState.disabled))
          return Colors.blue;
        return null; // Defer to the widget's default.
    }),
  ),
  onPressed: null,
  child: Text('ElevatedButton with custom disabled colors'),
)

如同前述案例,在經常需要進行此遷移的應用程式中,有明顯的方法可以讓新版實作更加簡潔。

遷移具有自訂陰影高度(elevation)的按鈕

#

這同樣屬於相對少見的自訂情境。通常,只有 ElevatedButton(最初稱為 RaisedButtons)會包含陰影高度的變化。若陰影高度是依據基準陰影高度(根據 Material Design 規範)成比例調整,可以很簡單地覆寫所有相關設定。

預設情況下,停用(disabled)按鈕的陰影高度為 0,其餘狀態則是以基準值 2 為相對基準來定義:

dart
disabled: 0
hovered or focused: baseline + 2
pressed: baseline + 6

因此,若要遷移一個已經定義所有 elevation 的 RaisedButton:

dart
RaisedButton(
  elevation: 2,
  focusElevation: 4,
  hoverElevation: 4,
  highlightElevation: 8,
  disabledElevation: 0,
  onPressed: () { },
  child: Text('RaisedButton with custom elevations'),
)
​
ElevatedButton(
  style: ElevatedButton.styleFrom(elevation: 2),
  onPressed: () { },
  child: Text('ElevatedButton with custom elevations'),
)

若只想任意覆寫某一個 elevation,例如 pressed elevation:

dart
RaisedButton(
  highlightElevation: 16,
  onPressed: () { },
  child: Text('RaisedButton with a custom elevation'),
)
​
ElevatedButton(
  style: ButtonStyle(
    elevation: MaterialStateProperty.resolveWith<double?>(
      (Set<MaterialState> states) {
        if (states.contains(MaterialState.pressed))
          return 16;
        return null;
      }),
  ),
  onPressed: () { },
  child: Text('ElevatedButton with a custom elevation'),
)

遷移具有自訂形狀與邊框的按鈕

#

原本的 FlatButton、RaisedButton 和 OutlineButton 類別都提供了一個 shape 參數,用來同時定義按鈕的形狀以及其外框(outline)的外觀。對應的新類別及其主題化(theming)則支援分別指定按鈕的形狀與邊框,分別透過 OutlinedBorder shape 和 BorderSide side 參數來設定。

在這個範例中,原本的 OutlineButton 版本在按鈕被高亮(按下)狀態時,為邊框指定了與其他狀態相同的顏色。

dart
OutlineButton(
  shape: StadiumBorder(),
  highlightedBorderColor: Colors.red,
  borderSide: BorderSide(
    width: 2,
    color: Colors.red
  ),
  onPressed: () { },
  child: Text('OutlineButton with custom shape and border'),
)
​
OutlinedButton(
  style: OutlinedButton.styleFrom(
    shape: StadiumBorder(),
    side: BorderSide(
      width: 2,
      color: Colors.red
    ),
  ),
  onPressed: () { },
  child: Text('OutlinedButton with custom shape and border'),
)

大多數新的 OutlinedButton 元件的樣式參數,包括其形狀與邊框,都可以使用 MaterialStateProperty 值來指定。也就是說,這些參數可以根據按鈕的不同狀態有不同的值。若要在按鈕被按下時指定不同的邊框顏色,請按照以下方式操作:

dart
OutlineButton(
  shape: StadiumBorder(),
  highlightedBorderColor: Colors.blue,
  borderSide: BorderSide(
    width: 2,
    color: Colors.red
  ),
  onPressed: () { },
  child: Text('OutlineButton with custom shape and border'),
)
​
OutlinedButton(
  style: ButtonStyle(
    shape: MaterialStateProperty.all<OutlinedBorder>(StadiumBorder()),
    side: MaterialStateProperty.resolveWith<BorderSide>(
      (Set<MaterialState> states) {
        final Color color = states.contains(MaterialState.pressed)
          ? Colors.blue
          : Colors.red;
        return BorderSide(color: color, width: 2);
      }
    ),
  ),
  onPressed: () { },
  child: Text('OutlinedButton with custom shape and border'),
)

時程

#

首次納入版本:1.20.0-0.0.pre
穩定版釋出:2.0.0

參考資料

#

API 文件:

相關 PR: