建構自適應的應用

概覽

Flutter 為在移動端、桌面端和 Web 端使用同樣的程式碼建構應用創造了新的機會。伴隨著機會而來的,是新的挑戰。你可能會希望你的應用既能在儘可能複用的情況下自適應多個平台,又能保證流暢且無縫的體驗,還可以讓使用者保持一致的使用習慣。這樣的應用不僅僅是為了多個平台而建構的,它能完全地自適應平台的變化。

在建構平台自適應的應用時,有眾多的考量因素,總的來說分為以下幾類:

指南將透過程式碼片段,詳細說明三個類別的概念。若你想了解這些概念的實際落地情況,可以參考 FlokkFolio 範例。

Original demo code for adaptive app development techniques from flutter-adaptive-demo.

建構自適應的佈局

在建構多平臺的應用時,首要考慮的是如何針對不同大小的裝置進行尺寸適配。

佈局 widgets

如果你已經開發過應用或網站,那你可能已經熟悉如何建構自適應的介面。好訊息是,對於 Flutter 開發者而言,有非常多的 widgets 讓建構更為簡單。

Flutter 中最有用的部分佈局 widgets 包括:

單子級 (Single child)

  • Align——讓子級在其內部進行對齊。可使用 -1 至 1 之間的任意值在垂直和水平方向上進行對齊。

  • AspectRatio——嘗試讓子級以指定的比例進行佈局。

  • ConstrainedBox——對子級施加尺寸限制,可以控制最小和最大的尺寸。

  • CustomSingleChildLayout——使用代理方法對單個子級進行定位。代理方法可以為子級確定佈局限制和定位。

  • ExpandedFlexible——允許 RowColumn 的子級填充剩餘空間或者儘可能地小。

  • FractionallySizedBox——基於剩餘空間的比例限定子級的大小。

  • LayoutBuilder——讓子級可以基於父級的尺寸重新調整其佈局。

  • SingleChildScrollView——為單一的子級新增滾動。通常配合 RowColumn 進行使用。

多子級 (Multi child)

  • ColumnRowFlex—— 在同一水平線或垂直線上放置所有子級。 ColumnRow 都繼承了 Flex widget。

  • CustomMultiChildLayout—— 在佈局過程中使用代理方法對多個子級進行定位。

  • Flow——相對於 CustomMultiChildLayout 更高效的佈局方式。在繪製過程中使用代理方法對多個子級進行定位。

  • ListViewGridViewCustomScrollView—— 為所有子級增加滾動支援。

  • Stack——基於 Stack 的邊界對多個子級進行放置和定位。與 CSS 中的 position: fixed 功能類似。

  • Table——使用經典的表格佈局演算法,可以組合多列和多行。

  • Wrap——將子級順序顯示在多行或多列內。

檢視 佈局 widgets 瞭解更多的 widgets 和程式碼範例。

視覺密度

不同的裝置會提供不同級別的顯示密度,使得操作的命中區域也要隨之變化。 Flutter 的 VisualDensity 類可以讓你快速地調整整個應用的檢視密度,比如在可觸控裝置上放大一個按鈕(使其更容易被點選)。

不同的裝置會提供不同級別的顯示密度,使得操作的命中區域也要隨之變化。 Flutter 的 VisualDensity 類可以讓你快速地調整整個應用的檢視密度,比如在可觸控裝置上放大一個按鈕(使其更容易被點選)。

在你改變 MaterialAppVisualDensity 時,已支援 VisualDensityMaterialComponents 會以動畫過渡的形式改變其自身的密度。水平和垂直方向的密度預設都為 0.0,你可以將它設定為任意的正負值,這樣就可以透過調整密度輕鬆地調整你的 UI:

Adaptive scaffold

若想使用自訂的視覺密度,請在你的 MaterialApp 的主題中進行設定:

double densityAmt = touchMode ? 0.0 : -1.0;
VisualDensity density =
    VisualDensity(horizontal: densityAmt, vertical: densityAmt);
