建立適應性應用程式

概觀

Flutter 提供新的機會,可以從單一程式碼庫建立可以在手機、電腦和網路執行的應用程式。然而,有了這些機會,也出現了新的挑戰。您希望您的應用程式對使用者來說很熟悉,透過最佳化可用性並確保舒適且無縫的體驗,來適應每個平台。也就是說,您需要建立的不只是跨平台應用程式,而是完全適應平台的應用程式。

開發適應平台的應用程式有很多考量因素,但它們可分為三大類

此頁面使用程式碼片段詳細說明所有三種類別,以說明這些概念。如果您想了解這些概念如何結合在一起,請查看使用這裡所述概念建立的 FlokkFolio 範例。

來自 flutter-adaptive-demo 的適應性應用程式開發技術原始範例程式碼。

建立適應性版面

撰寫適用於多個平台的應用程式時,您必須考慮的第一件事之一,是如何讓應用程式適應它將執行的各種螢幕大小和形狀。

版面小工具

如果您一直在建置應用程式或網站,您可能熟悉建立回應式介面。對 Flutter 開發人員來說很幸運的是,有許多小工具可以讓這件事變得更容易。

Flutter 最有用的版面小工具包括

單一子項

  • Align—將子項與自身對齊。它採用介於 -1 和 1 之間的雙值,用於垂直和水平對齊。

  • AspectRatio—嘗試將子項調整為特定長寬比。

  • ConstrainedBox—對其子項施加大小限制,提供對最小或最大大小的控制。

  • CustomSingleChildLayout—使用委派函式來定位單一子項。委派可以決定子項的版面限制和定位。

  • ExpandedFlexible—允許 RowColumn 的子項縮小或擴大以填滿任何可用空間。

  • FractionallySizedBox—將其子項調整為可用空間的一小部分。

  • LayoutBuilder—建置一個小工具,可以根據其父項大小重新整理自身。

  • SingleChildScrollView—將捲動新增至單一子項。通常與 RowColumn 一起使用。

多子項

  • ColumnRowFlex—將子項排列在單一水平或垂直執行中。ColumnRow 都擴充了 Flex 這個小工具。

  • CustomMultiChildLayout—在配置階段使用委派函式來定位多個子項。

  • Flow—類似於 CustomMultiChildLayout,但更有效率,因為它是在繪製階段而不是配置階段執行。

  • ListViewGridViewCustomScrollView—提供子項的可捲動清單。

  • Stack—相對於 Stack 的邊緣,分層並定位多個子項。功能類似於 CSS 中的 position-fixed。

  • Table—為其子項使用傳統的表格配置演算法,結合多個列和欄。

  • Wrap—在多個水平或垂直執行中顯示其子項。

如需查看更多可用的小工具和範例程式碼,請參閱Layout 小工具

視覺密度

不同的輸入裝置提供不同精準度等級,這需要不同大小的點擊區域。Flutter 的 VisualDensity 類別讓您能輕鬆調整整個應用程式的檢視密度,例如,在觸控裝置上將按鈕加大(因此更容易點選)。

當您變更 MaterialAppVisualDensity 時,支援它的 MaterialComponents 會動態調整其密度以符合。預設情況下,水平和垂直密度都設定為 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 Components 通常對每個視覺密度單位使用約 4 個邏輯畫素的值。如需有關支援元件的更多資訊,請參閱 VisualDensity API。如需有關一般密度原則的更多資訊,請參閱 Material Design 指南

情境式配置

如果你需要的不只是密度變更,而且找不到符合你需求的小工具,你可以採取更程序化的方式來調整參數、計算大小、交換小工具,或完全重新建構你的使用者介面以符合特定的外觀因素。

基於螢幕的斷點

程序化配置最簡單的形式是使用基於螢幕的斷點。在 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;
}

基於螢幕的斷點最適合用於在你的應用程式中做出頂層決策。在全球基礎上定義時,最好變更視覺密度、內距或字型大小等事項。

你也可以使用基於螢幕的斷點來重新整理你的頂層小工具樹。例如,當使用者不在手機上時,你可以從垂直配置切換到水平配置

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

在另一個小工具中,你可能會完全交換一些子項

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

使用 LayoutBuilder 以獲得額外的彈性

即使檢查總螢幕大小對於全螢幕頁面或做出全球配置決策來說很棒,但對於巢狀子檢視來說通常並非理想。通常,子檢視有自己的內部斷點,而且只關心它們可用於呈現的空間。

在 Flutter 中處理此問題最簡單的方式是使用 LayoutBuilder 類別。 LayoutBuilder 允許小工具回應傳入的區域大小限制,這可以讓小工具比依賴於全域變數時更靈活。

先前的範例可以使用 LayoutBuilder 重寫。

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

這個小工具現在可以在側邊欄、對話方塊,甚至全螢幕檢視中組成,並根據提供的空間調整其版面。

裝置區隔

有時,您會希望根據實際執行的平台做出版面決策,而不管大小。例如,在建立自訂標題列時,您可能需要檢查作業系統類型並調整標題列的版面,以免被原生視窗按鈕覆蓋。

要判斷您在哪些平台組合上,您可以使用 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 建置存取 Platform API 而不會擲回例外,因為 dart.io 套件不支援 Web 目標。因此,上述程式碼會先檢查 Web,而且由於短路,Dart 永遠不會在 Web 目標上呼叫 Platform

在邏輯絕對必須針對特定平台執行時,請使用 Platform/kIsWeb。例如,與僅在 iOS 上運作的外掛程式對話,或顯示僅符合 Play 商店政策而非 App Store 的小工具。

樣式的單一真實來源

如果您為填充、間距、角落形狀、字型大小等樣式值建立單一真實來源,您可能會發現維護您的檢視變得更容易。這可以使用一些輔助類別輕鬆完成

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 = TextStyle(
    fontFamily: Fonts.raleway,
  );
  static TextStyle buttonText1 =
      const TextStyle(fontWeight: FontWeight.bold, fontSize: 14);
  static TextStyle buttonText2 =
      const TextStyle(fontWeight: FontWeight.normal, fontSize: 11);
  static TextStyle h1 =
      const TextStyle(fontWeight: FontWeight.bold, fontSize: 22);
  static TextStyle h2 =
      const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
  static TextStyle body1 = raleway.copyWith(color: const Color(0xFF42A5F5));
  // etc
}

這些常數接著可以使用在硬編碼數字值的地方

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

使用 Theme.of(context).platform 進行主題和設計選擇,例如要顯示哪種類型的切換以及一般性的 Cupertino/Material 改編。

透過所有檢視參照相同的共用設計系統規則,它們往往看起來更好且更一致。在單一位置變更或調整特定平台的值,而不是使用容易出錯的搜尋和取代。使用共用規則有額外的優點,有助於在設計方面強制一致性。

一些可以這樣表示的常見設計系統類別是

  • 動畫時機
  • 大小和中斷點
  • 內嵌和填充
  • 圓角半徑
  • 陰影
  • 筆劃
  • 字型系列、大小和樣式

就像大多數規則一樣,有例外:一次性值,在應用程式中其他地方沒有使用。用這些值弄亂樣式規則沒有什麼意義,但值得考慮它們是否應該從現有值衍生(例如,padding + 1.0)。您還應該注意相同語意值的重複使用或複製。這些值應該新增到全域樣式規則集。

針對每個外形因子的優點進行設計

除了螢幕大小之外,您還應該花時間考慮不同外形因子的獨特優點和缺點。您的多平台應用程式在所有地方提供相同的功能並不總是理想的。考慮是否專注於特定功能,甚至在某些裝置類別中移除特定功能是否合理。

例如,行動裝置是可攜式的且配備相機,但它們不適合用於詳細的創意工作。考量到這點,您可能會更專注於擷取內容並標記其位置資料以用於行動 UI,但專注於整理或操作該內容以用於平板電腦或桌上型電腦 UI。

