4. 响应式布局适配手机与桌面窗口
为什么“响应式”在跨平台里不等于“媒体查询堆叠”
同一套页面要同时跑在手机竖屏、平板横屏和桌面宽窗口上,真正变化的不是“屏幕宽度”本身,而是三件事:可用宽度、输入方式(触摸/鼠标)和内容密度需求。如果只用 MediaQuery.of(context).size.width 做判断,窗口拉伸时会出现“宽度够了但高度不够”“平板横屏被当成桌面”等误判。更稳的做法是:用约束布局让组件按父级给的约束自适应,用断点系统只在“布局结构需要切换”时介入。
断点定义与布局策略
下面这套断点直接对应本案例的三种目标形态,数值取自 Material 3 的窗口尺寸类别,便于与 Flutter 的 LayoutBuilder 配合:
| 窗口类别 | 宽度范围 | 典型设备 | 布局策略 |
|---|---|---|---|
| Compact | < 600 | 手机竖屏 | 单列,底部导航,内容区全宽 |
| Medium | 600 – 839 | 平板竖屏 / 手机横屏 | 双列网格,NavigationRail 侧边栏 |
| Expanded | ≥ 840 | 平板横屏 / 桌面宽窗口 | 三列网格 + 常驻侧栏 + 详情面板 |
断点判断应基于父级约束而非整屏宽度。当页面被嵌入分栏、抽屉或桌面多窗口时,整屏宽度会误导布局。
核心实现:LayoutBuilder + 断点枚举
关键点是把“窗口类别”抽成一个枚举,让布局代码只关心语义,不关心具体像素。下面代码可直接放入一个页面文件运行,包含断点判定、三套布局和自适应网格列数。
import 'package:flutter/material.dart';
enum WindowSize { compact, medium, expanded }
WindowSize windowSizeOf(double width) {
if (width < 600) return WindowSize.compact;
if (width < 840) return WindowSize.medium;
return WindowSize.expanded;
}
class ResponsiveHomePage extends StatelessWidget {
const ResponsiveHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('响应式首页')),
body: LayoutBuilder(
builder: (context, constraints) {
final size = windowSizeOf(constraints.maxWidth);
switch (size) {
case WindowSize.compact:
return const _CompactLayout();
case WindowSize.medium:
return const _MediumLayout();
case WindowSize.expanded:
return const _ExpandedLayout();
}
},
),
bottomNavigationBar: LayoutBuilder(
builder: (context, constraints) {
// 仅在 compact 下显示底部导航
return windowSizeOf(constraints.maxWidth) == WindowSize.compact
? NavigationBar(
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: '首页'),
NavigationDestination(icon: Icon(Icons.search), label: '搜索'),
NavigationDestination(icon: Icon(Icons.person), label: '我的'),
],
onDestinationSelected: (_) {},
)
: const SizedBox.shrink();
},
),
);
}
}
class _CompactLayout extends StatelessWidget {
const _CompactLayout();
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(12),
children: const [
_CardTile(title: '今日推荐', subtitle: '单列全宽展示'),
SizedBox(height: 12),
_CardTile(title: '最近浏览', subtitle: '触摸目标 ≥ 48dp'),
SizedBox(height: 12),
_CardTile(title: '快捷入口', subtitle: '底部导航切换'),
],
);
}
}
class _MediumLayout extends StatelessWidget {
const _MediumLayout();
@override
Widget build(BuildContext context) {
return Row(
children: [
const NavigationRail(
destinations: [
NavigationRailDestination(icon: Icon(Icons.home), label: Text('首页')),
NavigationRailDestination(icon: Icon(Icons.search), label: Text('搜索')),
NavigationRailDestination(icon: Icon(Icons.person), label: Text('我的')),
],
),
Expanded(
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(16),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.6,
children: const [
_CardTile(title: '今日推荐', subtitle: '双列网格'),
_CardTile(title: '最近浏览', subtitle: '双列网格'),
_CardTile(title: '快捷入口', subtitle: '双列网格'),
_CardTile(title: '数据概览', subtitle: '双列网格'),
],
),
),
],
);
}
}
class _ExpandedLayout extends StatelessWidget {
const _ExpandedLayout();
@override
Widget build(BuildContext context) {
return Row(
children: [
const SizedBox(
width: 220,
child: NavigationRail(
extended: true,
destinations: [
NavigationRailDestination(icon: Icon(Icons.home), label: Text('首页')),
NavigationRailDestination(icon: Icon(Icons.search), label: Text('搜索')),
NavigationRailDestination(icon: Icon(Icons.person), label: Text('我的')),
],
),
),
Expanded(
child: GridView.count(
crossAxisCount: 3,
padding: const EdgeInsets.all(24),
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.8,
children: const [
_CardTile(title: '今日推荐', subtitle: '三列网格'),
_CardTile(title: '最近浏览', subtitle: '三列网格'),
_CardTile(title: '快捷入口', subtitle: '三列网格'),
_CardTile(title: '数据概览', subtitle: '三列网格'),
_CardTile(title: '团队动态', subtitle: '三列网格'),
_CardTile(title: '系统通知', subtitle: '三列网格'),
],
),
),
const SizedBox(
width: 320,
child: Card(
margin: EdgeInsets.all(16),
child: Padding(
padding: EdgeInsets.all(16),
child: Text('详情面板:宽窗口下常驻,展示选中项内容。'),
),
),
),
],
);
}
}
class _CardTile extends StatelessWidget {
const _CardTile({required this.title, required this.subtitle});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 6),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
);
}
}
约束布局与断点的分工
断点只决定“用哪套骨架”,骨架内部的尺寸分配交给约束布局完成。例如卡片宽度不要写死,而是让 GridView 的 crossAxisCount 随断点变化,卡片自身用 Expanded 或 Flexible 填充剩余空间。这样在桌面窗口被拖到 900px 时,三列网格自动获得更宽的卡片,而不是出现空白或溢出。
验证与常见问题
- 验证方法:在桌面端拖动窗口宽度,观察 600 和 840 两个临界点处布局是否平滑切换;在手机模拟器上旋转屏幕,确认横屏进入 medium 后底部导航消失、侧栏出现。
- 溢出排查:若出现
RenderFlex overflowed,优先检查固定宽度的子组件是否被放进了过窄的约束中,而不是先改断点。 - 触摸目标:compact 下底部导航项高度不应小于 48dp;medium/expanded 下侧栏项宽度不应小于 72dp,否则触摸命中率下降。
- 桌面多窗口:当应用窗口被拖到很窄时,
LayoutBuilder会正确回落到 compact,而基于整屏宽度的判断不会,这是选择约束布局的核心原因。
本案例的交付物是一套可复用的
WindowSize 枚举 + 三套布局骨架。后续页面只需调用 windowSizeOf(constraints.maxWidth),即可获得与手机、平板、桌面一致的密度与操作区域。