return MaterialApp(
  theme: ThemeData(visualDensity: density),
  home: MainAppScaffold(),
  debugShowCheckedModeBanner: false,
);

若想在你的檢視中使用 VisualDensity,你可以向上查詢:

VisualDensity density = Theme.of(context).visualDensity;

在密度變化時,容器不僅能自動地對其做出反應,還會結合動畫進行過渡變化。所有的元件都會聯絡在一起,使整個應用平滑過渡。

我們可以看到,VisualDensity 是沒有單位的,所以在不同的檢視上可能有不同的含義。在以上的例子中,1 個單位的密度等同於 6 個邏輯畫素。具體的處理完全由你的檢視自行決定。無單位的設計讓它可以處理通用情況,能在大部分的場景下使用。

值得注意的是,在 Material 的元件中,1 個單位的視覺密度通常等於 4 個邏輯畫素。你可以檢視 VisualDensity API 文件瞭解更多支援視覺密度的元件。若想了解視覺密度的通用原則,請檢視 Material Design 指南

基於上下文的佈局

如果你需要的不僅是密度的變化,並且沒有找到一個滿足需求的 widget,那麼你可以使用程式碼進行更細化的控制、計算尺寸、切換 widgets 或是完全重新建構你的 UI 適配對應的外形結構。

基於螢幕大小的分界點

最簡單的程式碼控制佈局方式是基於螢幕尺寸來定義分界點。在 Flutter 中,你可以使用 MediaQuery API 實現這些分界點。具體需要使用的大小並沒有作出硬性規定,下方是一些通用的值:

class FormFactor {
  static double desktop = 900;
  static double tablet = 600;
  static double handset = 300;
}

使用分界點可以讓你透過簡單的判斷快速確定裝置的型別:

ScreenType getFormFactor(BuildContext context) {
  // Use .shortestSide to detect device type regardless of orientation
  double deviceWidth = MediaQuery.of(context).size.shortestSide;
  if (deviceWidth > FormFactor.desktop) return ScreenType.Desktop;
  if (deviceWidth > FormFactor.tablet) return ScreenType.Tablet;
  if (deviceWidth > FormFactor.handset) return ScreenType.Handset;
  return ScreenType.Watch;
}

又或者,你可以對大小型別進行更深層次的抽象,並且按照從小到大的方式定義:

enum ScreenSize { Small, Normal, Large, ExtraLarge }

ScreenSize getSize(BuildContext context) {
  double deviceWidth = MediaQuery.of(context).size.shortestSide;
  if (deviceWidth > 900) return ScreenSize.ExtraLarge;
  if (deviceWidth > 600) return ScreenSize.Large;
  if (deviceWidth > 300) return ScreenSize.Normal;
  return ScreenSize.Small;
}

使用基於螢幕大小的分界點的最佳場景,是在應用的最上層進行尺寸決策。在需要改變視覺密度、邊距或者字型大小時,定義全域的基數是最好的方式。

你也可以利用分界點重新組織最上層的 widget 結構。例如,你可以判斷使用者是否使用手持裝置,來切換垂直或水平的佈局:

bool isHandset = MediaQuery.of(context).size.width < 600;
return Flex(
    children: [Text('Foo'), Text('Bar'), Text('Baz')],
    direction: isHandset ? Axis.vertical : Axis.horizontal);

在其他的 widget 中,你也可以切換部分子級 widget:

Widget foo = Row(
  children: [
    ...isHandset ? _getHandsetChildren() : _getNormalChildren(),
  ],
);

使用 LayoutBuilder 提升佈局靈活性

儘管對於全屏頁面或者全域的佈局決策而言,判斷整個螢幕大小非常有效,但對於內嵌的子檢視而言,並不一定是合理的方案。子檢視通常有自己的分界點,並且只關心它們可用的渲染空間。

在 Flutter 內處理這類場景最簡單的做法是使用 LayoutBuilderLayoutBuilder 讓 widget 可以根據其父級的限制進行調整,相比依賴全域的尺寸限制而言更為通用。

之前的範例可以使用 LayoutBuilder 重寫:

Widget foo = LayoutBuilder(
    builder: (context, constraints) {
  bool useVerticalLayout = constraints.maxWidth < 400.0;
  return Flex(
    children: [
      Text('Hello'),
      Text('World'),
    ],
    direction: useVerticalLayout ? Axis.vertical : Axis.horizontal,
  );
});

現在這個 widget 可以組裝在側邊面板、彈框又或是全屏檢視中,並且根據尺寸自適應佈局。

裝置細分

有時你可能需要根據實際執行的平台進行佈局處理,而不是基於大小。例如,在建構自訂的標題欄時,你可能需要判斷裝置的平台來處理佈局,以防被原生視窗的按鈕遮擋。

想判斷應用當前所處的平台,你可以使用 Platform API 和 kIsWeb 組合進行判斷:

bool get isMobileDevice => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
bool get isDesktopDevice =>
    !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
bool get isMobileDeviceOrWeb => kIsWeb || isMobileDevice;
bool get isDesktopDeviceOrWeb => kIsWeb || isDesktopDevice;

在建構 Web 平台應用時,由於 dart.io package 不支援 Web 平台,導致使用 Platform API 時會例外。所以在上面的程式碼中,會首先判斷是否在 Web 平台,基於這個條件,在 Web 平臺上永遠不會呼叫 Platform API。

使用單一來源控制樣式

使用單一的來源對樣式進行維護,可以讓你更簡便地控制邊距、間距、圓角、字型等樣式值。你可以利用一些幫助類進行實現:

class Insets {
  static const double xsmall = 3;
  static const double small = 4;
  static const double medium = 5;
  static const double large = 10;
  static const double extraLarge = 20;
  // etc
}

class Fonts {
  static const String raleway = 'Raleway';
  // etc
}

class TextStyles {
  static const TextStyle raleway = const TextStyle(
    fontFamily: Fonts.raleway,
  );
  static TextStyle buttonText1 =
      TextStyle(fontWeight: FontWeight.bold, fontSize: 14);
  static TextStyle buttonText2 =
      TextStyle(fontWeight: FontWeight.normal, fontSize: 11);
  static TextStyle h1 = TextStyle(fontWeight: FontWeight.bold, fontSize: 22);
  static TextStyle h2 = TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
  static late TextStyle body1 = raleway.copyWith(color: Color(0xFF42A5F5));
  // etc
}

這些常量可以用來替代硬編碼的值:

return Padding(
  padding: EdgeInsets.all(Insets.small),
  child: Text('Hello!', style: TextStyles.body1),
);

由於所有的檢視都參考了相同設計系統的規範,它們通常看起來更一致且更順暢。與其進行容易出錯的搜尋替換,你可以將平台對應樣式值的修改集中在一處。使用共享的規則也對設計的一致性有所幫助。

常見的設計型別裡,如下這些類別可以以這樣的方式進行組織:

  • 動畫時間

  • 尺寸大小和分界點

  • 遮蓋和內邊距區域

  • 圓角

  • 陰影

  • 筆畫

  • 字體系列、大小和樣式

當然,上述的例子也有一些例外:在應用中只使用了一次的值。將這些值放在樣式規則裡屬實無用之舉,但可以考慮它們是否能從現有的值延伸(例如 padding + 1.0)。你也可以留意一些有著相同意義複用的值,這些值也許可以新增到全域的樣式規則裡。

針對不同外形螢幕的特性進行設計

除了螢幕尺寸以外,你也應當花時間,針對各種不同外形螢幕的優劣點進行設計。支援多平臺的應用,並不能在所有的裝置上都提供理想的體驗。實際開發時,可以考慮某些特定的功能是否合理,也可以考慮在某些平臺上移除特定的功能。

舉個例子,移動裝置是十分便攜的,一般還配有攝影頭,但它們並不適合深度的內容創作工作。基於這個前提,你的應用可以更側重於內容捕獲,並使用位置資訊對其進行標記,配上移動端的介面,而另一方面,在平板和桌面介面上專注於組織和操作產出的內容。

