第 5 章 常用组件详解(完整可运行示例)

本章每个组件都是一个可以直接运行的单文件程序:照着「创建步骤」三步走,把代码整体替换进 lib/main.dart 就能看到真机效果;也可以直接粘贴到 dartpad.dev 运行。每节都配「关键点」和动态效果示意。

📌 三步运行任意示例 flutter create demo_app && cd demo_app → 用本节代码覆盖 lib/main.dart → flutter run。 代码里所有 import、main()、页面类都是齐全的,不需要再补任何东西。

5.1 文本与图片

5.1.1 Text —— 文字

创建步骤
  1. flutter create text_demo,进入目录;
  2. 用下面代码整体替换 lib/main.dart;
  3. flutter run,按 r 热重载改数值试效果。
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: TextDemoPage()));

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Text 示例')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start, // 左对齐,否则 Column 默认居中
          children: [
            const Text('默认样式的文字'),
            const SizedBox(height: 12),

            // 字号 + 字重
            const Text('18 号加粗',
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),

            // 颜色 + 斜体
            const Text('蓝色斜体',
                style: TextStyle(color: Colors.blue, fontStyle: FontStyle.italic)),
            const SizedBox(height: 12),

            // height 是行高倍数:正文 1.5~1.8,中文段落更透气
            const Text('行高 1.8:正文推荐 1.5~1.8,中文长段落读起来更舒适。',
                style: TextStyle(height: 1.8)),
            const SizedBox(height: 20),

            // 溢出控制:最多 2 行,超出显示省略号
            const Text(
              '这是一段很长的文字,用来演示 maxLines 与 overflow 的组合效果,'
              '超过两行的部分会被截断并显示省略号,列表页的标题经常会这样处理。',
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
            const SizedBox(height: 20),

            // 富文本:一段话里混排不同样式
            Text.rich(
              const TextSpan(
                text: '我同意 ',
                style: TextStyle(fontSize: 14),
                children: [
                  TextSpan(
                      text: '《用户协议》',
                      style: TextStyle(
                          color: Colors.blue, fontWeight: FontWeight.bold)),
                  TextSpan(text: ' 与 '),
                  TextSpan(
                      text: '《隐私政策》',
                      style: TextStyle(color: Colors.blue)),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}
关键点
动态效果 · 文字样式与溢出截断
默认文字
18 号加粗
蓝色斜体
这是一段很长的文字,用来演示 maxLines 与 overflow 的组合效果,超过两行的部分会被截断…
🎮 Playground 1:Text 文本样式调参器
Flutter 是 Google 开源的 UI 工具包,一套代码同时运行在移动端、Web 与桌面端,热重载让界面调试快得像改网页一样。

5.1.2 Image —— 图片(网络图 + 占位 + 错误兜底)

创建步骤
  1. 新建工程后,用下面代码替换 lib/main.dart;
  2. 确保设备能联网(示例图来自 picsum.photos,无需 key);
  3. 运行后可把 URL 改成错误地址,观察 errorBuilder 的兜底效果。
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: ImageDemoPage()));

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Image 示例')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // ① 圆角裁剪 + 固定高度 + 加载中/失败兜底(生产写法)
          ClipRRect(
            borderRadius: BorderRadius.circular(14),
            child: Image.network(
              'https://picsum.photos/600/300',
              height: 170,
              width: double.infinity,
              fit: BoxFit.cover, // cover 铺满可能裁边;contain 完整显示留白
              loadingBuilder: (context, child, progress) {
                if (progress == null) return child; // 加载完成
                final expected = progress.expectedTotalBytes;
                return Container(
                  height: 170,
                  color: Colors.grey.shade200,
                  alignment: Alignment.center,
                  child: expected == null
                      ? const CircularProgressIndicator()
                      : CircularProgressIndicator(
                          value: progress.cumulativeBytesLoaded / expected,
                        ),
                );
              },
              errorBuilder: (context, error, stack) => Container(
                height: 170,
                color: Colors.grey.shade200,
                alignment: Alignment.center,
                child: const Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Icon(Icons.broken_image_outlined, size: 34),
                    SizedBox(height: 6),
                    Text('图片加载失败', style: TextStyle(fontSize: 12)),
                  ],
                ),
              ),
            ),
          ),
          const SizedBox(height: 20),

          // ② 头像:圆形 + 网络图
          Row(
            children: [
              const CircleAvatar(
                radius: 26,
                backgroundImage: NetworkImage('https://i.pravatar.cc/120?img=5'),
              ),
              const SizedBox(width: 12),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: const [
                  Text('张三', style: TextStyle(fontWeight: FontWeight.w600)),
                  Text('产品经理', style: TextStyle(fontSize: 12, color: Colors.grey)),
                ],
              ),
            ],
          ),
          const SizedBox(height: 20),

          // ③ 本地资源图(需要先在 pubspec.yaml 声明 assets/images/)
          // const Image(image: AssetImage('assets/images/logo.png'), height: 60),
          Container(
            height: 60,
            alignment: Alignment.center,
            decoration: BoxDecoration(
              color: Colors.grey.shade100,
              borderRadius: BorderRadius.circular(10),
            ),
            child: const Text('本地图:AssetImage + pubspec 声明 assets',
                style: TextStyle(fontSize: 12, color: Colors.grey)),
          ),
        ],
      ),
    );
  }
}
关键点
动态效果 · 加载中 → 加载完成
🎮 Playground 2:Image 图片调参器
网络图片

