跳转至正文

用户输入与无障碍

真正自适应的应用还需处理用户输入方式的差异,并兼顾无障碍辅助功能。

仅适配应用外观不够,还需支持多种用户输入。鼠标与键盘带来触控设备之外的输入类型,如滚轮、右键、悬停交互、Tab 遍历与键盘快捷键。

部分功能在 Material widget 上默认可用。但若创建了自定义 widget,可能需要自行实现。

优秀应用设计的部分功能也有助使用辅助技术的用户。例如,除属于良好应用设计外,Tab 遍历与键盘快捷键等对_使用辅助设备的用户至关重要_。除创建无障碍应用的标准建议外,本页涵盖同时实现自适应_与_无障碍的信息。

自定义 widget 的滚轮

#

ScrollViewListView 等滚动 widget 默认支持滚轮,几乎所有可滚动自定义 widget 都基于它们构建,因此同样有效。

若需实现自定义滚动行为,可使用 Listener widget 自定义 UI 对滚轮的反应。

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

Tab 遍历与焦点交互

#

使用实体键盘的用户预期可用 Tab 键快速导航应用,运动或视觉差异用户往往完全依赖键盘导航。

Tab 交互需考虑两点:焦点如何在 widget 间移动(遍历),以及 widget 获焦时的视觉高亮。

按钮、文本字段等大多数内置 widget 默认支持遍历与高亮。若希望自定义 widget 参与遍历,可用 FocusableActionDetector 创建控件。该 widget 便于在一个 widget 中组合焦点、鼠标输入与快捷键;可创建定义 action 与键绑定的检测器,并提供处理焦点与悬停高亮的回调。

dart
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 时 widget 的聚焦顺序,可用 FocusTraversalGroup 定义 Tab 时应作为一组处理的树片段。

例如,可先 Tab 遍历表单所有字段,再 Tab 到提交按钮:

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

Flutter 有多种内置遍历 widget 与组的方式,默认使用 ReadingOrderTraversalPolicy。该类通常表现良好,也可改用其他预定义 TraversalPolicy 或创建自定义策略。

键盘加速器

#

除 Tab 遍历外,桌面与 Web 用户习惯将各种快捷键绑定到特定操作。无论是 Delete 快速删除还是 Control+N 新建文档,务必考虑用户预期的加速器。键盘是强大的输入工具,尽量榨取其效率,用户会感谢你!

在 Flutter 中实现键盘加速器有多种方式,取决于你的目标。

若单个 widget(如已有焦点节点的 TextFieldButton)需处理快捷键,可用 KeyboardListenerFocus 包裹并监听键盘事件:

dart
  @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 widget:

dart
// 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 很有用,因为仅当该 widget 树或其子级有焦点且可见时才触发快捷键。

最后一种选择是全局监听器,可用于始终开启的应用级快捷键,或面板可见时(无论焦点状态)接受快捷键。用 HardwareKeyboard 添加全局监听器很简单:

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

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

用全局监听器检查键组合时,可使用 HardwareKeyboard.instance.logicalKeysPressed 集合。例如,以下方法可检查是否按住所提供的任一键:

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

将两者结合,可在按下 Shift+N 时触发操作:

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

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

  return false;
}

使用静态监听器时注意:用户正在字段中输入或关联 widget 不可见时,通常需禁用它。与 ShortcutsKeyboardListener 不同,这由你自行管理。绑定 Delete/Backspace 加速器时尤其重要,若子级有用户可能正在输入的 TextField

自定义 widget 的鼠标进入、离开与悬停

#

在桌面上,常通过改变鼠标光标指示悬停内容的功能。例如,悬停按钮时通常为手型光标,悬停文字时为 I 型光标。

Flutter 的 Material 按钮处理标准按钮与文字光标的基本焦点状态。(例外:若将 Material 按钮默认样式的 overlayColor 设为透明。)

为应用中任何自定义按钮或手势检测器实现焦点状态。若更改默认 Material 按钮样式,请测试键盘焦点状态并在需要时自行实现。

在自定义 widget 内更改光标,请使用 MouseRegion

dart
// 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 也适用于创建自定义 rollover 与悬停效果:

dart
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,
  ),
);

有关在获焦时为按钮添加轮廓样式的示例,请参阅 Wonderous 应用的按钮代码。应用通过 FocusNode.hasFocus 检查按钮是否获焦并添加轮廓。

视觉密度

#

例如,你可能考虑扩大 widget 的「点击区域」以适配触摸屏。

不同输入设备精度不同,需要不同尺寸的点击区域。Flutter 的 VisualDensity 类便于在全应用调整视图密度,例如在触控设备上让按钮更大(更易点击)。

当你为 MaterialApp 更改 VisualDensity 时,支持它的 MaterialComponents 会动画化其密度以匹配。默认情况下,水平和垂直密度均设为 0.0,但你可以将密度设为任意负值或正值。在不同密度之间切换,可轻松调整 UI。

Adaptive scaffold

要设置自定义视觉密度,将密度注入 MaterialApp 主题:

dart
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 时,可查找:

dart
VisualDensity density = Theme.of(context).visualDensity;

容器不仅会自动响应密度变化,变化时还会动画,将自定义与内置 widget 串联,在全应用实现平滑过渡。

如上所示,VisualDensity 无单位,对不同视图含义可不同。以下示例中 1 密度单位等于 6 像素,但完全由你决定。无单位使其相当灵活,在多数场景应能适用。

值得注意的是,Material 通常每个视觉密度单位约 4 逻辑像素。有关受支持 widget 的更多信息,请参阅 VisualDensity API。有关密度原则的更多信息,请参阅 Material Design 指南