另一個範例是利用網路極低的共用門檻。如果您正在部署網路應用程式,請決定要支援哪些深度連結,並考量這些連結來設計您的導覽路徑。

這裡的重點是思考每個平台最擅長的事,並查看是否有您可以利用的獨特功能。

使用桌上型電腦建置目標進行快速測試

測試適應式介面的最有效方法之一是利用桌上型電腦建置目標。

在桌上型電腦上執行時,您可以在應用程式執行期間輕鬆調整視窗大小以預覽各種螢幕大小。這與熱重載結合使用,可以大幅加速回應式 UI 的開發。

Adaptive scaffold 2

優先解決觸控問題

建立優良的觸控 UI 通常比傳統的桌上型電腦 UI 困難,部分原因是缺乏右鍵按一下、滾動滾輪或鍵盤快速鍵等輸入加速器。

因應此挑戰的方法之一是最初專注於優良的觸控導向 UI。您仍然可以使用桌上型電腦目標進行大部分測試以取得其反覆運算速度。但是,請記得頻繁切換到行動裝置以驗證一切是否正確。

在觸控介面經過調整後,您可以微調滑鼠使用者的視覺密度,然後分層處理所有其他輸入。將這些其他輸入視為加速器,即讓任務更快的替代方案。要考慮的重要事項是使用者在使用特定輸入裝置時會預期什麼,並努力在您的應用程式中反映這一點。

輸入

當然,光是調整應用程式的顯示方式是不夠的,您還必須支援不同的使用者輸入。滑鼠和鍵盤會引入觸控裝置上沒有的輸入類型,例如滾輪、右鍵點擊、游標互動、Tab 鍵瀏覽和鍵盤快速鍵。

滾輪

捲動小工具(例如 ScrollViewListView)預設支援滾輪,而且由於幾乎每個可捲動的客製小工具都是使用其中一個小工具建置的,因此它也支援這些小工具。

如果您需要實作客製捲動行為,可以使用 Listener 小工具,它讓您可以自訂使用者介面對滾輪的反應方式。

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

Tab 鍵瀏覽和焦點互動

使用實體鍵盤的使用者預期他們可以使用 Tab 鍵快速瀏覽您的應用程式,而有動作或視覺障礙的使用者通常完全依賴鍵盤瀏覽。

標籤互動有兩個考量:焦點如何在小工具之間移動(稱為遍歷),以及小工具獲得焦點時顯示的視覺亮點。

大多數內建元件(例如按鈕和文字欄位)預設支援遍歷和亮點。如果您有自己的小工具想要包含在遍歷中,您可以使用 FocusableActionDetector 小工具建立自己的控制項。它結合了 ActionsShortcutsMouseRegionFocus 小工具的功能,建立一個偵測器,定義動作和按鍵繫結,並提供用於處理焦點和懸停亮點的回呼函式。

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: [
          const 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 鍵時小工具的獲得焦點順序,您可以使用 FocusTraversalGroup 定義樹狀結構中應在標籤鍵中視為群組的部分。

例如,您可能想要在標籤鍵跳到送出按鈕之前,先標籤鍵跳過表單中的所有欄位

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

Flutter 有多種內建方式可以遍歷小工具和群組,預設為 ReadingOrderTraversalPolicy 類別。此類別通常運作良好,但您可以使用其他預先定義的 TraversalPolicy 類別,或建立自訂政策來修改此類別。

鍵盤加速器

除了標籤瀏覽之外,桌面和網路使用者習慣於將各種鍵盤快速鍵繫結到特定動作。無論是 Delete 鍵用於快速刪除,還是 Control+N 用於建立新文件,請務必考慮使用者預期的不同加速器。鍵盤是一個強大的輸入工具,因此請盡可能從中擠出最大的效率。您的使用者會很感激的!

在 Flutter 中,可以透過幾種方式來完成鍵盤加速器,具體取決於您的目標。

如果您有一個像 TextFieldButton 這樣的單一小工具,它已經有一個焦點節點,您可以將它包在 KeyboardListenerFocus 小工具中,並偵聽鍵盤事件

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

如果您想將一組鍵盤快速鍵套用至樹狀結構的大部分區段,可以使用 Shortcuts 小工具

// 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: const <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 小工具之所以有用,是因為它只允許在這個小工具樹或其子項具有焦點且可見時才觸發快速鍵。

最後一個選項是全域偵聽器。此偵聽器可用於持續開啟的應用程式範圍快速鍵,或用於在可見時可以接受快速鍵的面板(無論其焦點狀態如何)。使用 HardwareKeyboard 可以輕鬆新增全域偵聽器

@override
void initState() {
  super.initState();
  HardwareKeyboard.instance.addHandler(_handleKey);
}

@override
void dispose() {
  HardwareKeyboard.instance.removeHandler(_handleKey);
  super.dispose();
}

若要使用全域監聽器檢查按鍵組合,你可以使用 HardwareKeyboard.instance.logicalKeysPressed 集合。例如,類似下列方法可以檢查是否按住任何提供的按鍵

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

將這兩件事放在一起,你可以在按下 Shift+N 時觸發動作

bool _handleKey(KeyEvent event) {
  bool isShiftDown = isKeyDown({
    LogicalKeyboardKey.shiftLeft,
    LogicalKeyboardKey.shiftRight,
  });

  if (isShiftDown && event.logicalKey == LogicalKeyboardKey.keyN) {
    _createNewItem();
    return true;
  }

  return false;
}

使用靜態監聽器時要注意的一點是,當使用者在欄位中輸入或與其關聯的小工具從檢視中隱藏時,你通常需要停用它。這與 ShortcutsKeyboardListener 不同,這是你的管理責任。當你為 Delete 繫結 Delete/Backspace 加速器,但使用者可能會輸入子 TextFields 時,這一點尤其重要。

滑鼠進入、離開和懸停

在桌面上,通常會變更滑鼠游標以指示滑鼠懸停在內容上的功能。例如,當你懸停在按鈕上時,通常會看到一個手形游標,或當你懸停在文字上時,會看到一個 I 游標。

Material Component 集合內建支援你的標準按鈕和文字游標。若要從你自己的小工具中變更游標,請使用 MouseRegion

// 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 和網路,一位負責 Mac 和 iOS。

目標是獲得持續且明智的回饋,讓應用程式在每個平台上感覺都很棒。應鼓勵倡導者挑剔一點,指出他們認為與其裝置上典型應用程式不同的任何部分。一個簡單的範例是對話方塊中的預設按鈕在 Mac 和 Linux 上通常在左側,但在 Windows 上則在右側。如果您沒有定期使用某個平台,很容易忽略此類細節。

保持獨特性

符合預期行為並不表示您的應用程式需要使用預設元件或樣式。許多最受歡迎的多平台應用程式都有非常獨特且有見地的使用者介面,包括自訂按鈕、內容選單和標題列。

您在不同平台上統一樣式和行為的程度越高,開發和測試就越容易。訣竅在於平衡建立具有強烈識別性的獨特體驗,同時尊重每個平台的規範。

常見的慣用語和規範供您參考

快速瀏覽幾個您可能想要考慮的特定規範和慣用語,以及您可以在 Flutter 中如何處理這些規範和慣用語。

捲軸列的外觀和行為

桌上型電腦和行動裝置使用者預期捲軸列,但他們預期在不同平台上會以不同的方式運作。行動裝置使用者預期較小的捲軸列,只會在捲動時出現,而桌上型電腦使用者通常預期無所不在、較大的捲軸列,他們可以按一下或拖曳。

Flutter 內建 Scrollbar 小工具,它已經支援根據目前平台調整顏色和大小。您可能想做的唯一調整是在桌上型電腦平台上切換 alwaysShown

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

對細節的微妙關注可以讓您的應用程式在特定平台上感覺更舒適。

多重選取

在清單中處理多重選取是另一個在不同平台上有微妙差異的領域

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

若要執行控制或命令的平台感知檢查,您可以撰寫類似以下內容

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。您可以使用單次輕觸選取或取消選取項目,通常會有按鈕可以全選清除目前的選取。

您在不同裝置上處理多重選取的方式取決於您的特定使用案例,但重要的是確保您為每個平台提供最佳的互動模式。

可選取文字

網頁上(以及在較小程度上,桌面)的常見預期是,大多數可見文字都可以用滑鼠游標選取。當文字無法選取時,網頁上的使用者往往會有不良反應。

幸運的是,這很容易透過 SelectableText 小工具來支援

return const SelectableText('Select me!');

若要支援豐富文字,請使用 TextSpan

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

標題列

在現代桌面應用程式中,自訂應用程式視窗的標題列是很常見的,加入標誌以加強品牌識別度或加入情境控制項以協助在主 UI 中節省垂直空間。

Samples of title bars

這在 Flutter 中並未直接支援,但你可以使用 bits_dojo 套件來停用原生標題列,並用你自己的標題列取代它們。

這個套件讓你可以在 TitleBar 中加入任何你想要的元件,因為它在後台使用純粹的 Flutter 元件。這使得在導覽到應用程式的不同區段時,可以輕鬆調整標題列。

內容選單和工具提示

在桌面上,有許多互動會以覆疊中顯示的小工具形式呈現,但觸發、解除和定位方式有所不同

  • 內容選單—通常透過右鍵觸發,內容選單會定位在滑鼠附近,並透過按一下任何地方、從選單中選取選項或按一下選單外側來解除。

  • 工具提示—通常透過將滑鼠游標懸停在互動元素上 200-400 毫秒來觸發,工具提示通常會固定在小工具上(與滑鼠位置相反),並在滑鼠游標離開該小工具時解除。

  • 彈出式面板(也稱為飛出式面板)—類似於工具提示,彈出式面板通常繫結至小工具。主要差異在於面板通常會在點選事件中顯示,且通常不會在游標離開時隱藏。相反地,面板通常會在點選面板外側或按下關閉提交按鈕時關閉。

若要在 Flutter 中顯示基本工具提示,請使用內建的 Tooltip 小工具

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

Flutter 也在編輯或選取文字時提供內建的內容選單。

若要顯示更進階的工具提示、彈出式面板,或建立自訂內容選單,您可以使用其中一個可用的套件,或使用 StackOverlay 自行建置。

一些可用的套件包括

雖然這些控制項對於觸控使用者來說可以作為加速器,但對於滑鼠使用者來說卻是必要的。這些使用者預期可以右鍵點選項目、就地編輯內容,以及將游標懸停以取得更多資訊。無法滿足這些期望可能會導致使用者失望,或至少會覺得有些地方不對勁。

水平按鈕順序

在 Windows 上,在呈現一行按鈕時,確認按鈕會置於該行的開頭(左側)。在所有其他平台上,則相反。確認按鈕會置於該行的結尾(右側)。

這可以使用 Row 上的 TextDirection 屬性在 Flutter 中輕鬆處理

TextDirection btnDirection =
    DeviceType.isWindows ? TextDirection.rtl : TextDirection.ltr;
return Row(
  children: [
    const 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 Finder 或 Windows Explorer,你會看到它們就是這樣運作的:你只要選取一個項目並開始拖曳即可。

在 Flutter 中,你可以用很多方式實作拖放。討論具體實作並不在本文的範圍內,但一些高階選項是

了解基本可用性原則

當然,此頁面並未列出您可能會考慮的所有事項。您支援的作業系統、外形規格和輸入裝置越多,在設計中指定每個排列組合的難度就越高。

花時間學習基本可用性原則,讓開發人員能夠做出更好的決策,減少生產期間與設計人員之間的往返迭代,並透過更好的結果提高生產力。

以下是一些讓您入門的資源