随着移动设备和桌面端访问方式的多样化,响应式设计已成为现代应用开发中不可或缺的一部分。本教程将指导你如何使用 Flutter 中的 layout 包,以简洁高效的方式开发一个适应各种屏幕尺寸的响应式后台管理面板。

利用 Layout 组件轻松打造 Flutter 响应式界面

PC宽屏

移动屏幕

视频

前言

原文 优雅实现Flutter响应式界面的Layout组件指南

随着移动设备和桌面端访问方式的多样化,响应式设计已成为现代应用开发中不可或缺的一部分。本教程将指导你如何使用 Flutter 中的 layout 包,以简洁高效的方式开发一个适应各种屏幕尺寸的响应式后台管理面板。

通过本教程,你将学习如何:

  • 使用 Flutter layout 包实现响应式布局
  • 创建适配不同屏幕尺寸的用户界面
  • 实现响应式的侧边栏、卡片和图表组件
  • 使用 AdaptiveBuilder 根据不同断点渲染不同的 UI

代码

https://github.com/ducafecat/flutter_develop_tips/tree/main/flutter_application_autolayout

设计稿

https://www.figma.com/community/file/1153320445661469840

参考

https://pub.dev/packages/layout

https://m2.material.io/design/layout/understanding-layout.html

https://docs.flutter.dev/ui/layout

https://docs.flutter.dev/ui/adaptive-responsive

准备工作

依赖项配置

首先,我们需要在 pubspec.yaml 文件中添加 layout 包:

# 文件路径: pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  # 其他依赖...
  layout: ^1.0.5

添加后运行 flutter pub get 安装依赖。

项目结构

一个良好的项目结构能让我们的代码更加清晰、易于维护。以下是推荐的项目结构:

lib/
  ├── main.dart                # 入口文件
  ├── screens/                 # 页面
  │   ├── main_layout.dart     # 主布局
  │   └── dashboard_screen.dart # 仪表盘页面
  ├── components/              # 可复用组件
  │   ├── sidebar.dart         # 侧边栏
  │   └── cards/               # 卡片组件
  ├── theme/                   # 主题配置
  │   └── app_theme.dart       # 主题定义
  ├── models/                  # 数据模型
  ├── utils/                   # 工具函数
  └── config/                  # 配置文件

响应式布局核心概念

什么是 layout 包?

layout 包是 Flutter 生态中专门用于处理响应式布局的工具,它遵循 Material Design 指南,通过使用统一的元素和间距来鼓励跨平台、环境和屏幕尺寸的一致性。它提供了一套简洁的 API,使我们能够轻松地创建适应不同屏幕尺寸的 UI。

断点系统

layout 包使用断点系统来定义不同屏幕尺寸的阈值。默认的断点配置为:

  • xs: 0 – 599px (手机)
  • sm: 600 – 1023px (平板/小桌面)
  • md: 1024 – 1439px (桌面)
  • lg: 1440 – 1919px (大桌面)
  • xl: 1920px+ (超大屏幕)

这些断点基于 Material Design 指南,每个断点范围都决定了列数以及建议的边距和间距。你可以通过 Layout 小部件的参数自定义这些断点。

layout 包核心功能

Layout 小部件

所有布局功能的起点是 Layout 小部件。通常添加在应用的顶层,它使用其约束条件计算断点、列、间距和边距。

@override
Widget build(BuildContext context) {
  return Layout(
    // 可以通过以下参数自定义布局配置
    // breakpoints: [0, 600, 1024, 1440, 1920],
    child: MaterialApp(
      title: '响应式应用',
      home: HomePage(),
    ),
  );
}

获取当前断点

你可以随时通过上下文获取当前的断点信息:

@override
Widget build(BuildContext context) {
  if(context.breakpoint > LayoutBreakpoint.md) {
    return DesktopView();
  } else {
    return MobileView();
  }
}

使用 LayoutValue 定义响应式属性

LayoutValue 是一个相对于屏幕宽度的值。这样你可以定义响应式变量,重复使用并在需要时应用它们:

// 基本用法
final fontSize = LayoutValue(xs: 16.0, md: 20.0, lg: 24.0);
final padding = LayoutValue(
  xs: const EdgeInsets.all(8),
  md: const EdgeInsets.all(16),
  lg: const EdgeInsets.all(24),
);

// 如果某个断点未提供值,将使用前一个较小断点的值
final spacing = LayoutValue(
  xs: 8.0,  // sm 的值也将是 8.0
  md: 16.0, // lg 的值也将是 16.0
  xl: 24.0
);

@override
Widget build(BuildContext context) {
  return Container(
    padding: padding.resolve(context),
    child: Text(
      'Hello World',
      style: TextStyle(fontSize: fontSize.resolve(context)),
    ),
  );
}

你还可以创建可在应用不同部分重用的 LayoutValue

// 定义可重用的 LayoutValue
final displaySidebar = LayoutValue(xs: false, md: true);

// 使用 builder 创建更复杂的值
final horizontalMargin = LayoutValue.builder((layout) {
  double margin = layout.width >= 500 ? 24.0 : 16.0;
  margin += 8.0 * layout.visualDensity.horizontal;
  return EdgeInsets.symmetric(horizontal: margin);
});

// 在任何小部件中使用
@override
Widget build(BuildContext context) {
  return Column(
    children: [
      Padding(
        padding: horizontalMargin.resolve(context),
        child: child,
      ),
      if(displaySidebar.resolve(context))
        SideBar(),
    ],
  );
}

使用 Margin 组件添加边距

Margin 组件是一个便捷的包装器,用于在内容和屏幕左右边缘之间添加空间。边距宽度在每个断点范围都有固定值:

@override
Widget build(BuildContext context) {
  return Margin(
    // 可选:自定义边距
    // margin: LayoutValue(...),
    child: Text('带有自适应边距的文本'),
  );
}

默认情况下,边距值遵循 Material Design 指南:小于 720dp 宽度的屏幕使用 16dp,更大的屏幕使用 24dp。

使用 FluidMargin 组件

FluidMargin 组件会动态更新以保持其内部子部件的固定大小:

@override
Widget build(BuildContext context) {
  return FluidMargin(
    child: Text('这个文本在不同屏幕上将保持相同的宽度'),
  );
}

使用 AdaptiveBuilder 创建响应式布局

AdaptiveBuilder 是一个强大的小部件,允许根据不同断点渲染完全不同的 UI 结构:

// 基本用法
AdaptiveBuilder(
  xs: (context) => MobileLayout(),
  sm: (context) => TabletLayout(),
  md: (context) => DesktopLayout(),
);

// 更复杂的场景
AdaptiveBuilder.builder(
  builder: (context, layout, child) {
    if (layout.breakpoint < LayoutBreakpoint.lg) {
      return LayoutWithBottomNavigationBar(child: child);
    } else {
      return LayoutWithTrailingNavigationBar(child: child);
    }
  },
  child: YourContent(),
);

响应式断点值的使用示例

// 在 StatefulWidget 中定义响应式值
class _MyScreenState extends State<MyScreen> {
  // 定义布局断点的响应式值
  final showSidebarInDrawer = LayoutValue(xs: true, md: false);
  final showSidebarInline = LayoutValue(xs: false, md: true);
  final contentPadding = LayoutValue(
    xs: const EdgeInsets.all(16),
    sm: const EdgeInsets.all(20),
    md: const EdgeInsets.all(24),
    lg: const EdgeInsets.all(30),
  );

  @override
  Widget build(BuildContext context) {
    // 使用断点值
    final bool isMobileOrTablet = showSidebarInDrawer.resolve(context);
    final EdgeInsets padding = contentPadding.resolve(context);

    return Scaffold(
      drawer: isMobileOrTablet ? Drawer(...) : null,
      body: Padding(
        padding: padding,
        child: YourContent(),
      ),
    );
  }
}

构建响应式后台面板

第一步:配置应用入口

main.dart 文件中,我们需要用 Layout 小部件包装整个应用:

文件路径: lib/main.dart

import 'package:flutter/material.dart';
import 'package:layout/layout.dart';
import 'package:your_app/screens/main_layout.dart';
import 'package:your_app/theme/app_theme.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return Layout(
      child: MaterialApp(
        title: '响应式后台面板',
        debugShowCheckedModeBanner: false,
        theme: AppTheme.lightTheme,
        home: const MainLayout(),
      ),
    );
  }
}

第二步:创建主布局

主布局是我们应用的骨架,它负责根据屏幕尺寸组织侧边栏和主内容区域:

文件路径: lib/screens/main_layout.dart

import 'package:flutter/material.dart';
import 'package:layout/layout.dart';
import 'package:your_app/components/sidebar.dart';
import 'package:your_app/screens/dashboard_screen.dart';
import 'package:your_app/theme/app_theme.dart';

class MainLayout extends StatefulWidget {
  const MainLayout({super.key});

  @override
  State<MainLayout> createState() => _MainLayoutState();
}

class _MainLayoutState extends State<MainLayout> {
  final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();

  // 定义布局断点的响应式值
  final showSidebarInDrawer = LayoutValue(xs: true, md: false);
  final showSidebarInline = LayoutValue(xs: false, md: true);

  @override
  Widget build(BuildContext context) {
    // 使用断点值判断当前设备类型
    final bool isMobileOrTablet = showSidebarInDrawer.resolve(context);
    final bool isDesktop = showSidebarInline.resolve(context);

    return Scaffold(
      key: _scaffoldKey,
      backgroundColor: AppColors.background,
      // 只在移动端和平板上显示抽屉
      drawer: isMobileOrTablet ? const Drawer(child: Sidebar()) : null,
      // 在移动端添加应用栏,显示菜单按钮
      appBar: isMobileOrTablet
          ? AppBar(
              title: const Text('后台管理系统'),
              leading: IconButton(
                icon: const Icon(Icons.menu),
                onPressed: () => _scaffoldKey.currentState?.openDrawer(),
              ),
            )
          : null,
      body: AdaptiveBuilder(
        // 移动端和平板布局
        xs: (context) => const DashboardScreen(),
        // 桌面端布局
        md: (context) => Row(
          children: [
            const Sidebar(),
            const Expanded(
              child: DashboardScreen(),
            ),
          ],
        ),
      ),
    );
  }
}

第三步:实现响应式侧边栏

侧边栏是后台面板的核心导航组件,需要在不同屏幕尺寸下有不同的显示方式:

文件路径: lib/components/sidebar.dart

import 'package:flutter/material.dart';
import 'package:your_app/theme/app_theme.dart';

class SidebarItem {
  final String title;
  final IconData icon;
  final bool isActive;
  final VoidCallback onTap;

