第 3 部分 · 熟悉基础 Widget(Vue ↔ Flutter)
前两篇我们把"心智模型"和一个 Todo 练习啃下来了,这一篇专攻基础 Widget。每个块我都会给一个 Vue ↔ Flutter 对照 demo,可以直接拷走跑。
一、3~4 天的学习节奏
我自己摸索时走过最大的弯路是"一次性把所有 Widget 都过一遍",结果什么都记不住。后来改成按"功能块"切分,每天只啃一类,效果好太多。建议你也照这个节奏走:
| 天 | 主题 | 核心 Widget | 对应 Vue/HTML 概念 |
|---|---|---|---|
| Day 1 | 布局 | Container Row Column Padding Center SizedBox Expanded Stack |
div + flex + 定位 |
| Day 2 | 基础显示 | Text Image Icon Card Divider |
<p> <img> <i> |
| Day 3 | 列表与滚动 | ListView ListView.builder GridView SingleChildScrollView RefreshIndicator |
v-for + scroll |
| Day 4 | 交互与表单 | TextField ElevatedButton Checkbox Switch GestureDetector Scaffold AppBar |
<input> <button> 事件 |
二、Day 1:布局三件套
布局是 Flutter 上手最劝退的部分------倒不是它难,而是 Vue/CSS 老司机会一直想"我应该写个 div 然后......",但 Flutter 没有 div,一切都是 Widget。
幸运的是,会下面这三个,80% 的页面都能搭出来。
1. Row / Column ------ 等同于 flex
dart
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // justify-content
crossAxisAlignment: CrossAxisAlignment.center, // align-items
children: [Text('左'), Text('右')],
)
对照 CSS: display: flex; justify-content: space-between; align-items: center;
记住这个映射就够了:
| Flex 属性 | Flutter 属性(在 Row/Column 上) |
|---|---|
flex-direction: row |
Row(...) |
flex-direction: column |
Column(...) |
justify-content |
mainAxisAlignment |
align-items |
crossAxisAlignment |
小坑:
Row的"主轴"是水平的,Column的"主轴"是垂直的。所以mainAxisAlignment在 Row 里管左右、在 Column 里管上下。别用justify/align的肌肉记忆去死记。
2. Expanded / Flexible ------ 等同于 flex: 1
dart
Row(children: [
Text('固定'),
Expanded(child: Text('占满剩余')), // ≈ flex: 1
])
Expanded 比 Flexible 更"霸道",会强制吃满剩余空间;Flexible 则可以小于内容尺寸。日常 90% 用 Expanded 就够。
3. Container ------ 万能盒子(≈ div + style)
dart
Container(
width: 100,
height: 100,
margin: EdgeInsets.all(8),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 4)],
),
child: Text('Hello'),
)
注意一个细节:有 decoration 的时候 color 要写在 decoration 里 ,不能直接写在 Container 上,否则会报错。这是新手最常踩的坑之一。
4. Stack ------ 等同于 position: absolute
dart
Stack(
children: [
Image.network('https://picsum.photos/300/120'),
Positioned(
top: 8, right: 8,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
color: Colors.red,
child: Text('NEW', style: TextStyle(color: Colors.white)),
),
),
],
)
Stack + Positioned 就是 CSS 里的 position: relative + absolute。
完整对照 demo
Vue 版本(Layout.vue):
vue
<template>
<div class="container">
<div class="row space-between center">
<div class="box red">A</div>
<div class="box green">B</div>
<div class="box blue">C</div>
</div>
<div class="column" style="height: 120px;">
<div class="box yellow">头</div>
<div class="box flex-1">中间占满</div>
<div class="box yellow">尾</div>
</div>
<div class="card">这是一个圆角阴影卡片</div>
<div class="stack">
<img src="https://picsum.photos/300/120" />
<span class="badge">NEW</span>
</div>
</div>
</template>
<style scoped>
.row { display: flex; flex-direction: row; }
.column { display: flex; flex-direction: column; }
.space-between { justify-content: space-between; }
.center { align-items: center; }
.flex-1 { flex: 1; }
.box { padding: 12px; margin: 4px; color: white; }
.red { background: crimson; }
.green { background: seagreen; }
.blue { background: steelblue; }
.yellow { background: goldenrod; }
.card {
margin: 12px 0; padding: 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.stack { position: relative; display: inline-block; }
.badge {
position: absolute; top: 8px; right: 8px;
background: red; color: white;
padding: 2px 6px; border-radius: 4px; font-size: 12px;
}
</style>
Flutter 版本(layout_demo.dart):
dart
import 'package:flutter/material.dart';
class LayoutDemo extends StatelessWidget {
const LayoutDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Layout Demo')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Row + 主轴/交叉轴对齐(≈ flex + justify-content + align-items)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
_Box(label: 'A', color: Colors.red),
_Box(label: 'B', color: Colors.green),
_Box(label: 'C', color: Colors.blue),
],
),
const SizedBox(height: 16),
// Column + Expanded(≈ flex-direction:column + flex:1)
SizedBox(
height: 120,
child: Column(
children: const [
_Box(label: '头', color: Colors.orange),
Expanded(child: _Box(label: '中间占满', color: Colors.purple)),
_Box(label: '尾', color: Colors.orange),
],
),
),
const SizedBox(height: 16),
// Container 样式(圆角、阴影、内外边距)
Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 2)),
],
),
child: const Text('这是一个圆角阴影卡片'),
),
const SizedBox(height: 16),
// Stack(绝对定位)≈ CSS position: absolute
Stack(
children: [
Image.network('https://picsum.photos/300/120'),
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
color: Colors.red,
child: const Text('NEW',
style: TextStyle(color: Colors.white, fontSize: 12)),
),
),
],
),
],
),
),
);
}
}
class _Box extends StatelessWidget {
final String label;
final Color color;
const _Box({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.all(4),
color: color,
child: Text(label, style: const TextStyle(color: Colors.white)),
);
}
}
跑一遍对比效果,你会发现:在 Flutter 里,"样式"和"结构"是同一棵树,没有 CSS 文件、没有 class 选择器、没有作用域问题。这是和 Vue 最大的心智差异。
三、Day 2:基础显示(文本/图片/图标/卡片)
1. Text ------ 比 <p> 强大很多
dart
Text(
'你好,Flutter',
style: TextStyle(
fontSize: 16,
color: Colors.red,
fontWeight: FontWeight.bold,
height: 1.5, // line-height
letterSpacing: 0.5,
),
maxLines: 2,
overflow: TextOverflow.ellipsis, // 省略号
)
注意 maxLines + overflow 的组合,这是 Flutter 处理长文本的标配,比 CSS 的 -webkit-line-clamp 简单多了。
要在一段文字里混排不同样式?用 RichText:
dart
RichText(
text: TextSpan(
style: TextStyle(color: Colors.black, fontSize: 14),
children: [
TextSpan(text: '价格:'),
TextSpan(text: '¥99', style: TextStyle(color: Colors.red, fontSize: 18)),
TextSpan(text: ' 起'),
],
),
)
2. Image ------ 网络/资源/文件三种姿势
dart
// 网络图
Image.network('https://picsum.photos/200')
// 资源图(pubspec.yaml 里要先声明 assets)
Image.asset('assets/logo.png')
// 文件图
Image.file(File('/path/to/img.jpg'))
fit 参数等同于 CSS 的 object-fit:
dart
Image.network(
url,
width: 100, height: 100,
fit: BoxFit.cover, // contain / cover / fill / fitWidth / fitHeight
)
3. Icon ------ Material 图标库直接用
dart
Icon(Icons.favorite, color: Colors.red, size: 24)
不用引第三方图标库、不用配字体文件,Icons.xxx 几千个图标开箱即用。
4. Card + Divider ------ 卡片和分割线
dart
Card(
elevation: 2,
margin: EdgeInsets.all(8),
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
children: [
Text('标题', style: TextStyle(fontWeight: FontWeight.bold)),
Divider(),
Text('内容内容内容'),
],
),
),
)
Card 自带圆角和阴影,比自己用 Container + BoxDecoration 写要省心。
四、Day 3:列表与滚动
这一块是 Flutter 体验明显优于 Vue 的地方------长列表的虚拟滚动是官方内置的。
短列表 vs 长列表的黄金法则
| 场景 | Vue | Flutter |
|---|---|---|
| 短列表 (<20 项) | v-for |
Column(children: items.map(...).toList()) 或 ListView(children: [...]) |
| 长列表(按需渲染) | 需手动分页 / vue-virtual-scroller |
ListView.builder(自带懒加载) |
| 网格 | CSS grid | GridView.builder |
| 下拉刷新 | 自己写 | RefreshIndicator |
黄金法则:只要列表可能 > 20 项,立刻用
ListView.builder,不要用Column + ...map。
Column + map会把所有 child 一次性 build 出来,列表一长就掉帧;ListView.builder只 build 屏幕上能看到的几个,1000 项也丝滑。
三种列表写法
dart
// 1. 短列表(直接列出)
ListView(
children: const [
ListTile(title: Text('苹果')),
ListTile(title: Text('香蕉')),
ListTile(title: Text('橙子')),
],
)
// 2. 长列表(懒加载)
ListView.builder(
itemCount: 1000,
itemBuilder: (ctx, i) => ListTile(title: Text('第 ${i + 1} 项')),
)
// 3. 带分割线的长列表
ListView.separated(
itemCount: 1000,
itemBuilder: (ctx, i) => ListTile(title: Text('第 $i 项')),
separatorBuilder: (ctx, i) => const Divider(height: 1),
)
下拉刷新(一行代码搞定)
dart
RefreshIndicator(
onRefresh: () async {
await fetchData();
},
child: ListView.builder(...),
)
Vue 这边要么自己写 touch 事件,要么引第三方库;Flutter 直接套一层 RefreshIndicator 就完事了。
完整对照 demo
Vue 版本(ListDemo.vue):
vue
<template>
<div>
<h3>普通列表(短)</h3>
<ul>
<li v-for="item in shortList" :key="item">{{ item }}</li>
</ul>
<h3>长列表(虚拟滚动需自己写)</h3>
<ul style="max-height: 200px; overflow-y: auto;">
<li v-for="i in longList" :key="i">第 {{ i }} 项</li>
</ul>
<h3>网格</h3>
<div class="grid">
<div v-for="i in 9" :key="i" class="cell">{{ i }}</div>
</div>
<h3>下拉刷新(伪代码)</h3>
<button @click="refresh">手动刷新</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const shortList = ['苹果', '香蕉', '橙子']
const longList = ref(Array.from({ length: 1000 }, (_, i) => i + 1))
async function refresh() {
await new Promise(r => setTimeout(r, 800))
longList.value = Array.from({ length: 1000 }, (_, i) => i + 1)
}
</script>
<style scoped>
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.cell { background: #eee; padding: 16px; text-align: center; }
</style>
Flutter 版本(list_demo.dart):
dart
import 'package:flutter/material.dart';
class ListDemo extends StatefulWidget {
const ListDemo({super.key});
@override
State<ListDemo> createState() => _ListDemoState();
}
class _ListDemoState extends State<ListDemo> {
List<int> longList = List.generate(1000, (i) => i + 1);
Future<void> _refresh() async {
await Future.delayed(const Duration(milliseconds: 800));
setState(() {
longList = List.generate(1000, (i) => i + 1);
});
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('List Demo'),
bottom: const TabBar(tabs: [
Tab(text: '短列表'),
Tab(text: '长列表'),
Tab(text: '网格'),
]),
),
body: TabBarView(
children: [
// 1. 短列表:直接 ListView + children
ListView(
children: const [
ListTile(title: Text('苹果')),
ListTile(title: Text('香蕉')),
ListTile(title: Text('橙子')),
],
),
// 2. 长列表:builder(懒加载)+ 下拉刷新
RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
itemCount: longList.length,
itemBuilder: (ctx, i) => ListTile(title: Text('第 ${longList[i]} 项')),
),
),
// 3. 网格
GridView.builder(
padding: const EdgeInsets.all(8),
itemCount: 9,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemBuilder: (ctx, i) => Container(
color: Colors.grey.shade300,
alignment: Alignment.center,
child: Text('${i + 1}'),
),
),
],
),
),
);
}
}
五、Day 4:交互与表单
1. TextField ------ 等同于 <input>
dart
final ctrl = TextEditingController();
TextField(
controller: ctrl,
decoration: InputDecoration(
labelText: '账号',
hintText: '请输入账号',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
onChanged: (v) => print(v),
onSubmitted: (v) => print('回车: $v'),
)
// 别忘了在 dispose 中:ctrl.dispose();
Vue 用
v-model一行就双向绑定,Flutter 这边要么用controller取值,要么onChanged自己存 state。习惯了就好------这是为了让数据流单向、可追踪。
2. 按钮们
dart
ElevatedButton(onPressed: () {}, child: Text('提交')),
TextButton(onPressed: () {}, child: Text('文字按钮')),
OutlinedButton(onPressed: () {}, child: Text('描边按钮')),
IconButton(icon: Icon(Icons.search), onPressed: () {}),
关键点:
onPressed: null时按钮自动变灰禁用 。所以要禁用按钮,传null就行,不用单独写disabled属性:
dartElevatedButton( onPressed: canSubmit ? _submit : null, // null = disabled child: Text('登录'), )
3. Checkbox / Switch
dart
CheckboxListTile(
value: _remember,
onChanged: (v) => setState(() => _remember = v ?? false),
title: const Text('记住我'),
)
SwitchListTile(
value: _darkMode,
onChanged: (v) => setState(() => _darkMode = v),
title: const Text('深色模式'),
)
PC 上习惯用 Checkbox,但移动端 Switch 更常见,根据场景挑。
4. GestureDetector / InkWell ------ 给任意 Widget 加点击
dart
// 想要点击水波纹效果(Material 风格)→ InkWell
InkWell(
onTap: () {},
child: Container(padding: EdgeInsets.all(8), child: Text('点我')),
)
// 想要纯手势检测,无视觉反馈 → GestureDetector
GestureDetector(
onTap: () {},
onDoubleTap: () {},
onLongPress: () {},
child: ...,
)
5. Scaffold ------ 整页脚手架
这是 Flutter 给的"福利"------一整页的标准布局(顶栏 + 内容 + 底栏 + 浮动按钮 + 抽屉)官方一个 Widget 就解决了。Vue 这边没有内置脚手架,要么自己写,要么引 Element Plus 这种 UI 库。
dart
Scaffold(
appBar: AppBar(title: Text('页面标题')),
body: ...,
floatingActionButton: FloatingActionButton(
onPressed: ...,
child: Icon(Icons.add),
),
bottomNavigationBar: BottomNavigationBar(...),
drawer: Drawer(...), // 侧边栏
)
完整对照 demo(登录表单)
Vue 版本(FormDemo.vue):
vue
<template>
<form @submit.prevent="submit" class="form">
<label>账号</label>
<input v-model="username" placeholder="请输入账号" />
<label>密码</label>
<input v-model="password" type="password" placeholder="请输入密码" />
<label><input type="checkbox" v-model="remember" /> 记住我</label>
<label><input type="checkbox" v-model="darkMode" /> 深色模式</label>
<button type="submit" :disabled="!canSubmit">登录</button>
<p v-if="msg" :style="{ color: 'red' }">{{ msg }}</p>
</form>
</template>
<script setup>
import { ref, computed } from 'vue'
const username = ref('')
const password = ref('')
const remember = ref(false)
const darkMode = ref(false)
const msg = ref('')
const canSubmit = computed(() => username.value && password.value)
function submit() {
if (!canSubmit.value) {
msg.value = '请填写完整'
return
}
msg.value = ''
alert(`登录:${username.value} / 记住=${remember.value}`)
}
</script>
<style scoped>
.form { display: flex; flex-direction: column; gap: 8px; max-width: 320px; }
input { padding: 8px; }
</style>
Flutter 版本(form_demo.dart):
dart
import 'package:flutter/material.dart';
class FormDemo extends StatefulWidget {
const FormDemo({super.key});
@override
State<FormDemo> createState() => _FormDemoState();
}
class _FormDemoState extends State<FormDemo> {
final _usernameCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _remember = false;
bool _darkMode = false;
String _msg = '';
bool get _canSubmit =>
_usernameCtrl.text.isNotEmpty && _passwordCtrl.text.isNotEmpty;
@override
void dispose() {
_usernameCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_canSubmit) {
setState(() => _msg = '请填写完整');
return;
}
setState(() => _msg = '');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登录:${_usernameCtrl.text} / 记住=$_remember')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Form Demo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _usernameCtrl,
decoration: const InputDecoration(
labelText: '账号',
hintText: '请输入账号',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
TextField(
controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
hintText: '请输入密码',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
CheckboxListTile(
value: _remember,
onChanged: (v) => setState(() => _remember = v ?? false),
title: const Text('记住我'),
contentPadding: EdgeInsets.zero,
),
SwitchListTile(
value: _darkMode,
onChanged: (v) => setState(() => _darkMode = v),
title: const Text('深色模式'),
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _canSubmit ? _submit : null,
child: const Text('登录'),
),
if (_msg.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_msg, style: const TextStyle(color: Colors.red)),
),
],
),
),
);
}
}
完整对照 demo(整页脚手架)
Vue 版本(PageScaffold.vue):
vue
<template>
<!-- Vue 里没有内置脚手架,通常自己写或借助 element-plus 等 UI 库 -->
<div class="page">
<header class="app-bar">
<button @click="back">←</button>
<h2>页面标题</h2>
<button @click="more">⋮</button>
</header>
<main class="body">
<p>这里是页面正文</p>
</main>
<button class="fab" @click="onFab">+</button>
<nav class="bottom-bar">
<button :class="{active: tab===0}" @click="tab=0">首页</button>
<button :class="{active: tab===1}" @click="tab=1">消息</button>
<button :class="{active: tab===2}" @click="tab=2">我的</button>
</nav>
</div>
</template>
<script setup>
import { ref } from 'vue'
const tab = ref(0)
function back() {}
function more() {}
function onFab() { alert('FAB!') }
</script>
<style scoped>
.page { display: flex; flex-direction: column; height: 100vh; }
.app-bar { display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; background: #4caf50; color: white; }
.body { flex: 1; padding: 16px; }
.fab { position: fixed; right: 16px; bottom: 80px;
width: 56px; height: 56px; border-radius: 50%;
background: #ff5722; color: white; border: none; font-size: 24px; }
.bottom-bar { display: flex; }
.bottom-bar button { flex: 1; padding: 12px; }
.bottom-bar button.active { color: #4caf50; }
</style>
Flutter 版本(scaffold_demo.dart):
dart
import 'package:flutter/material.dart';
class ScaffoldDemo extends StatefulWidget {
const ScaffoldDemo({super.key});
@override
State<ScaffoldDemo> createState() => _ScaffoldDemoState();
}
class _ScaffoldDemoState extends State<ScaffoldDemo> {
int _tab = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('页面标题'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.maybePop(context),
),
actions: [
IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}),
],
),
body: const Center(child: Text('这里是页面正文')),
floatingActionButton: FloatingActionButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('FAB!')),
);
},
child: const Icon(Icons.add),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _tab,
onTap: (i) => setState(() => _tab = i),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: '首页'),
BottomNavigationBarItem(icon: Icon(Icons.message), label: '消息'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
],
),
);
}
}
把 Vue 版的 60 多行 CSS + 模板和 Flutter 版的 40 行声明式代码放一起,差距就出来了:移动端那些通用模式,Flutter 直接给你打包好了。
六、问题总结
最后总结一下我自己上手时反复栽跟头的几个点,写在这里给后来人省点血:
Container的color和decoration.color不能并存 ------ 有 decoration 时 color 必须写在 decoration 里。- 想横排不一定要 Row ------ 文字混排用
Text.rich/RichText更合适,Row 会让每个 child 各占一行高度。 Expanded必须在 Row/Column/Flex 里 ------ 单独用会报"Incorrect use of ParentDataWidget"。- Image.network 要给宽高 ------ 不然布局期间不知道占多大,可能闪一下。
- TextEditingController 记得 dispose ------ Vue 里 ref 自动回收,Flutter 这边手动管,忘了就是内存泄漏。