📐 10 - 布局(Row/Column/Stack)

阶段二:Widget基础 · 第10课 · 预计学习时间:60分钟
✅ 验证通过

📖 Flutter布局模型

Flutter使用约束传递模型:父组件告诉子组件自己的约束,子组件在约束内决定自己的大小,然后告诉父组件。

约束传递流程: ┌──────────────────┐ │ 父Widget │ │ "你可以最宽360 │──→ 约束向下传递 │ 最高640" │ └──────────────────┘ ↓ ┌──────────────────┐ │ 子Widget │ │ "我选择宽200 │──→ 大小向上报告 │ 高100" │ └──────────────────┘ ↓ ┌──────────────────┐ │ 父Widget │ │ "好的,我把你 │──→ 确定位置 │ 放在(80, 50)" │ └──────────────────┘

📖 Row — 水平布局

// ✅ 验证通过:Flutter 3.22

/// Row核心属性
Row(
  mainAxisAlignment,  // 主轴(水平)对齐
  crossAxisAlignment, // 交叉轴(垂直)对齐
  mainAxisSize,       // 主轴大小
  textDirection,      // 文本方向(LTR/RTL)
  verticalDirection,  // 垂直方向(down/up)
  children,           // 子组件列表
)

// mainAxisAlignment 选项:
// start     ←[A][B][C]           靠左
// center    ←  [A][B][C]  →      居中
// end       [A][B][C]→           靠右
// spaceBetween [A]  [B]  [C]     两端对齐
// spaceAround   [A] [B] [C]      等间距(含两端半间距)
// spaceEvenly  [A] [B] [C]       完全等间距

// crossAxisAlignment 选项:
// start      顶部对齐
// center     居中对齐
// end        底部对齐
// stretch    拉伸填满
// baseline   基线对齐(需指定textBaseline)

class RowDemos extends StatelessWidget {
  const RowDemos({super.key});
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 基本Row
        const Text('基本水平排列:'),
        Row(
          children: [
            Container(width: 50, height: 50, color: Colors.red),
            Container(width: 50, height: 50, color: Colors.green),
            Container(width: 50, height: 50, color: Colors.blue),
          ],
        ),
        
        const SizedBox(height: 16),
        
        // 居中对齐
        const Text('居中排列:'),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(width: 50, height: 50, color: Colors.red),
            Container(width: 50, height: 50, color: Colors.green),
            Container(width: 50, height: 50, color: Colors.blue),
          ],
        ),
        
        const SizedBox(height: 16),
        
        // 等间距
        const Text('等间距排列:'),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            Container(width: 50, height: 50, color: Colors.red),
            Container(width: 50, height: 50, color: Colors.green),
            Container(width: 50, height: 50, color: Colors.blue),
          ],
        ),
        
        const SizedBox(height: 16),
        
        // 交叉轴对齐
        const Text('交叉轴对齐:'),
        SizedBox(
          height: 100,
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              Container(width: 50, height: 30, color: Colors.red),
              Container(width: 50, height: 60, color: Colors.green),
              Container(width: 50, height: 45, color: Colors.blue),
            ],
          ),
        ),
        
        const SizedBox(height: 16),
        
        // Expanded — 占据剩余空间
        const Text('Expanded分配空间:'),
        Row(
          children: [
            Container(width: 50, height: 50, color: Colors.red),
            Expanded(
              child: Container(height: 50, color: Colors.green),
            ),
            Container(width: 50, height: 50, color: Colors.blue),
          ],
        ),
        
        const SizedBox(height: 16),
        
        // Flexible — 灵活分配
        const Text('Flexible按比例分配:'),
        Row(
          children: [
            Flexible(flex: 1, child: Container(height: 50, color: Colors.red)),
            Flexible(flex: 2, child: Container(height: 50, color: Colors.green)),
            Flexible(flex: 1, child: Container(height: 50, color: Colors.blue)),
          ],
        ),
      ],
    );
  }
}