  SidebarItem({
    required this.title,
    required this.icon,
    this.isActive = false,
    required this.onTap,
  });
}

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

  @override
  Widget build(BuildContext context) {
    final List<SidebarItem> menuItems = [
      SidebarItem(
        title: "仪表盘",
        icon: Icons.dashboard_rounded,
        isActive: true,
        onTap: () {},
      ),
      SidebarItem(
        title: "数据分析",
        icon: Icons.analytics_outlined,
        onTap: () {},
      ),
      SidebarItem(
        title: "用户管理",
        icon: Icons.people_outline,
        onTap: () {},
      ),
      SidebarItem(
        title: "设置",
        icon: Icons.settings_outlined,
        onTap: () {},
      ),
    ];

    return Container(
      width: 260,
      color: AppColors.white,
      child: Column(
        children: [
          const SizedBox(height: 24),
          _buildLogo(),
          const SizedBox(height: 32),
          Expanded(
            child: ListView(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              children: [
                ...menuItems.map((item) => _buildMenuItem(item)),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildLogo() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 20),
      child: Row(
        children: [
          Text(
            "管理系统",
            style: TextStyle(
              fontWeight: FontWeight.bold,
              fontSize: 24,
              color: AppColors.textPrimary,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildMenuItem(SidebarItem item) {
    return Container(
      margin: const EdgeInsets.only(bottom: 8),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(10),
        color: item.isActive ? AppColors.primary : Colors.transparent,
      ),
      child: ListTile(
        onTap: item.onTap,
        leading: Icon(
          item.icon,
          color: item.isActive ? AppColors.white : AppColors.textLight,
          size: 22,
        ),
        title: Text(
          item.title,
          style: TextStyle(
            fontWeight: item.isActive ? FontWeight.bold : FontWeight.w500,
            fontSize: 16,
            color: item.isActive ? AppColors.white : AppColors.textLight,
          ),
        ),
        contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      ),
    );
  }
}

第四步:创建响应式内容布局

在仪表盘内容区域,我们需要根据屏幕尺寸调整布局:

文件路径: lib/screens/dashboard_screen.dart

import 'package:flutter/material.dart';
import 'package:layout/layout.dart';
import 'package:your_app/components/cards/stat_card.dart';
import 'package:your_app/components/cards/chart_card.dart';
import 'package:your_app/theme/app_theme.dart';

// 定义不同断点下的内容边距值
final contentPadding = LayoutValue(
  xs: const EdgeInsets.all(16),
  sm: const EdgeInsets.all(20),
  md: const EdgeInsets.all(24),
  lg: const EdgeInsets.all(30),
);

// 定义不同断点下的组件间距
final sectionSpacing = LayoutValue(
  xs: 20.0,
  sm: 30.0,
  md: 40.0,
  lg: 50.0,
);

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

  @override
  Widget build(BuildContext context) {
    // 获取当前断点下的间距值
    final EdgeInsets padding = contentPadding.resolve(context);
    final double spacing = sectionSpacing.resolve(context);

    return Scaffold(
      backgroundColor: AppColors.background,
      body: SingleChildScrollView(
        padding: padding,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '仪表盘',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            SizedBox(height: spacing / 2),
            Text(
              '欢迎回来,这是您的数据概览',
              style: Theme.of(context).textTheme.bodyMedium,
            ),
            SizedBox(height: spacing),
            _buildStatCards(context),
            SizedBox(height: spacing),
            _buildCharts(context),
          ],
        ),
      ),
    );
  }

  Widget _buildStatCards(BuildContext context) {
    return AdaptiveBuilder(
      // 移动设备:单列布局
      xs: (context) => Column(
        children: [
          StatCard(
            title: '总用户数',
            value: '3,721',
            growth: 12.5,
            icon: Icons.people,
            onTap: () {},
          ),
          SizedBox(height: sectionSpacing.resolve(context) / 2),
          StatCard(
            title: '总收入',
            value: '¥28,692',
            growth: 8.2,
            icon: Icons.attach_money,
            onTap: () {},
          ),
          SizedBox(height: sectionSpacing.resolve(context) / 2),
          StatCard(
            title: '总订单',
            value: '1,842',
            growth: -2.4,
            icon: Icons.shopping_cart,
            onTap: () {},
          ),
        ],
      ),
      // 平板设备:双列布局
      sm: (context) => GridView.count(
        crossAxisCount: 2,
        childAspectRatio: 2,
        crossAxisSpacing: 16,
        mainAxisSpacing: 16,
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        children: [
          StatCard(
            title: '总用户数',
            value: '3,721',
            growth: 12.5,
            icon: Icons.people,
            onTap: () {},
          ),
          StatCard(
            title: '总收入',
            value: '¥28,692',
            growth: 8.2,
            icon: Icons.attach_money,
            onTap: () {},
          ),
          StatCard(
            title: '总订单',
            value: '1,842',
            growth: -2.4,
            icon: Icons.shopping_cart,
            onTap: () {},
          ),
          StatCard(
            title: '转化率',
            value: '24.8%',
            growth: 4.1,
            icon: Icons.auto_graph,
            onTap: () {},
          ),
        ],
      ),
      // 桌面设备:四列布局
      lg: (context) => Row(
        children: [
          Expanded(
            child: StatCard(
              title: '总用户数',
              value: '3,721',
              growth: 12.5,
              icon: Icons.people,
              onTap: () {},
            ),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: StatCard(
              title: '总收入',
              value: '¥28,692',
              growth: 8.2,
              icon: Icons.attach_money,
              onTap: () {},
            ),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: StatCard(
              title: '总订单',
              value: '1,842',
              growth: -2.4,
              icon: Icons.shopping_cart,
              onTap: () {},
            ),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: StatCard(
              title: '转化率',
              value: '24.8%',
              growth: 4.1,
              icon: Icons.auto_graph,
              onTap: () {},
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildCharts(BuildContext context) {
    return AdaptiveBuilder(
      // 移动设备:单列布局
      xs: (context) => Column(
        children: [
          ChartCard(
            title: '本月收入趋势',
            onTap: () {},
          ),
          SizedBox(height: sectionSpacing.resolve(context) / 2),
          ChartCard(
            title: '用户增长分析',
            onTap: () {},
          ),
        ],
      ),
      // 桌面设备:双列布局
      md: (context) => Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: ChartCard(
              title: '本月收入趋势',
              onTap: () {},
            ),
          ),
          const SizedBox(width: 24),
          Expanded(
            child: ChartCard(
              title: '用户增长分析',
              onTap: () {},
            ),
          ),
        ],
      ),
    );
  }
}

第五步:实现响应式卡片组件

创建一个响应式的统计数据卡片组件:

文件路径: lib/components/cards/stat_card.dart

import 'package:flutter/material.dart';
import 'package:layout/layout.dart';
import 'package:your_app/theme/app_theme.dart';

class StatCard extends StatelessWidget {
  final String title;
  final String value;
  final double growth;
  final IconData icon;
  final VoidCallback onTap;

  const StatCard({
    super.key,
    required this.title,
    required this.value,
    required this.growth,
    required this.icon,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    final bool isPositiveGrowth = growth >= 0;

    return Card(
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(16),
        child: Padding(
          padding: const EdgeInsets.all(20),
          child: AdaptiveBuilder(
            // 移动设备:横向布局
            xs: (context) => Row(
              children: [
                _buildIcon(),
                const SizedBox(width: 16),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        title,
                        style: Theme.of(context).textTheme.bodyMedium,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        value,
                        style: Theme.of(context).textTheme.headlineSmall,
                      ),
                      const SizedBox(height: 8),
                      _buildGrowthIndicator(isPositiveGrowth),
                    ],
                  ),
                ),
              ],
            ),
            // 平板及以上:纵向布局
            md: (context) => Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                _buildIcon(),
                const SizedBox(height: 12),
                Text(
                  title,
                  style: Theme.of(context).textTheme.bodyMedium,
                ),
                const SizedBox(height: 8),
                Text(
                  value,
                  style: Theme.of(context).textTheme.headlineSmall,
                ),
                const SizedBox(height: 12),
                _buildGrowthIndicator(isPositiveGrowth),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildIcon() {
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: AppColors.primary.withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Icon(
        icon,
        color: AppColors.primary,
        size: 24,
      ),
    );
  }

  Widget _buildGrowthIndicator(bool isPositive) {
    return Row(
      children: [
        Icon(
          isPositive ? Icons.arrow_upward : Icons.arrow_downward,
          color: isPositive ? Colors.green : Colors.red,
          size: 16,
        ),
        const SizedBox(width: 4),
        Text(
          '${isPositive ? "+" : ""}${growth.toStringAsFixed(1)}%',
          style: TextStyle(
            color: isPositive ? Colors.green : Colors.red,
            fontWeight: FontWeight.w500,
            fontSize: 14,
          ),
        ),
        const SizedBox(width: 4),
        Text(
          'vs 上月',
          style: TextStyle(
            color: AppColors.textLight,
            fontSize: 12,
          ),
        ),
      ],
    );
  }
}

响应式设计的最佳实践

  1. 移动优先设计:始终从最小的屏幕开始设计,然后逐步扩展到更大的屏幕。
  2. 使用相对单位:避免硬编码的尺寸,使用 Expanded、Flexible 和 FractionallySizedBox 等小部件。
  3. 设置最大宽度:在大屏幕上,给内容设置最大宽度,避免过度拉伸:
    Center(
      child: ConstrainedBox(
        constraints: BoxConstraints(maxWidth: 1200),
        child: YourContent(),
      ),
    )
    
  4. 保持一致性:在不同尺寸的屏幕上保持设计的一致性,仅调整布局,而不是完全改变用户体验。
  5. 测试多种尺寸:在开发过程中,经常切换不同的屏幕尺寸进行测试,确保在所有设备上都有良好的显示效果。

代码

https://github.com/ducafecat/flutter_develop_tips/tree/main/flutter_application_autolayout

结语

通过本教程,你已经学习了如何使用 Flutter 的 layout 包构建响应式后台管理面板。这种方法不仅使你的应用能够适应各种屏幕尺寸,还能提供一致的用户体验。

响应式设计是一项重要的技能,特别是在当今多样化的设备环境中。通过掌握这些技术,你将能够创建既美观又实用的跨平台应用程序。

希望这个教程对你有所帮助!祝你的 Flutter 开发之旅顺利!

感谢阅读本文

如果有什么建议,请在评论中让我知道。我很乐意改进。


猫哥 APP

flutter 学习路径


© 猫哥 ducafecat.com

end