把高度拉大、fit 切到 contain 看到的是「留空」,切到 fill 图片被拉扁变形——这是三种 fit 的核心区别。圆角拉满后生成的代码会自动改用 ClipRRect 包裹(Image.network 本身没有圆角参数)。

5.2 布局组件

5.2.1 Container + Stack —— 装饰卡片与角标

创建步骤
  1. 新建工程,替换 lib/main.dart;
  2. 运行后可调 padding、borderRadius、boxShadow 观察变化;
  3. 用本节下方的 Playground 3 拖动参数,加深对「内边距 vs 外边距」的理解。
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: LayoutDemoPage()));

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Container / Stack 示例')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // ① 带圆角、边框、阴影的卡片
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(18),           // 内边距:内容到边框的距离
            margin: const EdgeInsets.only(bottom: 20),   // 外边距:卡片到别人的距离
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(16),
              border: Border.all(color: Colors.grey.shade200),
              boxShadow: const [
                BoxShadow(
                    color: Color(0x14000000),
                    blurRadius: 14,
                    offset: Offset(0, 6)),
              ],
            ),
            child: const Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('本月支出', style: TextStyle(fontSize: 12, color: Colors.grey)),
                SizedBox(height: 6),
                Text('¥3,014.50',
                    style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800)),
              ],
            ),
          ),

          // ② Stack 叠放:图片 + 右上角角标
          SizedBox(
            height: 160,
            child: Stack(
              children: [
                ClipRRect(
                  borderRadius: BorderRadius.circular(16),
                  child: Image.network(
                    'https://picsum.photos/600/300?random=2',
                    width: double.infinity,
                    height: 160,
                    fit: BoxFit.cover,
                  ),
                ),
                // Positioned 只能在 Stack 直系子节点中使用
                Positioned(
                  top: 10,
                  right: 10,
                  child: Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
                    decoration: BoxDecoration(
                      color: Colors.black.withValues(alpha: 0.55),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    child: const Text('NEW',
                        style: TextStyle(
                            color: Colors.white,
                            fontSize: 11,
                            fontWeight: FontWeight.w600)),
                  ),
                ),
                // 左下角叠加文字
                const Positioned(
                  left: 14,
                  bottom: 12,
                  child: Text('城市夜景',
                      style: TextStyle(
                          color: Colors.white,
                          fontSize: 15,
                          fontWeight: FontWeight.w700,
                          shadows: [Shadow(blurRadius: 6, color: Colors.black54)])),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
关键点
动态效果 · 卡片浮起 + 角标
本月支出
¥3,014.50
NEW
🎮 Playground 3:Container 容器调参器
Container

试着把 padding 拉到 0、圆角拉满、颜色换几种,观察预览与生成代码的对应关系——Container 就是 Flutter 里最常用的「带装饰的盒子」。

5.2.2 Row / Column / Expanded —— 一行的三段式布局

先用本节上方 Playground 4 拖过 mainAxisAlignment 与 crossAxisAlignment,再看这段真实写法:

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: FlexDemoPage()));

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Row / Column / Expanded')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('① Expanded 按比例分配剩余宽度', style: TextStyle(fontSize: 13)),
            const SizedBox(height: 8),
            SizedBox(
              height: 56,
              child: Row(
                children: [
                  Expanded(flex: 2, child: _box('flex: 2', Colors.blue)),
                  const SizedBox(width: 8),
                  Expanded(flex: 1, child: _box('flex: 1', Colors.orange)),
                ],
              ),
            ),
            const SizedBox(height: 22),

            const Text('② 左右分布:SpaceBetween', style: TextStyle(fontSize: 13)),
            const SizedBox(height: 8),
            Container(
              height: 56,
              padding: const EdgeInsets.symmetric(horizontal: 12),
              color: Colors.grey.shade100,
              child: const Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Icon(Icons.menu),
                  Text('标题居中靠 Spacer', style: TextStyle(fontWeight: FontWeight.w600)),
                  Icon(Icons.search),
                ],
              ),
            ),
            const SizedBox(height: 22),

            const Text('③ 列表项:头像 + 单行省略文字 + 金额(不会溢出)',
                style: TextStyle(fontSize: 13)),
            const SizedBox(height: 8),
            Row(
              children: [
                const CircleAvatar(radius: 18, child: Icon(Icons.person, size: 18)),
                const SizedBox(width: 10),
                // 文字很长时,必须用 Expanded 限制宽度,否则会黄黑条纹溢出
                const Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text('这是一个非常长的账目备注名称会超出屏幕宽度',
                          maxLines: 1, overflow: TextOverflow.ellipsis),
                      Text('今天 12:30', style: TextStyle(fontSize: 11, color: Colors.grey)),
                    ],
                  ),
                ),
                const SizedBox(width: 8),
                const Text('-¥28.00',
                    style: TextStyle(
                        color: Colors.red, fontWeight: FontWeight.w700)),
              ],
            ),
          ],
        ),
      ),
    );
  }

  static Widget _box(String label, MaterialColor color) => Container(
        alignment: Alignment.center,
        decoration: BoxDecoration(
          color: color.shade100,
          borderRadius: BorderRadius.circular(10),
        ),
        child: Text(label,
            style: TextStyle(color: color.shade800, fontSize: 12)),
      );
}
关键点
动态效果 · flex 比例变化
flex: 2
flex: 1
🎮 Playground 4:Row / Column 排列调参器