另一個例子是充分利用 Web 平台的快速分享能力。如果你正在部署 Web 應用,可以考慮哪些頁面會使用 deep link,並根據配置來設計應用的導航。

此處的關鍵點在於,如何發揮每個平台的長處,尋找平台可以利用的特有功能。

透過建構桌面應用程式進行快速測試

測試自適應介面的最快方式,是利用桌面端快速進行建構。

在桌面上執行應用時,你可以在應用執行時輕易地改變視窗的大小,預覽多種尺寸的佈局。配上熱重載,能極大程度地加快響應式開發的速度。

Adaptive scaffold 2

優先處理觸控操作

在移動端建構優良的觸控互動式 UI 通常比傳統的桌面端更為困難,因為它缺少類似右鍵單擊、滾輪或鍵盤快捷鍵這樣的快速輸入裝置。

在一開始就專注於提升觸控體驗的 UI,足以應對這樣的挑戰。你依舊可以使用桌面端來提高你的開發效率,但要記得時不時切換回移動端,驗證開發的內容是否正常。

完善了觸控介面後,你可以調整面向滑鼠使用者的視覺密度,然後對所有的輸入裝置進行分層。這些輸入裝置應當作為加快你的應用使用速度的途徑。在這裡需要考慮的應當是使用者對於應用體驗的期望,並在應用中合理地實現這些期望。

輸入

當然,應用只適配了介面是遠遠不夠的,你還需要適配各種使用者的輸入操作。滑鼠和鍵盤提供了觸控裝置不具備的輸入方式,例如滾輪、右鍵點選、懸停互動、Tab 遍歷切換和鍵盤快捷鍵。

滾輪

ScrollViewListView 這樣的滾動 widget 預設支援滾輪行為,而大部分可滾動的自訂 widget 都是基於它們建構的,所以也同樣支援。

如果你需要實現自訂的滑動行為,可以使用 Listener widget,透過它你可以完全自訂 UI 如何響應滾輪行為。

return Listener(
    onPointerSignal: (event) {
      if (event is PointerScrollEvent) print(event.scrollDelta.dy);
    },
    child: ListView());

Tab 遍歷切換和焦點互動

使用鍵盤的使用者,可能會希望透過 Tab 鍵在應用中快速導航,特別是對有動效和視覺障礙的使用者,他們幾乎完全依賴於鍵盤導航。

在考慮 Tab 遍歷切換時,有兩點需要注意:焦點如何在 widget 之間遍歷,以及 widget 聚焦時的突出顯示。

大部分內建的元件,類似於按鈕和輸入框,都預設支援遍歷和高亮。如果你想讓自己的 widget 包含在遍歷中,你可以利用 FocusableActionDetector 進行控制。它將 ActionsShortcutsMouseRegionFocus 的能力進行了整合,創建出一個可以定義行為和鍵位繫結,並且提供聚焦和懸浮高亮事件回呼(Callback)的 widget。

class _BasicActionDetectorState extends State<BasicActionDetector> {
  bool _hasFocus = false;
  @override
  Widget build(BuildContext context) {
    return FocusableActionDetector(
      onFocusChange: (value) => setState(() => _hasFocus = value),
      actions: <Type, Action<Intent>>{
        ActivateIntent: CallbackAction<Intent>(onInvoke: (intent) {
          print('Enter or Space was pressed!');
          return null;
        }),
      },
      child: Stack(
        clipBehavior: Clip.none,
        children: [
          FlutterLogo(size: 100),
          // Position focus in the negative margin for a cool effect
          if (_hasFocus)
            Positioned(
                left: -4,
                top: -4,
                bottom: -4,
                right: -4,
                child: _roundedBorder())
        ],
      ),
    );
  }
}

控制遍歷的順序

想要控制使用者按下 Tab 鍵時的 widget 切換順序,你可以使用 FocusTraversalGroup 來指定樹中的區域,作為切換時的組別。

例如,你可能想要使用者逐個切換所有的輸入框,最後再切換到提交按鈕:

return Column(children: [
  FocusTraversalGroup(
    child: MyFormWithMultipleColumnsAndRows(),
  ),
  SubmitButton(),
]);

Flutter 有幾種內建的方法對 widget 和組別進行遍歷,預設使用的是 ReadingOrderTraversalPolicy 類別。這個類通常可以正常使用,你也可以建立另一個 TraversalPolicy 或建立一個自訂的規則,對它進行定義。

提升使用者操作速度的鍵盤

除了使用 Tab 遍歷元素以外,桌面和 Web 使用者還習慣將為各種操作繫結鍵盤快捷鍵。無論是 Delete 鍵進行快速刪除,還是 Control+N 新建文件,你都需要認真考慮使用者對這些操作的期望。鍵盤是非常強力的輸入工具,所以請儘可能讓它發揮最大的作用和效果。使用者會給予高度評價。

根據目標的不同,在 Flutter 中可以通過幾種方式實現利用鍵盤提升使用者操作速度。

如果你已經有一個包含焦點的 widget,例如 TextField 或者 Button,你可以巢狀(Nesting)一個 RawKeyboardListener 監聽鍵盤事件:

  @override
  Widget build(BuildContext context) {
    return Focus(
      onKey: (node, event) {
        if (event is RawKeyDownEvent) {
          print(event.logicalKey);
        }
        return KeyEventResult.ignored;
      },
      child: ConstrainedBox(
        constraints: BoxConstraints(maxWidth: 400),
        child: TextField(
          decoration: InputDecoration(
            border: OutlineInputBorder(),
          ),
        ),
      ),
    );
  }
}

如果你想將一組鍵盤快捷鍵應用到更大範圍的 widget,你可以使用 Shortcuts widget:

// Define a class for each type of shortcut action you want
class CreateNewItemIntent extends Intent {
  const CreateNewItemIntent();
}

Widget build(BuildContext context) {
  return Shortcuts(
    // Bind intents to key combinations
    shortcuts: <ShortcutActivator, Intent>{
      SingleActivator(LogicalKeyboardKey.keyN, control: true):
          CreateNewItemIntent(),
    },
    child: Actions(
      // Bind intents to an actual method in your code
      actions: <Type, Action<Intent>>{
        CreateNewItemIntent: CallbackAction<CreateNewItemIntent>(
            onInvoke: (intent) => _createNewItem()),
      },
      // Your sub-tree must be wrapped in a focusNode, so it can take focus.
      child: Focus(
        autofocus: true,
        child: Container(),
      ),
    ),
  );
}

Shortcuts widget 非常有用,因為它會讓 widget 樹的這一分支或它的子級僅在有焦點且可見時觸發快捷方式。

最後,你還可以全域新增監聽。這樣的監聽可以用於始終需要監聽,且為應用全域的快捷鍵,或是在任何時候(無論是否已聚焦)都接收快捷鍵的部分。使用 RawKeyboard 新增全域監聽非常簡單:

void initState() {
  super.initState();
  RawKeyboard.instance.addListener(_handleKey);
}

@override
void dispose() {
  RawKeyboard.instance.removeListener(_handleKey);
  super.dispose();
}

要想在全域監聽中判斷組合按鍵,你可以使用 RawKeyboard.instance.keysPressed 這個 Map 進行判斷。例如下面這個方法,可以判斷是否已經按下了指定的按鍵:

static bool isKeyDown(Set<LogicalKeyboardKey> keys) {
  return keys.intersection(RawKeyboard.instance.keysPressed).isNotEmpty;
}

將它們合併判斷,你就可以在 Shift+N 同時按下時觸發行為:

void _handleKey(event) {
  if (event is RawKeyDownEvent) {
    bool isShiftDown = isKeyDown({
      LogicalKeyboardKey.shiftLeft,
      LogicalKeyboardKey.shiftRight,
    });
    if (isShiftDown && event.logicalKey == LogicalKeyboardKey.keyN) {
      _createNewItem();
    }
  }
}

