建構自適應的應用
概覽
Flutter 為在移動端、桌面端和 Web 端使用同樣的程式碼建構應用創造了新的機會。伴隨著機會而來的,是新的挑戰。你可能會希望你的應用既能在儘可能複用的情況下自適應多個平台,又能保證流暢且無縫的體驗,還可以讓使用者保持一致的使用習慣。這樣的應用不僅僅是為了多個平台而建構的,它能完全地自適應平台的變化。
在建構平台自適應的應用時,有眾多的考量因素,總的來說分為以下幾類:
指南將透過程式碼片段,詳細說明三個類別的概念。若你想了解這些概念的實際落地情況,可以參考 Flokk 和 Folio 範例。
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
——使用代理方法對單個子級進行定位。代理方法可以為子級確定佈局限制和定位。 -
FractionallySizedBox
——基於剩餘空間的比例限定子級的大小。 -
LayoutBuilder
——讓子級可以基於父級的尺寸重新調整其佈局。 -
SingleChildScrollView
——為單一的子級新增滾動。通常配合Row
或Column
進行使用。
多子級 (Multi child)
-
Column
、Row
和Flex
—— 在同一水平線或垂直線上放置所有子級。Column
和Row
都繼承了Flex
widget。 -
CustomMultiChildLayout
—— 在佈局過程中使用代理方法對多個子級進行定位。 -
Flow
——相對於CustomMultiChildLayout
更高效的佈局方式。在繪製過程中使用代理方法對多個子級進行定位。 -
ListView
、GridView
和CustomScrollView
—— 為所有子級增加滾動支援。 -
Stack
——基於Stack
的邊界對多個子級進行放置和定位。與 CSS 中的position: fixed
功能類似。 -
Table
——使用經典的表格佈局演算法,可以組合多列和多行。 -
Wrap
——將子級順序顯示在多行或多列內。
檢視 佈局 widgets 瞭解更多的 widgets 和程式碼範例。
視覺密度
不同的裝置會提供不同級別的顯示密度,使得操作的命中區域也要隨之變化。
Flutter 的 VisualDensity
類可以讓你快速地調整整個應用的檢視密度,比如在可觸控裝置上放大一個按鈕(使其更容易被點選)。
不同的裝置會提供不同級別的顯示密度,使得操作的命中區域也要隨之變化。
Flutter 的 VisualDensity
類可以讓你快速地調整整個應用的檢視密度,比如在可觸控裝置上放大一個按鈕(使其更容易被點選)。
在你改變 MaterialApp
的 VisualDensity
時,已支援 VisualDensity
的 MaterialComponents
會以動畫過渡的形式改變其自身的密度。水平和垂直方向的密度預設都為 0.0,你可以將它設定為任意的正負值,這樣就可以透過調整密度輕鬆地調整你的 UI:
若想使用自訂的視覺密度,請在你的 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 內處理這類場景最簡單的做法是使用 LayoutBuilder
。
LayoutBuilder
讓 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,並根據配置來設計應用的導航。
此處的關鍵點在於,如何發揮每個平台的長處,尋找平台可以利用的特有功能。
透過建構桌面應用程式進行快速測試
測試自適應介面的最快方式,是利用桌面端快速進行建構。
在桌面上執行應用時,你可以在應用執行時輕易地改變視窗的大小,預覽多種尺寸的佈局。配上熱重載,能極大程度地加快響應式開發的速度。
優先處理觸控操作
在移動端建構優良的觸控互動式 UI 通常比傳統的桌面端更為困難,因為它缺少類似右鍵單擊、滾輪或鍵盤快捷鍵這樣的快速輸入裝置。
在一開始就專注於提升觸控體驗的 UI,足以應對這樣的挑戰。你依舊可以使用桌面端來提高你的開發效率,但要記得時不時切換回移動端,驗證開發的內容是否正常。
完善了觸控介面後,你可以調整面向滑鼠使用者的視覺密度,然後對所有的輸入裝置進行分層。這些輸入裝置應當作為加快你的應用使用速度的途徑。在這裡需要考慮的應當是使用者對於應用體驗的期望,並在應用中合理地實現這些期望。
輸入
當然,應用只適配了介面是遠遠不夠的,你還需要適配各種使用者的輸入操作。滑鼠和鍵盤提供了觸控裝置不具備的輸入方式,例如滾輪、右鍵點選、懸停互動、Tab 遍歷切換和鍵盤快捷鍵。
滾輪
像 ScrollView
和 ListView
這樣的滾動 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
進行控制。它將 Actions
、Shortcuts
、MouseRegion
和 Focus
的能力進行了整合,創建出一個可以定義行為和鍵位繫結,並且提供聚焦和懸浮高亮事件回呼(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 從檢視中隱藏時,通常需要禁用監聽。與 Shortcuts
和 RawKeyboardListener
不同,你需要自己對它們進行管理。當你在為 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 或者其他控制的需求,能節省介面對於垂直空間的佔用。
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,或利用 Stack
和 Overlay
進行建構。
可以使用的 package 包括:
儘管這些控制對於觸控使用者來說只是一種增強,但對於桌面使用者而言,它們是必不可少的。桌面使用者會期望能夠右鍵點選其中一些內容,當場進行編輯,懸浮時檢視更多資訊。若你的應用並不包含這類互動,相關的使用者群體可能會感到有些失望,或是認為某些地方不合理。
按鈕的水平排列
在 Windows 上展示一行按鈕時,確認按鈕會在一行的起始位置(左側)。而在其他平臺上,則是完全相反的,確認按鈕顯示在末尾位置(右側)。
在 Flutter 裡你可以很輕鬆地修改 Row
的 TextDirection
來達到這個效果:
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)),
],
),
],
);
選單欄
桌面平台有另一種常見的內容:選單欄。在 Windows 和 Linux 上,Chrome 的選單欄整合在標題欄內,而在 macOS 上,選單欄在主螢幕的頂部。
目前你可以使用一個原型外掛來指定選單欄的入口,我們希望這個功能最終能合併到 SDK 中。
值得一提的是,在 Windows 和 Linux 上,你無法將自訂的標題欄與選單欄整合在一起。在建構自訂的標題欄時,實際上是替換了整個原生的標題欄,意味著你也同時失去了原生的選單欄。
如果你同時需要自訂的標題欄和選單欄,你可以使用 Flutter 進行實現,類似於自訂的上下文選單。
拖放(拖動和放置)
拖放是基於觸控和指標的互動的一項核心。雖然這兩種互動型別都需要拖放,但是在滑動整個包含可拖拽元素的列表時,仍然需要考慮其中的差異。
一般來說,觸屏使用者希望看到可拖動的手柄,以區分拖動和滾動的範圍,或者透過長按操作來進行拖動。這是由於滑動和拖動操作都是由一個觸控點完成的。
滑鼠使用者有著不止一種輸入方式。他們可以使用滾輪和滑動條進行滑動,這樣便不再專門需要操作手柄進行指示操作。如果你使用過 macOS 的訪達和 Windows 的資源管理器,你會看到它們在選中一個元素後,就可以開始拖動。
在 Flutter 中,你可以用多種方式實現拖放。但是我們不在本篇文章中討論這個話題,以下是一些更進階的選項:
在 Flutter 中,你可以用多種方式實現拖放。但是我們不在本篇文章中討論這個話題,以下是一些更進階的選項:
-
使用
Draggable
和DragTarget
API 客製介面和互動。 -
監聽
onPan
手勢事件,利用Stack
移動物件。 -
使用 pub.dev 上一些 預先實現的 package。
自身做到熟悉基本的可用性原則
當然,這篇文章並不代表你僅需要考慮這些內容。針對平台設計的規範,會隨著你適配的平台、裝置外形和輸入裝置數量的增加而變得更為複雜。
作為開發人員,你應當花一些時間學習基本的可用性原則,幫助你做出更好的決策,減少由設計細節帶來的返工時間消耗,從而提升自己的生產力,產出更好的結果。
你可以從下列的資源開始學習: