4. 响应式布局适配手机与桌面窗口

响应式布局适配手机与桌面窗口 学习卡片

为什么“响应式”在跨平台里不等于“媒体查询堆叠”

同一套页面要同时跑在手机竖屏、平板横屏和桌面宽窗口上,真正变化的不是“屏幕宽度”本身,而是三件事:可用宽度、输入方式(触摸/鼠标)和内容密度需求。如果只用 MediaQuery.of(context).size.width 做判断,窗口拉伸时会出现“宽度够了但高度不够”“平板横屏被当成桌面”等误判。更稳的做法是:用约束布局让组件按父级给的约束自适应,用断点系统只在“布局结构需要切换”时介入。

断点定义与布局策略

下面这套断点直接对应本案例的三种目标形态,数值取自 Material 3 的窗口尺寸类别,便于与 Flutter 的 LayoutBuilder 配合:

窗口类别宽度范围典型设备布局策略
Compact< 600手机竖屏单列,底部导航,内容区全宽
Medium600 – 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 时,三列网格自动获得更宽的卡片,而不是出现空白或溢出。

窗口宽度 → 断点判定 → 布局骨架 → 约束分配 父级约束宽度 constraints.maxWidth windowSizeOf(width) <600 → compact 600–839 → medium ≥840 → expanded 布局骨架切换 compact:单列 ListView + 底部导航 medium:双列 GridView + NavigationRail expanded:三列 GridView + 侧栏 + 详情面板 三种目标形态的内容密度与操作区域 手机竖屏 · Compact 卡片 1(全宽) 卡片 2(全宽) 卡片 3(全宽) 底部导航(触摸目标 ≥48dp) 平板横屏 · Medium NavigationRail 卡片 1 卡片 2 卡片 3 卡片 4 桌面宽窗口 · Expanded 扩展侧栏 卡片1 卡片2 卡片3 卡片4 详情面板(常驻) 断点决定骨架,约束布局决定骨架内部尺寸分配;两者配合才能避免溢出与空白。

验证与常见问题

  • 验证方法:在桌面端拖动窗口宽度,观察 600 和 840 两个临界点处布局是否平滑切换;在手机模拟器上旋转屏幕,确认横屏进入 medium 后底部导航消失、侧栏出现。
  • 溢出排查:若出现 RenderFlex overflowed,优先检查固定宽度的子组件是否被放进了过窄的约束中,而不是先改断点。
  • 触摸目标:compact 下底部导航项高度不应小于 48dp;medium/expanded 下侧栏项宽度不应小于 72dp,否则触摸命中率下降。
  • 桌面多窗口:当应用窗口被拖到很窄时,LayoutBuilder 会正确回落到 compact,而基于整屏宽度的判断不会,这是选择约束布局的核心原因。
本案例的交付物是一套可复用的 WindowSize 枚举 + 三套布局骨架。后续页面只需调用 windowSizeOf(constraints.maxWidth),即可获得与手机、平板、桌面一致的密度与操作区域。