构建自适应应用

概述

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——在多个水平或垂直运行中显示其子级。

要查看更多可用的窗口小部件和示例代码,请参阅 布局窗口小部件

视觉密度

不同的输入设备提供不同级别的精度,这需要不同大小的命中区域。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 指南

上下文布局

如果您需要超过密度更改并且找不到满足您需要的窗口小部件,则可以采取更程序化的方式来调整参数、计算大小、交换窗口小部件或完全重新构建 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;
}

基于屏幕的断点最适合在您的应用中做出顶级决策。最好在全局基础上定义更改视觉密度、填充或字体大小等内容。

您还可以使用基于屏幕的断点来重新排列您的顶级窗口小部件树。例如,当用户不在手机上时,您可以从垂直布局切换到水平布局

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 组织或操作该内容。

另一个例子是利用网络极低的共享障碍。如果您正在部署 Web 应用程序,请决定支持哪些深度链接,并根据这些链接设计您的导航路线。

这里的关键是要考虑每个平台最擅长的方面,并查看是否有您可以利用的独特功能。

使用桌面构建目标进行快速测试

测试自适应界面的最有效方法之一是利用桌面构建目标。

在桌面上运行时,您可以在应用程序运行时轻松调整窗口大小以预览各种屏幕尺寸。这与热重载相结合,可以极大地加速响应式 UI 的开发。

Adaptive scaffold 2

优先解决触控

构建出色的触控 UI 通常比传统的桌面 UI 更困难,部分原因是缺少诸如右键单击、滚轮或键盘快捷键之类的输入加速器。

应对这一挑战的一种方法是,最初专注于出色的触控式 UI。您仍然可以使用桌面目标进行大部分测试,以提高其迭代速度。但请记住,要频繁切换到移动设备,以验证一切是否正常。

在触控界面完善后,您可以调整鼠标用户的视觉密度,然后分层添加所有其他输入。将这些其他输入视为加速器,即可以加快任务的替代方案。需要考虑的重要事项是,用户在使用特定输入设备时会有什么期望,并努力在您的应用中反映这一点。

输入

当然,仅仅调整应用的外观是不够的,您还必须支持不同的用户输入。鼠标和键盘引入了触控设备上没有的输入类型,例如滚轮、右键单击、悬停交互、制表符遍历和键盘快捷键。

滚轮

滚动小组件(如 ScrollViewListView)默认支持滚轮,而且由于几乎每个可滚动自定义小组件都是使用其中一个构建的,因此它也适用于这些小组件。

如果您需要实现自定义滚动行为,可以使用 Listener 小组件,它允许您自定义 UI 对滚轮的反应方式。

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

制表符遍历和焦点交互

使用实体键盘的用户希望他们可以使用制表符键快速导航您的应用程序,而行动或视觉有差异的用户通常完全依赖键盘导航。

制表符交互有两个注意事项:焦点如何从一个小组件移动到另一个小组件(称为遍历),以及小组件获得焦点时显示的视觉突出显示。

大多数内置组件(如按钮和文本字段)默认支持遍历和突出显示。如果您有自己的小组件希望包含在遍历中,可以使用 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(),
            )
        ],
      ),
    );
  }
}

控制遍历顺序

要更好地控制用户按下制表符时小组件获得焦点的顺序,可以使用 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 和 Web 指定一个倡导者,为 Mac 和 iOS 指定一个倡导者。

目标是获得持续的、明智的反馈,以便该应用在每个平台上都能感觉良好。应鼓励倡导者非常挑剔,指出他们认为与设备上的典型应用程序不同的任何内容。一个简单的例子是,对话框中的默认按钮通常在 Mac 和 Linux 上位于左侧,但在 Windows 上位于右侧。如果您不经常使用平台,则很容易错过此类详细信息。

保持独特性

遵循预期行为并不意味着您的应用需要使用默认组件或样式。许多最流行的多平台应用具有非常独特且有见地的 UI,包括自定义按钮、上下文菜单和标题栏。

您在不同平台上整合样式和行为的程度越高,开发和测试就越容易。诀窍在于在尊重每个平台的规范的同时,平衡创建具有独特体验和强大标识。

要考虑的常见惯用语和规范

快速了解一些您可能想要考虑的特定规范和惯用语,以及如何在 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 中,您可以通过多种方式实现拖放。讨论具体的实现超出了本文的范围,但一些高级选项是

了解基本可用性原则

当然,此页面并未构成您可能考虑的事项的详尽列表。您支持的操作系统、外形尺寸和输入设备越多,在设计中指定每个排列就变得越困难。

作为一名开发人员,花时间学习基本可用性原则使您能够做出更好的决策,减少在生产过程中与设计之间的反复迭代,并通过更好的结果提高生产力。

以下是一些帮助您入门的信息