Build interfaces that adapt to the parent's constraints, to the window and to each OS's conventions.
Open this lesson in KodokonMediaQuery describes the logical window, not the physical screen: size, padding (notch, system bar), viewInsets (keyboard), textScaler... A classic pitfall: MediaQuery.of(context) subscribes the widget to all these properties. When the keyboard opens, viewInsets changes and every subscribed widget rebuilds - including those that only read the width. Since Flutter 3.10, MediaQueryData is exposed via an InheritedModel: the targeted accessors MediaQuery.sizeOf(context), paddingOf, viewInsetsOf... subscribe the widget only to the requested aspect. Expert reflex: banish .of(context).size from your codebase.
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')),
),
);
}
}MediaQuery answers "how big is the window?"; LayoutBuilder answers "how much room does my parent give me?". The nuance is decisive for reusable components: a panel shown sometimes full-screen, sometimes in a 320 px column must react to its constraints, not to the window - otherwise it thinks it's on a tablet when it's actually cramped. A useful internal detail: LayoutBuilder's builder runs during the layout phase, when the constraints are known; you therefore can't trigger a synchronous setState inside it, the framework forbids it.
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),
],
);
},
);
}
}For iOS/Android differences, the source of truth on the widget side is Theme.of(context).platform: it derives from defaultTargetPlatform while respecting overrides - essential for testing the iOS rendering from a test or simulating another platform. Many adaptations are already built into the framework: scroll physics (iOS bounce versus Android glow), page transitions, the edge swipe-to-go-back gesture. The .adaptive constructors (Switch.adaptive, CircularProgressIndicator.adaptive) switch to the Cupertino rendering on their own. Reserve your manual branches for genuinely divergent conventions, such as the labeling of actions or the position of dialog buttons.
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,
),
);
}
}