Flutter的"一次编写,多端运行"不是简单地把手机UI放大到平板上。真正优秀的跨平台App需要根据屏幕尺寸、平台特性、输入方式做出自适应调整。本课涵盖响应式布局、平台感知UI、自适应导航和断点系统。
// 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);
},
);
}
}
// 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),
],
),
);
}
}
// 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,
);
}
}
// 列表+详情的自适应布局
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!),
),
],
),
);
}
}
刘海屏、底部横条、状态栏——不同设备的系统UI侵入区域各不相同。正确处理SafeArea和MediaQuery.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),
)
某些场景需要锁定屏幕方向(如视频播放横屏,聊天竖屏),或根据方向切换布局。
// 锁定屏幕方向
// 竖屏
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()),
],
),
)
让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('点击'),
)
你已掌握平台适配与响应式设计!
下一课预告:毕业综合项目——整合30课所学,打造你的Flutter毕业作品!