📐 29 - 平台适配与响应式设计

阶段四:高级UI · 第29课
✅ 验证通过

📖 核心概念

Flutter的"一次编写,多端运行"不是简单地把手机UI放大到平板上。真正优秀的跨平台App需要根据屏幕尺寸、平台特性、输入方式做出自适应调整。本课涵盖响应式布局、平台感知UI、自适应导航和断点系统。

📐 响应式布局断点系统

1. Material 3自适应规范

// lib/utils/breakpoints.dart
enum ScreenType { mobile, tablet, desktop }

class ResponsiveHelper {
  static ScreenType getScreenType(BuildContext context) {
    final width = MediaQuery.sizeOf(context).width;
    if (width >= 1200) return ScreenType.desktop;
    if (width >= 600) return ScreenType.tablet;
    return ScreenType.mobile;
  }

  static bool isMobile(BuildContext context) =>
    getScreenType(context) == ScreenType.mobile;
  static bool isTablet(BuildContext context) =>
    getScreenType(context) == ScreenType.tablet;
  static bool isDesktop(BuildContext context) =>
    getScreenType(context) == ScreenType.desktop;

  // 自适应列数
  static int gridColumns(BuildContext context) => switch (getScreenType(context)) {
    ScreenType.mobile => 2,
    ScreenType.tablet => 3,
    ScreenType.desktop => 4,
  };

  // 自适应内边距
  static EdgeInsets bodyPadding(BuildContext context) => switch (getScreenType(context)) {
    ScreenType.mobile => const EdgeInsets.all(12),
    ScreenType.tablet => const EdgeInsets.all(24),
    ScreenType.desktop => const EdgeInsets.symmetric(horizontal: 48, vertical: 24),
  };

  // 自适应最大宽度
  static double maxContentWidth(BuildContext context) => switch (getScreenType(context)) {
    ScreenType.mobile => double.infinity,
    ScreenType.tablet => 720,
    ScreenType.desktop => 960,
  };
}

// 响应式Builder组件
class ResponsiveBuilder extends StatelessWidget {
  final WidgetBuilder mobile;
  final WidgetBuilder? tablet;
  final WidgetBuilder? desktop;

  const ResponsiveBuilder({
    required this.mobile, this.tablet, this.desktop,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth >= 1200 && desktop != null) {
          return desktop!(context);
        }
        if (constraints.maxWidth >= 600 && tablet != null) {
          return tablet!(context);
        }
        return mobile(context);
      },
    );
  }
}

🧭 自适应导航

2. 根据屏幕尺寸切换导航模式

// lib/widgets/adaptive_scaffold.dart
class AdaptiveScaffold extends StatelessWidget {
  final int selectedIndex;
  final ValueChanged<int> onDestinationSelected;
  final List<NavigationDestination> destinations;
  final Widget body;

  const AdaptiveScaffold({
    required this.selectedIndex,
    required this.onDestinationSelected,
    required this.destinations,
    required this.body,
  });

  @override
  Widget build(BuildContext context) {
    return ResponsiveBuilder(
      mobile: (context) => _MobileScaffold(
        selectedIndex: selectedIndex,
        onDestinationSelected: onDestinationSelected,
        destinations: destinations,
        body: body,
      ),
      tablet: (context) => _TabletScaffold(
        selectedIndex: selectedIndex,
        onDestinationSelected: onDestinationSelected,
        destinations: destinations,
        body: body,
      ),
      desktop: (context) => _DesktopScaffold(
        selectedIndex: selectedIndex,
        onDestinationSelected: onDestinationSelected,
        destinations: destinations,
        body: body,
      ),
    );
  }
}

// 手机:底部导航栏
class _MobileScaffold extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: body,
      bottomNavigationBar: NavigationBar(
        selectedIndex: selectedIndex,
        onDestinationSelected: onDestinationSelected,
        destinations: destinations,
      ),
    );
  }
}

// 平板:NavigationRail
class _TabletScaffold extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          NavigationRail(
            selectedIndex: selectedIndex,
            onDestinationSelected: onDestinationSelected,
            labelType: NavigationRailLabelType.all,
            destinations: destinations.map((d) => NavigationRailDestination(
              icon: d.icon, selectedIcon: d.selectedIcon, label: Text(d.label),
            )).toList(),
          ),
          const VerticalDivider(thickness: 1, width: 1),
          Expanded(child: body),
        ],
      ),
    );
  }
}

// 桌面:侧边导航栏
class _DesktopScaffold extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          NavigationDrawer(
            selectedIndex: selectedIndex,
            onDestinationSelected: onDestinationSelected,
            children: [
              const Padding(
                padding: EdgeInsets.fromLTRB(28, 16, 16, 10),
                child: Text('我的App', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
              ),
              ...destinations.map((d) => NavigationDrawerDestination(
                icon: d.icon, selectedIcon: d.selectedIcon, label: Text(d.label),
              )),
            ],
          ),
          const VerticalDivider(thickness: 1, width: 1),
          Expanded(child: body),
        ],
      ),
    );
  }
}