使用靜態的監聽時有一件值得注意的事情,當用戶在輸入框中輸入內容,或關聯的 widget 從檢視中隱藏時,通常需要禁用監聽。與 ShortcutsRawKeyboardListener 不同,你需要自己對它們進行管理。當你在為 Delete 鍵建構一個刪除或退格行為的監聽時,需要尤其注意,因為使用者可能會在 TextField 中輸入內容時受到影響。

滑鼠進入、移出和懸停事件

在桌面平臺上,常會在滑鼠懸停在內容上時,改變游標以表明不同的功能用途。例如,你會在滑鼠懸停的按鈕上看到手指游標,或是在懸停的文字上看到一個 I

Material 系列元件內建了對標準的按鈕和文字的游標支援。你可以使用 MouseRegion 在你自己的 widget 上改變游標。

// Show hand cursor
return MouseRegion(
  cursor: SystemMouseCursors.click,
  // Request focus when clicked
  child: GestureDetector(
    onTap: () {
      Focus.of(context).requestFocus();
      _submit();
    },
    child: Logo(showBorder: hasFocus),
  ),
);

MouseRegion 對於建立自訂翻轉和懸停效果也很有用:

return MouseRegion(
  onEnter: (_) => setState(() => _isMouseOver = true),
  onExit: (_) => setState(() => _isMouseOver = false),
  onHover: (e) => print(e.localPosition),
  child: Container(
    height: 500,
    color: _isMouseOver ? Colors.blue : Colors.black,
  ),
);

平台行為習慣與規範

最後,我們需要為自適應應用考慮平台標準。每個平台都有其不同的行為習慣與規範,這些名義和事實上的標準將操作應用的方法告知了使用者。在當下網路如此便利的時代,使用者更傾向於更加個性化的體驗,但是提供這些平台標準,依然可以帶來一些顯著的好處:

  • 減少認知學習成本——與使用者期望的互動進行匹配,讓使用者更直接地完成操作,而無需過多地思考,從而提高生產力,減少其中的頓挫感。

  • 建立與使用者之間的信任——在應用的互動表現不如預期時,使用者會逐漸對應用本身產生懷疑。相反,使用讓使用者感到熟悉的 UI,可以快速地建立應用與使用者之間的信任,讓使用者提高對應用品質的評價。同時這也會讓應用商店的評級更為可觀——皆大歡喜。

考慮每個平台的預期互動行為

考慮的第一步,是花一些時間思考應用在這個平臺上期望的外觀、表現或者行為。試著將當前能否實現的限制拋諸腦後,僅針對理想的使用者體驗進行逆向思考。

另一種思考方式,是向自己提問:「該平台的使用者要想完成這個操作,需要什麼樣的互動?」接著開始設想如何在應用內正常且無妥協地實現它。

如果你本身不是這個平台的常用使用者,這項工作就有一定的難度。某些特定的行為和習慣,很容易會被你完全忽略。例如,一位一直使用 Android 的使用者很有可能不清楚 iOS 平台的約定,同樣還有 macOS、Linux 和 Windows。對於身為開發者的你來說,這些差異可能微乎其微,但對於有經驗的使用者來說是顯而易見的。

尋找一位平台的實際使用者(倡導者)

最好為每一種適配平台指定一位負責人。理想情況下,負責人以他們熟悉的平台為主,提供他們對平台特有的看法和意見。若想減少人員,兼顧角色,可以安排一位支援 Windows 和 Android,一位支援 Linux 和 Web,一位支援 Mac 和 iOS。

這樣做的目的是為了得到持續且有效的反饋,讓應用在每個平臺上都能表現良好。負責人應該以挑剔的角度對平台實現進行把關。一個非常簡單的例子是在對話方塊裡,對話方塊本身按鈕的預設位置在 Mac 和 Linux 上通常位於左側,而在 Windows 上位於右側。如果你不是平台的常用使用者,通常會錯過這樣的細節。

保持應用的獨特

應用並不一定需要預設的元件或樣式來保證其符合期望的行為。許多非常流行的多平臺應用都有自成一派的 UI,包括自訂按鈕、選項選單和標題欄等。

