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
,你可以向上查找:
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 = LayoutBuilder(
builder: (context, constraints) {
bool useVerticalLayout = constraints.maxWidth < 400.0;
return Flex(
children: [
Text('Hello'),
Text('World'),
direction: useVerticalLayout ? Axis.vertical : Axis.horizontal,
现在这个 widget 可以组装在侧边面板、弹框又或是全屏视图中,并且根据尺寸自适应布局。
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。
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),
由于所有的视图都引用了相同设计系统的规范,它们通常看起来更一致且更顺畅。与其进行容易出错的搜索替换,你可以将平台对应样式值的修改集中在一处。使用共享的规则也对设计的一致性有所帮助。
常见的设计类型里,如下这些类别可以以这样的方式进行组织:
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
来指定树中的区域,作为切换时的组别。
例如,你可能想要用户逐个切换所有的输入框,最后再切换到提交按钮:
// 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
添加全局监听非常简单:
static bool isKeyDown(Set<LogicalKeyboardKey> keys) {
return keys.intersection(RawKeyboard.instance.keysPressed).isNotEmpty;
将它们合并判断,你就可以在 Shift+N
同时按下时触发行为:
if (event is RawKeyDownEvent) {
bool isShiftDown = isKeyDown({
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
if (isShiftDown && event.logicalKey == LogicalKeyboardKey.keyN) {
_createNewItem();
使用静态的监听时有一件值得注意的事情,当用户在输入框中输入内容,或关联的 widget 从视图中隐藏时,通常需要禁用监听。与 Shortcuts
和 RawKeyboardListener
不同,你需要自己对它们进行管理。当你在为 Delete
键构建一个删除或退格行为的监听时,需要尤其注意,因为用户可能会在 TextField
中输入内容时受到影响。
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,
平台行为习惯与规范
最后,我们需要为自适应应用考虑平台标准。每个平台都有其不同的行为习惯与规范,这些名义和事实上的标准将操作应用的方法告知了用户。在当下网络如此便利的时代,用户更倾向于更加个性化的体验,但是提供这些平台标准,依然可以带来一些显著的好处:
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 键,你可以编写以下的代码:
if (Platform.isMacOS) {
isDown = isKeyDown(
{LogicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaRight});
} else {
isDown = isKeyDown(
{LogicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlRight});
return isDown;
最后一项针对键盘用户需要考虑的是 全选 操作。如果你的列表里有很多的可选择内容,可能你的许多用户也会希望能使用 Control+A
选中所有内容。
在触屏设备上,多选操作通常会被简化,与在桌面上按下了 isMultiSelectModifier
(多选按钮)的行为类似。
在不同设备上处理多选操作,取决于你的用例是否有区分,但更重要的是为各个平台提供最好的交互模式。
children: [
TextSpan(text: 'Hello'),
TextSpan(text: 'Bold', style: TextStyle(fontWeight: FontWeight.bold)),
在现代的桌面应用程序中,经常会有定制应用窗口的标题栏、添加 Logo 或者其他控制的需求,能节省界面对于垂直空间的占用。
return const Tooltip(
message: 'I am a Tooltip',
child: Text('Hover over the text to show a tooltip.'),
Flutter 同时也为编辑和选择文字提供了内置的上下文菜单。
若你想显示更高级的提示、悬浮面板或自定义的上下文菜单,你可以使用已有的 package,或利用 Stack
和 Overlay
进行构建。
可以使用的 package 包括:
TextDirection btnDirection =
DeviceType.isWindows ? TextDirection.rtl : TextDirection.ltr;
return Row(
children: [
Spacer(),
textDirection: btnDirection,
children: [
DialogButton(
label: 'Cancel',
onPressed: () => Navigator.pop(context, false)),
DialogButton(
label: 'Ok', onPressed: () => Navigator.pop(context, true)),