🖥️ 平台感知UI

3. 根据平台调整交互模式

// lib/utils/platform_utils.dart
class PlatformUtils {
  static bool get isIOS => defaultTargetPlatform == TargetPlatform.iOS;
  static bool get isAndroid => defaultTargetPlatform == TargetPlatform.android;
  static bool get isMacOS => defaultTargetPlatform == TargetPlatform.macOS;
  static bool get isWindows => defaultTargetPlatform == TargetPlatform.windows;
  static bool get isDesktop => isMacOS || isWindows;
  static bool get isMobile => isIOS || isAndroid;

  // 平台特定的滑动返回
  static Widget slideBackWrapper(Widget child, BuildContext context) {
    if (isIOS) {
      return CupertinoPageRoute.buildPageTransitions(
        const CupertinoPageRouteBuilder(),
        context,
        const AnimationController(duration: Duration(milliseconds: 300)),
        const AnimationController(duration: Duration(milliseconds: 300)),
        child,
      );
    }
    return child;
  }
}

// 平台感知组件
class PlatformButton extends StatelessWidget {
  final String label;
  final VoidCallback onPressed;

  const PlatformButton({required this.label, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    if (PlatformUtils.isIOS) {
      return CupertinoButton.filled(
        onPressed: onPressed,
        child: Text(label),
      );
    }
    return FilledButton(
      onPressed: onPressed,
      child: Text(label),
    );
  }
}

class PlatformSwitch extends StatelessWidget {
  final bool value;
  final ValueChanged<bool> onChanged;

  const PlatformSwitch({required this.value, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    if (PlatformUtils.isIOS) {
      return CupertinoSwitch(value: value, onChanged: onChanged);
    }
    return Switch(value: value, onChanged: onChanged);
  }
}

// 键盘快捷键(桌面端)
class KeyboardShortcuts extends StatelessWidget {
  final Widget child;
  final Map<LogicalKeyboardKey, VoidCallback> shortcuts;

  const KeyboardShortcuts({required this.child, required this.shortcuts});

  @override
  Widget build(BuildContext context) {
    return Shortcuts(
      shortcuts: {
        for (final entry in shortcuts.entries)
          SingleActivator(entry.key): CallbackAction(
            onInvoke: (_) => entry.value(),
          ),
      },
      child: child,
    );
  }
}

4. 自适应详情/列表布局

// 列表+详情的自适应布局
class AdaptiveMasterDetail extends StatelessWidget {
  final List<Item> items;
  final Item? selectedItem;
  final ValueChanged<Item> onSelect;
  final Widget Function(Item) detailBuilder;

  @override
  Widget build(BuildContext context) {
    return ResponsiveBuilder(
      mobile: (context) => selectedItem == null
        ? ItemListPage(items: items, onSelect: onSelect)
        : DetailPage(item: selectedItem!, onBack: () => onSelect(items.first)),
      tablet: (context) => Row(
        children: [
          SizedBox(
            width: 320,
            child: ItemListPage(
              items: items,
              onSelect: onSelect,
              selectedItem: selectedItem,
            ),
          ),
          const VerticalDivider(width: 1),
          Expanded(
            child: selectedItem != null
              ? detailBuilder(selectedItem!)
              : const Center(child: Text('选择一个项目')),
          ),
        ],
      ),
      desktop: (context) => Row(
        children: [
          SizedBox(
            width: 280,
            child: ItemListPage(
              items: items,
              onSelect: onSelect,
              selectedItem: selectedItem,
            ),
          ),
          const VerticalDivider(width: 1),
          Expanded(
            child: selectedItem != null
              ? detailBuilder(selectedItem!)
              : const Center(child: Text('选择一个项目')),
          ),
          // 桌面端可能还有侧边面板
          if (selectedItem != null)
            SizedBox(
              width: 300,
              child: ItemSidePanel(item: selectedItem!),
            ),
        ],
      ),
    );
  }
}
✓ 断点系统:mobile(<600) / tablet(600-1200) / desktop(1200+) ✓ 自适应导航:BottomNav / Rail / Drawer ✓ 平台感知:iOS Cupertino / Android Material ✓ 列表详情:手机分页 / 平板并排 / 桌面三栏 ✓ 键盘快捷键:桌面端快捷操作支持 ✓ 安全区域:正确处理刘海屏/状态栏

📱 安全区域与系统UI适配

5. SafeArea与ViewPadding

刘海屏、底部横条、状态栏——不同设备的系统UI侵入区域各不相同。正确处理SafeAreaMediaQuery.viewPadding是全屏适配的基础。

// SafeArea基本用法
SafeArea(
  child: Scaffold(body: content),
)

// 精确控制哪些边需要安全区域
SafeArea(
  left: true,   // 左侧安全区域
  top: true,    // 顶部(状态栏)
  right: true,
  bottom: true, // 底部(Home Indicator)
  child: content,
)

// 自定义处理 - 获取精确padding
class AdaptivePadding extends StatelessWidget {
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final padding = MediaQuery.viewPaddingOf(context);
    final bottom = padding.bottom;  // 底部安全区域
    final top = padding.top;       // 顶部安全区域(状态栏)

