第 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 —— 文字
flutter create text_demo,进入目录;- 用下面代码整体替换
lib/main.dart; 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)),
],
),
),
],
),
),
);
}
}
style是TextStyle,字号/颜色/字重/行高/字间距全在这里;不要在 Text 外层套 Container 改字色。maxLines必须配overflow: TextOverflow.ellipsis才不报溢出;只给 maxLines 会直接裁切。- 一段话多种样式用
Text.rich + TextSpan,比拼多个 Text 更省布局开销。 - Column 里的 Text 默认会被拉伸到最宽,需要左对齐给 Column 设
crossAxisAlignment.start。
5.1.2 Image —— 图片(网络图 + 占位 + 错误兜底)
- 新建工程后,用下面代码替换
lib/main.dart; - 确保设备能联网(示例图来自 picsum.photos,无需 key);
- 运行后可把 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)),
),
],
),
);
}
}
- 网络图必须写
loadingBuilder与errorBuilder,否则弱网下就是长时间空白或红屏。 fit:cover铺满(可能裁边,封面图首选)、contain完整显示(可能留白)、fill拉伸变形(慎用)。- 圆角用
ClipRRect包住;直接给 Image 设 borderRadius 是无效的(Image 没有该参数)。 - 本地图三步走:建
assets/images/→ pubspec 的flutter: assets:声明 →AssetImage('assets/images/x.png')。
把高度拉大、fit 切到 contain 看到的是「留空」,切到 fill 图片被拉扁变形——这是三种 fit 的核心区别。圆角拉满后生成的代码会自动改用 ClipRRect 包裹(Image.network 本身没有圆角参数)。
5.2 布局组件
5.2.1 Container + Stack —— 装饰卡片与角标
- 新建工程,替换
lib/main.dart; - 运行后可调
padding、borderRadius、boxShadow观察变化; - 用本节下方的 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)])),
),
],
),
),
],
),
);
}
}
Container同时有color和decoration会报错——颜色写进decoration.color。margin是自己的外部留白,padding是内容的内缩,位置不同。Stack默认按「后写的在上层」;Positioned必须直接放在 Stack 的 children 里。- 阴影不要滥用:大量模糊阴影会掉帧,列表项建议用极浅的
Color(0x14000000)或不加。
试着把 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)),
);
}
Expanded(flex: n)= 原生layout_weight:按比例瓜分剩余空间。- Row 里放长文字必须用
Expanded/Flexible包住,否则宽度无约束 → 溢出。 - 想在中间挤开两边,用
Spacer()(本质是 flex:1 的空 Expanded)。 mainAxisSize: MainAxisSize.min可以让 Column 只占内容高度,常配合居中弹窗使用。
这是全手册最重要的一个 Playground:mainAxisAlignment 决定子项沿主轴怎么排(start/center/end/spaceBetween 三等分间隙方案),crossAxisAlignment 决定沿交叉轴怎么贴。写页面时 80% 的布局问题都在调这两个参数。
5.3 列表与滚动
5.3.1 ListView.builder + ListTile + 下拉刷新(列表页标准写法)
- 新建工程,替换
lib/main.dart; - 运行后下拉列表,会触发刷新并弹出提示;
- 把
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')),
),
);
},
),
),
);
}
}
- 数据多或动态一定用
builder:它只构建屏幕内的 item;直接给children会一次性构建全部。 RefreshIndicator的onRefresh必须返回Future,否则转圈立即消失。- 内容不满一屏时下拉无效——加
AlwaysScrollableScrollPhysics()。 - 无限加载监听
ScrollController,在距底部约 300px 时请求下一页(项目代码里就是这样做的)。
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)),
],
),
),
),
),
);
}
}
childAspectRatio是宽高比:设为 1 是正方形,小于 1 会更高。- 需要子项自适应宽度(如平板)用
SliverGridDelegateWithMaxCrossAxisExtent。 - 网格里再套滚动列表必须给固定高度,否则「无限高度」报错。
childAspectRatio 是宽 / 高之比:调小于 1(如 0.6)格子变高、调大于 1(如 1.6)格子变扁,做商品图、封面墙时主要就是调它。
5.4 Material 组件(页面骨架与导航)
5.4.1 Scaffold + AppBar + NavigationBar + FAB(一个完整的多 Tab 应用)
- 新建工程,替换
lib/main.dart; - 运行后点击底部三个 Tab,页面切换且状态保留;
- 点右下角 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: '我的'),
],
),
);
}
}
bottomNavigationBar用NavigationBar(M3),老教程的BottomNavigationBar是 M2 写法。IndexedStack会保留所有子页状态;如果页面很重、希望切走就销毁,改用普通_pages[_index]。ScaffoldMessenger.of(context)是弹 SnackBar 的唯一正确入口,不要用showDialog代替轻提示。- FAB 与底部导航同时存在时,注意给列表底部留出约 90px 的 padding,避免最后一项被遮挡。
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),
),
),
],
),
);
}
}
ListTile已内置 72px 高度、左右 16px 间距、主副标题排版,能用它就别手写 Row。- 需要点击水波纹时,把
InkWell放在Card内部并给 Card 设clipBehavior: Clip.antiAlias。 - 三行以上文本用
isThreeLine: true,否则副标题会被压缩。
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('自定义样式'),
),
],
),
),
);
}
}
- Material 3 的层级:
FilledButton(主)>FilledButton.tonal(次)>OutlinedButton(再次)>TextButton(弱)。 onPressed: null就是禁用态,会自动变灰,不需要手动改颜色。- 一屏只放一个主按钮,多了用户不知道点哪个。
点右边的按钮会真的弹出 SnackBar(2 秒后自动消失),同时左侧代码切换成对应的 ScaffoldMessenger 写法——按钮四大金刚(Elevated / Filled / Outlined / Text)的视觉层级从重到轻,按操作重要性选用。
5.5.2 TextField / Form —— 登录表单与校验
- 新建工程,替换
lib/main.dart; - 直接点「登录」,能看到两条校验提示;
- 输入手机号 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('登录'),
),
),
],
),
),
),
);
}
}
- 三个必备件:
Form+GlobalKey<FormState>+ 每个字段的validator。 validator返回 null 表示通过,返回字符串则显示为红色错误提示。- 取值用
controller.text,不要试图通过onChanged拼字符串(会漏掉输入法组合态)。 - 密码框要提供「显示/隐藏」切换,这是可用性基本要求。
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)),
],
);
}
}
showDialog<T>的泛型决定返回值类型;关闭时用Navigator.pop(ctx, 值)回传。- await 之后再用
context前,先判断context.mounted(页面可能已经销毁)。 - 底部面板里有输入框时,两件事必做:
isScrollControlled: true+ 底部加viewInsets.bottom,否则键盘挡住输入框。 - 删除类操作放红色按钮并在右侧,取消放左侧——符合平台习惯,降低误操作。