普通视图
《uni-app跨平台开发完全指南》- 05 - 基础组件使用
基础组件
欢迎回到《uni-app跨平台开发完全指南》系列!在之前的文章中,我们搭好了开发环境,了解了项目目录结构、Vue基础以及基本的样式,这一章节带大家了解基础组件如何使用。掌握了基础组件的使用技巧,就能独立拼装出应用的各个页面了!
一、 初识uni-app组件
在开始之前,先自问下什么是组件?
你可以把它理解为一个封装了结构(WXML)、样式(WXSS)和行为(JS)的、可复用的自定义标签。比如一个按钮、一个导航栏、一个商品卡片,都可以是组件。
uni-app的组件分为两类:
-
基础组件:框架内置的,如
<view>,<text>,<image>等。这些是官方为我们准备好的标准组件。 - 自定义组件:开发者自己封装的,用于实现特定功能或UI的组件,可反复使用。
就是这些基础组件,它们遵循小程序规范,同时被映射到各端,是实现“一套代码,多端运行”的基础。
为了让大家对基础组件有个全面的认识,参考下面的知识脉络图:
graph TD
A[uni-app 基础组件] --> B[视图容器类];
A --> C[基础内容类];
A --> D[表单类];
A --> E[导航类];
A --> F[自定义组件];
B --> B1[View];
B --> B2[Scroll-View];
C --> C1[Text];
C --> C2[Image];
D --> D1[Button];
D --> D2[Input];
D --> D3[Checkbox/Radio];
E --> E1[Navigator];
F --> F1[创建];
F --> F2[通信];
F --> F3[生命周期];
接下来,我们详细介绍下这些内容。
二、 视图与内容:View、Text、Image
这三个组件是构建页面最基础、最核心的部分,几乎无处不在。
2.1 一切的容器:View
<view> 组件是一个视图容器。它相当于传统HTML中的 <div> 标签,是一个块级元素,主要用于布局和包裹其他内容。
核心特性:
- 块级显示:默认独占一行。
-
样式容器:通过为其添加
class或style,可以轻松实现Flex布局、Grid布局等。 -
事件容器:可以绑定各种触摸事件,如
@tap(点击)、@touchstart(触摸开始)等。
以一个简单的Flex布局为例:
<!-- 模板部分 -->
<template>
<view class="container">
<view class="header">我是头部</view>
<view class="content">
<view class="left-sidebar">左边栏</view>
<view class="main-content">主内容区</view>
</view>
<view class="footer">我是底部</view>
</view>
</template>
<style scoped>
/* 样式部分 */
.container {
display: flex;
flex-direction: column; /* 垂直排列 */
height: 100vh; /* 满屏高度 */
}
.header, .footer {
height: 50px;
background-color: #007AFF;
color: white;
text-align: center;
line-height: 50px; /* 垂直居中 */
}
.content {
flex: 1; /* 占据剩余所有空间 */
display: flex; /* 内部再启用Flex布局 */
}
.left-sidebar {
width: 100px;
background-color: #f0f0f0;
}
.main-content {
flex: 1; /* 占据content区域的剩余空间 */
background-color: #ffffff;
}
</style>
以上代码:
- 我们通过多个
<view>的嵌套,构建了一个经典的“上-中-下”布局。 - 外层的
.container使用flex-direction: column实现垂直排列。 - 中间的
.content自己也是一个Flex容器,实现了内部的水平排列。 -
flex: 1是Flex布局的关键,表示弹性扩展,填满剩余空间。
小结一下View:
- 它是布局的骨架,万物皆可
<view>。 - 熟练掌握Flex布局,再复杂的UI也能用
<view>拼出来。
2.2 Text
<text> 组件是一个文本容器。它相当于HTML中的 <span> 标签,是行内元素。最重要的特点是:只有 <text> 组件内部的文字才是可选中的、长按可以复制!
核心特性:
- 行内显示:默认不会换行。
- 文本专属:用于包裹文本,并对文本设置样式和事件。
-
选择与复制:支持
user-select属性控制文本是否可选。 - 嵌套与富文本:内部可以嵌套,自身也支持部分HTML实体和富文本。
以一个文本样式与事件为例:
<template>
<view>
<!-- 普通的view里的文字无法长按复制 -->
<view>这段文字在view里,无法长按复制。</view>
<!-- text里的文字可以 -->
<text user-select @tap="handleTextTap" class="my-text">
这段文字在text里,可以长按复制!点击我也有反应。
<text style="color: red; font-weight: bold;">我是嵌套的红色粗体文字</text>
</text>
</view>
</template>
<script>
export default {
methods: {
handleTextTap() {
uni.showToast({
title: '你点击了文字!',
icon: 'none'
});
}
}
}
</script>
<style>
.my-text {
color: #333;
font-size: 16px;
/* 注意:text组件不支持设置宽高和margin-top/bottom,因为是行内元素 */
/* 如果需要,可以设置 display: block 或 inline-block */
}
</style>
以上代码含义:
-
user-select属性开启了文本的可选状态。 -
<text>组件可以绑定@tap事件,而<view>里的纯文字不能。 - 内部的
<text>嵌套展示了如何对部分文字进行特殊样式处理。
Text使用小技巧:
-
何时用? 只要是涉及交互(点击、长按)或需要复制功能的文字,必须用
<text>包裹。 -
样式注意:它是行内元素,设置宽高和垂直方向的margin/padding可能不生效,可通过
display: block改变。 - 性能:避免深度嵌套,尤其是与富文本一起使用时。
2.3 Image
<image> 组件用于展示图片。它相当于一个增强版的HTML <img>标签,提供了更丰富的功能和更好的性能优化。
核心特性与原理:
-
多种模式:通过
mode属性控制图片的裁剪、缩放模式,这是它的灵魂所在! -
懒加载:
lazy-load属性可以在页面滚动时延迟加载图片,提升性能。 - 缓存与 headers:支持配置网络图片的缓存策略和请求头。
mode属性详解(非常重要!)
mode属性决定了图片如何适应容器的宽高。我们来画个图理解一下:
stateDiagram-v2
[*] --> ImageMode选择
state ImageMode选择 {
[*] --> 首要目标判断
首要目标判断 --> 保持完整不裁剪: 选择
首要目标判断 --> 保持比例不变形: 选择
首要目标判断 --> 固定尺寸裁剪: 选择
保持完整不裁剪 --> scaleToFill: 直接进入
scaleToFill : scaleToFill\n拉伸至填满,可能变形
保持比例不变形 --> 适应方式判断
适应方式判断 --> aspectFit: 完全显示
适应方式判断 --> aspectFill: 填满容器
aspectFit : aspectFit\n适应模式\n容器可能留空
aspectFill : aspectFill\n填充模式\n图片可能被裁剪
固定尺寸裁剪 --> 多种裁剪模式
多种裁剪模式 : widthFix / top / bottom\n等裁剪模式
}
scaleToFill --> [*]
aspectFit --> [*]
aspectFill --> [*]
多种裁剪模式 --> [*]
下面用一段代码来展示不同Mode的效果
<template>
<view>
<view class="image-demo">
<text>scaleToFill (默认,拉伸):</text>
<!-- 容器 200x100,图片会被拉伸 -->
<image src="/static/logo.png" mode="scaleToFill" class="img-container"></image>
</view>
<view class="image-demo">
<text>aspectFit (适应):</text>
<!-- 图片完整显示,上下或左右留白 -->
<image src="/static/logo.png" mode="aspectFit" class="img-container"></image>
</view>
<view class="image-demo">
<text>aspectFill (填充):</text>
<!-- 图片填满容器,但可能被裁剪 -->
<image src="/static/logo.png" mode="aspectFill" class="img-container"></image>
</view>
<view class="image-demo">
<text>widthFix (宽度固定,高度自适应):</text>
<!-- 非常常用!高度会按比例自动计算 -->
<image src="/static/logo.png" mode="widthFix" class="img-auto-height"></image>
</view>
</view>
</template>
<style>
.img-container {
width: 200px;
height: 100px; /* 固定高度的容器 */
background-color: #eee; /* 用背景色看出aspectFit的留白 */
border: 1px solid #ccc;
}
.img-auto-height {
width: 200px;
/* 不设置height,由图片根据widthFix模式自动计算 */
}
.image-demo {
margin-bottom: 20rpx;
}
</style>
Image使用注意:
-
首选
widthFix:在需要图片自适应宽度(如商品详情图、文章配图)时,mode="widthFix"是神器,无需计算高度。 - ** 必设宽高**:无论是直接设置还是通过父容器继承,必须让
<image>有确定的宽高,否则可能显示异常。 -
加载失败处理:使用
@error事件监听加载失败,并设置默认图。<image :src="avatarUrl" @error="onImageError" class="avatar"></image>onImageError() { this.avatarUrl = '/static/default-avatar.png'; // 替换为默认头像 } -
性能优化:对于列表图片,务必加上
lazy-load。
三、 按钮与表单组件
应用不能只是展示,更需要与用户交互。
3.1 Button
<button> 组件用于捕获用户的点击操作。它功能强大,样式多样,甚至能直接调起系统的某些功能。
核心特性
-
多种类型:通过
type属性控制基础样式,如default(默认)、primary(主要)、warn(警告)。 -
开放能力:通过
open-type属性可以直接调起微信的获取用户信息、分享、客服等功能。 -
样式自定义:虽然提供了默认样式,但可以通过
hover-class等属性实现点击反馈,也可以通过CSS完全自定义。
用一段代码来展示各种按钮:
<template>
<view class="button-group">
<!-- 基础样式按钮 -->
<button type="default">默认按钮</button>
<button type="primary">主要按钮</button>
<button type="warn">警告按钮</button>
<!-- 禁用状态 -->
<button :disabled="true" type="primary">被禁用的按钮</button>
<!-- 加载状态 -->
<button loading type="primary">加载中...</button>
<!-- 获取用户信息 -->
<button open-type="getUserInfo" @getuserinfo="onGetUserInfo">获取用户信息</button>
<!-- 分享 -->
<button open-type="share">分享</button>
<!-- 自定义样式 - 使用 hover-class -->
<button class="custom-btn" hover-class="custom-btn-hover">自定义按钮</button>
</view>
</template>
<script>
export default {
methods: {
onGetUserInfo(e) {
console.log('用户信息:', e.detail);
// 在这里处理获取到的用户信息
}
}
}
</script>
<style>
.button-group button {
margin-bottom: 10px; /* 给按钮之间加点间距 */
}
.custom-btn {
background-color: #4CD964; /* 绿色背景 */
color: white;
border: none; /* 去除默认边框 */
border-radius: 10px; /* 圆角 */
}
.custom-btn-hover {
background-color: #2AC845; /* hover时更深的绿色 */
}
</style>
Button要点:
-
open-type:这是uni-app和小程序生态打通的关键,让你能用一行代码实现复杂的原生功能。 -
自定义样式:默认按钮样式可能不符合设计,记住一个原则:先重置,再定义。使用
border: none; background: your-color;来覆盖默认样式。 -
表单提交:在
<form>标签内,<button>的form-type属性可以指定为submit或reset。
3.2 表单组件 - Input, Checkbox, Radio, Picker...
表单用于收集用户输入。uni-app提供了一系列丰富的表单组件。
Input - 文本输入框
核心属性:
-
v-model:双向绑定输入值,最常用! -
type:输入框类型,如text,number,idcard,password等。 -
placeholder:占位符。 -
focus:自动获取焦点。 -
@confirm:点击完成按钮时触发。
下面写一个登录输入框:
<template>
<view class="login-form">
<input v-model="username" type="text" placeholder="请输入用户名" class="input-field" />
<input v-model="password" type="password" placeholder="请输入密码" class="input-field" @confirm="onLogin" />
<button type="primary" @tap="onLogin">登录</button>
</view>
</template>
<script>
export default {
data() {
return {
username: '',
password: ''
};
},
methods: {
onLogin() {
// 验证用户名和密码
if (!this.username || !this.password) {
uni.showToast({ title: '请填写完整', icon: 'none' });
return;
}
console.log('登录信息:', this.username, this.password);
// 发起登录请求...
}
}
}
</script>
<style>
.input-field {
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
margin-bottom: 15px;
height: 40px;
}
</style>
Checkbox 与 Radio - 选择与单选
这两个组件需要和<checkbox-group>, <radio-group>一起使用,来管理一组选项。
代码实战:选择兴趣爱好
<template>
<view>
<text>请选择你的兴趣爱好:</text>
<checkbox-group @change="onHobbyChange">
<label class="checkbox-label">
<checkbox value="reading" :checked="true" /> 阅读
</label>
<label class="checkbox-label">
<checkbox value="music" /> 音乐
</label>
<label class="checkbox-label">
<checkbox value="sports" /> 运动
</label>
</checkbox-group>
<view>已选:{{ selectedHobbies.join(', ') }}</view>
<text>请选择性别:</text>
<radio-group @change="onGenderChange">
<label class="radio-label">
<radio value="male" /> 男
</label>
<label class="radio-label">
<radio value="female" /> 女
</label>
</radio-group>
<view>已选:{{ selectedGender }}</view>
</view>
</template>
<script>
export default {
data() {
return {
selectedHobbies: ['reading'], // 默认选中阅读
selectedGender: ''
};
},
methods: {
onHobbyChange(e) {
// e.detail.value 是一个数组,包含所有被选中的checkbox的value
this.selectedHobbies = e.detail.value;
console.log('兴趣爱好变化:', e.detail.value);
},
onGenderChange(e) {
// e.detail.value 是单个被选中的radio的value
this.selectedGender = e.detail.value;
console.log('性别变化:', e.detail.value);
}
}
}
</script>
<style>
.checkbox-label, .radio-label {
display: block;
margin: 5px 0;
}
</style>
表单组件使用技巧:
-
善用
v-model:能够极大简化双向数据绑定的代码。 -
理解事件:
checkbox和radio的change事件发生在组(group) 上,通过e.detail.value获取所有值。 - UI统一:原生组件样式在各端可能略有差异,对于要求高的场景,可以考虑使用UI库(如uView)的自定义表单组件。
四、 导航与容器组件
当应用内容变多,我们需要更好的方式来组织页面结构和实现页面跳转。
4.1 Navigator
<navigator> 组件是一个页面链接,用于在应用内跳转到指定页面。它相当于HTML中的 <a> 标签,但功能更丰富。
核心属性与跳转模式:
-
url:必填,指定要跳转的页面路径。 -
open-type:跳转类型,决定了跳转行为。-
navigate:默认值,保留当前页面,跳转到新页面(可返回)。 -
redirect:关闭当前页面,跳转到新页面(不可返回)。 -
switchTab:跳转到tabBar页面,并关闭所有非tabBar页面。 -
reLaunch:关闭所有页面,打开到应用内的某个页面。 -
navigateBack:关闭当前页面,返回上一页面或多级页面。
-
-
delta:当open-type为navigateBack时有效,表示返回的层数。
为了更清晰地理解这几种跳转模式对页面栈的影响,我画了下面这张图:
![]()
下面用代码实现一个简单的导航
<template>
<view class="nav-demo">
<!-- 普通跳转,可以返回 -->
<navigator url="/pages/about/about" hover-class="navigator-hover">
<button>关于我们(普通跳转)</button>
</navigator>
<!-- 重定向,无法返回 -->
<navigator url="/pages/index/index" open-type="redirect">
<button type="warn">回首页(重定向)</button>
</navigator>
<!-- 跳转到TabBar页面 -->
<navigator url="/pages/tabbar/my/my" open-type="switchTab">
<button type="primary">个人中心(Tab跳转)</button>
</navigator>
<!-- 返回上一页 -->
<navigator open-type="navigateBack">
<button>返回上一页</button>
</navigator>
<!-- 返回上两页 -->
<navigator open-type="navigateBack" :delta="2">
<button>返回上两页</button>
</navigator>
</view>
</template>
<style>
.nav-demo button {
margin: 10rpx;
}
.navigator-hover {
background-color: #f0f0f0; /* 点击时的反馈色 */
}
</style>
Navigator避坑:
-
url路径:必须以/开头,在pages.json中定义。 -
跳转TabBar:必须使用
open-type="switchTab",否则无效。 -
传参:可以在
url后面拼接参数,如/pages/detail/detail?id=1&name=test,在目标页面的onLoad生命周期中通过options参数获取。 -
跳转限制:小程序中页面栈最多十层,注意使用
redirect避免层级过深。
4.2 Scroll-View
<scroll-view> 是一个可滚动的视图容器。当内容超过容器高度(或宽度)时,提供滚动查看的能力。
核心特性:
-
滚动方向:通过
scroll-x(横向)和scroll-y(纵向)控制。 -
滚动事件:可以监听
@scroll事件,获取滚动位置。 -
上拉加载/下拉刷新:通过
@scrolltolower和@scrolltoupper等事件模拟,但更推荐使用页面的onReachBottom和onPullDownRefresh。
代码实现一个横向滚动导航和纵向商品列表
<template>
<view>
<!-- 横向滚动导航 -->
<scroll-view scroll-x class="horizontal-scroll">
<view v-for="(item, index) in navList" :key="index" class="nav-item">
{{ item.name }}
</view>
</scroll-view>
<!-- 纵向滚动商品列表 -->
<scroll-view scroll-y :style="{ height: scrollHeight + 'px' }" @scrolltolower="onLoadMore">
<view v-for="(product, idx) in productList" :key="idx" class="product-item">
<image :src="product.image" mode="aspectFill" class="product-img"></image>
<text class="product-name">{{ product.name }}</text>
</view>
<view v-if="loading" class="loading-text">加载中...</view>
</scroll-view>
</view>
</template>
<script>
export default {
data() {
return {
navList: [ /* ... 导航数据 ... */ ],
productList: [ /* ... 商品数据 ... */ ],
scrollHeight: 0,
loading: false
};
},
onLoad() {
// 动态计算scroll-view的高度,使其充满屏幕剩余部分
const sysInfo = uni.getSystemInfoSync();
// 假设横向导航高度为50px,需要根据实际情况计算
this.scrollHeight = sysInfo.windowHeight - 50;
},
methods: {
onLoadMore() {
// 加载更多
if (this.loading) return;
this.loading = true;
console.log('开始加载更多数据...');
// 请求数据
setTimeout(() => {
// ... 获取新数据并拼接到productList ...
this.loading = false;
}, 1000);
}
}
}
</script>
<style>
.horizontal-scroll {
white-space: nowrap; /* 让子元素不换行 */
width: 100%;
background-color: #f7f7f7;
}
.nav-item {
display: inline-block; /* 让子元素行内排列 */
padding: 10px 20px;
margin: 5px;
background-color: #fff;
border-radius: 15px;
}
.product-item {
display: flex;
padding: 10px;
border-bottom: 1px solid #eee;
}
.product-img {
width: 80px;
height: 80px;
border-radius: 5px;
}
.product-name {
margin-left: 10px;
align-self: center;
}
.loading-text {
text-align: center;
padding: 10px;
color: #999;
}
</style>
Scroll-View使用心得:
-
横向滚动:牢记两个CSS:容器
white-space: nowrap;,子项display: inline-block;。 -
性能:
<scroll-view>内不适合放过多或过于复杂的子节点,尤其是图片,可能导致滚动卡顿。对于长列表,应使用官方的<list>组件或社区的长列表组件。 -
高度问题:纵向滚动的
<scroll-view>必须有一个固定的高度,否则会无法滚动。通常通过JS动态计算。
五、 自定义组件基础
当项目变得复杂,我们会发现很多UI模块或功能块在重复编写。这时,就该自定义组件了!它能将UI和功能封装起来,实现复用和解耦。
5.1 为什么要用自定义组件?
- 复用性:一次封装,到处使用。
- 可维护性:功能集中在一处,修改方便。
- 清晰性:将复杂页面拆分成多个组件,结构清晰,便于协作。
5.2 创建与使用一个自定义组件
让我们来封装一个简单的UserCard组件。
第一步:创建组件文件
在项目根目录创建components文件夹,然后在里面创建user-card/user-card.vue文件。uni-app会自动识别components目录下的组件。
第二步:编写组件模板、逻辑与样式
<!-- components/user-card/user-card.vue -->
<template>
<view class="user-card" @tap="onCardClick">
<image :src="avatarUrl" class="avatar" mode="aspectFill"></image>
<view class="info">
<text class="name">{{ name }}</text>
<text class="bio">{{ bio }}</text>
</view>
<view class="badge" v-if="isVip">VIP</view>
</view>
</template>
<script>
export default {
// 声明组件的属性,外部传入的数据
props: {
avatarUrl: {
type: String,
default: '/static/default-avatar.png'
},
name: {
type: String,
required: true
},
bio: String, // 简写方式,只定义类型
isVip: Boolean
},
// 组件内部数据
data() {
return {
// 这里放组件自己的状态
};
},
methods: {
onCardClick() {
// 触发一个自定义事件,通知父组件
this.$emit('cardClick', { name: this.name });
// 也可以在这里处理组件内部的逻辑
uni.showToast({
title: `点击了${this.name}的名片`,
icon: 'none'
});
}
}
}
</script>
<style scoped>
.user-card {
display: flex;
padding: 15px;
background-color: #fff;
border-radius: 8px;
margin: 10px;
position: relative;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
.avatar {
width: 50px;
height: 50px;
border-radius: 25px;
}
.info {
display: flex;
flex-direction: column;
margin-left: 12px;
justify-content: space-around;
}
.name {
font-size: 16px;
font-weight: bold;
}
.bio {
font-size: 12px;
color: #999;
}
.badge {
position: absolute;
top: 10px;
right: 10px;
background-color: #ffd700;
color: #333;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
}
</style>
第三步:在页面中使用组件
<!-- pages/index/index.vue -->
<template>
<view>
<text>用户列表</text>
<!-- 使用自定义组件 -->
<!-- 1. 通过属性传递数据 -->
<user-card
name="码小明"
bio="热爱编程"
:is-vip="true"
avatar-url="/static/avatar1.jpg"
@cardClick="onUserCardClick" <!-- 2. 监听子组件发出的自定义事件 -->
/>
<user-card
name="产品经理小鱼儿"
bio="让世界更美好"
:is-vip="false"
@cardClick="onUserCardClick"
/>
</view>
</template>
<script>
// 2. 导入组件
// import UserCard from '@/components/user-card/user-card.vue';
export default {
// 3. 注册组件
// components: { UserCard },
methods: {
onUserCardClick(detail) {
console.log('父组件收到了卡片的点击事件:', detail);
// 这里可以处理跳转逻辑
// uni.navigateTo({ url: '/pages/user/detail?name=' + detail.name });
}
}
}
</script>
5.3 核心概念:Props, Events, Slots
一个完整的自定义组件通信机制,主要围绕这三者展开。它们的关系可以用下图清晰地表示:
![]()
-
Props(属性):由外到内的数据流。父组件通过属性的方式将数据传递给子组件。子组件用
props选项声明接收。 -
Events(事件):由内到外的通信。子组件通过
this.$emit('事件名', 数据)触发一个自定义事件,父组件通过v-on或@来监听这个事件。 - Slots(插槽):内容分发。父组件可以将一段模板内容“插入”到子组件指定的位置。这极大地增强了组件的灵活性。
插槽(Slot)简单示例:
假设我们的UserCard组件,想在bio下面留一个区域给父组件自定义内容。
在子组件中:
<!-- user-card.vue -->
<view class="info">
<text class="name">{{ name }}</text>
<text class="bio">{{ bio }}</text>
<!-- 默认插槽,父组件传入的内容会渲染在这里 -->
<slot></slot>
<!-- 具名插槽 -->
<!-- <slot name="footer"></slot> -->
</view>
在父组件中:
<user-card name="小明" bio="...">
<!-- 传入到默认插槽的内容 -->
<view style="margin-top: 5px;">
<button size="mini">关注</button>
</view>
<!-- 传入到具名插槽footer的内容 -->
<!-- <template v-slot:footer> ... </template> -->
</user-card>
5.4 EasyCom
你可能会注意到,在上面的页面中,我们并没有import和components注册,但组件却正常使用了。这是因为uni-app的 easycom 规则。
规则:只要组件安装在项目的components目录下,并符合components/组件名称/组件名称.vue的目录结构,就可以不用手动引入和注册,直接在页面中使用。极大地提升了开发效率!
六、 内容总结
至此基本组件内容就介绍完了,又到了总结的时候了,本节主要内容:
-
View、Text、Image:构建页面的三大核心组件。注意图片Image的
mode属性。 -
Button与表单组件:与用户交互的核心。Button的
open-type能调起强大原生功能。表单组件用v-model实现数据双向绑定。 -
Navigator与Scroll-View:组织页面和内容。Navigator负责路由跳转,要理解五种
open-type的区别。Scroll-View提供滚动区域,要注意它的高度和性能问题。 -
自定义组件:必会内容。理解了
Props下行、Events上行、Slots分发的数据流,你就掌握了组件通信的精髓。easycom规则让组件使用更便捷。
如果你觉得这篇文章对你有所帮助,能够对uni-app的基础组件有更清晰的认识,不要吝啬你的“一键三连”(点赞、关注、收藏)哦(手动狗头)!你的支持是我持续创作的最大动力。 在学习过程中遇到任何问题,或者有哪里没看明白,都欢迎在评论区留言,我会尽力解答。
版权声明:本文为【《uni-app跨平台开发完全指南》】系列第五篇,原创文章,转载请注明出处。
《Flutter全栈开发实战指南:从零到高级》- 12 -状态管理Bloc
《uni-app跨平台开发完全指南》- 04 - 页面布局与样式基础
uni-app:掌握页面布局与样式
新手刚接触uni-app布局可能会遇到以下困惑:明明在模拟器上完美显示的页面,到了真机上就面目全非;iOS上对齐的元素,到Android上就错位几个像素,相信很多开发者都经历过。今天就带大家摸清了uni-app布局样式的门道,把这些经验毫无保留地分享给大家,让你少走弯路。
一、Flex布局
1.1 为什么Flex布局是移动端首选?
传统布局的痛点:
/* 传统方式实现垂直居中 */
.container {
position: relative;
height: 400px;
}
.center {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 100px;
margin-top: -50px; /* 需要计算 */
margin-left: -100px; /* 需要计算 */
}
Flex布局:
/* Flex布局实现垂直居中 */
.container {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.center {
width: 200px;
height: 100px;
}
从对比中不难看出,Flex布局用更少的代码、更清晰的逻辑解决了复杂的布局问题。
1.2 Flex布局的核心概念
为了更好地理解Flex布局,我们先来看一下它的基本模型:
Flex容器 (display: flex)
├─────────────────────────────────┤
│ 主轴方向 (flex-direction) → │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ │ 元素1 │ │ 元素2 │ │ 元素3 │ ← Flex元素
│ └─────────┘ └─────────┘ └─────────┘
│ │
│ ↑ │
│ 交叉轴方向 │
└─────────────────────────────────┘
Flex布局的两大核心:
-
容器:设置
display: flex的元素,控制内部项目的布局 - 元素:容器的直接子元素,受容器属性控制
1.3 容器属性
1.3.1 flex-direction:布局方向
这个属性决定了元素的排列方向,是Flex布局的基础:
.container {
/* 水平方向,从左到右(默认) */
flex-direction: row;
/* 水平方向,从右到左 */
flex-direction: row-reverse;
/* 垂直方向,从上到下 */
flex-direction: column;
/* 垂直方向,从下到上 */
flex-direction: column-reverse;
}
实际应用场景分析:
| 属性值 | 适用场景 |
|---|---|
row |
水平导航、卡片列表 |
column |
表单布局、设置页面 |
row-reverse |
阿拉伯语等从右向左语言 |
column-reverse |
聊天界面(最新消息在底部) |
1.3.2 justify-content:主轴对齐
这个属性控制元素在主轴上的对齐方式,使用频率非常高:
.container {
display: flex;
/* 起始位置对齐 */
justify-content: flex-start;
/* 末尾位置对齐 */
justify-content: flex-end;
/* 居中对齐 */
justify-content: center;
/* 两端对齐,项目间隔相等 */
justify-content: space-between;
/* 每个项目两侧间隔相等 */
justify-content: space-around;
/* 均匀分布,包括两端 */
justify-content: space-evenly;
}
空间分布对比关系:
- start - 从头开始
- end - 从尾开始
- center - 居中对齐
- between - 元素"之间"有间隔
- around - 每个元素"周围"有空间
- evenly - 所有空间"均匀"分布
1.3.3 align-items:交叉轴对齐
控制元素在交叉轴上的对齐方式:
.container {
display: flex;
height: 300rpx; /* 需要明确高度 */
/* 交叉轴起点对齐 */
align-items: flex-start;
/* 交叉轴终点对齐 */
align-items: flex-end;
/* 交叉轴中点对齐 */
align-items: center;
/* 基线对齐(文本相关) */
align-items: baseline;
/* 拉伸填充(默认) */
align-items: stretch;
}
温馨提示:align-items的效果与flex-direction密切相关:
- 当
flex-direction: row时,交叉轴是垂直方向 - 当
flex-direction: column时,交叉轴是水平方向
1.4 元素属性
1.4.1 flex-grow
控制元素放大比例,默认0(不放大):
.item {
flex-grow: <number>; /* 默认0 */
}
计算原理:
总剩余空间 = 容器宽度 - 所有元素宽度总和
每个元素分配空间 = (元素的flex-grow / 所有元素flex-grow总和) × 总剩余空间
示例分析:
.container {
width: 750rpx;
display: flex;
}
.item1 { width: 100rpx; flex-grow: 1; }
.item2 { width: 100rpx; flex-grow: 2; }
.item3 { width: 100rpx; flex-grow: 1; }
/* 计算过程:
剩余空间 = 750 - (100+100+100) = 450rpx
flex-grow总和 = 1+2+1 = 4
item1分配 = (1/4)×450 = 112.5rpx → 最终宽度212.5rpx
item2分配 = (2/4)×450 = 225rpx → 最终宽度325rpx
item3分配 = (1/4)×450 = 112.5rpx → 最终宽度212.5rpx
*/
1.4.2 flex-shrink
控制元素缩小比例,默认1(空间不足时缩小):
.item {
flex-shrink: <number>; /* 默认1 */
}
小技巧:设置flex-shrink: 0可以防止元素被压缩,常用于固定宽度的元素。
1.4.3 flex-basis
定义元素在分配多余空间之前的初始大小:
.item {
flex-basis: auto | <length>; /* 默认auto */
}
1.4.4 flex
flex是flex-grow、flex-shrink和flex-basis的简写:
.item {
/* 等价于 flex: 0 1 auto */
flex: none;
/* 等价于 flex: 1 1 0% */
flex: 1;
/* 等价于 flex: 1 1 auto */
flex: auto;
/* 自定义 */
flex: 2 1 200rpx;
}
1.5 完整页面布局实现
让我们用Flex布局实现一个典型的移动端页面:
<view class="page-container">
<!-- 顶部导航 -->
<view class="header">
<view class="nav-back">←</view>
<view class="nav-title">商品详情</view>
<view class="nav-actions">···</view>
</view>
<!-- 内容区域 -->
<view class="content">
<!-- 商品图 -->
<view class="product-image">
<image src="/static/product.jpg" mode="aspectFit"></image>
</view>
<!-- 商品信息 -->
<view class="product-info">
<view class="product-name">高端智能手机 8GB+256GB</view>
<view class="product-price">
<text class="current-price">¥3999</text>
<text class="original-price">¥4999</text>
</view>
<view class="product-tags">
<text class="tag">限时优惠</text>
<text class="tag">分期免息</text>
<text class="tag">赠品</text>
</view>
</view>
<!-- 规格选择 -->
<view class="spec-section">
<view class="section-title">选择规格</view>
<view class="spec-options">
<view class="spec-option active">8GB+256GB</view>
<view class="spec-option">12GB+512GB</view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="footer">
<view class="footer-actions">
<view class="action-btn cart">购物车</view>
<view class="action-btn buy-now">立即购买</view>
</view>
</view>
</view>
.page-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
/* 头部导航 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 32rpx;
background: white;
border-bottom: 1rpx solid #eee;
}
.nav-back, .nav-actions {
width: 60rpx;
text-align: center;
font-size: 36rpx;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: bold;
}
/* 内容区域 */
.content {
flex: 1;
overflow-y: auto;
}
.product-image {
height: 750rpx;
background: white;
}
.product-image image {
width: 100%;
height: 100%;
}
.product-info {
padding: 32rpx;
background: white;
margin-bottom: 20rpx;
}
.product-name {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 20rpx;
line-height: 1.4;
}
.product-price {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.current-price {
font-size: 48rpx;
color: #ff5000;
font-weight: bold;
margin-right: 20rpx;
}
.original-price {
font-size: 28rpx;
color: #999;
text-decoration: line-through;
}
.product-tags {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.tag {
padding: 8rpx 20rpx;
background: #fff2f2;
color: #ff5000;
font-size: 24rpx;
border-radius: 8rpx;
}
/* 规格选择 */
.spec-section {
background: white;
padding: 32rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 24rpx;
}
.spec-options {
display: flex;
gap: 20rpx;
}
.spec-option {
padding: 20rpx 40rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
font-size: 28rpx;
}
.spec-option.active {
border-color: #007AFF;
background: #f0f8ff;
color: #007AFF;
}
/* 底部操作栏 */
.footer {
background: white;
border-top: 1rpx solid #eee;
padding: 20rpx 32rpx;
}
.footer-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: bold;
}
.cart {
background: #fff2f2;
color: #ff5000;
border: 2rpx solid #ff5000;
}
.buy-now {
background: #ff5000;
color: white;
}
这个例子展示了如何用Flex布局构建复杂的页面结构,包含了水平布局、垂直布局、空间分配等各种技巧。
二、跨端适配:rpx单位系统
2.1 像素密度
要理解rpx的价值,首先要明白移动端面临的问题:
设备现状:
设备A: 4.7英寸, 750×1334像素, 326ppi
设备B: 6.1英寸, 828×1792像素, 326ppi
设备C: 6.7英寸, 1284×2778像素, 458ppi
同样的CSS像素在不同设备上的物理尺寸不同,这就是我们需要响应式单位的原因。
2.2 rpx的工作原理
rpx的核心思想很简单:以屏幕宽度为基准的相对单位
rpx计算原理:
1rpx = (屏幕宽度 / 750) 物理像素
不同设备上的表现:
| 设备宽度 | 1rpx对应的物理像素 | 计算过程 |
|---|---|---|
| 750px | 1px | 750/750 = 1 |
| 375px | 0.5px | 375/750 = 0.5 |
| 1125px | 1.5px | 1125/750 = 1.5 |
2.3 rpx与其他单位的对比分析
为了更好地理解rpx,我们把它和其他常用单位做个对比:
/* 不同单位的对比示例 */
.element {
width: 750rpx; /* 总是占满屏幕宽度 */
width: 100%; /* 占满父容器宽度 */
width: 375px; /* 固定像素值 */
width: 50vw; /* 视窗宽度的50% */
}
2.4 rpx实际应用与问题排查
2.4.1 设计稿转换
情况一:750px设计稿(推荐)
设计稿测量值 = 直接写rpx值
设计稿200px → width: 200rpx
情况二:375px设计稿
rpx值 = (设计稿测量值 ÷ 375) × 750
设计稿200px → (200÷375)×750 = 400rpx
情况三:任意尺寸设计稿
// 通用转换公式
function pxToRpx(px, designWidth = 750) {
return (px / designWidth) * 750;
}
// 使用示例
const buttonWidth = pxToRpx(200, 375); // 返回400
2.4.2 rpx常见问题
问题1:边框模糊
/* 不推荐 - 可能在不同设备上模糊 */
.element {
border: 1rpx solid #e0e0e0;
}
/* 推荐 - 使用px保证清晰度 */
.element {
border: 1px solid #e0e0e0;
}
问题2:大屏设备显示过大
.container {
width: 750rpx; /* 在小屏上合适,大屏上可能太大 */
}
/* 解决方案:媒体查询限制最大宽度 */
@media (min-width: 768px) {
.container {
width: 100%;
max-width: 500px;
margin: 0 auto;
}
}
2.5 响应式网格布局案例
<view class="product-grid">
<view class="product-card" v-for="item in 8" :key="item">
<image class="product-img" src="/static/product.jpg"></image>
<view class="product-info">
<text class="product-name">商品标题{{item}}</text>
<text class="product-desc">商品描述信息</text>
<view class="product-bottom">
<text class="product-price">¥199</text>
<text class="product-sales">销量: 1.2万</text>
</view>
</view>
</view>
</view>
.product-grid {
display: flex;
flex-wrap: wrap;
padding: 20rpx;
gap: 20rpx; /* 间隙,需要确认平台支持 */
}
.product-card {
width: calc((100% - 20rpx) / 2); /* 2列布局 */
background: white;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.08);
}
/* 兼容不支持gap的方案 */
.product-grid {
display: flex;
flex-wrap: wrap;
padding: 20rpx;
justify-content: space-between;
}
.product-card {
width: 345rpx; /* (750-20*2-20)/2 = 345 */
margin-bottom: 20rpx;
}
.product-img {
width: 100%;
height: 345rpx;
display: block;
}
.product-info {
padding: 20rpx;
}
.product-name {
display: block;
font-size: 28rpx;
font-weight: bold;
margin-bottom: 10rpx;
line-height: 1.4;
}
.product-desc {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 20rpx;
line-height: 1.4;
}
.product-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-price {
font-size: 32rpx;
color: #ff5000;
font-weight: bold;
}
.product-sales {
font-size: 22rpx;
color: #999;
}
/* 平板适配 */
@media (min-width: 768px) {
.product-card {
width: calc((100% - 40rpx) / 3); /* 3列布局 */
}
}
/* 大屏适配 */
@media (min-width: 1024px) {
.product-grid {
max-width: 1200px;
margin: 0 auto;
}
.product-card {
width: calc((100% - 60rpx) / 4); /* 4列布局 */
}
}
这个网格布局会在不同设备上自动调整列数,真正实现"一次编写,到处运行"。
三、样式作用域
3.1 全局样式
全局样式是整个应用的样式基石,应该在App.vue中统一定义:
/* App.vue - 全局样式体系 */
<style>
/* CSS变量定义 */
:root {
/* 颜色 */
--color-primary: #007AFF;
--color-success: #4CD964;
--color-warning: #FF9500;
--color-error: #FF3B30;
--color-text-primary: #333333;
--color-text-secondary: #666666;
--color-text-tertiary: #999999;
/* 间距 */
--spacing-xs: 10rpx;
--spacing-sm: 20rpx;
--spacing-md: 30rpx;
--spacing-lg: 40rpx;
--spacing-xl: 60rpx;
/* 圆角 */
--border-radius-sm: 8rpx;
--border-radius-md: 12rpx;
--border-radius-lg: 16rpx;
--border-radius-xl: 24rpx;
/* 字体 */
--font-size-xs: 20rpx;
--font-size-sm: 24rpx;
--font-size-md: 28rpx;
--font-size-lg: 32rpx;
--font-size-xl: 36rpx;
/* 阴影 */
--shadow-sm: 0 2rpx 8rpx rgba(0,0,0,0.1);
--shadow-md: 0 4rpx 20rpx rgba(0,0,0,0.12);
--shadow-lg: 0 8rpx 40rpx rgba(0,0,0,0.15);
}
/* 全局重置样式 */
page {
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
SimSun, sans-serif;
background-color: #F8F8F8;
color: var(--color-text-primary);
font-size: var(--font-size-md);
line-height: 1.6;
}
/* 工具类 - 原子CSS */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.flex { display: flex; }
.flex-column { flex-direction: column; }
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.m-10 { margin: 10rpx; }
.m-20 { margin: 20rpx; }
.p-10 { padding: 10rpx; }
.p-20 { padding: 20rpx; }
/* 通用组件样式 */
.uni-button {
padding: 24rpx 48rpx;
border-radius: var(--border-radius-md);
font-size: var(--font-size-lg);
border: none;
background-color: var(--color-primary);
color: white;
transition: all 0.3s ease;
}
.uni-button:active {
opacity: 0.8;
transform: scale(0.98);
}
</style>
3.2 局部样式
局部样式通过scoped属性实现样式隔离,避免样式污染:
scoped样式原理:
<!-- 编译前 -->
<template>
<view class="container">
<text class="title">标题</text>
</view>
</template>
<style scoped>
.container {
padding: 32rpx;
}
.title {
color: #007AFF;
font-size: 36rpx;
}
</style>
<!-- 编译后 -->
<template>
<view class="container" data-v-f3f3eg9>
<text class="title" data-v-f3f3eg9>标题</text>
</view>
</template>
<style>
.container[data-v-f3f3eg9] {
padding: 32rpx;
}
.title[data-v-f3f3eg9] {
color: #007AFF;
font-size: 36rpx;
}
</style>
3.3 样式穿透
当需要修改子组件样式时,使用深度选择器:
/* 修改uni-ui组件样式 */
.custom-card ::v-deep .uni-card {
border-radius: 24rpx;
box-shadow: var(--shadow-lg);
}
.custom-card ::v-deep .uni-card__header {
padding: 32rpx 32rpx 0;
border-bottom: none;
}
/* 兼容不同平台的写法 */
.custom-card /deep/ .uni-card__content {
padding: 32rpx;
}
3.4 条件编译
uni-app的条件编译可以针对不同平台编写特定样式:
/* 通用基础样式 */
.button {
padding: 24rpx 48rpx;
border-radius: 12rpx;
font-size: 32rpx;
}
/* 微信小程序特有样式 */
/* #ifdef MP-WEIXIN */
.button {
border-radius: 8rpx;
}
/* #endif */
/* H5平台特有样式 */
/* #ifdef H5 */
.button {
cursor: pointer;
transition: all 0.3s ease;
}
.button:hover {
opacity: 0.9;
transform: translateY(-2rpx);
}
/* #endif */
/* App平台特有样式 */
/* #ifdef APP-PLUS */
.button {
border-radius: 16rpx;
}
/* #endif */
3.5 样式架构
推荐的项目样式结构:
styles/
├── variables.css # CSS变量定义
├── reset.css # 重置样式
├── mixins.css # 混合宏
├── components/ # 组件样式
│ ├── button.css
│ ├── card.css
│ └── form.css
├── pages/ # 页面样式
│ ├── home.css
│ ├── profile.css
│ └── ...
└── utils.css # 工具类
在App.vue中导入:
<style>
/* 导入样式文件 */
@import './styles/variables.css';
@import './styles/reset.css';
@import './styles/utils.css';
@import './styles/components/button.css';
</style>
四、CSS3高级特性
4.1 渐变与阴影
4.1.1 渐变
/* 线性渐变 */
.gradient-bg {
/* 基础渐变 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* 多色渐变 */
background: linear-gradient(90deg,
#FF6B6B 0%,
#4ECDC4 33%,
#45B7D1 66%,
#96CEB4 100%);
/* 透明渐变 - 遮罩效果 */
background: linear-gradient(
to bottom,
rgba(0,0,0,0.8) 0%,
rgba(0,0,0,0) 100%
);
}
/* 文字渐变效果 */
.gradient-text {
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
4.1.2 阴影
/* 基础阴影层级 */
.shadow-layer-1 {
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.shadow-layer-2 {
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.12);
}
.shadow-layer-3 {
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.15);
}
/* 内阴影 */
.shadow-inner {
box-shadow: inset 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
}
/* 多重阴影 */
.shadow-multi {
box-shadow:
0 2rpx 4rpx rgba(0, 0, 0, 0.1),
0 8rpx 16rpx rgba(0, 0, 0, 0.1);
}
/* 悬浮效果 */
.card {
transition: all 0.3s ease;
box-shadow: var(--shadow-md);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-4rpx);
}
4.2 变换与动画
4.2.1 变换
/* 2D变换 */
.transform-2d {
/* 平移 */
transform: translate(100rpx, 50rpx);
/* 缩放 */
transform: scale(1.1);
/* 旋转 */
transform: rotate(45deg);
/* 倾斜 */
transform: skew(15deg, 5deg);
/* 组合变换 */
transform: translateX(50rpx) rotate(15deg) scale(1.05);
}
/* 3D变换 */
.card-3d {
perspective: 1000rpx; /* 透视点 */
}
.card-inner {
transition: transform 0.6s;
transform-style: preserve-3d; /* 保持3D空间 */
}
.card-3d:hover .card-inner {
transform: rotateY(180deg);
}
.card-front, .card-back {
backface-visibility: hidden; /* 隐藏背面 */
}
.card-back {
transform: rotateY(180deg);
}
4.2.2 动画
/* 关键帧动画 */
@keyframes slideIn {
0% {
opacity: 0;
transform: translateY(60rpx) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-20rpx);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* 动画类 */
.slide-in {
animation: slideIn 0.6s ease-out;
}
.bounce {
animation: bounce 0.6s ease-in-out;
}
.pulse {
animation: pulse 2s infinite;
}
/* 交互动画 */
.interactive-btn {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.interactive-btn:active {
transform: scale(0.95);
opacity: 0.8;
}
4.3 高级交互动效
<template>
<view class="interactive-demo">
<!-- 悬浮操作按钮 -->
<view class="fab" :class="{ active: menuOpen }" @click="toggleMenu">
<text class="fab-icon">+</text>
</view>
<!-- 悬浮菜单 -->
<view class="fab-menu" :class="{ active: menuOpen }">
<view class="fab-item" @click="handleAction('share')"
:style="{ transitionDelay: '0.1s' }">
<text class="fab-icon">📤</text>
<text class="fab-text">分享</text>
</view>
<view class="fab-item" @click="handleAction('favorite')"
:style="{ transitionDelay: '0.2s' }">
<text class="fab-icon">❤️</text>
<text class="fab-text">收藏</text>
</view>
<view class="fab-item" @click="handleAction('download')"
:style="{ transitionDelay: '0.3s' }">
<text class="fab-icon">📥</text>
<text class="fab-text">下载</text>
</view>
</view>
<!-- 动画卡片网格 -->
<view class="animated-grid">
<view class="grid-item" v-for="(item, index) in gridItems"
:key="index"
:style="{
animationDelay: `${index * 0.1}s`,
background: item.color
}"
@click="animateItem(index)">
<text class="item-text">{{ item.text }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
menuOpen: false,
gridItems: [
{ text: '卡片1', color: 'linear-gradient(135deg, #667eea, #764ba2)' },
{ text: '卡片2', color: 'linear-gradient(135deg, #f093fb, #f5576c)' },
{ text: '卡片3', color: 'linear-gradient(135deg, #4facfe, #00f2fe)' },
{ text: '卡片4', color: 'linear-gradient(135deg, #43e97b, #38f9d7)' },
{ text: '卡片5', color: 'linear-gradient(135deg, #fa709a, #fee140)' },
{ text: '卡片6', color: 'linear-gradient(135deg, #a8edea, #fed6e3)' }
]
}
},
methods: {
toggleMenu() {
this.menuOpen = !this.menuOpen
},
handleAction(action) {
uni.showToast({
title: `执行: ${action}`,
icon: 'none'
})
this.menuOpen = false
},
animateItem(index) {
// 可以添加更复杂的动画逻辑
console.log('点击卡片:', index)
}
}
}
</script>
<style scoped>
.interactive-demo {
padding: 40rpx;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* 悬浮操作按钮 */
.fab {
position: fixed;
bottom: 80rpx;
right: 40rpx;
width: 120rpx;
height: 120rpx;
background: #FF3B30;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(255, 59, 48, 0.4);
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
z-index: 1000;
cursor: pointer;
}
.fab-icon {
font-size: 48rpx;
color: white;
transition: transform 0.4s ease;
}
.fab.active {
transform: rotate(135deg);
background: #007AFF;
}
/* 悬浮菜单 */
.fab-menu {
position: fixed;
bottom: 220rpx;
right: 70rpx;
opacity: 0;
visibility: hidden;
transform: translateY(40rpx) scale(0.8);
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.fab-menu.active {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.fab-item {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20rpx);
padding: 24rpx 32rpx;
margin-bottom: 20rpx;
border-radius: 50rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
transform: translateX(60rpx);
opacity: 0;
transition: all 0.4s ease;
}
.fab-menu.active .fab-item {
transform: translateX(0);
opacity: 1;
}
.fab-text {
font-size: 28rpx;
color: #333;
margin-left: 16rpx;
white-space: nowrap;
}
/* 动画网格 */
.animated-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 30rpx;
margin-top: 40rpx;
}
.grid-item {
height: 200rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.2);
animation: cardEntrance 0.6s ease-out both;
transition: all 0.3s ease;
cursor: pointer;
}
.grid-item:active {
transform: scale(0.95);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.3);
}
.item-text {
color: white;
font-size: 32rpx;
font-weight: bold;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
/* 入场动画 */
@keyframes cardEntrance {
from {
opacity: 0;
transform: translateY(60rpx) scale(0.9) rotateX(45deg);
}
to {
opacity: 1;
transform: translateY(0) scale(1) rotateX(0);
}
}
/* 响应式调整 */
@media (max-width: 750px) {
.animated-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 751px) and (max-width: 1200px) {
.animated-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1201px) {
.animated-grid {
grid-template-columns: repeat(4, 1fr);
max-width: 1200px;
margin: 40rpx auto;
}
}
</style>
五、性能优化
5.1 样式性能优化
5.1.1 选择器性能
/* 不推荐 - 性能差 */
.container .list .item .title .text {
color: red;
}
/* 推荐 - 性能好 */
.item-text {
color: red;
}
/* 不推荐 - 通用选择器性能差 */
* {
margin: 0;
padding: 0;
}
/* 推荐 - 明确指定元素 */
view, text, image {
margin: 0;
padding: 0;
}
5.1.2 动画性能优化
/* 不推荐 - 触发重排的属性 */
.animate-slow {
animation: changeWidth 1s infinite;
}
@keyframes changeWidth {
0% { width: 100rpx; }
100% { width: 200rpx; }
}
/* 推荐 - 只触发重绘的属性 */
.animate-fast {
animation: changeOpacity 1s infinite;
}
@keyframes changeOpacity {
0% { opacity: 1; }
100% { opacity: 0.5; }
}
/* 启用GPU加速 */
.gpu-accelerated {
transform: translateZ(0);
will-change: transform;
}
5.2 维护性
5.2.1 BEM命名规范
/* Block - 块 */
.product-card { }
/* Element - 元素 */
.product-card__image { }
.product-card__title { }
.product-card__price { }
/* Modifier - 修饰符 */
.product-card--featured { }
.product-card__price--discount { }
5.2.2 样式组织架构
styles/
├── base/ # 基础样式
│ ├── variables.css
│ ├── reset.css
│ └── typography.css
├── components/ # 组件样式
│ ├── buttons.css
│ ├── forms.css
│ └── cards.css
├── layouts/ # 布局样式
│ ├── header.css
│ ├── footer.css
│ └── grid.css
├── utils/ # 工具类
│ ├── spacing.css
│ ├── display.css
│ └── text.css
└── themes/ # 主题样式
├── light.css
└── dark.css
通过本节的学习,我们掌握了:Flex布局 、rpx单位、样式设计、css3高级特性,欢迎在评论区留言,我会及时解答。
版权声明:本文内容基于实战经验总结,欢迎分享交流,但请注明出处。禁止商业用途转载。
《Flutter全栈开发实战指南:从零到高级》- 11 -状态管理Provider
《Flutter全栈开发实战指南:从零到高级》- 10 -状态管理setState与InheritedWidget
《Flutter全栈开发实战指南:从零到高级》- 09 -常用UI组件库实战
《Flutter全栈开发实战指南:从零到高级》- 09 -常用UI组件库深度解析与实战
1. 前言:UI组件库在Flutter开发中的核心地位
在Flutter应用开发中,UI组件库构成了应用界面的基础版块块。就像建筑工人使用标准化的砖块、门窗和楼梯来快速建造房屋一样,Flutter开发者使用组件库来高效构建应用界面。
组件库的核心价值:
- 提高开发效率,减少重复代码
- 保证UI一致性
- 降低设计和技术门槛
- 提供最佳实践和性能优化
2. Material Design组件
2.1 Material Design设计架构
Material Design是Google推出的设计语言,它的核心思想是将数字界面视为一种特殊的"材料" 。这种材料具有物理特性:可以滑动、折叠、展开,有阴影和深度,遵循真实的物理规律。
Material Design架构层次:
┌─────────────────┐
│ 动效层 │ ← 提供有意义的过渡和反馈
├─────────────────┤
│ 组件层 │ ← 按钮、卡片、对话框等UI元素
├─────────────────┤
│ 颜色/字体层 │ ← 色彩系统和字体层级
├─────────────────┤
│ 布局层 │ ← 栅格系统和间距规范
└─────────────────┘
2.2 核心布局组件详解
2.2.1 Scaffold:应用骨架组件
Scaffold是Material应用的基础布局结构,它协调各个视觉元素的位置关系。
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('应用标题'),
actions: [
IconButton(icon: Icon(Icons.search), onPressed: () {})
],
),
drawer: Drawer(
child: ListView(
children: [/* 抽屉内容 */]
),
),
body: Center(child: Text('主要内容')),
bottomNavigationBar: BottomNavigationBar(
items: [/* 导航项 */],
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: Icon(Icons.add),
),
);
}
}
Scaffold组件关系图:
Scaffold
├── AppBar (顶部应用栏)
├── Drawer (侧边抽屉)
├── Body (主要内容区域)
├── BottomNavigationBar (底部导航)
└── FloatingActionButton (悬浮按钮)
2.2.2 Container:多功能容器组件
Container是Flutter中最灵活的布局组件,可以理解为HTML中的div元素。
Container(
width: 200,
height: 100,
margin: EdgeInsets.all(16),
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
blurRadius: 5,
offset: Offset(0, 3),
)
],
),
child: Text('容器内容'),
)
Container布局流程:
graph TD
A[Container创建] --> B{有子组件?}
B -->|是| C[包裹子组件]
B -->|否| D[填充可用空间]
C --> E[应用约束条件]
D --> E
E --> F[应用装饰效果]
F --> G[渲染完成]
2.3 表单组件深度实战
表单是应用中最常见的用户交互模式,Flutter提供了完整的表单解决方案。
2.3.1 表单验证架构
class LoginForm extends StatefulWidget {
@override
_LoginFormState createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: '邮箱',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '邮箱不能为空';
}
if (!RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$')
.hasMatch(value)) {
return '请输入有效的邮箱地址';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: '密码',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return '密码不能为空';
}
if (value.length < 6) {
return '密码至少6位字符';
}
return null;
},
),
SizedBox(height: 24),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
_performLogin();
}
},
child: Text('登录'),
),
],
),
);
}
void _performLogin() {
// 执行登录逻辑
}
}
表单验证流程图:
sequenceDiagram
participant U as 用户
participant F as Form组件
participant V as 验证器
participant S as 提交逻辑
U->>F: 点击提交按钮
F->>V: 调用验证器
V->>V: 检查每个字段
alt 验证通过
V->>F: 返回null
F->>S: 执行提交逻辑
S->>U: 显示成功反馈
else 验证失败
V->>F: 返回错误信息
F->>U: 显示错误提示
end
3. Cupertino风格组件:iOS原生体验
3.1 Cupertino
Cupertino设计语言基于苹果的Human Interface Guidelines,强调清晰、遵从和深度。
Cupertino设计原则:
- 清晰度:文字易读,图标精确
- 遵从性:内容优先,UI辅助
- 深度:层级分明,动效过渡自然
3.2 Cupertino组件实战
3.2.1 Cupertino页面架构
import 'package:flutter/cupertino.dart';
class CupertinoStylePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text('iOS风格页面'),
trailing: CupertinoButton(
child: Icon(CupertinoIcons.add),
onPressed: () {},
),
),
child: SafeArea(
child: ListView(
children: [
CupertinoListSection(
children: [
CupertinoListTile(
title: Text('设置'),
leading: Icon(CupertinoIcons.settings),
trailing: CupertinoListTileChevron(),
onTap: () {},
),
CupertinoListTile(
title: Text('通知'),
leading: Icon(CupertinoIcons.bell),
trailing: CupertinoSwitch(
value: true,
onChanged: (value) {},
),
),
],
),
],
),
),
);
}
}
Cupertino页面结构图:
CupertinoPageScaffold
├── CupertinoNavigationBar
│ ├── leading (左侧按钮)
│ ├── middle (标题)
│ └── trailing (右侧按钮)
└── child (主要内容)
└── SafeArea
└── ListView
└── CupertinoListSection
├── CupertinoListTile
└── CupertinoListTile
3.2.2 自适应开发模式
在跨平台开发中,提供平台原生的用户体验非常重要。
class AdaptiveComponent {
static Widget buildButton({
required BuildContext context,
required String text,
required VoidCallback onPressed,
}) {
final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
if (isIOS) {
return CupertinoButton(
onPressed: onPressed,
child: Text(text),
);
} else {
return ElevatedButton(
onPressed: onPressed,
child: Text(text),
);
}
}
static void showAlert({
required BuildContext context,
required String title,
required String content,
}) {
final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
if (isIOS) {
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(title),
content: Text(content),
actions: [
CupertinoDialogAction(
child: Text('确定'),
onPressed: () => Navigator.pop(context),
),
],
),
);
} else {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
child: Text('确定'),
onPressed: () => Navigator.pop(context),
),
],
),
);
}
}
}
平台适配流程图:
graph LR
A[组件初始化] --> B{检测运行平台}
B -->|iOS| C[使用Cupertino组件]
B -->|Android| D[使用Material组件]
C --> E[渲染iOS风格UI]
D --> F[渲染Material风格UI]
4. 第三方UI组件库
4.1 第三方库选择标准与架构
在选择第三方UI库时,需要有一定系统的评估标准。当然这些评估标准也没有定式,适合自己的才是最重要的~~~
第三方库评估矩阵:
| 评估维度 | 权重 | 评估标准 |
|---|---|---|
| 维护活跃度 | 30% | 最近更新、Issue响应 |
| 文档完整性 | 25% | API文档、示例代码 |
| 测试覆盖率 | 20% | 单元测试、集成测试 |
| 社区生态 | 15% | Star数、贡献者 |
| 性能表现 | 10% | 内存占用、渲染性能 |
4.2 状态管理库集成
状态管理是复杂应用的核心,Provider是目前最流行的解决方案之一。
import 'package:provider/provider.dart';
// 用户数据模型
class UserModel with ChangeNotifier {
String _name = '默认用户';
int _age = 0;
String get name => _name;
int get age => _age;
void updateUser(String newName, int newAge) {
_name = newName;
_age = newAge;
notifyListeners(); // 通知监听者更新
}
}
// 主题数据模型
class ThemeModel with ChangeNotifier {
bool _isDarkMode = false;
bool get isDarkMode => _isDarkMode;
void toggleTheme() {
_isDarkMode = !_isDarkMode;
notifyListeners();
}
}
// 应用入口配置
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => UserModel()),
ChangeNotifierProvider(create: (_) => ThemeModel()),
],
child: MyApp(),
),
);
}
// 使用Provider的页面
class ProfilePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('用户资料'),
),
body: Consumer2<UserModel, ThemeModel>(
builder: (context, user, theme, child) {
return Column(
children: [
ListTile(
title: Text('用户名: ${user.name}'),
subtitle: Text('年龄: ${user.age}'),
),
SwitchListTile(
title: Text('深色模式'),
value: theme.isDarkMode,
onChanged: (value) => theme.toggleTheme(),
),
],
);
},
),
);
}
}
Provider状态管理架构图:
graph TB
A[数据变更] --> B[notifyListeners]
B --> C[Provider监听到变化]
C --> D[重建依赖的Widget]
D --> E[UI更新]
F[用户交互] --> G[调用Model方法]
G --> A
subgraph "Provider架构"
H[ChangeNotifierProvider] --> I[数据提供]
I --> J[Consumer消费]
J --> K[UI构建]
end
5. 自定义组件开发:构建专属设计系统
5.1 自定义组件设计方法论
开发自定义组件需要遵循系统化的设计流程。
组件开发生命周期:
需求分析 → API设计 → 组件实现 → 测试验证 → 文档编写 → 发布维护
5.2 实战案例:可交互评分组件开发
下面开发一个支持点击、滑动交互的动画评分组件。
// 动画评分组件
class InteractiveRatingBar extends StatefulWidget {
final double initialRating;
final int itemCount;
final double itemSize;
final Color filledColor;
final Color unratedColor;
final ValueChanged<double> onRatingChanged;
const InteractiveRatingBar({
Key? key,
this.initialRating = 0.0,
this.itemCount = 5,
this.itemSize = 40.0,
this.filledColor = Colors.amber,
this.unratedColor = Colors.grey,
required this.onRatingChanged,
}) : super(key: key);
@override
_InteractiveRatingBarState createState() => _InteractiveRatingBarState();
}
class _InteractiveRatingBarState extends State<InteractiveRatingBar>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
double _currentRating = 0.0;
bool _isInteracting = false;
@override
void initState() {
super.initState();
_currentRating = widget.initialRating;
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_animation = Tween<double>(
begin: widget.initialRating,
end: widget.initialRating,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
));
}
void _updateRating(double newRating) {
setState(() {
_currentRating = newRating;
});
_animateTo(newRating);
widget.onRatingChanged(newRating);
}
void _animateTo(double targetRating) {
_animation = Tween<double>(
begin: _currentRating,
end: targetRating,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
));
_animationController.forward(from: 0.0);
}
double _calculateRatingFromOffset(double dx) {
final itemWidth = widget.itemSize;
final totalWidth = widget.itemCount * itemWidth;
final rating = (dx / totalWidth) * widget.itemCount;
return rating.clamp(0.0, widget.itemCount.toDouble());
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return GestureDetector(
onPanDown: (details) {
_isInteracting = true;
final rating = _calculateRatingFromOffset(details.localPosition.dx);
_updateRating(rating);
},
onPanUpdate: (details) {
final rating = _calculateRatingFromOffset(details.localPosition.dx);
_updateRating(rating);
},
onPanEnd: (details) {
_isInteracting = false;
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(widget.itemCount, (index) {
return _buildRatingItem(index);
}),
),
);
},
);
}
Widget _buildRatingItem(int index) {
final ratingValue = _animation.value;
final isFilled = index < ratingValue;
final fillAmount = (ratingValue - index).clamp(0.0, 1.0);
return CustomPaint(
size: Size(widget.itemSize, widget.itemSize),
painter: _StarPainter(
fill: fillAmount,
filledColor: widget.filledColor,
unratedColor: widget.unratedColor,
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}
// 自定义星星绘制器
class _StarPainter extends CustomPainter {
final double fill;
final Color filledColor;
final Color unratedColor;
_StarPainter({
required this.fill,
required this.filledColor,
required this.unratedColor,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = unratedColor
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
final fillPaint = Paint()
..color = filledColor
..style = PaintingStyle.fill;
// 绘制星星路径
final path = _createStarPath(size);
// 绘制未填充的轮廓
canvas.drawPath(path, paint);
// 绘制填充部分
if (fill > 0) {
canvas.save();
final clipRect = Rect.fromLTWH(0, 0, size.width * fill, size.height);
canvas.clipRect(clipRect);
canvas.drawPath(path, fillPaint);
canvas.restore();
}
}
Path _createStarPath(Size size) {
final path = Path();
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// 五角星绘制算法
for (int i = 0; i < 5; i++) {
final angle = i * 4 * pi / 5 - pi / 2;
final point = center + Offset(cos(angle) * radius, sin(angle) * radius);
if (i == 0) {
path.moveTo(point.dx, point.dy);
} else {
path.lineTo(point.dx, point.dy);
}
}
path.close();
return path;
}
@override
bool shouldRepaint(covariant _StarPainter oldDelegate) {
return fill != oldDelegate.fill ||
filledColor != oldDelegate.filledColor ||
unratedColor != oldDelegate.unratedColor;
}
}
自定义组件交互流程图:
sequenceDiagram
participant U as 用户
participant G as GestureDetector
participant A as AnimationController
participant C as CustomPainter
participant CB as 回调函数
U->>G: 手指按下/移动
G->>G: 计算对应评分
G->>A: 启动动画
A->>C: 触发重绘
C->>C: 根据fill值绘制
G->>CB: 调用onRatingChanged
CB->>U: 更新外部状态
5.3 组件性能优化策略
性能优化是自定义组件开发的非常重要的一环。
组件优化:
| 优化方法 | 适用场景 | 实现方式 |
|---|---|---|
| const构造函数 | 静态组件 | 使用const创建widget |
| RepaintBoundary | 复杂绘制 | 隔离重绘区域 |
| ValueKey | 列表优化 | 提供唯一标识 |
| 缓存策略 | 重复计算 | 缓存计算结果 |
// 优化后的组件示例
class OptimizedComponent extends StatelessWidget {
const OptimizedComponent({
Key? key,
required this.data,
}) : super(key: key);
final ExpensiveData data;
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: Container(
child: _buildExpensiveContent(),
),
);
}
Widget _buildExpensiveContent() {
// 复杂绘制逻辑
return CustomPaint(
painter: _ExpensivePainter(data),
);
}
}
class _ExpensivePainter extends CustomPainter {
final ExpensiveData data;
_ExpensivePainter(this.data);
@override
void paint(Canvas canvas, Size size) {
// 复杂绘制操作
}
@override
bool shouldRepaint(covariant _ExpensivePainter oldDelegate) {
return data != oldDelegate.data;
}
}
6. 综合实战:电商应用商品列表页面
下面构建一个完整的电商商品列表页面,综合运用各种UI组件。
// 商品数据模型
class Product {
final String id;
final String name;
final String description;
final double price;
final double originalPrice;
final String imageUrl;
final double rating;
final int reviewCount;
final bool isFavorite;
Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.originalPrice,
required this.imageUrl,
required this.rating,
required this.reviewCount,
this.isFavorite = false,
});
Product copyWith({
bool? isFavorite,
}) {
return Product(
id: id,
name: name,
description: description,
price: price,
originalPrice: originalPrice,
imageUrl: imageUrl,
rating: rating,
reviewCount: reviewCount,
isFavorite: isFavorite ?? this.isFavorite,
);
}
}
// 商品列表页面
class ProductListPage extends StatefulWidget {
@override
_ProductListPageState createState() => _ProductListPageState();
}
class _ProductListPageState extends State<ProductListPage> {
final List<Product> _products = [];
final ScrollController _scrollController = ScrollController();
bool _isLoading = false;
int _currentPage = 1;
final int _pageSize = 10;
@override
void initState() {
super.initState();
_loadProducts();
_scrollController.addListener(_scrollListener);
}
void _scrollListener() {
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent) {
_loadMoreProducts();
}
}
Future<void> _loadProducts() async {
setState(() {
_isLoading = true;
});
// 网络请求
await Future.delayed(Duration(seconds: 1));
final newProducts = List.generate(_pageSize, (index) => Product(
id: '${_currentPage}_$index',
name: '商品 ${_currentPage * _pageSize + index + 1}',
description: '商品的详细描述',
price: 99.99 + index * 10,
originalPrice: 199.99 + index * 10,
imageUrl: 'https://picsum.photos/200/200?random=${_currentPage * _pageSize + index}',
rating: 3.5 + (index % 5) * 0.5,
reviewCount: 100 + index * 10,
));
setState(() {
_products.addAll(newProducts);
_isLoading = false;
_currentPage++;
});
}
Future<void> _loadMoreProducts() async {
if (_isLoading) return;
await _loadProducts();
}
void _toggleFavorite(int index) {
setState(() {
_products[index] = _products[index].copyWith(
isFavorite: !_products[index].isFavorite,
);
});
}
void _onProductTap(int index) {
final product = _products[index];
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailPage(product: product),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('商品列表'),
actions: [
IconButton(
icon: Icon(Icons.search),
onPressed: () {},
),
IconButton(
icon: Icon(Icons.filter_list),
onPressed: () {},
),
],
),
body: Column(
children: [
// 筛选栏
_buildFilterBar(),
// 商品列表
Expanded(
child: RefreshIndicator(
onRefresh: _refreshProducts,
child: ListView.builder(
controller: _scrollController,
itemCount: _products.length + 1,
itemBuilder: (context, index) {
if (index == _products.length) {
return _buildLoadingIndicator();
}
return _buildProductItem(index);
},
),
),
),
],
),
);
}
Widget _buildFilterBar() {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
children: [
_buildFilterChip('综合'),
SizedBox(width: 8),
_buildFilterChip('销量'),
SizedBox(width: 8),
_buildFilterChip('价格'),
Spacer(),
Text('${_products.length}件商品'),
],
),
);
}
Widget _buildFilterChip(String label) {
return FilterChip(
label: Text(label),
onSelected: (selected) {},
);
}
Widget _buildProductItem(int index) {
final product = _products[index];
final discount = ((product.originalPrice - product.price) /
product.originalPrice * 100).round();
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: () => _onProductTap(index),
child: Padding(
padding: EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 商品图片
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
product.imageUrl,
width: 100,
height: 100,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 100,
height: 100,
color: Colors.grey[200],
child: Icon(Icons.error),
);
},
),
),
if (discount > 0)
Positioned(
top: 0,
left: 0,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: Text(
'$discount%',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
SizedBox(width: 12),
// 商品信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 4),
Text(
product.description,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 8),
// 评分和评论
Row(
children: [
_buildRatingStars(product.rating),
SizedBox(width: 4),
Text(
product.rating.toStringAsFixed(1),
style: TextStyle(fontSize: 12),
),
SizedBox(width: 4),
Text(
'(${product.reviewCount})',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
SizedBox(height: 8),
// 价格信息
Row(
children: [
Text(
'¥${product.price.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
SizedBox(width: 8),
if (product.originalPrice > product.price)
Text(
'¥${product.originalPrice.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14,
color: Colors.grey,
decoration: TextDecoration.lineThrough,
),
),
],
),
],
),
),
// 收藏按钮
IconButton(
icon: Icon(
product.isFavorite ? Icons.favorite : Icons.favorite_border,
color: product.isFavorite ? Colors.red : Colors.grey,
),
onPressed: () => _toggleFavorite(index),
),
],
),
),
),
);
}
Widget _buildRatingStars(double rating) {
return Row(
children: List.generate(5, (index) {
final starRating = index + 1.0;
return Icon(
starRating <= rating
? Icons.star
: starRating - 0.5 <= rating
? Icons.star_half
: Icons.star_border,
color: Colors.amber,
size: 16,
);
}),
);
}
Widget _buildLoadingIndicator() {
return _isLoading
? Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(),
),
)
: SizedBox();
}
Future<void> _refreshProducts() async {
_currentPage = 1;
_products.clear();
await _loadProducts();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
电商列表页面架构图:
graph TB
A[ProductListPage] --> B[AppBar]
A --> C[Column]
C --> D[FilterBar]
C --> E[Expanded]
E --> F[RefreshIndicator]
F --> G[ListView.builder]
G --> H[商品卡片]
H --> I[商品图片]
H --> J[商品信息]
H --> K[收藏按钮]
J --> L[商品标题]
J --> M[商品描述]
J --> N[评分组件]
J --> O[价格显示]
subgraph "状态管理"
P[产品列表]
Q[加载状态]
R[分页控制]
end
7. 组件性能监控与优化
7.1 性能分析工具使用
Flutter提供了丰富的性能分析工具来监控组件性能。
性能分析:
| 工具名称 | 主要功能 | 使用场景 |
|---|---|---|
| Flutter DevTools | 综合性能分析 | 开发阶段性能调试 |
| Performance Overlay | 实时性能覆盖层 | UI性能监控 |
| Timeline | 帧时间线分析 | 渲染性能优化 |
| Memory Profiler | 内存使用分析 | 内存泄漏检测 |
7.2 性能优化技巧
// 示例
class OptimizedProductList extends StatelessWidget {
final List<Product> products;
final ValueChanged<int> onProductTap;
final ValueChanged<int> onFavoriteToggle;
const OptimizedProductList({
Key? key,
required this.products,
required this.onProductTap,
required this.onFavoriteToggle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: products.length,
// 为每个列表项提供唯一key
itemBuilder: (context, index) {
return ProductItem(
key: ValueKey(products[index].id), // 优化列表diff
product: products[index],
onTap: () => onProductTap(index),
onFavoriteToggle: () => onFavoriteToggle(index),
);
},
);
}
}
// 使用const优化的商品项组件
class ProductItem extends StatelessWidget {
final Product product;
final VoidCallback onTap;
final VoidCallback onFavoriteToggle;
const ProductItem({
Key? key,
required this.product,
required this.onTap,
required this.onFavoriteToggle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return const RepaintBoundary( // 隔离重绘区域
child: ProductItemContent(
product: product,
onTap: onTap,
onFavoriteToggle: onFavoriteToggle,
),
);
}
}
// 使用const构造函数的内容组件
class ProductItemContent extends StatelessWidget {
const ProductItemContent({
Key? key,
required this.product,
required this.onTap,
required this.onFavoriteToggle,
}) : super(key: key);
final Product product;
final VoidCallback onTap;
final VoidCallback onFavoriteToggle;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const CachedProductImage(imageUrl: product.imageUrl),
const SizedBox(width: 12),
const Expanded(
child: ProductInfo(product: product),
),
const FavoriteButton(
isFavorite: product.isFavorite,
onToggle: onFavoriteToggle,
),
],
),
),
);
}
}
8. 总结
8.1 核心知识点回顾
通过本篇文章,我们系统学习了Flutter UI组件库的各个方面:
Material Design组件体系:
- 理解了Material Design的实现原理
- 掌握了Scaffold、Container等核心布局组件
- 学会了表单验证和复杂列表的实现
Cupertino风格组件:
- 了解了iOS设计规范与实现
- 掌握了平台自适应开发模式
第三方组件库:
- 第三方库评估标准
- 掌握了状态管理库的集成使用
- 了解了流行UI扩展库的应用场景
自定义组件开发:
- 学会了组件设计的方法论
- 掌握了自定义绘制和动画实现
- 理解了组件性能优化的手段
8.2 实际开发建议
组件选择策略:
- 优先使用官方组件,保证稳定性和性能
- 谨慎选择第三方库,选择前先评估
- 适时开发自定义组件
性能优化原则:
- 合理使用const构造函数减少重建
- 为列表项提供唯一Key优化diff算法
- 使用RepaintBoundary隔离重绘区域
- 避免在build方法中执行耗时操作
如果觉得这篇文章对你有帮助,请点赞、关注、收藏支持一下!!! 你的支持是我持续创作优质内容的最大动力! 有任何问题欢迎在评论区留言讨论,我会及时回复解答。
《Flutter全栈开发实战指南:从零到高级》- 08 -导航与路由管理
《Flutter全栈开发实战指南:从零到高级》- 06 -常用布局组件
《Flutter全栈开发实战指南:从零到高级》- 05 - 基础组件实战:构建登录界面
手把手教你实现一个Flutter登录页面
嗨,各位Flutter爱好者!今天我要和大家分享一个超级实用的功能——用Flutter构建一个功能完整的登录界面。说实话,第一次接触Flutter时,看着那些组件列表也是一头雾水,但当真正动手做出第一个登录页面后,才发现原来一切都这么有趣!
登录界面就像餐厅的门面,直接影响用户的第一印象。今天,我们就一起来打造一个既美观又实用的"门面"!
我们要实现什么?
先来看看我们的目标——一个支持多种登录方式的登录界面:
含以下功能点:
- 双登录方式:账号密码 + 手机验证码
- 实时表单验证
- 记住密码和自动登录
- 验证码倒计时
- 第三方登录(微信&QQ&微博)
- 交互动画
是不是已经迫不及待了?别急,工欲善其事,必先利其器!!! 在开始搭建之前,我们先来熟悉一下Flutter的基础组件,这些组件就像乐高积木,每个都有独特的用途,组合起来就能创造奇迹!
一、Flutter基础组件
1.1 Text组件:不只是显示文字
Text组件就像聊天时的文字消息,不同的样式能传达不同的情感。让我给你展示几个实用的例子:
// 基础文本 - 就像普通的聊天消息
Text('你好,Flutter!')
// 带样式的文本 - 像加了特效的消息
Text(
'欢迎回来!',
style: TextStyle(
fontSize: 24.0, // 字体大小
fontWeight: FontWeight.bold, // 字体粗细
color: Colors.blue[800], // 字体颜色
letterSpacing: 1.2, // 字母间距
),
)
// 富文本 - 像一条消息中有不同样式的部分
Text.rich(
TextSpan(
children: [
TextSpan(
text: '已有账号?',
style: TextStyle(color: Colors.grey[600]),
),
TextSpan(
text: '立即登录',
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
],
),
)
实用技巧:
- 文字超出时显示省略号:
overflow: TextOverflow.ellipsis - 限制最多显示行数:
maxLines: 2 - 文字居中显示:
textAlign: TextAlign.center
1.2 TextField组件:用户输入
TextField就像餐厅的点菜单,用户在上面写下需求,我们负责处理。来看看如何打造一个贴心的输入体验:
// 基础输入框
TextField(
decoration: InputDecoration(
labelText: '用户名', // 标签文字
hintText: '请输入用户名', // 提示文字
prefixIcon: Icon(Icons.person), // 前缀图标
),
)
// 密码输入框 - 带显示/隐藏切换
TextField(
obscureText: true, // 隐藏输入内容
decoration: InputDecoration(
labelText: '密码',
prefixIcon: Icon(Icons.lock),
suffixIcon: IconButton( // 后缀图标按钮
icon: Icon(Icons.visibility),
onPressed: () {
// 切换密码显示/隐藏
},
),
),
)
// 带验证的输入框
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入内容'; // 验证失败时的提示
}
return null; // 验证成功
},
)
TextField的核心技能:
-
controller:管理输入内容 -
focusNode:跟踪输入焦点 -
keyboardType:为不同场景准备合适的键盘 -
onChanged:实时监听用户的每个输入
1.3 按钮组件:触发事件的开关
按钮就像电梯的按键,按下它就会带你到达想去的楼层。Flutter提供了多种类型按钮,每种都有其独有的特性:
// 1. ElevatedButton - 主要操作按钮(有立体感)
ElevatedButton(
onPressed: () {
print('按钮被点击了!');
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue, // 背景色
foregroundColor: Colors.white, // 文字颜色
padding: EdgeInsets.all(16), // 内边距
shape: RoundedRectangleBorder( // 形状
borderRadius: BorderRadius.circular(12),
),
),
child: Text('登录'),
)
// 2. TextButton - 次要操作按钮
TextButton(
onPressed: () {
print('忘记密码');
},
child: Text('忘记密码?'),
)
// 3. OutlinedButton - 边框按钮
OutlinedButton(
onPressed: () {},
child: Text('取消'),
style: OutlinedButton.styleFrom(
side: BorderSide(color: Colors.grey),
),
)
// 4. IconButton - 图标按钮
IconButton(
onPressed: () {},
icon: Icon(Icons.close),
color: Colors.grey,
)
按钮状态管理很重要:
- 加载时禁用按钮,防止重复提交
- 根据表单验证结果控制按钮可用性
- 提供视觉反馈,让用户知道操作已被接收
1.4 布局组件
布局组件就像房子的承重墙,它们决定了界面元素的排列方式。掌握它们,你就能轻松构建各种复杂布局:
// Container - 万能的容器
Container(
width: 200,
height: 100,
margin: EdgeInsets.all(16), // 外边距
padding: EdgeInsets.all(20), // 内边距
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [ // 阴影效果
BoxShadow(
color: Colors.black12,
blurRadius: 10,
),
],
),
child: Text('内容'),
)
// Row - 水平排列
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('左边'),
Text('右边'),
],
)
// Column - 垂直排列
Column(
children: [
Text('第一行'),
SizedBox(height: 16), // 间距组件
Text('第二行'),
],
)
现在我们已经熟悉了基础组件,是时候开始真正的功能实战了!
二、功能实战:构建多功能登录页面
2.1 项目目录结构
在开始编码前,我们先规划好项目结构,就像建房子前先画好房体图纸一样:
lib/
├── main.dart # 应用入口
├── models/ # 数据模型
│ ├── user_model.dart # 用户模型
│ └── login_type.dart # 登录类型
├── pages/ # 页面文件
│ ├── login_page.dart # 登录页面
│ ├── home_page.dart # 首页
│ └── register_page.dart # 注册页面
├── widgets/ # 自定义组件
│ ├── login_tab_bar.dart # 登录选项卡
│ ├── auth_text_field.dart # 认证输入框
│ └── third_party_login.dart # 第三方登录
├── services/ # 服务层
│ └── auth_service.dart # 认证服务
├── utils/ # 工具类
│ └── validators.dart # 表单验证
└── theme/ # 主题配置
└── app_theme.dart # 应用主题
2.2 数据模型定义
我们先定义需要用到的数据模型:
// 登录类型枚举
enum LoginType {
account, // 账号密码登录
phone, // 手机验证码登录
}
// 用户数据模型
class User {
final String id;
final String name;
final String email;
final String phone;
User({
required this.id,
required this.name,
required this.email,
required this.phone,
});
}
2.3 实现登录页面
下面我将会带你一步步构建登录页面。
第一步:状态管理
首先,我们需要管理页面的各种状态,就像我们平时开车时要关注各项指标:
class _LoginPageState extends State<LoginPage> {
// 登录方式状态
LoginType _loginType = LoginType.account;
// 文本控制器
final TextEditingController _accountController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _smsController = TextEditingController();
// 焦点管理
final FocusNode _accountFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
final FocusNode _phoneFocus = FocusNode();
final FocusNode _smsFocus = FocusNode();
// 状态变量
bool _isLoading = false;
bool _rememberPassword = true;
bool _autoLogin = false;
bool _isPasswordVisible = false;
bool _isSmsLoading = false;
int _smsCountdown = 0;
// 错误信息
String? _accountError;
String? _passwordError;
String? _phoneError;
String? _smsError;
// 表单Key
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_loadSavedData();
}
void _loadSavedData() {
// 从本地存储加载保存的账号
if (_rememberPassword) {
_accountController.text = 'user@example.com';
}
}
}
第二步:构建页面
接下来,我们构建页面的整体结构:
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[50],
body: SafeArea(
child: SingleChildScrollView(
physics: BouncingScrollPhysics(),
child: Container(
padding: EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBackButton(), // 返回按钮
SizedBox(height: 20),
_buildHeader(), // 页面标题
SizedBox(height: 40),
_buildLoginTypeTab(), // 登录方式切换
SizedBox(height: 32),
_buildDynamicForm(), // 动态表单
SizedBox(height: 24),
_buildRememberSection(), // 记住密码选项
SizedBox(height: 32),
_buildLoginButton(), // 登录按钮
SizedBox(height: 40),
_buildThirdPartyLogin(), // 第三方登录
SizedBox(height: 24),
_buildRegisterPrompt(), // 注册提示
],
),
),
),
),
),
);
}
第三步:构建各个组件
现在我们来逐一实现每个功能组件:
登录方式切换选项卡:
Widget _buildLoginTypeTab() {
return Container(
height: 48,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// 账号登录选项卡
_buildTabItem(
title: '账号登录',
isSelected: _loginType == LoginType.account,
onTap: () {
setState(() {
_loginType = LoginType.account;
});
},
),
// 手机登录选项卡
_buildTabItem(
title: '手机登录',
isSelected: _loginType == LoginType.phone,
onTap: () {
setState(() {
_loginType = LoginType.phone;
});
},
),
],
),
);
}
动态表单区域:
Widget _buildDynamicForm() {
return AnimatedSwitcher(
duration: Duration(milliseconds: 300),
child: _loginType == LoginType.account
? _buildAccountForm() // 账号登录表单
: _buildPhoneForm(), // 手机登录表单
);
}
账号输入框组件:
Widget _buildAccountField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('邮箱/用户名'),
SizedBox(height: 8),
TextFormField(
controller: _accountController,
focusNode: _accountFocus,
decoration: InputDecoration(
hintText: '请输入邮箱或用户名',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
errorText: _accountError,
),
onChanged: (value) {
setState(() {
_accountError = _validateAccount(value);
});
},
),
],
);
}
登录按钮组件:
Widget _buildLoginButton() {
bool isFormValid = _loginType == LoginType.account
? _accountError == null && _passwordError == null
: _phoneError == null && _smsError == null;
return SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: isFormValid && !_isLoading ? _handleLogin : null,
child: _isLoading
? CircularProgressIndicator()
: Text('立即登录'),
),
);
}
第四步:实现业务逻辑
表单验证:
String? _validateAccount(String? value) {
if (value == null || value.isEmpty) {
return '请输入账号';
}
final emailRegex = RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
if (!emailRegex.hasMatch(value)) {
return '请输入有效的邮箱';
}
return null;
}
登录逻辑:
Future<void> _handleLogin() async {
if (_isLoading) return;
if (_formKey.currentState!.validate()) {
setState(() {
_isLoading = true;
});
try {
User user;
if (_loginType == LoginType.account) {
user = await AuthService.loginWithAccount(
account: _accountController.text,
password: _passwordController.text,
);
} else {
user = await AuthService.loginWithPhone(
phone: _phoneController.text,
smsCode: _smsController.text,
);
}
await _handleLoginSuccess(user);
} catch (error) {
_handleLoginError(error);
} finally {
setState(() {
_isLoading = false;
});
}
}
}
效果展示与总结
![]()
至此我们终于完成了一个功能完整的登录页面!让我们总结一下实现的功能:
实现功能点
- 双登录方式:用户可以在账号密码和手机验证码之间无缝切换
- 智能验证:实时表单验证,即时错误提示
- 用户体验:加载状态、错误提示、流畅动画
- 第三方登录:支持微信、QQ、微博登录
- 状态记忆:记住密码和自动登录选项
学到了什么?
通过这个项目,我们掌握了:
- 组件使用:Text、TextField、Button等基础组件的深度使用
- 状态管理:使用setState管理复杂的页面状态
- 表单处理:实时验证和用户交互
- 布局技巧:创建响应式和美观的界面布局
- 业务逻辑:处理用户输入和API调用
最后的话
看到这里,你已经成功构建了一个完整的登录界面!这个登录页面只是开始,期待你能创造出更多更好的应用!
有什么问题或想法?欢迎在评论区留言讨论~, Happy Coding!✨
《Flutter全栈开发实战指南:从零到高级》- 04 - Widget核心概念与生命周期
Flutter Widget核心概念与生命周期
掌握Flutter UI构建的基石,告别"面向谷歌编程"
前言:为什么Widget如此重要?
还记得我刚开始学Flutter的时候,最让我困惑的就是那句"Everything is a Widget"。当时我想,这怎么可能呢?按钮是Widget,文字是Widget,连整个页面都是Widget,这也太抽象了吧!
经过几个实际项目的打磨,我才真正明白Widget设计的精妙之处。今天我就用最通俗易懂的方式,把我踩过的坑和总结的经验都分享给大家。
1. StatelessWidget vs StatefulWidget:静态与动态的艺术
1.1 StatelessWidget:一次成型的雕塑
通俗理解:就像一张照片,拍好之后内容就固定不变了。
// 用户信息卡片 - 典型的StatelessWidget
class UserCard extends StatelessWidget {
// 这些final字段就像雕塑的原材料,一旦设定就不能改变
final String name;
final String email;
final String avatarUrl;
// const构造函数让Widget可以被Flutter优化
const UserCard({
required this.name,
required this.email,
required this.avatarUrl,
});
@override
Widget build(BuildContext context) {
// build方法描述这个Widget长什么样
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(backgroundImage: NetworkImage(avatarUrl)),
SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: TextStyle(fontWeight: FontWeight.bold)),
Text(email, style: TextStyle(color: Colors.grey)),
],
),
],
),
),
);
}
}
使用场景总结:
- ✅ 显示静态内容(文字、图片)
- ✅ 布局容器(Row、Column、Container)
- ✅ 数据完全来自父组件的展示型组件
- ✅ 不需要内部状态的纯UI组件
1.2 StatefulWidget:有记忆的智能助手
举个例子:就像一个智能闹钟,它能记住你设置的时间,响应用户操作。
// 计数器组件 - 典型的StatefulWidget
class Counter extends StatefulWidget {
@override
_CounterState createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0; // 状态数据,可以变化
void _increment() {
// setState告诉Flutter:状态变了,请重新构建UI
setState(() {
_count++;
});
}
void _decrement() {
setState(() {
_count--;
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('当前计数: $_count', style: TextStyle(fontSize: 24)),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(onPressed: _decrement, child: Text('减少')),
SizedBox(width: 20),
ElevatedButton(onPressed: _increment, child: Text('增加')),
],
),
],
);
}
}
使用场景总结:
- ✅ 需要用户交互(按钮、输入框)
- ✅ 有内部状态需要管理
- ✅ 需要执行初始化或清理操作
- ✅ 需要响应数据变化
1.3 选择指南:我的实用判断方法
刚开始我经常纠结该用哪种,后来总结了一个简单的方法:
问自己三个问题:
- 这个组件需要记住用户的操作吗?
- 组件的数据会自己变化吗?
- 需要执行初始化或清理操作吗?
如果答案都是"否",用StatelessWidget;如果有一个"是",就用StatefulWidget。
2. Widget生命周期:从出生到退休的完整旅程
2.1 生命周期全景图
我把Widget的生命周期比作人的职业生涯,这样更容易理解:
class LifecycleExample extends StatefulWidget {
@override
_LifecycleExampleState createState() => _LifecycleExampleState();
}
class _LifecycleExampleState extends State<LifecycleExample> {
// 1. 构造函数 - 准备简历
_LifecycleExampleState() {
print('📝 构造函数:创建State对象');
}
// 2. initState - 办理入职
@override
void initState() {
super.initState();
print('🎯 initState:组件初始化完成');
// 在这里初始化数据、注册监听器
}
// 3. didChangeDependencies - 熟悉环境
@override
void didChangeDependencies() {
super.didChangeDependencies();
print('🔄 didChangeDependencies:依赖发生变化');
// 当父组件或全局数据变化时调用
}
// 4. build - 开始工作
@override
Widget build(BuildContext context) {
print('🎨 build:构建UI界面');
return Container(child: Text('生命周期演示'));
}
// 5. didUpdateWidget - 岗位调整
@override
void didUpdateWidget(LifecycleExample oldWidget) {
super.didUpdateWidget(oldWidget);
print('📝 didUpdateWidget:组件配置更新');
// 比较新旧配置,决定是否需要更新状态
}
// 6. deactivate - 办理离职
@override
void deactivate() {
print('👋 deactivate:组件从树中移除');
super.deactivate();
}
// 7. dispose - 彻底退休
@override
void dispose() {
print('💀 dispose:组件永久销毁');
// 清理资源:取消订阅、关闭控制器等
super.dispose();
}
}
2.2 生命周期流程图
创建阶段:
createState() → initState() → didChangeDependencies() → build()
更新阶段:
setState() → build() 或 didUpdateWidget() → build()
销毁阶段:
deactivate() → dispose()
2.3 实战经验:我踩过的那些坑
坑1:在initState中访问Context
// ❌ 错误做法
@override
void initState() {
super.initState();
Theme.of(context); // Context可能还没准备好!
}
// ✅ 正确做法
@override
void didChangeDependencies() {
super.didChangeDependencies();
Theme.of(context); // 这里才是安全的
}
坑2:忘记清理资源
@override
void initState() {
super.initState();
_timer = Timer.periodic(Duration(seconds: 1), _onTick);
}
// ❌ 忘记在dispose中取消定时器
// ✅ 一定要在dispose中清理
@override
void dispose() {
_timer?.cancel(); // 重要!
super.dispose();
}
坑3:异步操作中的setState
Future<void> fetchData() async {
final data = await api.getData();
// ❌ 直接调用setState
// setState(() { _data = data; });
// ✅ 先检查组件是否还在
if (mounted) {
setState(() {
_data = data;
});
}
}
当然还有很多其他的坑,这里就不一一介绍了,感兴趣的朋友可以留言,看到一定会回复~
3. BuildContext:组件的身份证和通信证
3.1 BuildContext的本质
简单来说,BuildContext就是组件在组件树中的"身份证"。它告诉我们:
- 这个组件在树中的位置
- 能访问哪些祖先组件提供的数据
- 如何与其他组件通信
class ContextExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 使用Context获取主题信息
final theme = Theme.of(context);
// 使用Context获取设备信息
final media = MediaQuery.of(context);
// 使用Context进行导航
void navigateToDetail() {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => DetailPage(),
));
}
return Container(
color: theme.primaryColor,
width: media.size.width * 0.8,
child: ElevatedButton(
onPressed: navigateToDetail,
child: Text('跳转到详情'),
),
);
}
}
3.2 Context的层次结构
想象一下组件树就像公司组织架构:
- 每个组件都有自己的Context
- Context知道自己的"上级"(父组件)
- 可以通过Context找到"领导"(祖先组件)
// 查找特定类型的祖先组件
final scaffold = context.findAncestorWidgetOfExactType<Scaffold>();
// 获取渲染对象
final renderObject = context.findRenderObject();
// 遍历子组件
context.visitChildElements((element) {
print('子组件: ${element.widget}');
});
3.3 常见问题解决方案
问题:Scaffold.of()找不到Scaffold
// ❌ 可能失败
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Hello'),
));
},
child: Text('显示提示'),
);
}
// ✅ 使用Builder确保正确的Context
Widget build(BuildContext context) {
return Builder(
builder: (context) {
return ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Hello'),
));
},
child: Text('显示提示'),
);
},
);
}
4. 组件树与渲染原理:Flutter的三大支柱
4.1 三棵树架构:设计图、施工队和建筑物
我用建筑行业来比喻Flutter的三棵树,这样特别容易理解:
Widget树 = 建筑设计图
- 描述UI应该长什么样
- 配置信息(颜色、尺寸、文字等)
- 不可变的(immutable)
Element树 = 施工队
- 负责按照图纸施工
- 管理组件生命周期
- 可复用的
RenderObject树 = 建筑物本身
- 实际可见的UI
- 负责布局和绘制
- 性能关键
4.2 渲染流程详解
阶段1:构建(Build)
// Flutter执行build方法,创建Widget树
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
child: Row(
children: [
Text('Hello'),
Icon(Icons.star),
],
),
);
}
阶段2:布局(Layout)
- 计算每个组件的大小和位置
- 父组件向子组件传递约束条件
- 子组件返回自己的尺寸
阶段3:绘制(Paint)
- 将组件绘制到屏幕上
- 只绘制需要更新的部分
- 高效的重绘机制
4.3 setState的工作原理
很多人对setState有误解,以为它直接更新UI。其实过程是这样的:
- 标记脏状态:setState标记当前Element为"脏"
- 重新构建Widget:调用build方法生成新的Widget
- 对比更新:比较新旧Widget的差异
- 更新RenderObject:只更新发生变化的部分
- 重绘:在屏幕上显示更新
void _updateCounter() {
setState(() {
// 1. 这里的代码同步执行
_counter++;
});
// 2. setState完成后,Flutter会安排一帧来更新UI
// 3. 不是立即更新,而是在下一帧时更新
}
5. 性能优化实战技巧
5.1 减少不必要的重建
// ❌ 不好的做法:在build中创建新对象
Widget build(BuildContext context) {
return ListView(
children: [
ItemWidget(), // 每次build都创建新实例
ItemWidget(),
],
);
}
// ✅ 好的做法:使用const或成员变量
class MyWidget extends StatelessWidget {
// 这些Widget只创建一次
static const _itemWidgets = [
ItemWidget(),
ItemWidget(),
];
@override
Widget build(BuildContext context) {
return ListView(children: _itemWidgets);
}
}
5.2 合理使用const
// ✅ 尽可能使用const
const Text('Hello World');
const SizedBox(height: 16);
const Icon(Icons.star);
// 对于自定义Widget,也可以使用const构造函数
class MyWidget extends StatelessWidget {
const MyWidget({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Text(title);
}
}
5.3 使用Key优化列表
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ListItem(
key: ValueKey(items[index].id), // 帮助Flutter识别项的身份
item: items[index],
);
},
)
5.4 避免在build中执行耗时操作
// ❌ 不要在build中做这些
Widget build(BuildContext context) {
// 网络请求
// 复杂计算
// 文件读写
return Container();
}
// ✅ 在initState或专门的方法中执行
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
final data = await api.fetchData();
if (mounted) {
setState(() {
_data = data;
});
}
}
6. 实战案例:构建高性能列表
让我分享一个实际项目中的优化案例:
class ProductList extends StatefulWidget {
@override
_ProductListState createState() => _ProductListState();
}
class _ProductListState extends State<ProductList> {
final List<Product> _products = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadProducts();
}
Future<void> _loadProducts() async {
if (_isLoading) return;
setState(() => _isLoading = true);
try {
final products = await ProductApi.getProducts();
setState(() => _products.addAll(products));
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('商品列表')),
body: _buildContent(),
);
}
Widget _buildContent() {
if (_products.isEmpty && _isLoading) {
return Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: _products.length + (_isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == _products.length) {
return _buildLoadingIndicator();
}
final product = _products[index];
return ProductItem(
key: ValueKey(product.id), // 重要:使用Key
product: product,
onTap: () => _showProductDetail(product),
);
},
);
}
Widget _buildLoadingIndicator() {
return Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
void _showProductDetail(Product product) {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ProductDetailPage(product: product),
));
}
@override
void dispose() {
// 清理工作
super.dispose();
}
}
7. 调试技巧:快速定位问题
7.1 使用Flutter Inspector
- 在Android Studio或VS Code中打开Flutter Inspector
- 查看Widget树结构
- 检查渲染性能
- 调试布局问题
7.2 打印生命周期日志
@override
void initState() {
super.initState();
debugPrint('$runtimeType initState');
}
@override
void dispose() {
debugPrint('$runtimeType dispose');
super.dispose();
}
7.3 性能分析工具
- 使用Flutter Performance面板
- 检查帧率(目标是60fps)
- 识别渲染瓶颈
- 分析内存使用情况
最后的话
学习Flutter Widget就像学骑自行车,开始可能会摔倒几次,但一旦掌握了平衡,就能自由驰骋。记住几个关键点:
- 多动手实践 - 光看理论是不够的
- 理解原理 - 知道为什么比知道怎么做更重要
- 循序渐进 - 不要想一口吃成胖子
- 善用工具 - Flutter提供了很好的调试工具
我在学习过程中最大的体会是:每个Flutter高手都是从不断的踩坑和总结中成长起来的。希望我的经验能帮你少走一些弯路。
🎯 写这篇文章花了我很多时间,如果对你有帮助,动动发财的小手来个一键三连!
你的支持真的对我很重要!有什么问题欢迎在评论区留言,我会尽力解答。 我们下篇文章见! 🚀