这是全手册最重要的一个 Playground:mainAxisAlignment 决定子项沿主轴怎么排(start/center/end/spaceBetween 三等分间隙方案),crossAxisAlignment 决定沿交叉轴怎么贴。写页面时 80% 的布局问题都在调这两个参数。

5.3 列表与滚动

5.3.1 ListView.builder + ListTile + 下拉刷新(列表页标准写法)

创建步骤
  1. 新建工程,替换 lib/main.dart;
  2. 运行后下拉列表,会触发刷新并弹出提示;
  3. 把 itemCount 改成 1000,观察滚动依旧流畅(懒加载生效)。
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: ListDemoPage()));

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

  @override
  State<ListDemoPage> createState() => _ListDemoPageState();
}

class _ListDemoPageState extends State<ListDemoPage> {
  // 用 List.generate 造 30 条假数据,实际项目里换成接口返回
  List<String> _items = List.generate(30, (i) => '第 ${i + 1} 条记录');

  Future<void> _refresh() async {
    await Future.delayed(const Duration(seconds: 1)); // 模拟网络请求
    if (!mounted) return;
    setState(() {
      _items = ['刚刚刷新的新记录', ..._items];
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ListView 示例')),
      body: RefreshIndicator(
        onRefresh: _refresh, // 必须返回 Future,转圈才会持续到请求结束
        child: ListView.separated(
          physics: const AlwaysScrollableScrollPhysics(), // 内容不满一屏也能下拉
          padding: const EdgeInsets.all(12),
          itemCount: _items.length,
          separatorBuilder: (context, index) =>
              const Divider(height: 1, indent: 68),
          itemBuilder: (context, index) {
            final title = _items[index];
            return ListTile(
              leading: CircleAvatar(
                backgroundColor: Colors.blue.shade50,
                child: Text('${index + 1}',
                    style: const TextStyle(fontSize: 12, color: Colors.blue)),
              ),
              title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
              subtitle: const Text('点击查看详情'),
              trailing: const Icon(Icons.chevron_right, size: 18),
              onTap: () => ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('点击了:$title')),
              ),
            );
          },
        ),
      ),
    );
  }
}
关键点
动态效果 · 列表滚动 + 下拉刷新
第 1 条记录
第 2 条记录
第 3 条记录
第 4 条记录

5.3.2 GridView —— 两列网格

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: GridDemoPage()));

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('GridView 示例')),
      body: GridView.builder(
        padding: const EdgeInsets.all(12),
        // 固定列数:手机两列,平板可自行判断宽度改成 3~4 列
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          mainAxisSpacing: 12,   // 上下间距
          crossAxisSpacing: 12,  // 左右间距
          childAspectRatio: 1.1, // 宽/高,越小越高
        ),
        itemCount: 8,
        itemBuilder: (context, index) => Card(
          margin: EdgeInsets.zero,
          child: InkWell(
            borderRadius: BorderRadius.circular(12),
            onTap: () {},
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(Icons.folder_outlined,
                    size: 34, color: Colors.blue.shade300),
                const SizedBox(height: 8),
                Text('分类 ${index + 1}', style: const TextStyle(fontSize: 13)),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
关键点
动态效果 · 网格淡入
分类 1
分类 2
分类 3
分类 4
🎮 Playground 5:ListView 列表调参器
🎮 Playground 6:GridView 网格调参器

childAspectRatio 是宽 / 高之比:调小于 1(如 0.6)格子变高、调大于 1(如 1.6)格子变扁,做商品图、封面墙时主要就是调它。

5.4 Material 组件(页面骨架与导航)

5.4.1 Scaffold + AppBar + NavigationBar + FAB(一个完整的多 Tab 应用)

创建步骤
  1. 新建工程,替换 lib/main.dart;
  2. 运行后点击底部三个 Tab,页面切换且状态保留;
  3. 点右下角 FAB,观察 SnackBar;再点 AppBar 的搜索图标。
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: HomeShell(),
    ));

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

  @override
  State<HomeShell> createState() => _HomeShellState();
}

class _HomeShellState extends State<HomeShell> {
  int _index = 0;

  static const _titles = ['首页', '发现', '我的'];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // AppBar:标题 + 右侧动作
      appBar: AppBar(
        title: Text(_titles[_index]),
        centerTitle: false,
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('搜索')),
            ),
          ),
        ],
      ),

      // IndexedStack:切换 Tab 时保留各页状态(滚动位置、输入内容)
      body: IndexedStack(
        index: _index,
        children: const [
          Center(child: Text('首页内容')),
          Center(child: Text('发现内容')),
          Center(child: Text('我的内容')),
        ],
      ),

      floatingActionButton: FloatingActionButton(
        onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('点击了 FAB')),
        ),
        child: const Icon(Icons.add),
      ),

      // Material 3 底部导航
      bottomNavigationBar: NavigationBar(
        selectedIndex: _index,
        onDestinationSelected: (i) => setState(() => _index = i),
        destinations: const [
          NavigationDestination(
              icon: Icon(Icons.home_outlined),
              selectedIcon: Icon(Icons.home),
              label: '首页'),
          NavigationDestination(
              icon: Icon(Icons.explore_outlined),
              selectedIcon: Icon(Icons.explore),
              label: '发现'),
          NavigationDestination(
              icon: Icon(Icons.person_outline),
              selectedIcon: Icon(Icons.person),
              label: '我的'),
        ],
      ),
    );
  }
}
关键点
动态效果 · 底部导航切换
页面内容
首页
发现
我的

5.4.2 Card + ListTile —— 卡片列表项

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: CardDemoPage()));

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Card + ListTile')),
      body: ListView(
        padding: const EdgeInsets.all(12),
        children: const [
          Card(
            elevation: 2,
            child: ListTile(
              leading: CircleAvatar(child: Icon(Icons.music_note)),
              title: Text('晴天'),
              subtitle: Text('周杰伦 · 叶惠美'),
              trailing: Icon(Icons.more_horiz),
            ),
          ),
          Card(
            elevation: 2,
            child: ListTile(
              leading: Icon(Icons.folder, color: Colors.amber),
              title: Text('工作资料'),
              subtitle: Text('12 个文件 · 34.5 MB'),
              trailing: Icon(Icons.chevron_right),
            ),
          ),
        ],
      ),
    );
  }
}
关键点
动态效果 · 卡片点击反馈
晴天
周杰伦 · 叶惠美
📁
工作资料
12 个文件 · 34.5 MB
🎮 Playground 7:Scaffold 页面骨架调参器
☰ 首页
body 内容区
+
首页
分类
我的
🎮 Playground 8:Card + ListTile 调参器
卡片标题
副标题:ListTile 三行文字区

ListTile 是列表行的标准布局(leading / title / subtitle / trailing 五个槽位),外面套 Card 就是最常见的「设置项」「消息项」写法;阴影为 0 时卡片只剩边框感,适合深色主题。

5.5 表单与交互

5.5.1 按钮全家桶 + SnackBar

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: ButtonDemoPage()));

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

  void _toast(BuildContext context, String text) {
    ScaffoldMessenger.of(context)
      ..hideCurrentSnackBar() // 连续点击时先收起上一个
      ..showSnackBar(SnackBar(
        content: Text(text),
        duration: const Duration(seconds: 2),
        action: SnackBarAction(label: '撤销', onPressed: () {}),
      ));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('按钮示例')),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // 主操作:实心按钮
            FilledButton(
              onPressed: () => _toast(context, 'FilledButton:页面主操作'),
              child: const Text('提交'),
            ),
            const SizedBox(height: 12),

            // 次级操作:淡色填充
            FilledButton.tonal(
              onPressed: () => _toast(context, 'FilledButton.tonal:次级操作'),
              child: const Text('保存草稿'),
            ),
            const SizedBox(height: 12),

            // 次要操作:描边
            OutlinedButton.icon(
              onPressed: () => _toast(context, 'OutlinedButton'),
              icon: const Icon(Icons.share_outlined, size: 18),
              label: const Text('分享'),
            ),
            const SizedBox(height: 12),

            // 弱操作:纯文字
            TextButton(
              onPressed: () => _toast(context, 'TextButton'),
              child: const Text('了解更多'),
            ),
            const SizedBox(height: 12),

            // 禁用态:onPressed 传 null
            const FilledButton(onPressed: null, child: Text('不可点击(禁用态)')),
            const SizedBox(height: 12),

            // 自定义样式:全宽圆角
            ElevatedButton(
              style: ElevatedButton.styleFrom(
                backgroundColor: const Color(0xFF2E7D6B),
                foregroundColor: Colors.white,
                minimumSize: const Size(double.infinity, 50),
                shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(25)),
              ),
              onPressed: () => _toast(context, '自定义样式的按钮'),
              child: const Text('自定义样式'),
            ),
          ],
        ),
      ),
    );
  }
}
关键点
动态效果 · 点击水波纹
提交
分享
🎮 Playground 9:按钮 + SnackBar 调参器(点按钮真的会弹)

点右边的按钮会真的弹出 SnackBar(2 秒后自动消失),同时左侧代码切换成对应的 ScaffoldMessenger 写法——按钮四大金刚(Elevated / Filled / Outlined / Text)的视觉层级从重到轻,按操作重要性选用。

5.5.2 TextField / Form —— 登录表单与校验

创建步骤
  1. 新建工程,替换 lib/main.dart;
  2. 直接点「登录」,能看到两条校验提示;
  3. 输入手机号 13800000000、密码 123456 再提交,会弹出成功提示。
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: LoginFormPage()));

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

  @override
  State<LoginFormPage> createState() => _LoginFormPageState();
}

class _LoginFormPageState extends State<LoginFormPage> {
  // Form 的钥匙:校验、重置都靠它
  final _formKey = GlobalKey<FormState>();
  final _phoneController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _obscure = true;