跨平臺樣式內容越多,開發和測試就越輕鬆。在建構你的使用者體驗時,要注意平衡對它們的選擇,同時還要尊重各個平台的規範。

需要考慮的常見平台行為習慣與規範

讓我們來快速瀏覽一下你可能需要考慮的規範和習慣,瞭解一下在 Flutter 中如何實現它們。

捲軸的外觀和行為

無論是桌面端還是移動端的使用者,都需要捲軸,但他們對不同平台所期待的行為是不一樣的。移動端的使用者希望捲軸小一些,只在滾動時出現,而桌面端的使用者一般想要更大且一直顯示的捲軸,同時可以點選和拖動。

Flutter 內建了 Scrollbar widget,會根據當前所在的平台自適應顏色和大小。你可能會需要調整 alwaysShown 以在桌面平臺上一直顯示捲軸:

return Scrollbar(
  thumbVisibility: DeviceType.isDesktop,
  controller: _scrollController,
  child: GridView.count(
      controller: _scrollController,
      padding: EdgeInsets.all(Insets.extraLarge),
      childAspectRatio: 1,
      crossAxisCount: colCount,
      children: listChildren),
);

對這些細節的把握,可以讓你的應用在對應平臺上體驗更為良好。

多選

跨平臺的另一個存在差異的地方,是如何處理列表中的多選:

static bool get isSpanSelectModifierDown =>
    isKeyDown({LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftRight});

要想監測不同平台的 Control 或 Command 鍵,你可以編寫以下的程式碼:

static bool get isMultiSelectModifierDown {
  bool isDown = false;
  if (Platform.isMacOS) {
    isDown = isKeyDown(
        {LogicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaRight});
  } else {
    isDown = isKeyDown(
        {LogicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlRight});
  }
  return isDown;
}

最後一項針對鍵盤使用者需要考慮的是 全選 操作。如果你的列表裡有很多的可選擇內容,可能你的許多使用者也會希望能使用 Control+A 選中所有內容。

觸屏裝置

在觸屏裝置上,多選操作通常會被簡化,與在桌面上按下了 isMultiSelectModifier(多選按鈕)的行為類似。

在不同裝置上處理多選操作,取決於你的使用案例是否有區分,但更重要的是為各個平台提供最好的互動模式。

可選中的文字

對於 Web 平台(以及小部分的桌面平台)而言,大部分能看到的文字都是可以使用滑鼠選擇的。如果不能選擇,使用者可能會感到不正常。

幸運的是,使用 SelectableText 就可以很簡單地支援選擇:

return SelectableText('Select me!');

可以用 TextSpan 支援富文字:

return SelectableText.rich(
  TextSpan(
    children: [
      TextSpan(text: 'Hello'),
      TextSpan(text: 'Bold', style: TextStyle(fontWeight: FontWeight.bold)),
    ],
  ),
);

標題欄

在現代的桌面應用程式中,經常會有客製應用視窗的標題欄、新增 Logo 或者其他控制的需求,能節省介面對於垂直空間的佔用。

Samples of title bars

Flutter 並沒有內建這樣的支援,但是你可以使用 bits_dojo package 禁用標題欄,並且替換成自己的。

你可以利用這個 package 將任意 widget 應用在標題欄上,因為它是基於 Flutter 的 widget 進行設定的。如此一來,當你在應用內各個地方瀏覽時,標題欄都能以非常便捷的方式進行適配。

上下文選單和提示

在桌面平臺上,通常有幾種在疊加層中顯示的互動元件,它們各自有不同的觸發、關閉和定位方式:

  • 上下文選單——通常在右鍵單擊時顯示,上下文選單會顯示在滑鼠點選位置的附近,可以點選任意位置關閉、點選選項關閉或點選外部區域關閉。

  • 提示——提示通常會在互動元素上懸停 200-400 毫秒後出現,一般會錨定在 widget 上(與滑鼠位置相反),並在滑鼠移出元素後消失。

  • 懸浮面板(浮出控制項)——懸浮面板與提示類似,通常會錨定在 widget 上。它與提示的區別是一般會在點選事件觸發時顯示,並且在滑鼠移出時不會自動消失。通常來說,點選外部區域或者 關閉提交 按鈕時會關閉懸浮面板。

