巴拉巴拉~~ 2025-12-15 11:59:29 发布引言
在HarmonyOS ArkUI开发中,自定义组件是提升开发效率、保证代码一致性的核心手段。原生组件虽能满足基础需求,但面对复杂业务场景(如自定义表单、个性化列表项)时,需通过组件封装实现复用。很多开发者在封装自定义组件时,常遇到参数设计不合理、事件回调混乱、复用性差等问题。本文从组件封装原则出发,通过“下拉刷新组件”和“表单录入组件”两个实战案例,详解自定义组件的设计、开发、复用与扩展全流程,帮助开发者打造高质量可复用组件。
一、ArkUI自定义组件核心设计原则
好的自定义组件需满足“高内聚、低耦合、可复用、易扩展”四大原则,具体落地为以下四点:
- 单一职责原则:一个组件只负责一个核心功能,如下拉刷新组件仅处理刷新逻辑,不包含列表展示逻辑;
- 参数化配置:通过入参控制组件样式和行为,如允许配置刷新图标、刷新文案、触发阈值;
- 事件回调清晰:通过回调函数向外暴露组件内部事件,如刷新开始、刷新结束事件;
- 样式隔离:组件内部样式不影响外部,外部可通过入参覆盖核心样式。
二、实战一:通用下拉刷新组件封装
2.1 需求分析
封装一款通用下拉刷新组件,满足以下需求:1. 支持下拉触发刷新;2. 可配置刷新图标、文案(下拉中/刷新中/刷新完成);3. 支持自定义刷新触发阈值;4. 暴露刷新开始和结束回调。
2.2 组件设计
- 入参设计: refreshIcon:刷新图标资源(默认使用系统图标);
- pullText:下拉文案(默认“下拉刷新”);
- loadingText:刷新中文案(默认“刷新中...”);
- successText:刷新完成文案(默认“刷新完成”);
- triggerHeight:触发刷新的阈值高度(默认80px);
- onRefresh:刷新触发回调(必填,业务层实现刷新逻辑)。
- 内部状态:
pullDistance:下拉距离; - refreshState:刷新状态(初始/下拉中/刷新中/刷新完成);
- isRefreshing:是否正在刷新。
- 对外方法:
finishRefresh():结束刷新,更新状态为“刷新完成”后恢复初始状态。
2.3 代码实现
import router from '@ohos.router';
// 刷新状态枚举
export enum RefreshState {
INIT = 0, // 初始状态
PULLING = 1, // 下拉中
REFRESHING = 2, // 刷新中
SUCCESS = 3 // 刷新完成
}
// 组件入参接口
export interface PullRefreshProps {
refreshIcon?: Resource; // 刷新图标
pullText?: string; // 下拉文案
loadingText?: string; // 刷新中文案
successText?: string; // 刷新完成文案
triggerHeight?: number; // 触发阈值
onRefresh: () => void; // 刷新回调
}
@Component
export struct PullRefresh {
// 入参(设置默认值)
private props: PullRefreshProps = {
refreshIcon: $r('app.media.refresh_icon'),
pullText: '下拉刷新',
loadingText: '刷新中...',
successText: '刷新完成',
triggerHeight: 80,
onRefresh: () => {}
};
// 内部状态
@State pullDistance: number = 0;
@State refreshState: RefreshState = RefreshState.INIT;
private isRefreshing: boolean = false;
// 动画控制器(用于刷新完成后回弹)
private animationController: AnimationController = new AnimationController({
duration: 300,
curve: Curve.EaseOut
});
// 组件构造函数(接收外部入参)
constructor(props: PullRefreshProps) {
Object.assign(this.props, props);
}
// 下拉事件处理
private onTouchMove(event: TouchEvent) {
// 正在刷新时不处理下拉
if (this.isRefreshing) return;
// 获取下拉距离(仅处理垂直方向)
const deltaY = event.touches[0].y - event.touches[0].startY;
if (deltaY > 0) { // 仅处理下拉
this.pullDistance = Math.min(deltaY, this.props.triggerHeight * 1.5); // 限制最大下拉距离
// 根据下拉距离更新状态
if (this.pullDistance < this.props.triggerHeight) {
this.refreshState = RefreshState.PULLING;
} else {
this.refreshState = RefreshState.REFRESHING;
}
}
}
// 松手事件处理
private onTouchEnd() {
if (this.isRefreshing) return;
// 下拉距离达到阈值,触发刷新
if (this.pullDistance >= this.props.triggerHeight) {
this.isRefreshing = true;
this.refreshState = RefreshState.REFRESHING;
// 调用外部刷新回调
this.props.onRefresh();
} else {
// 未达到阈值,回弹到初始状态
this.resetPullState();
}
}
// 结束刷新
finishRefresh() {
if (!this.isRefreshing) return;
this.refreshState = RefreshState.SUCCESS;
this.isRefreshing = false;
// 显示成功文案1秒后回弹
setTimeout(() => {
this.resetPullState();
}, 1000);
}
// 重置下拉状态
private resetPullState() {
// 执行回弹动画
Animation.create(this.animationController)
.onFrame(() => {
this.pullDistance = this.pullDistance * 0.8;
if (this.pullDistance < 1) {
this.pullDistance = 0;
this.refreshState = RefreshState.INIT;
this.animationController.stop();
}
})
.play();
}
// 获取当前显示的文案
private getDisplayText(): string {
switch (this.refreshState) {
case RefreshState.PULLING:
return this.props.pullText!;
case RefreshState.REFRESHING:
return this.props.loadingText!;
case RefreshState.SUCCESS:
return this.props.successText!;
default:
return '';
}
}
build() {
// 层叠布局:刷新头部 + 内容区域
Stack() {
// 内容区域(通过slot接收外部内容)
Slot()
.offset({ y: this.pullDistance }); // 随下拉距离偏移
// 刷新头部
Column({ space: 8, alignItems: ItemAlign.Center })
.height(this.pullDistance > 0 ? this.pullDistance : 0)
.justifyContent(FlexAlign.End)
.padding({ bottom: 10 }) {
// 刷新图标(刷新中时旋转)
Image(this.props.refreshIcon)
.width(30)
.height(30)
.rotate({
angle: this.refreshState === RefreshState.REFRESHING ? 360 : 0,
centerX: '50%',
centerY: '50%'
})
.animation({
duration: 1000,
curve: Curve.Linear,
iterations: this.refreshState === RefreshState.REFRESHING ? Infinity : 1
});
// 刷新文案
Text(this.getDisplayText())
.fontSize(14)
.color('#666666');
}
}
// 绑定触摸事件
.onTouch((event) => {
if (event.type === TouchType.MOVE) {
this.onTouchMove(event);
} else if (event.type === TouchType.UP || event.type === TouchType.CANCEL) {
this.onTouchEnd();
}
})
}
}
2.4 组件使用示例
import { PullRefresh } from '../components/PullRefresh';
@Entry
@Component
struct RefreshDemoPage {
private pullRefresh: PullRefresh | null = null;
@State dataList: string[] = ['数据1', '数据2', '数据3'];
// 模拟刷新数据
async fetchData() {
// 模拟网络请求耗时
await new Promise(resolve => setTimeout(resolve, 2000));
// 更新数据
this.dataList = [...this.dataList, `新数据${this.dataList.length + 1}`];
// 通知组件刷新完成
this.pullRefresh?.finishRefresh();
}
build() {
Column() {
Text('下拉刷新示例')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.margin({ top: 30, left: 20 })
.alignSelf(ItemAlign.Start);
// 使用自定义下拉刷新组件
PullRefresh({
pullText: '下拉即可刷新',
loadingText: '正在加载...',
triggerHeight: 100,
onRefresh: () => this.fetchData()
})
.ref(ref => this.pullRefresh = ref) // 获取组件实例
.width('100%')
.flexGrow(1) {
// 内容区域:列表
List({ space: 10 }) {
ForEach(this.dataList, (item) => {
ListItem() {
Text(item)
.fontSize(18)
.padding(20)
.backgroundColor('#ffffff')
.borderRadius(12);
}
}, (item) => item);
}
.padding({ left: 20, right: 20, top: 20 });
}
}
.width('100%')
.backgroundColor('#f5f5f5')
}
}
三、实战二:表单录入组件封装(高复用场景)
3.1 需求分析
表单是应用开发的高频场景,封装一款通用表单组件,支持输入框、选择器、开关等表单项,满足以下需求:1. 支持多种表单类型配置;2. 表单数据联动(如选择“其他”后显示输入框);3. 表单验证与错误提示;4. 一键获取表单数据。
3.2 组件实现(核心代码)
// 表单项类型枚举
export enum FormItemType {
INPUT = 'input', // 输入框
SELECT = 'select', // 选择器
SWITCH = 'switch' // 开关
}
// 表单项配置接口
export interface FormItemConfig {
id: string; // 表单项唯一标识
label: string; // 标签文本
type: FormItemType; // 类型
placeholder?: string; // 输入框占位符
options?: Array<{ label: string; value: string }>; // 选择器选项
defaultValue?: string | boolean; // 默认值
required?: boolean; // 是否必填
validator?: (value: any) => string | boolean; // 自定义验证函数
showCondition?: (formData: Record<string, any>) => boolean; // 显示条件(数据联动)
}
// 表单组件入参接口
export interface FormProps {
items: FormItemConfig[]; // 表单项配置
onSubmit?: (data: Record<string, any>) => void; // 提交回调
}
@Component
export struct UniversalForm {
private props: FormProps;
// 表单数据存储
@State formData: Record<string, any> = {};
// 错误信息存储
@State errorMsg: Record<string, string> = {};
constructor(props: FormProps) {
this.props = props;
// 初始化表单数据(设置默认值)
this.initFormData();
}
// 初始化表单数据
private initFormData() {
this.props.items.forEach(item => {
if (item.defaultValue !== undefined) {
this.formData[item.id] = item.defaultValue;
} else {
this.formData[item.id] = item.type === FormItemType.SWITCH ? false : '';
}
});
}
// 表单验证
private validateForm(): boolean {
let isValid = true;
this.errorMsg = {};
this.props.items.forEach(item => {
const value = this.formData[item.id];
// 必填项验证
if (item.required && (value === '' || value === undefined || value === null)) {
this.errorMsg[item.id] = `${item.label}不能为空`;
isValid = false;
return;
}
// 自定义验证
if (item.validator) {
const result = item.validator(value);
if (typeof result === 'string') {
this.errorMsg[item.id] = result;
isValid = false;
}
}
});
return isValid;
}
// 提交表单
submitForm() {
if (this.validateForm()) {
this.props.onSubmit?.(this.formData);
}
}
// 获取表单数据
getFormData(): Record<string, any> {
return { ...this.formData };
}
// 渲染单个表单项
@Builder
renderFormItem(item: FormItemConfig) {
// 根据显示条件判断是否渲染
if (item.showCondition && !item.showCondition(this.formData)) {
return;
}
Column({ space: 8 }) {
// 标签
Row({ justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center }) {
Text(item.label)
.fontSize(18)
.fontWeight(Medium);
if (item.required) {
Text('*')
.fontSize(18)
.color('#ff3b30');
}
}
// 根据类型渲染不同表单项
if (item.type === FormItemType.INPUT) {
TextField({
placeholder: item.placeholder || `请输入${item.label}`,
text: this.formData[item.id] + ''
})
.width('100%')
.height(45)
.border({ width: 1, color: '#cccccc', radius: 8 })
.padding({ left: 15 })
.onChange((value) => {
this.formData[item.id] = value;
// 输入时清除错误提示
delete this.errorMsg[item.id];
});
} else if (item.type === FormItemType.SELECT) {
Select(item.options?.map(opt => opt.label) || [])
.width('100%')
.height(45)
.border({ width: 1, color: '#cccccc', radius: 8 })
.padding({ left: 15 })
.onChange((index) => {
const value = item.options?.[index].value;
this.formData[item.id] = value;
delete this.errorMsg[item.id];
});
} else if (item.type === FormItemType.SWITCH) {
Switch({
checked: this.formData[item.id] as boolean
})
.onChange((isChecked) => {
this.formData[item.id] = isChecked;
delete this.errorMsg[item.id];
});
}
// 错误提示
if (this.errorMsg[item.id]) {
Text(this.errorMsg[item.id])
.fontSize(14)
.color('#ff3b30')
.alignSelf(ItemAlign.Start);
}
}
.margin({ bottom: 20 });
}
build() {
Column({ space: 20 }) {
// 渲染所有表单项
ForEach(this.props.items, (item) => {
this.renderFormItem(item);
}, (item) => item.id);
// 提交按钮
Button('提交')
.width('90%')
.height(50)
.backgroundColor('#007aff')
.fontColor('#ffffff')
.borderRadius(25)
.onClick(() => this.submitForm());
}
.padding({ left: 20, right: 20, top: 30 });
}
}
3.3 组件使用示例(用户信息表单)
import { UniversalForm, FormItemType } from '../components/UniversalForm';
@Entry
@Component
struct UserFormPage {
// 表单配置
private formConfig = [
{
id: 'name',
label: '姓名',
type: FormItemType.INPUT,
placeholder: '请输入姓名',
required: true
},
{
id: 'gender',
label: '性别',
type: FormItemType.SELECT,
options: [
{ label: '男', value: 'male' },
{ label: '女', value: 'female' },
{ label: '其他', value: 'other' }
],
required: true
},
{
id: 'otherGender',
label: '其他性别',
type: FormItemType.INPUT,
placeholder: '请输入性别',
// 显示条件:性别选择“其他”时显示
showCondition: (formData) => formData.gender === 'other'
},
{
id: 'phone',
label: '手机号',
type: FormItemType.INPUT,
placeholder: '请输入手机号',
required: true,
// 自定义验证:手机号格式
validator: (value) => {
const reg = /^1[3-9]\d{9}$/;
return reg.test(value) ? true : '手机号格式不正确';
}
},
{
id: 'agree',
label: '同意用户协议',
type: FormItemType.SWITCH,
required: true,
validator: (value) => value ? true : '请同意用户协议'
}
];
// 表单提交处理
handleSubmit(data: Record<string, any>) {
console.log('表单数据:', JSON.stringify(data));
// 提交到服务器逻辑...
}
build() {
Column() {
Text('用户信息录入')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.margin({ top: 30, left: 20 })
.alignSelf(ItemAlign.Start);
// 使用通用表单组件
UniversalForm({
items: this.formConfig,
onSubmit: (data) => this.handleSubmit(data)
})
.flexGrow(1);
}
.width('100%')
.backgroundColor('#f5f5f5')
}
}
四、自定义组件复用与扩展技巧
4.1 组件复用策略
- 抽离公共组件库:将通用组件(如下拉刷新、表单、弹窗)抽离到独立的组件库模块,多个项目可通过依赖引入;
- 配置化驱动:通过JSON配置实现组件行为定制,减少代码修改;
- 组合复用:复杂组件由多个简单组件组合而成,如“表单组件”由“输入框组件”“选择器组件”组合。
4.2 组件扩展技巧
- 插槽(Slot)扩展:通过Slot暴露组件内部节点,允许外部自定义部分UI,如下拉刷新组件的内容区域通过Slot传入;
- 继承扩展:基于现有组件继承,重写部分方法实现扩展,如基于“输入框组件”扩展“验证码输入组件”;
- 事件扩展:通过多回调函数暴露更多事件,如表单组件暴露“输入变化”“验证失败”等事件。
五、总结
本文通过两个实战案例,详解了ArkUI自定义组件的设计与开发流程。好的自定义组件需在设计阶段明确入参、状态和回调,遵循单一职责原则,通过参数化配置和事件回调提升复用性。下拉刷新组件聚焦交互逻辑封装,表单组件聚焦配置化和数据联动,两种封装思路可覆盖大部分业务场景。
开发者在实际开发中,可根据业务复杂度选择合适的封装粒度,简单场景可封装为基础组件,复杂场景可封装为复合组件,同时注重组件的可测试性和文档编写,提升团队协作效率。
相关推荐
2030
0
鸿蒙小助手
7468
0
云端物理学家
3312
0
3647
0
用心写App的人
1926
0
巴拉巴拉~~
我还没有写个人简介......
帖子
提问
粉丝
纯血鸿蒙HarmonyOS NEXT学习路线——从入门到企业级开发
2025-12-23 14:37:48 发布鸿蒙ArkTS开发规范实战指南——从规范到高效编码
2025-12-23 14:37:10 发布