📖 Column — 垂直布局

// ✅ 验证通过
/// Column与Row完全对称,只是方向不同
/// 主轴=垂直,交叉轴=水平

class ColumnDemos extends StatelessWidget {
  const ColumnDemos({super.key});
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 基本Column
        const Text('基本垂直排列:'),
        Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Container(width: 150, height: 40, color: Colors.red),
            Container(width: 100, height: 40, color: Colors.green),
            Container(width: 50, height: 40, color: Colors.blue),
          ],
        ),
        
        // 交叉轴对齐(水平)
        const Text('交叉轴stretch:'),
        Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          mainAxisSize: MainAxisSize.min,
          children: [
            Container(height: 40, color: Colors.red),
            Container(height: 40, color: Colors.green),
            Container(height: 40, color: Colors.blue),
          ],
        ),
        
        // 嵌套Row+Column
        const Text('嵌套布局:'),
        Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: const [
                Text('左侧'),
                Text('右侧'),
              ],
            ),
            Row(
              children: [
                Expanded(child: ElevatedButton(onPressed: () {}, child: const Text('取消'))),
                const SizedBox(width: 16),
                Expanded(child: FilledButton(onPressed: () {}, child: const Text('确认'))),
              ],
            ),
          ],
        ),
      ],
    );
  }
}

/// ⚠️ 常见错误:Column内容溢出
/// 当Column的子组件总高度超过可用空间时,会报overflow错误

// ❌ 错误示例
Column(
  children: [
    Container(height: 300), // 太多子组件...
    Container(height: 300),
    Container(height: 300), // 溢出!
  ],
)

// ✅ 解决方案1:用Expanded包裹可变高度的子组件
Column(
  children: [
    Container(height: 100), // 固定高度
    Expanded(               // 占据剩余空间
      child: ListView(...), // 内部可滚动
    ),
  ],
)

// ✅ 解决方案2:用SingleChildScrollView包裹整个Column
SingleChildScrollView(
  child: Column(
    children: [
      Container(height: 300),
      Container(height: 300),
      Container(height: 300), // 不会溢出
    ],
  ),
)

📖 Stack — 层叠布局

// ✅ 验证通过
/// Stack允许子组件重叠,类似CSS的position:absolute

Stack(
  alignment,           // 对齐方式
  textDirection,       // 文本方向
  fit,                 // 非Positioned子组件的适配方式
  clipBehavior,        // 裁剪行为
  children,            // 子组件(后面的覆盖前面的)
)

class StackDemos extends StatelessWidget {
  const StackDemos({super.key});
  
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 基本Stack
        const Text('基本层叠:'),
        SizedBox(
          width: 200,
          height: 200,
          child: Stack(
            children: [
              Container(
                width: 200,
                height: 200,
                color: Colors.blue.withOpacity(0.3),
              ),
              Container(
                width: 120,
                height: 120,
                color: Colors.green.withOpacity(0.5),
              ),
              Container(
                width: 60,
                height: 60,
                color: Colors.red.withOpacity(0.7),
              ),
            ],
          ),
        ),
        
        const SizedBox(height: 16),
        
        // Positioned — 精确定位
        const Text('Positioned定位:'),
        SizedBox(
          width: 200,
          height: 200,
          child: Stack(
            children: [
              Container(color: Colors.grey[300]),
              // 左上角
              const Positioned(
                top: 10,
                left: 10,
                child: Icon(Icons.star, color: Colors.amber),
              ),
              // 右下角
              const Positioned(
                bottom: 10,
                right: 10,
                child: Icon(Icons.favorite, color: Colors.red),
              ),
              // 居中
              const Positioned(
                top: 0,
                left: 0,
                right: 0,
                bottom: 0,
                child: Center(child: Text('居中文字')),
              ),
            ],
          ),
        ),
        