  @override
  void dispose() {
    // 控制器必须释放,否则页面销毁后仍有内存引用
    _phoneController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _submit() {
    // validate() 会遍历所有 TextFormField 的 validator
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('登录成功:${_phoneController.text}')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('表单校验')),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Form(
          key: _formKey,
          // 用户输入时就实时校验,体验比「提交后才知道错」好
          autovalidateMode: AutovalidateMode.onUserInteraction,
          child: Column(
            children: [
              TextFormField(
                controller: _phoneController,
                keyboardType: TextInputType.phone,
                maxLength: 11,
                decoration: const InputDecoration(
                  labelText: '手机号',
                  prefixIcon: Icon(Icons.phone_iphone),
                  counterText: '', // 隐藏右下角字数
                ),
                validator: (value) {
                  if (value == null || value.isEmpty) return '请输入手机号';
                  if (!RegExp(r'^1\d{10}$').hasMatch(value)) return '手机号格式不正确';
                  return null; // 返回 null 表示通过
                },
              ),
              const SizedBox(height: 14),
              TextFormField(
                controller: _passwordController,
                obscureText: _obscure,
                decoration: InputDecoration(
                  labelText: '密码',
                  prefixIcon: const Icon(Icons.lock_outline),
                  suffixIcon: IconButton(
                    icon: Icon(_obscure
                        ? Icons.visibility_off_outlined
                        : Icons.visibility_outlined),
                    onPressed: () => setState(() => _obscure = !_obscure),
                  ),
                ),
                validator: (value) =>
                    (value == null || value.length < 6) ? '密码至少 6 位' : null,
              ),
              const SizedBox(height: 24),
              SizedBox(
                width: double.infinity,
                child: FilledButton(
                  onPressed: _submit,
                  child: const Text('登录'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
关键点
动态效果 · 校验提示
138001
手机号格式不正确
🎮 Playground 10:TextField 输入框调参器(可直接输入)
📱
手机号

5.6 对话框与浮层

5.6.1 AlertDialog + showModalBottomSheet

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: DialogDemoPage()));

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('对话框与底部面板')),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // ① 确认弹窗:await 拿返回值
            FilledButton(
              onPressed: () async {
                final ok = await showDialog<bool>(
                  context: context,
                  barrierDismissible: false, // 点遮罩不关闭,防止误触
                  builder: (ctx) => AlertDialog(
                    title: const Text('删除确认'),
                    content: const Text('删除后不可恢复,确定删除这条记录吗?'),
                    actions: [
                      TextButton(
                        onPressed: () => Navigator.pop(ctx, false),
                        child: const Text('取消'),
                      ),
                      FilledButton(
                        style: FilledButton.styleFrom(
                            backgroundColor: Theme.of(ctx).colorScheme.error),
                        onPressed: () => Navigator.pop(ctx, true),
                        child: const Text('删除'),
                      ),
                    ],
                  ),
                );
                if (ok == true && context.mounted) {
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('已删除')),
                  );
                }
              },
              child: const Text('弹出确认框'),
            ),
            const SizedBox(height: 12),

            // ② 底部面板:常用来放表单、分享、选择器
            OutlinedButton(
              onPressed: () => showModalBottomSheet<void>(
                context: context,
                showDragHandle: true,       // 顶部拖动把手
                isScrollControlled: true,   // 内容高时允许占满
                builder: (ctx) => Padding(
                  // 让面板随键盘上移
                  padding: EdgeInsets.only(
                      bottom: MediaQuery.of(ctx).viewInsets.bottom),
                  child: SizedBox(
                    height: 260,
                    child: Column(
                      children: [
                        const Padding(
                          padding: EdgeInsets.all(16),
                          child: Text('分享到',
                              style: TextStyle(
                                  fontSize: 16, fontWeight: FontWeight.w700)),
                        ),
                        Row(
                          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                          children: const [
                            _ShareItem(icon: Icons.chat, label: '微信'),
                            _ShareItem(icon: Icons.link, label: '复制链接'),
                            _ShareItem(icon: Icons.report, label: '举报'),
                          ],
                        ),
                      ],
                    ),
                  ),
                ),
              ),
              child: const Text('弹出底部面板'),
            ),
          ],
        ),
      ),
    );
  }
}

class _ShareItem extends StatelessWidget {
  const _ShareItem({required this.icon, required this.label});

  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        CircleAvatar(radius: 26, child: Icon(icon)),
        const SizedBox(height: 6),
        Text(label, style: const TextStyle(fontSize: 12)),
      ],
    );
  }
}
关键点
动态效果 · 弹窗淡入 + 底部面板上滑
删除确认
删除后不可恢复
取消 删除
🎮 Playground 11:对话框与浮层交互器(点按钮真实弹出)
消息列表
← 上一章第 4 章 核心概念:Widget 与声明式 UI下一章 →第 6 章 路由与导航
✉ 联系作者:linghurongjun@sina.com