构建自适应的应用

概览

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 的能力进行了整合,创建出一个可以定义行为和键位绑定,并且提供聚焦和悬浮高亮事件回调的 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 从视图中隐藏时,通常需要禁用监听。与 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 中,你可以用多种方式实现拖放。但是我们不在本篇文章中讨论这个话题,以下是一些更高级的选项:

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

当然,这篇文章并不代表你仅需要考虑这些内容。针对平台设计的规范,会随着你适配的平台、设备外形和输入设备数量的增加而变得更为复杂。

作为开发人员,你应当花一些时间学习基本的可用性原则,帮助你做出更好的决策,减少由设计细节带来的返工时间消耗,从而提升自己的生产力,产出更好的结果。

你可以从下列的资源开始学习: