Kodokon kodokon.com

自适应:LayoutBuilder、MediaQuery 与平台

构建能够适应父节点约束、窗口以及各个操作系统惯例的界面。

9 分钟 · 3 题

在 Kodokon 中打开本课

MediaQuery 描述的是逻辑窗口,而不是物理屏幕:尺寸、padding(刘海、系统栏)、viewInsets(键盘)、textScaler…… 一个经典的陷阱:MediaQuery.of(context) 会把 widget 订阅到所有这些属性上。当键盘弹出时,viewInsets 发生变化,每一个订阅了的 widget 都会重建,包括那些只读取宽度的 widget。自 Flutter 3.10 起,MediaQueryData 通过一个 InheritedModel 暴露出来:MediaQuery.sizeOf(context)paddingOfviewInsetsOf…… 这些有针对性的访问器只会把 widget 订阅到它所请求的那个方面。专家的条件反射:把 .of(context).size 从你的代码库里彻底赶走。

DART
import 'package:flutter/material.dart';

class AdaptiveGrid extends StatelessWidget {
  const AdaptiveGrid({super.key});

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.sizeOf(context).width;
    final columns = switch (width) {
      >= 900 => 4,
      >= 600 => 3,
      _ => 2,
    };
    return GridView.builder(
      gridDelegate:
          SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: columns,
      ),
      itemCount: 24,
      itemBuilder: (context, i) => Card(
        child: Center(child: Text('Tile $i')),
      ),
    );
  }
}
sizeOf + 关系型模式:清晰易读的断点。

MediaQuery 回答的是 "窗口有多大?";LayoutBuilder 回答的是 "我的父节点给了我多少空间?"。对于可复用的组件来说,这个细微差别是决定性的:一个有时全屏显示、有时挤在 320 像素宽的列里显示的面板,必须对它的约束做出反应,而不是对窗口做出反应,否则它明明局促得很,却以为自己是在一台平板上。一个有用的内部细节:LayoutBuilder 的 builder 在布局阶段运行,此时约束已经确定;因此你不能在它内部触发一次同步的 setState,框架禁止这么做。

DART
import 'package:flutter/material.dart';

class MasterDetail extends StatelessWidget {
  const MasterDetail({
    super.key,
    required this.list,
    required this.detail,
  });

  final Widget list;
  final Widget detail;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth < 720) {
          return list;
        }
        return Row(
          children: [
            SizedBox(width: 320, child: list),
            const VerticalDivider(width: 1),
            Expanded(child: detail),
          ],
        );
      },
    );
  }
}
无论被放在哪里,组件都会适应它所得到的空间。

至于 iOS/Android 的差异,在 widget 这一侧的权威来源是 Theme.of(context).platform:它派生自 defaultTargetPlatform,同时又尊重各种覆盖,这对于从测试中检验 iOS 的渲染效果或模拟另一个平台至关重要。许多适配已经内建在框架里了:滚动物理效果(iOS 的回弹对比 Android 的辉光)、页面过渡、从边缘滑动返回的手势。.adaptive 构造函数(Switch.adaptiveCircularProgressIndicator.adaptive)会自行切换到 Cupertino 风格的渲染。把你手写的分支留给真正存在分歧的惯例,比如操作的措辞或对话框按钮的位置。

DART
import 'package:flutter/material.dart';

class SettingsTile extends StatelessWidget {
  const SettingsTile({
    super.key,
    required this.value,
    required this.onChanged,
  });

  final bool value;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    final platform = Theme.of(context).platform;
    final isApple = platform == TargetPlatform.iOS ||
        platform == TargetPlatform.macOS;
    return ListTile(
      title: Text(
        isApple ? 'Settings' : 'Preferences',
      ),
      trailing: Switch.adaptive(
        value: value,
        onChanged: onChanged,
      ),
    );
  }
}
Switch.adaptive 在 iOS/macOS 上渲染成 Cupertino 风格的开关。

知识检测

确认你已牢记本课的重点内容。

  1. MediaQuery.of(context) 和 MediaQuery.sizeOf(context) 之间有什么区别?
    • sizeOf 只把 widget 订阅到尺寸这个方面:弹出键盘不会让它重建
    • sizeOf 返回以真实屏幕像素计的物理尺寸
    • of 已被弃用,将从框架中移除
  2. 什么时候你应该优先用 LayoutBuilder 而不是 MediaQuery 来适配布局?
    • 当决策取决于父节点分配的空间,而不是窗口尺寸时
    • 当你需要知道设备的朝向时
    • 当你想避免在主题变化时发生重建时
    • 永远不用:这两者可以互换
  3. 为什么要在 widget 代码里避免使用 Platform.isIOS?
    • 它来自 dart:io,在 web 上不可用,而且会忽略测试和主题所使用的覆盖
    • 它明显比 defaultTargetPlatform 慢
    • 它在 macOS 上返回 true