        // 实际应用:图片上的徽章
        const Text('图片徽章:'),
        SizedBox(
          width: 150,
          height: 150,
          child: Stack(
            clipBehavior: Clip.none, // 允许溢出
            children: [
              Container(
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(16),
                  color: Colors.purple[100],
                ),
              ),
              const Center(
                child: Icon(Icons.person, size: 60, color: Colors.purple),
              ),
              Positioned(
                top: -5,
                right: -5,
                child: Container(
                  padding: const EdgeInsets.all(6),
                  decoration: const BoxDecoration(
                    color: Colors.red,
                    shape: BoxShape.circle,
                  ),
                  child: const Text('3', style: TextStyle(color: Colors.white, fontSize: 12)),
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

📖 间距与分隔

// ✅ 验证通过
/// SizedBox — 固定大小间距
const SizedBox(width: 16)    // 水平间距
const SizedBox(height: 8)    // 垂直间距
const SizedBox(width: 16, height: 16) // 同时指定

/// Spacer — 在Row/Column中占据弹性空间
Row(
  children: [
    const Text('左'),
    const Spacer(),    // 推开中间空间
    const Text('右'),
  ],
)
// 等同于:
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [const Text('左'), const Text('右')],
)

/// Divider — 水平分隔线
const Divider(height: 1)

/// VerticalDivider — 垂直分隔线
const VerticalDivider(width: 1)

/// Padding — 内边距
Padding(
  padding: const EdgeInsets.all(16),
  child: child,
)

// EdgeInsets变体
EdgeInsets.all(16)              // 四边相同
EdgeInsets.symmetric(h: 16, v: 8) // 水平/垂直
EdgeInsets.only(left: 16, top: 8) // 指定边
EdgeInsets.fromLTRB(16, 8, 16, 8) // 左上右下

💻 实战:仪表盘布局

// ✅ 验证通过:Flutter 3.22 + Dart 3.4
// 文件:lib/dashboard_layout.dart

import 'package:flutter/material.dart';

void main() => runApp(const DashboardApp());

class DashboardApp extends StatelessWidget {
  const DashboardApp({super.key});
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '仪表盘',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const DashboardPage(),
    );
  }
}

// 统计卡片
class StatCard extends StatelessWidget {
  final String title;
  final String value;
  final IconData icon;
  final Color color;
  final String change;
  
  const StatCard({
    required this.title,
    required this.value,
    required this.icon,
    required this.color,
    this.change = '',
    super.key,
  });
  
  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(title, style: Theme.of(context).textTheme.bodySmall),
                Container(
                  padding: const EdgeInsets.all(8),
                  decoration: BoxDecoration(
                    color: color.withOpacity(0.15),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Icon(icon, color: color, size: 20),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Text(value, style: Theme.of(context).textTheme.headlineMedium?.copyWith(
              fontWeight: FontWeight.bold,
            )),
            if (change.isNotEmpty) ...[
              const SizedBox(height: 4),
              Text(
                change,
                style: TextStyle(
                  color: change.startsWith('+') ? Colors.green : Colors.red,
                  fontSize: 12,
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

// 活动项
class ActivityItem extends StatelessWidget {
  final String title;
  final String subtitle;
  final IconData icon;
  final Color color;
  final String time;
  
  const ActivityItem({
    required this.title,
    required this.subtitle,
    required this.icon,
    required this.color,
    required this.time,
    super.key,
  });
  
  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: CircleAvatar(
        backgroundColor: color.withOpacity(0.15),
        child: Icon(icon, color: color, size: 20),
      ),
      title: Text(title),
      subtitle: Text(subtitle, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
      trailing: Text(time, style: TextStyle(color: Colors.grey[500], fontSize: 12)),
    );
  }
}

// 主页面
class DashboardPage extends StatelessWidget {
  const DashboardPage({super.key});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('仪表盘'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          IconButton(onPressed: () {}, icon: const Icon(Icons.notifications_outlined)),
          const SizedBox(width: 8),
          const CircleAvatar(radius: 16, child: Icon(Icons.person, size: 18)),
          const SizedBox(width: 16),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 欢迎信息
            Text('欢迎回来 👋', style: Theme.of(context).textTheme.headlineSmall),
            const SizedBox(height: 4),
            Text('这是你的项目概览', style: TextStyle(color: Colors.grey[600])),
            
            const SizedBox(height: 24),
            
            // 统计卡片 — 2x2网格
            LayoutBuilder(
              builder: (context, constraints) {
                // 根据宽度决定列数
                var columns = constraints.maxWidth > 600 ? 4 : 2;
                return Wrap(
                  spacing: 12,
                  runSpacing: 12,
                  children: [
                    SizedBox(
                      width: (constraints.maxWidth - 12 * (columns - 1)) / columns,
                      child: const StatCard(
                        title: '总收入',
                        value: '¥128,430',
                        icon: Icons.trending_up,
                        color: Colors.green,
                        change: '+12.5%',
                      ),
                    ),
                    SizedBox(
                      width: (constraints.maxWidth - 12 * (columns - 1)) / columns,
                      child: const StatCard(
                        title: '用户数',
                        value: '3,842',
                        icon: Icons.people,
                        color: Colors.blue,
                        change: '+8.2%',
                      ),
                    ),
                    SizedBox(
                      width: (constraints.maxWidth - 12 * (columns - 1)) / columns,
                      child: const StatCard(
                        title: '订单量',
                        value: '1,256',
                        icon: Icons.shopping_cart,
                        color: Colors.orange,
                        change: '-3.1%',
                      ),
                    ),
                    SizedBox(
                      width: (constraints.maxWidth - 12 * (columns - 1)) / columns,
                      child: const StatCard(
                        title: '转化率',
                        value: '32.7%',
                        icon: Icons.speed,
                        color: Colors.purple,
                        change: '+2.4%',
                      ),
                    ),
                  ],
                );
              },
            ),
            
            const SizedBox(height: 24),
            
            // 最近活动
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('最近活动', style: Theme.of(context).textTheme.titleMedium),
                TextButton(onPressed: () {}, child: const Text('查看全部')),
              ],
            ),
            Card(
              child: Column(
                children: const [
                  ActivityItem(
                    title: '新用户注册',
                    subtitle: 'Alice 加入了平台',
                    icon: Icons.person_add,
                    color: Colors.green,
                    time: '2分钟前',
                  ),
                  Divider(height: 1),
                  ActivityItem(
                    title: '新订单',
                    subtitle: '订单 #1024 已创建',
                    icon: Icons.shopping_bag,
                    color: Colors.blue,
                    time: '15分钟前',
                  ),
                  Divider(height: 1),
                  ActivityItem(
                    title: '支付完成',
                    subtitle: '¥2,980 已收到',
                    icon: Icons.payment,
                    color: Colors.green,
                    time: '1小时前',
                  ),
                  Divider(height: 1),
                  ActivityItem(
                    title: '系统告警',
                    subtitle: 'CPU使用率超过90%',
                    icon: Icons.warning,
                    color: Colors.orange,
                    time: '2小时前',
                  ),
                ],
              ),
            ),
            
            const SizedBox(height: 24),
            
            // 快捷操作
            Text('快捷操作', style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 12),
            Row(
              children: [
                Expanded(
                  child: FilledButton.icon(
                    onPressed: () {},
                    icon: const Icon(Icons.add),
                    label: const Text('新建项目'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: () {},
                    icon: const Icon(Icons.upload),
                    label: const Text('导入数据'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

🏋️ 练习

练习1:响应式网格 📱

使用LayoutBuilder + Row实现响应式网格:

练习2:聊天消息布局 💬

实现聊天气泡布局:

练习3:卡片叠放效果 🃏

使用Stack实现扑克牌叠放效果:5张牌,每张偏移20px。

🏆 本课成就

📚 下节预告

下一课:容器与装饰——Container、BoxDecoration、Card等视觉装饰组件。