构建自适应的应用
概览
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
类可以让你快速地调整整个应用的视图密度,比如在可触控设备上放大一个按钮(使其更容易被点击)。
在你改变 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;
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
的能力进行了整合,创建出一个可以定义行为和键位绑定,并且提供聚焦和悬浮高亮事件回调的 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
,你可以嵌套一个 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。
自身做到熟悉基本的可用性原则
当然,这篇文章并不代表你仅需要考虑这些内容。针对平台设计的规范,会随着你适配的平台、设备外形和输入设备数量的增加而变得更为复杂。
作为开发人员,你应当花一些时间学习基本的可用性原则,帮助你做出更好的决策,减少由设计细节带来的返工时间消耗,从而提升自己的生产力,产出更好的结果。
你可以从下列的资源开始学习: