关于 adaptive-layout
面向 Flutter 开发者的自适应布局技能,指导界面依据可用空间而非设备类型调整。内容涵盖 Material 3 窗口尺寸类别、导航样式、列表详情布局、安全区域和键盘边距,并提供检查脚本。
主要功能
- 01按约束和窗口宽度选择布局
- 02使用 Material 3 窗口尺寸类别
- 03按宽度统一选择导航样式
- 04紧凑屏幕单栏、宽屏双栏
- 05处理安全区域、键盘和折叠屏铰链
- 06脚本检查自适应布局规范
适用场景
- 01适配调整大小的桌面窗口与分屏
- 02为手机和宽屏设计列表详情界面
- 03根据屏幕宽度切换导航组件
- 04提交前检查自适应布局实现
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill adaptive-layout安装后在 Claude Code 中输入/adaptive-layout确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/adaptive_scaffold.dartlist_detail_pane.dartreferences/list-detail-and-navigation.mdwindow-size-classes.mdscripts/check_adaptive.sh详细介绍
自适应布局
适应实际可用空间,而不是你认为自己所在的设备。折叠屏的外屏、调整过大小的桌面窗口,以及分屏模式下的平板都会让 Platform.isX 检查失效,但它们都会报告实际约束。根据宽度分支,而不是硬件。
根据当前任务阅读参考文档:
references/window-size-classes.md— Material 3 窗口尺寸类别断点这一通用术语、WindowSizeClass枚举模式、MediaQuery.sizeOf/paddingOf/viewInsetsOf与.of的区别、可读内容最大宽度、SafeArea 和显示屏缺口、键盘内边距、屏幕方向,以及对折叠屏铰链的感知。references/list-detail-and-navigation.md— 单窗格导航与并排双窗格、根据宽度选择导航方式并与 go_router shell 协调,以及将选择状态保存在 Notifier 中,让两个窗格保持一致。
提交 PR 前运行 scripts/check_adaptive.sh。
不可妥协的规则
-
根据约束/尺寸适配,绝不根据设备或平台适配。 不要使用
Platform.isAndroid/Platform.isIOS/kIsWeb来选择布局。使用LayoutBuilder(局部盒子约束)或MediaQuery.sizeOf(context)(窗口尺寸)。原因:调整过大小的窗口、分屏模式和折叠屏都会让设备检查失效;约束始终准确。 -
使用 Material 3 窗口尺寸类别作为断点术语。 紧凑
<600、中等600–840、展开840–1200、大1200–1600、超大≥1600(逻辑像素宽度)。原因:这些是标准的结构断点(不是设计令牌);使用统一枚举,就能让每个屏幕的断点保持一致。 -
仅读取所需的 MediaQuery 属性:
sizeOf/paddingOf/viewInsetsOf/viewPaddingOf,不要使用MediaQuery.of(context)。 原因:.of会让组件订阅 EVERY MediaQuery 变化(键盘、旋转、文本缩放);属性 getter 只会在对应字段变化时触发重建,参见flutter-performance。 -
根据宽度选择导航方式,而不是按屏幕猜测。
NavigationBar(紧凑)→NavigationRail(中等/展开)→NavigationDrawer(大)。在 shell 中一次性决定。原因:不同屏幕混用导航方式会让人迷失;统一的宽度→导航方式映射天然保持一致。与navigation-and-routing的StatefulShellRoute协调。 -
列表-详情布局在紧凑模式下折叠为单个窗格,在展开模式下拆分为两个窗格。 紧凑:从列表导航到详情路由。展开:列表和详情并排显示;共享选择状态。原因:手机上的双窗格难以阅读;桌面上的单窗格会浪费 70% 的宽度。
-
绝不锁定屏幕方向(使用
SystemChrome.setPreferredOrientations强制纵向)。原因:这会影响平板、折叠屏和辅助功能支架。让尺寸类别处理两种方向。 -
为可读内容设置最大宽度;顶层区域使用
Flexible/Expanded/FractionallySizedBox,不要使用固定像素宽度。 原因:宽屏上的全宽正文难以阅读(每行约 40–75 个字符才易读);固定的区域宽度会溢出或留下空白。 -
边缘内容使用
SafeArea包裹,并考虑显示屏缺口和键盘内边距。 使用MediaQuery.paddingOf/viewPaddingOf读取缺口/系统栏内边距;使用MediaQuery.viewInsetsOf读取键盘内边距。原因:忽略内边距会导致内容被刘海、打孔和屏幕键盘遮挡。 -
绝不假设单元格/行高固定。 大号文本缩放可能让单元格高度增加一倍;应让尺寸由内容决定。原因:硬编码的
SizedBox(height: 48)在 200% 文本缩放下会裁切内容,参见accessibility-as-code和widget-composition。 -
使用多尺寸黄金测试矩阵验证,而不是只测试一台设备。 在紧凑/中等/展开尺寸下,对每个自适应屏幕进行快照(另加最大文本、LTR/RTL)。原因:自适应 bug 只会出现在你没打开检查的断点上,参见
widget-golden-and-a11y-testing。
将尺寸类别定义为一个共享枚举
只定义一次断点;所有屏幕都读取同一张映射表。
enum WindowSizeClass { compact, medium, expanded, large, extraLarge }
WindowSizeClass windowSizeClassFor(double width) => switch (width) {
< 600 => WindowSizeClass.compact,
< 840 => WindowSizeClass.medium,
< 1200 => WindowSizeClass.expanded,
< 1600 => WindowSizeClass.large,
_ => WindowSizeClass.extraLarge,
};
// In a widget: rebuilds only when the window SIZE changes.
final sizeClass = windowSizeClassFor(MediaQuery.sizeOf(context).width);
如果判断取决于局部区域(例如分屏视图中的小部件、卡片网格),优先使用 LayoutBuilder;如果取决于整个窗口(例如顶层外壳),则使用 MediaQuery.sizeOf。
// Local constraints drive a card grid's column count.
LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth ~/ 280; // min card width, structural
return GridView.count(crossAxisCount: columns.clamp(1, 4), children: cards);
},
);
可读宽度与灵活区域
// Center and cap body text to a legible measure; never full-bleed on desktop.
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720), // structural readable cap
child: article,
),
);
// Split a wide body proportionally, not with magic fixed widths.
Row(
children: [
Expanded(flex: 2, child: primaryPane),
Expanded(flex: 3, child: secondaryPane),
],
);
内边距、屏幕缺口、键盘与折叠屏
// Keyboard height: rebuilds only when the keyboard shows/hides.
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
// Notch/system-bar padding without subscribing to text-scale/rotation changes.
final safeTop = MediaQuery.paddingOf(context).top;
// Foldable: a vertical fold/hinge spanning the full height => split around it.
final seam = MediaQuery.displayFeaturesOf(context)
.where((f) =>
(f.type == DisplayFeatureType.fold ||
f.type == DisplayFeatureType.hinge) &&
f.bounds.top == 0 && // starts at the top edge...
f.bounds.width < f.bounds.height) // ...taller than wide => vertical
.firstOrNull; // firstOrNull: import 'package:collection/collection.dart';
将最外层的交互区域包在 SafeArea 中;需要让 NavigationBar 或可滚动主体延伸至屏幕边缘时,可针对特定边取消避让(bottom: false)。
按宽度选择导航方式
// One place decides the shell chrome; screens stay affordance-agnostic.
Widget shellFor(WindowSizeClass sc, {required Widget body, required int index}) {
return switch (sc) {
WindowSizeClass.compact =>
Scaffold(body: body, bottomNavigationBar: _NavBar(index: index)),
WindowSizeClass.medium || WindowSizeClass.expanded =>
Scaffold(body: Row(children: [_Rail(index: index), Expanded(child: body)])),
WindowSizeClass.large || WindowSizeClass.extraLarge =>
Scaffold(body: Row(children: [_Drawer(index: index), Expanded(child: body)])),
};
}
完整外壳请参阅 examples/adaptive_scaffold.dart,双窗格模式请参阅 examples/list_detail_pane.dart。
反模式
- 使用
if (Platform.isIOS) ... else ...选择布局。应根据MediaQuery.sizeOf(context).width进行分支。 - 使用
MediaQuery.of(context).size.width——这会订阅所有 MediaQuery 变化。请使用MediaQuery.sizeOf(context)。 - 使用
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp])来规避横屏布局问题。应同时支持两种方向。 - 使用
Container(width: 375)或SizedBox(width: 320)为顶层窗格指定尺寸。请使用Expanded/Flexible/FractionallySizedBox/ConstrainedBox。 - 将
bool isTablet = MediaQuery.of(context).size.shortestSide > 600标志传递到各处。在外壳中根据宽度计算WindowSizeClass。 - 在紧凑宽度下显示两个窗格,迫使用户水平滚动。应折叠为单个窗格并通过导航切换。
- 桌面端的段落没有最大宽度,横跨整个屏幕。应限制文本宽度。
- 假设键盘处于隐藏状态,导致内容跳到键盘下方。应使用
MediaQuery.viewInsetsOf(context).bottom添加内边距。
完成标准
- 不使用
Platform.is*/kIsWeb/dart:io来选择布局(平台专用 插件 除外)。 - 断点来自共享的
WindowSizeClass辅助工具,而不是散布在各个屏幕中的临时数字。 - 通过
sizeOf/paddingOf/viewInsetsOf/viewPaddingOf读取 MediaQuery,而不是使用.of。 - 在同一个位置(外壳)按宽度选择导航方式,并与
navigation-and-routing协调。 - 列表-详情屏幕在紧凑模式下折叠为单个窗格,在扩展模式下拆分为两个窗格,并共享选中状态。
- 顶层区域没有固定像素宽度;可读内容设有最大宽度。
- 不锁定方向;边缘内容使用
SafeArea;正确处理键盘内边距。 - Golden 测试矩阵涵盖紧凑/中等/扩展模式(以及最大字号、RTL)。
scripts/check_adaptive.sh通过。
相关技能
widget-composition— 结构布局基元、计算尺寸、边到边与 SafeArea,以及避免单元格高度固定。navigation-and-routing— 此外壳渲染的StatefulShellRoute;宽度决定导航外观,路由决定分支。flutter-performance— 为什么sizeOf/select在控制重建范围方面优于.of。accessibility-as-code— 大字号会影响每个自适应单元格。widget-golden-and-a11y-testing— 用于验证自适应效果的设备 × 字号比例 × RTL Golden 测试矩阵。state-management-riverpod— 共享的列表-详情选中状态所在位置。
参考资料
- Material 3 布局/窗口尺寸类别:https://m3.material.io/foundations/layout/applying-layout/window-size-classes
- 自适应与响应式设计(Flutter):https://docs.flutter.dev/ui/adaptive-responsive
MediaQueryAPI(各维度获取器):https://api.flutter.dev/flutter/widgets/MediaQuery-class.htmlLayoutBuilder:https://api.flutter.dev/flutter/widgets/LayoutBuilder-class.htmlNavigationRail:https://api.flutter.dev/flutter/material/NavigationRail-class.htmlDisplayFeature/折叠屏:https://api.flutter.dev/flutter/dart-ui/DisplayFeature-class.html