若你想在 Flutter 中顯示一個簡單的提示,你可以使用 Tooltip widget:

return const Tooltip(
  message: 'I am a Tooltip',
  child: Text('Hover over the text to show a tooltip.'),
);

Flutter 同時也為編輯和選擇文字提供了內建的上下文選單。

若你想顯示更進階的提示、懸浮面板或自訂的上下文選單,你可以使用已有的 package,或利用 StackOverlay 進行建構。

可以使用的 package 包括:

儘管這些控制對於觸控使用者來說只是一種增強,但對於桌面使用者而言,它們是必不可少的。桌面使用者會期望能夠右鍵點選其中一些內容,當場進行編輯,懸浮時檢視更多資訊。若你的應用並不包含這類互動,相關的使用者群體可能會感到有些失望,或是認為某些地方不合理。

按鈕的水平排列

在 Windows 上展示一行按鈕時,確認按鈕會在一行的起始位置(左側)。而在其他平臺上,則是完全相反的,確認按鈕顯示在末尾位置(右側)。

在 Flutter 裡你可以很輕鬆地修改 RowTextDirection 來達到這個效果:

TextDirection btnDirection =
    DeviceType.isWindows ? TextDirection.rtl : TextDirection.ltr;
return Row(
  children: [
    Spacer(),
    Row(
      textDirection: btnDirection,
      children: [
        DialogButton(
            label: 'Cancel',
            onPressed: () => Navigator.pop(context, false)),
        DialogButton(
            label: 'Ok', onPressed: () => Navigator.pop(context, true)),
      ],
    ),
  ],
);

Sample of embedded image

Sample of embedded image

桌面平台有另一種常見的內容:選單欄。在 Windows 和 Linux 上,Chrome 的選單欄整合在標題欄內,而在 macOS 上,選單欄在主螢幕的頂部。

目前你可以使用一個原型外掛來指定選單欄的入口,我們希望這個功能最終能合併到 SDK 中。

值得一提的是,在 Windows 和 Linux 上,你無法將自訂的標題欄與選單欄整合在一起。在建構自訂的標題欄時,實際上是替換了整個原生的標題欄,意味著你也同時失去了原生的選單欄。

如果你同時需要自訂的標題欄和選單欄,你可以使用 Flutter 進行實現,類似於自訂的上下文選單。

拖放(拖動和放置)

拖放是基於觸控和指標的互動的一項核心。雖然這兩種互動型別都需要拖放,但是在滑動整個包含可拖拽元素的列表時,仍然需要考慮其中的差異。

一般來說,觸屏使用者希望看到可拖動的手柄,以區分拖動和滾動的範圍,或者透過長按操作來進行拖動。這是由於滑動和拖動操作都是由一個觸控點完成的。

滑鼠使用者有著不止一種輸入方式。他們可以使用滾輪和滑動條進行滑動,這樣便不再專門需要操作手柄進行指示操作。如果你使用過 macOS 的訪達和 Windows 的資源管理器,你會看到它們在選中一個元素後,就可以開始拖動。

在 Flutter 中,你可以用多種方式實現拖放。但是我們不在本篇文章中討論這個話題,以下是一些更進階的選項:

在 Flutter 中,你可以用多種方式實現拖放。但是我們不在本篇文章中討論這個話題,以下是一些更進階的選項:

自身做到熟悉基本的可用性原則

當然,這篇文章並不代表你僅需要考慮這些內容。針對平台設計的規範,會隨著你適配的平台、裝置外形和輸入裝置數量的增加而變得更為複雜。

作為開發人員,你應當花一些時間學習基本的可用性原則,幫助你做出更好的決策,減少由設計細節帶來的返工時間消耗,從而提升自己的生產力,產出更好的結果。

你可以從下列的資源開始學習: