设计真正能够自适应的用户交互

2023-01-06
9分钟阅读时长

本周,我在 FlutterVikings 大会上发表了一篇演讲,解释了在构建 Flutter 用户界面时你应该担心的问题(视频记录可在 YouTube 上找到)。

Flutter 的优势之一是它对各种目标的支持。但支持它们并不意味着一切都能自动进行。我将尝试给你一个概述,说明在为许多用户开发应用程序时什么是重要的。我不会对每个主题进行过多的讨论,所以请自行探索每个主题。

什么是适应性设计?

适应性设计没有明确的定义,但我选择这个术语是为了与另一个我们经常遇到的术语:响应式设计进行对比。

adaptative

响应式设计的定义通常是指我们的用户界面的布局应该如何适应屏幕的大小。这个定义是随着网络的出现而出现的,当时目标的多样性与今天移动的各种形式的事实相比并不重要。

移动设备引入了很多新的用例。用户拥有多种屏幕规格的设备,但除此之外。

  • 应用程序现在可以在不同的操作系统上运行,有不同的体验
  • 用户可以用不同的方式与你的应用程序互动:触摸手势、触控板、鼠标、语音……
  • 可访问性参数更多
  • 性能并不总是有保障:例如,用户可能会失去他的网络连接。

幸运的是,Flutter 提供了所有必要的工具来处理这些问题,并为您的用户提供适合其自身使用的最佳体验。

平台

了解你的应用程序当前运行的操作系统,可以让你增强这个特定平台的体验,让用户感觉更自在。

检测平台的最基本方法是通过 Platform 类。由于面向浏览器的构件有不同的构建系统,你可能也要对 kIsWeb 常量进行测试。

import 'dart:io';
import 'package:flutter/foundation.dart';

@override
Widget build(BuildContext context) {
    if(kIsWeb || Platform.isMacOS || Platform.isLinux || Platform.isWindows) {
        return DesktopLayout();
    }
    
    return MobileLayout();
}

Flutter 团队提供了两个 widgets 库,以帮助您调整您的应用程序的视觉效果。

platform

除了众所周知的安卓系统自带的 Material design 系统外,Cupertino 软件包还实现了很多原生的 IOS 组件。使用这些组件可以帮助你为你的 IOS 用户提供最流畅的体验。

不幸的是,现在还没有 Windows、macOS、Linux 库,但没有什么能阻止你建立自己的设计系统,这可能是适应你的界面的最简单的解决方案。

展示效果

在调整用户界面时,设备的屏幕可能是最重要的因素,并且此屏幕可能具有许多不同的形式。

MediaQuery

在使用 Flutter 开发时,您可能已经使用过 MediaQuery。可以肯定的是,这是因为 MediaQuery 让你可以访问很多描述当前用户环境的属性。

因为它是一个 InheritedWidget,你可以从你的 widget tree 中的任何地方访问它。如果你不熟悉这种部件(我强烈建议你观看 Flutter 101 系列的视频),任何从树上读取 InheritedWidgets 数据的子部件将在每次数据变化时自动被重建。

@override
Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    // Rebuilt every time a property of media query changes
    // ...
}

屏幕尺寸

无论平台是什么,屏幕都是向用户提供信息的主要方式,而且屏幕可以有各种尺寸。如果您还支持桌面目标(应用程序窗口可以动态调整大小),这比以往任何时候都更为真实。

screen_size

要读取你的应用程序窗口的当前大小,请使用 MediaQuerysize 属性。同样,如果窗口的大小发生变化,你的构建方法会被自动再次调用。

@override
Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);

    if (mediaQuery.size.width > 1024) {
      return LargeLayout();
    }

    if (mediaQuery.size.width > 332) {
      return MediumLayout();
    }

    return SmallLayout();
}

你现在可以根据屏幕的宽度提供各种布局:例如,在大屏幕上显示侧面菜单,在小屏幕上显示底部的标签栏。

屏幕密度

不太常用,但是熟悉屏幕密度可以帮助您针对多个设备优化应用程序。

每个屏幕都有一个专门的像素密度。为了让你了解什么是屏幕密度,我把它描述为你能在屏幕的一个物理空间里放多少像素(例如 1 英寸乘 1 英寸的正方形)。

density

对于具有相同物理屏幕尺寸的两个设备,其中一个的像素可能是另一个的四倍。

这个密度可以通古 MediaQuery 的 devicePixelRatio 方法查询。

@override
Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);

    if (mediaQuery.devicePixelRatio >= 2) {
      return HighDefinitionVideo();
    }

    return LowDefinitionVideo();
}

例如,你可以通过从网络上为密度较小的设备提供低清晰度的视频来节省网络带宽,因为他们不会看到高清视频的差异。

视图填充

如今,越来越多的设备都有一个“刘海”,“感谢”苹果公司,我们也必须调整我们的 UI 以适应它们。如果我们不这样做,信息可能会被隐藏在这些“刘海”后面,甚至更糟的是,像按钮这样的互动可能会被掩盖。

padding

要知道屏幕上哪些区域被缺口或圆角遮挡,可以从 MediaQuery 中读取 padding 属性。

@override
Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    return Padding(
      padding: EdgeInsets.only(
        top: mediaQuery.padding.top,
      ),
      child: Child(),
    );
}

Flutter 还为您提供了一个 widget 来帮助您完成这项任务:SafeArea

@override
Widget build(BuildContext context) {
    return SafeArea(
      left: false,
      right: false,
      bottom: false,
      child: Child(),
    );
}

insets

软件键盘可能会引发另一个问题。同样,使用 viewInsets 属性添加所需的边距。脚手架会自动为您管理这一点,但有时直接处理它可能会很有用。

@override
Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    return Padding(
      padding: EdgeInsets.only(
        bottom: mediaQuery.viewInsets.bottom,
      ),
      child: Child(),
    );
}

辅助功能设置

不要忘记,在一些场景下,用户可以通过系统设置默认的字体缩放和加粗字体来提高文本的可读性。

accessibility

这两个属性同样可以在 MediaQuery 中获取。

@override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    return Text(
      'Hello!',
      textScaleFactor: mediaQuery.textScaleFactor.clamp(1.0, 1.5),
      style: TextStyle(
        fontFamily: mediaQuery.boldText ? 'LightFont' : 'BoldFont',
      ),
    );
  }

文本的 textScaleFactor 值默认为 mediaQuery.textScaleFactor,所以在开发应用程序时一定要尝试更新这些设置,以确保您的布局没有被破坏。

黑暗模式

由于 Android 和 iOS 现在允许激活黑暗模式,越来越多的应用程序为其用户界面提供了黑暗的替代方案。这个特性似乎很受用户欢迎,并且增加了一点个性化。因此,如果你有可能添加一个黑暗的替代品,这样做!

dark_mode

platformBrightness 属性同样可以通过 MediaQuery 获取

 @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    return Image.asset(
      mediaQuery.platformBrightness == Brightness.dark
          ? 'assets/background_dark.png'
          : 'assets/background_light.png',
    );
  }

如果你使用的是 MaterialApp,你也可以提供 lightThemedarkTheme 来自动适应内置的材质组件。

国际化

intl

一定要为世界上多个地区创建不同的内容,以增加您的受众。为此,Flutter 集成了对 Local 的支持,其中包含用户的语言和国家信息。

要访问当前的用户地域,请使用 Localizations

@override
Widget build(BuildContext context) {
  final locale = Localizations.localeOf(context);
  return Image.asset('flag_${locale.countryCode}.png');
}

为了使你的应用程序的标签国际化,确保添加对 intl 包的引用。它也有很多帮助工具,比如 DateFormat

@override
Widget build(BuildContext context) {
  final locale = Localizations.localeOf(context);
  final formattedDate = DateFormat.yMMMMEEEEd(locale.toString());
  return Text(formattedDate);
}

最后,如果你支持从右到左的书面语言,你可能要经常尝试另一件事:所有基于 Directionality 的 widgets 都会适应这种方向配置,就像 Row 一样。所以要确保你的布局在 rtl 方向上也是正确的,因为它们会随着语言的变化自动更新。

如果你需要,你可以手动获取方向。

@override
Widget build(BuildContext context) {
  final direction = Directionality.of(context);
  return Image.asset('horizontal_illustration_${direction.value}.png');
}

用户输入

一个应用程序是一个双向的界面。仅仅调整呈现给用户的信息是不够的,你还必须调整用户与你的应用程序的互动方式,以提供尽可能好的体验。

语音阅读器

通过支持语音阅读器(从系统设置中启用),你可以让残疾用户用语音与你的应用程序进行互动。

voice

为了支持语音交互,Flutter 提供了 Semantics widget。如果您想描述您屏幕上的一个视觉区域,只需将您的部件包入一个带有细节的 Semantics

 @override
Widget build(BuildContext context) {
  return Semantics(
    button: true,
    label: 'My awesome button',
    child: Child(),
  );
}

幸运的是,所有默认的 Flutter widget 已经提供了语义,或者至少提供了从 widget 层面进行定制的方法。

键鼠

既然桌面支持已成为现实,而 iOS 支持触控板,键盘和鼠标的组合对用户非常重要。这通常是通过添加快捷方式或具有更好的指针精度来提高生产率的一种方法。

keyboard

Flutter 团队已经为这些用例设计了 widgets ,其中最有用的是 Shortcuts ,它显然允许你在按下键盘输入的组合时触发回调。

return Shortcuts(
  shortcuts: {
    LogicalKeySet(
      LogicalKeyboardKey.control,
      LogicalKeyboardKey.keyC,
    ): CopyIntent(),
  },
  child: Actions(
    actions: {
      CopyIntent: CallbackAction<Intent>(
        onInvoke: (intent) => copy(),
      ),
    },
    child: Child(),
  ),
);

如果你想要原始键盘输入,请使用 RawKeyboardListener 代替。

要检测鼠标指针,请使用提供各种回调的 Mouseregion

 @override
Widget build(BuildContext context) {
  return MouseRegion(
    onEnter: (event) => print('Enter'),
    onExit: (event) => print('Exit'),
    onHover: (event) => print('Hover'),
    child: Child(),
  );
}