    return Padding(
      padding: EdgeInsets.only(
        top: top,
        bottom: bottom,
        left: ResponsiveHelper.isDesktop(context) ? 0 : 16,
        right: ResponsiveHelper.isDesktop(context) ? 0 : 16,
      ),
      child: child,
    );
  }
}

// 沉浸式状态栏
SystemUiOverlayStyle get overlayStyle {
  final isDark = Theme.of(context).brightness == Brightness.dark;
  return SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
    statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
    statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
    systemNavigationBarColor: isDark ? const Color(0xFF1a1a2e) : Colors.white,
    systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
  );
}

// 在AnnotatedRegion中应用
AnnotatedRegion<SystemUiOverlayStyle>(
  value: overlayStyle,
  child: Scaffold(body: content),
)

6. 横竖屏适配

某些场景需要锁定屏幕方向(如视频播放横屏,聊天竖屏),或根据方向切换布局。

// 锁定屏幕方向
// 竖屏
SystemChrome.setPreferredOrientations([
  DeviceOrientation.portraitUp,
  DeviceOrientation.portraitDown,
]);

// 横屏(视频播放)
SystemChrome.setPreferredOrientations([
  DeviceOrientation.landscapeLeft,
  DeviceOrientation.landscapeRight,
]);

// 恢复自动旋转
SystemChrome.setPreferredOrientations([]);

// 根据方向切换布局
class OrientationAwareLayout extends StatelessWidget {
  final WidgetBuilder portraitBuilder;
  final WidgetBuilder landscapeBuilder;

  @override
  Widget build(BuildContext context) {
    final orientation = MediaQuery.orientationOf(context);
    if (orientation == Orientation.landscape) {
      return landscapeBuilder(context);
    }
    return portraitBuilder(context);
  }
}

// 使用:视频播放页
OrientationAwareLayout(
  portraitBuilder: (context) => Column(
    children: [
      VideoPlayer(),  // 16:9
      VideoInfo(),
      CommentList(),
    ],
  ),
  landscapeBuilder: (context) => Row(
    children: [
      Expanded(child: VideoPlayer()),  // 全屏
      SizedBox(width: 320, child: CommentList()),
    ],
  ),
)

7. 无障碍适配

让App对所有用户可访问——包括使用屏幕阅读器、大字体、高对比度的用户。

// Semantic标签 - 屏幕阅读器支持
Semantics(
  label: '商品图片:${product.name},价格${product.price}元',
  button: true,
  onTap: () => navigateToDetail(product),
  child: Image.network(product.imageUrl),
)

// 自定义语义化顺序
Semantics(
  container: true,
  child: Column(
    children: [
      // Flutter默认从上到下阅读
      // 使用SemanticsOrder自定义顺序
      Semantics(sortKey: OrdinalSortKey(1), child: Text('标题')),
      Semantics(sortKey: OrdinalSortKey(2), child: Text('描述')),
      Semantics(sortKey: OrdinalSortKey(3), child: Text('价格')),
    ],
  ),
)

// 大字体支持
// 使用MediaQuery.textScalerOf(context)获取缩放因子
Text(
  'Hello',
  textScaler: MediaQuery.textScalerOf(context),
  // 或限制缩放范围:
  // textScaler: TextScaler.linear(
  //   MediaQuery.textScalerOf(context).scale(1).clamp(1.0, 1.5)),
)

// 足够的触摸目标尺寸(至少48x48)
MaterialButton(
  minWidth: 48,
  height: 48,
  onPressed: onPressed,
  child: Text('点击'),
)

📝 练习

  1. 将你的电商App适配为三栏布局——左侧分类、中间商品、右侧详情
  2. 实现拖放功能——桌面端支持拖拽商品到购物车
  3. 添加鼠标悬停效果——桌面端按钮/卡片hover时高亮
  4. 实现窗口尺寸保存与恢复——记住用户上次打开的窗口大小

🏆 成就解锁:全端适配师

你已掌握平台适配与响应式设计!

下一课预告:毕业综合项目——整合30课所学,打造你的Flutter毕业作品!