mcpField
A
adaptive-layout

adaptive-layout

github.com/zakariaf设计与 UI63168 次安装5 周前更新
代码仓库

关于 adaptive-layout

面向 Flutter 开发者的自适应布局技能,指导界面依据可用空间而非设备类型调整。内容涵盖 Material 3 窗口尺寸类别、导航样式、列表详情布局、安全区域和键盘边距,并提供检查脚本。

主要功能

  1. 01按约束和窗口宽度选择布局
  2. 02使用 Material 3 窗口尺寸类别
  3. 03按宽度统一选择导航样式
  4. 04紧凑屏幕单栏、宽屏双栏
  5. 05处理安全区域、键盘和折叠屏铰链
  6. 06脚本检查自适应布局规范

适用场景

  1. 01适配调整大小的桌面窗口与分屏
  2. 02为手机和宽屏设计列表详情界面
  3. 03根据屏幕宽度切换导航组件
  4. 04提交前检查自适应布局实现

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill adaptive-layout

安装后在 Claude Code 中输入/adaptive-layout确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
adaptive_scaffold.dart
list_detail_pane.dart
references/
list-detail-and-navigation.md
window-size-classes.md
scripts/
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。

不可妥协的规则

  1. 根据约束/尺寸适配,绝不根据设备或平台适配。 不要使用 Platform.isAndroid/Platform.isIOS/kIsWeb 来选择布局。使用 LayoutBuilder(局部盒子约束)或 MediaQuery.sizeOf(context)(窗口尺寸)。原因:调整过大小的窗口、分屏模式和折叠屏都会让设备检查失效;约束始终准确。

  2. 使用 Material 3 窗口尺寸类别作为断点术语。 紧凑 <600、中等 600–840、展开 840–1200、大 1200–1600、超大 ≥1600(逻辑像素宽度)。原因:这些是标准的结构断点(不是设计令牌);使用统一枚举,就能让每个屏幕的断点保持一致。

  3. 仅读取所需的 MediaQuery 属性:sizeOf / paddingOf / viewInsetsOf / viewPaddingOf,不要使用 MediaQuery.of(context)。 原因:.of 会让组件订阅 EVERY MediaQuery 变化(键盘、旋转、文本缩放);属性 getter 只会在对应字段变化时触发重建,参见 flutter-performance。

  4. 根据宽度选择导航方式,而不是按屏幕猜测。 NavigationBar(紧凑)→ NavigationRail(中等/展开)→ NavigationDrawer(大)。在 shell 中一次性决定。原因:不同屏幕混用导航方式会让人迷失;统一的宽度→导航方式映射天然保持一致。与 navigation-and-routing 的 StatefulShellRoute 协调。

  5. 列表-详情布局在紧凑模式下折叠为单个窗格,在展开模式下拆分为两个窗格。 紧凑:从列表导航到详情路由。展开:列表和详情并排显示;共享选择状态。原因:手机上的双窗格难以阅读;桌面上的单窗格会浪费 70% 的宽度。

  6. 绝不锁定屏幕方向(使用 SystemChrome.setPreferredOrientations 强制纵向)。原因:这会影响平板、折叠屏和辅助功能支架。让尺寸类别处理两种方向。

  7. 为可读内容设置最大宽度;顶层区域使用 Flexible/Expanded/FractionallySizedBox,不要使用固定像素宽度。 原因:宽屏上的全宽正文难以阅读(每行约 40–75 个字符才易读);固定的区域宽度会溢出或留下空白。

  8. 边缘内容使用 SafeArea 包裹,并考虑显示屏缺口和键盘内边距。 使用 MediaQuery.paddingOf/viewPaddingOf 读取缺口/系统栏内边距;使用 MediaQuery.viewInsetsOf 读取键盘内边距。原因:忽略内边距会导致内容被刘海、打孔和屏幕键盘遮挡。

  9. 绝不假设单元格/行高固定。 大号文本缩放可能让单元格高度增加一倍;应让尺寸由内容决定。原因:硬编码的 SizedBox(height: 48) 在 200% 文本缩放下会裁切内容,参见 accessibility-as-code 和 widget-composition。

  10. 使用多尺寸黄金测试矩阵验证,而不是只测试一台设备。 在紧凑/中等/展开尺寸下,对每个自适应屏幕进行快照(另加最大文本、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 — 共享的列表-详情选中状态所在位置。

参考资料

评论

支持 Markdown