请记住,鼠标指针比触摸手势要精确得多:这是一个减少交互区域大小的机会,以便向用户显示更多内容。这就是 VisualDensityMaterialApp 中的作用。

性能

最后有几点,不取决于用户自己,而是取决于设备环境

可连接性

在移动环境中,网络的状态可能变化很大,它可以从一个能够提供大量流媒体内容的 WIFI 连接,变成一个性能较差的移动连接,甚至更糟的是,变成一个离线状态。

network

预测所有这些情况并给予用户反馈以改善他对这种连接状态的体验是非常重要的。幸运的是,有一个插件可以观察这些网络状态的变化。Connectivity

final connectivity = Connectivity();
final initialState = await connectivity.checkConnectivity();
updateIsOffline(initialState);
await for (var stateUpdate in connectivity.onConnectivityChanged) {
  updateIsOffline(stateUpdate);
}

提供离线模式可能需要很多努力,但对用户来说是一个重大改进。

动画效果

即使实际上不可能测试设备的渲染性能,用户也可以从其系统设置中禁用动画。这可能是出于无障碍的原因,但也是出于性能的原因。因此,根据这一设置来调整你的应用程序的过渡和动画可能很重要。

animations.png

disableAnimations 属性可以从 MediaQuery 中获得

@override
Widget build(BuildContext context) {
  final mediaQuery = MediaQuery.of(context);
  return mediaQuery.disableAnimations ? StaticChild() : AnimatedChild();
}

几点提高适应性的技巧

1、适应性预估

即使你的应用程序一开始只支持一种形式的因素,也要确保你配置的一切都能在以后支持适应性。这在很多情况下是值得的,因为通过重构整个代码库来引入适应性并不好玩,相信我。

2、避免使用常量

试着从你的 widget 代码中删除所有的常量。所有的常量,不管是字体大小、间距、颜色,都应该被避免,并尽可能地使用继承的部件来代替。

inherited.png

例如,如果你的视图被设计成下面这样,如果用户改变了窗口的大小,用户界面将保持不变,而且没有简单的方法来适应它。

 @override
Widget build(BuildContext context) {
  return Padding(
    padding: EdgeInsets.only(
      top: 20,
      bottom: 10,
    ),
    child: Text(
      "Hello",
      style: TextStyle(
        fontSize: 10,
      ),
    ),
  );
}

一种改进的方法是定义一个数据类,集中所有这些属性,通过 InheritedWidget 提供给 widget tree

class AdaptativeTheme {
  final double bigSpace;
  final double smallSpace;
  final double smallFontSize;
  const AdaptativeTheme({
    @required this.bigSpace,
    @required this.smallSpace,
    @required this.smallFontSize,
  });
}

 @override
Widget build(BuildContext context) {
  final theme = Provider.of<AdaptativeTheme>(context);
  return Padding(
    padding: EdgeInsets.only(
      top: theme.bigSpace,
      bottom: theme.smallSpace,
    ),
    child: Text(
      localization.hello,
      style: TextStyle(
        fontSize: theme.smallFontSize,
      ),
    ),
  );
}

你现在能够通过获取 MediaQuery 和提供各种主题来更新 padding 和字体大小。

@override
Widget build(BuildContext context) {
  final isSmall = MediaQuery.of(context).size.width < 500;
  return Provider.value(
    value: isSmall
        ? AdaptativeTheme(
            smallFontSize: 10,
            bigSpace: 20,
            smallSpace: 10,
          )
        : AdaptativeTheme(
            smallFontSize: 20,
            bigSpace: 40,
            smallSpace: 12,
          ),
    child: Child(),
  );
}

我以前的文章 可能有助于你更好地理解这种方法。

3、尽可能分割你的 widgets

widgets.png

您应该避免使用具有巨大构建方法的整体 widgets,因为这会使它们根本不可组合。强迫自己将构建方法拆分为许多 widgets ,这样就可以轻松地组合它们来创建各种布局。作为奖励,这也会带来更好的性能。

4、尝试 DevicePreview

读完这些之后,你可能会想,测试你的应用程序的适应性有多难。

combinations

插入设备、设置系统首选项、更改区域设置。。。所有这些都需要很多时间。

这就是为什么我决定创建一个 Flutter 包来帮助解决这些问题:DevicePreview。它是一个简单的 widget,可以包装整个应用程序,并允许覆盖媒体查询以测试其所有特性。它还可以让您预览应用程序在知名设备上的性能。

device_preview.png

DevicePreview 是开源的,所以请随时给我们反馈,帮助我改进它。

示例

我开始了一个开源自适应应用程序示例,为您提供我在这里介绍的技巧的指导。

Github 上的所有内容都可用,因此请继续关注更新。

总结

这只是冰山一角,但我希望它能让你意识到,构建一个真正的应用程序可能会有很多注意事项,而这些注意事项在预期中总是容易克服的。

我希望你喜欢这个,因为它只是我在 FlutterViking 会议上的演讲的一个改编,我也希望鉴于列举的内容,它不会太无聊。

再见了!

全文翻译自 Designing truly adaptative user interfaces 原作者为 Aloïs Deniel