|
|
@@ -0,0 +1,545 @@
|
|
|
+---
|
|
|
+description: UI组件开发模式与最佳实践
|
|
|
+globs: ["**/pages/**/*.ets", "**/view/**/*.ets", "**/dialog/**/*.ets"]
|
|
|
+alwaysApply: false
|
|
|
+---
|
|
|
+
|
|
|
+# UI组件开发模式与最佳实践
|
|
|
+
|
|
|
+## 页面组件结构
|
|
|
+
|
|
|
+### 基本页面模板
|
|
|
+```typescript
|
|
|
+import { CommonConstants } from '../common/constants/CommonConstants';
|
|
|
+import Logger from '../common/util/Logger';
|
|
|
+
|
|
|
+@Entry
|
|
|
+@Component
|
|
|
+struct PageName {
|
|
|
+ // 状态变量
|
|
|
+ @State isLoading: boolean = false;
|
|
|
+ @State dataList: Array<ItemType> = [];
|
|
|
+
|
|
|
+ // 上下文
|
|
|
+ private context = getContext(this);
|
|
|
+
|
|
|
+ // 生命周期
|
|
|
+ aboutToAppear() {
|
|
|
+ this.initData();
|
|
|
+ }
|
|
|
+
|
|
|
+ aboutToDisappear() {
|
|
|
+ this.cleanup();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化数据
|
|
|
+ private initData() {
|
|
|
+ // 初始化逻辑
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理资源
|
|
|
+ private cleanup() {
|
|
|
+ // 清理逻辑
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建方法
|
|
|
+ build() {
|
|
|
+ Column() {
|
|
|
+ // 页面内容
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .height('100%')
|
|
|
+ .backgroundColor($r('app.color.backgroundPrimary'))
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 导航栏组件
|
|
|
+```typescript
|
|
|
+@Builder
|
|
|
+NavigationArea(title: string, showBack: boolean = true) {
|
|
|
+ Row() {
|
|
|
+ if (showBack) {
|
|
|
+ Image($r('app.media.ic_back'))
|
|
|
+ .width(24)
|
|
|
+ .height(24)
|
|
|
+ .margin({ left: 16 })
|
|
|
+ .onClick(() => {
|
|
|
+ router.back();
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ Text(title)
|
|
|
+ .fontSize(18)
|
|
|
+ .fontWeight(FontWeight.Medium)
|
|
|
+ .fontColor($r('app.color.fontPrimary'))
|
|
|
+ .layoutWeight(1)
|
|
|
+ .textAlign(TextAlign.Center)
|
|
|
+ .margin({ right: showBack ? 40 : 16 })
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .height(56)
|
|
|
+ .backgroundColor($r('app.color.backgroundPrimary'))
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 列表组件
|
|
|
+
|
|
|
+### 基本列表组件
|
|
|
+```typescript
|
|
|
+@Component
|
|
|
+struct MusicListItem {
|
|
|
+ @Prop musicItem: MusicItem;
|
|
|
+ @Prop isPlaying: boolean = false;
|
|
|
+ private onItemClick?: (item: MusicItem) => void;
|
|
|
+
|
|
|
+ build() {
|
|
|
+ Row() {
|
|
|
+ Image(this.musicItem.cover || $r('app.media.default_music_icon'))
|
|
|
+ .width(50)
|
|
|
+ .height(50)
|
|
|
+ .borderRadius(8)
|
|
|
+ .objectFit(ImageFit.Cover)
|
|
|
+ .margin({ right: 12 })
|
|
|
+
|
|
|
+ Column() {
|
|
|
+ Text(this.musicItem.title)
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.fontPrimary'))
|
|
|
+ .maxLines(1)
|
|
|
+ .textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
|
+ .width('100%')
|
|
|
+
|
|
|
+ Text(this.musicItem.artist)
|
|
|
+ .fontSize(14)
|
|
|
+ .fontColor($r('app.color.fontSecondary'))
|
|
|
+ .maxLines(1)
|
|
|
+ .textOverflow({ overflow: TextOverflow.Ellipsis })
|
|
|
+ .width('100%')
|
|
|
+ .margin({ top: 4 })
|
|
|
+ }
|
|
|
+ .layoutWeight(1)
|
|
|
+ .alignItems(HorizontalAlign.Start)
|
|
|
+
|
|
|
+ if (this.isPlaying) {
|
|
|
+ Image($r('app.media.ic_playing'))
|
|
|
+ .width(24)
|
|
|
+ .height(24)
|
|
|
+ .margin({ left: 12 })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .height(70)
|
|
|
+ .padding({ horizontal: 16, vertical: 10 })
|
|
|
+ .onClick(() => {
|
|
|
+ if (this.onItemClick) {
|
|
|
+ this.onItemClick(this.musicItem);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 高性能列表
|
|
|
+```typescript
|
|
|
+@Component
|
|
|
+struct MusicList {
|
|
|
+ @State musicList: MusicItem[] = [];
|
|
|
+ @State currentPlayingId: string = '';
|
|
|
+
|
|
|
+ build() {
|
|
|
+ List({ space: 1 }) {
|
|
|
+ LazyForEach(new MusicDataSource(this.musicList), (item: MusicItem, index: number) => {
|
|
|
+ ListItem() {
|
|
|
+ MusicListItem({
|
|
|
+ musicItem: item,
|
|
|
+ isPlaying: item.id === this.currentPlayingId,
|
|
|
+ onItemClick: (musicItem: MusicItem) => {
|
|
|
+ this.playMusic(musicItem);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }, (item: MusicItem) => item.id)
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .layoutWeight(1)
|
|
|
+ .divider({ strokeWidth: 1, color: $r('app.color.compDivider') })
|
|
|
+ }
|
|
|
+
|
|
|
+ private playMusic(musicItem: MusicItem) {
|
|
|
+ this.currentPlayingId = musicItem.id;
|
|
|
+ // 播放音乐逻辑
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 数据源类
|
|
|
+class MusicDataSource implements IDataSource {
|
|
|
+ private listeners: DataChangeListener[] = [];
|
|
|
+ private data: MusicItem[] = [];
|
|
|
+
|
|
|
+ constructor(data: MusicItem[]) {
|
|
|
+ this.data = data;
|
|
|
+ }
|
|
|
+
|
|
|
+ totalCount(): number {
|
|
|
+ return this.data.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ getData(index: number): MusicItem {
|
|
|
+ return this.data[index];
|
|
|
+ }
|
|
|
+
|
|
|
+ registerDataChangeListener(listener: DataChangeListener): void {
|
|
|
+ if (this.listeners.indexOf(listener) < 0) {
|
|
|
+ this.listeners.push(listener);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ unregisterDataChangeListener(listener: DataChangeListener): void {
|
|
|
+ const pos = this.listeners.indexOf(listener);
|
|
|
+ if (pos >= 0) {
|
|
|
+ this.listeners.splice(pos, 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ notifyDataReload(): void {
|
|
|
+ this.listeners.forEach(listener => {
|
|
|
+ listener.onDataReloaded();
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 播放控制组件
|
|
|
+
|
|
|
+### 播放控制栏
|
|
|
+```typescript
|
|
|
+@Component
|
|
|
+struct PlayerControlBar {
|
|
|
+ @Prop isPlaying: boolean = false;
|
|
|
+ @Prop currentTime: number = 0;
|
|
|
+ @Prop duration: number = 0;
|
|
|
+ private onPlayPause?: () => void;
|
|
|
+ private onPrevious?: () => void;
|
|
|
+ private onNext?: () => void;
|
|
|
+ private onSeek?: (position: number) => void;
|
|
|
+
|
|
|
+ build() {
|
|
|
+ Column() {
|
|
|
+ // 进度条
|
|
|
+ Row() {
|
|
|
+ Text(this.formatTime(this.currentTime))
|
|
|
+ .fontSize(12)
|
|
|
+ .fontColor($r('app.color.fontSecondary'))
|
|
|
+
|
|
|
+ Slider({
|
|
|
+ value: this.currentTime,
|
|
|
+ min: 0,
|
|
|
+ max: this.duration || 1,
|
|
|
+ style: SliderStyle.InSet
|
|
|
+ })
|
|
|
+ .layoutWeight(1)
|
|
|
+ .margin({ horizontal: 12 })
|
|
|
+ .trackColor($r('app.color.compBackgroundTertiary'))
|
|
|
+ .selectedColor($r('app.color.brand'))
|
|
|
+ .blockColor($r('app.color.brand'))
|
|
|
+ .onChange((value: number) => {
|
|
|
+ if (this.onSeek) {
|
|
|
+ this.onSeek(value);
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ Text(this.formatTime(this.duration))
|
|
|
+ .fontSize(12)
|
|
|
+ .fontColor($r('app.color.fontSecondary'))
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .margin({ bottom: 20 })
|
|
|
+
|
|
|
+ // 控制按钮
|
|
|
+ Row() {
|
|
|
+ Button() {
|
|
|
+ Image($r('app.media.ic_previous'))
|
|
|
+ .width(28)
|
|
|
+ .height(28)
|
|
|
+ .fillColor($r('app.color.iconPrimary'))
|
|
|
+ }
|
|
|
+ .type(ButtonType.Circle)
|
|
|
+ .backgroundColor(Color.Transparent)
|
|
|
+ .width(48)
|
|
|
+ .height(48)
|
|
|
+ .onClick(() => {
|
|
|
+ if (this.onPrevious) {
|
|
|
+ this.onPrevious();
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ Button() {
|
|
|
+ Image(this.isPlaying ? $r('app.media.ic_pause') : $r('app.media.ic_play'))
|
|
|
+ .width(36)
|
|
|
+ .height(36)
|
|
|
+ .fillColor($r('app.color.iconOnPrimary'))
|
|
|
+ }
|
|
|
+ .type(ButtonType.Circle)
|
|
|
+ .backgroundColor($r('app.color.brand'))
|
|
|
+ .width(64)
|
|
|
+ .height(64)
|
|
|
+ .margin({ horizontal: 20 })
|
|
|
+ .onClick(() => {
|
|
|
+ if (this.onPlayPause) {
|
|
|
+ this.onPlayPause();
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ Button() {
|
|
|
+ Image($r('app.media.ic_next'))
|
|
|
+ .width(28)
|
|
|
+ .height(28)
|
|
|
+ .fillColor($r('app.color.iconPrimary'))
|
|
|
+ }
|
|
|
+ .type(ButtonType.Circle)
|
|
|
+ .backgroundColor(Color.Transparent)
|
|
|
+ .width(48)
|
|
|
+ .height(48)
|
|
|
+ .onClick(() => {
|
|
|
+ if (this.onNext) {
|
|
|
+ this.onNext();
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .justifyContent(FlexAlign.Center)
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .padding({ horizontal: 20, vertical: 16 })
|
|
|
+ }
|
|
|
+
|
|
|
+ private formatTime(time: number): string {
|
|
|
+ if (isNaN(time) || time < 0) return '00:00';
|
|
|
+
|
|
|
+ const minutes = Math.floor(time / 60);
|
|
|
+ const seconds = Math.floor(time % 60);
|
|
|
+ return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 对话框组件
|
|
|
+
|
|
|
+### 自定义对话框
|
|
|
+```typescript
|
|
|
+@Component
|
|
|
+struct CustomDialog {
|
|
|
+ @Prop title: string = '';
|
|
|
+ @Prop content: string = '';
|
|
|
+ @Prop confirmText: string = '确定';
|
|
|
+ @Prop cancelText: string = '取消';
|
|
|
+ @Prop showCancel: boolean = true;
|
|
|
+ private onConfirm?: () => void;
|
|
|
+ private onCancel?: () => void;
|
|
|
+
|
|
|
+ build() {
|
|
|
+ Column() {
|
|
|
+ // 标题
|
|
|
+ Text(this.title)
|
|
|
+ .fontSize(18)
|
|
|
+ .fontWeight(FontWeight.Medium)
|
|
|
+ .fontColor($r('app.color.fontPrimary'))
|
|
|
+ .margin({ top: 24, bottom: 12 })
|
|
|
+ .padding({ horizontal: 20 })
|
|
|
+
|
|
|
+ // 内容
|
|
|
+ Text(this.content)
|
|
|
+ .fontSize(14)
|
|
|
+ .fontColor($r('app.color.fontSecondary'))
|
|
|
+ .textAlign(TextAlign.Center)
|
|
|
+ .padding({ horizontal: 20 })
|
|
|
+ .margin({ bottom: 24 })
|
|
|
+
|
|
|
+ // 按钮
|
|
|
+ Row() {
|
|
|
+ if (this.showCancel) {
|
|
|
+ Button(this.cancelText)
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.fontPrimary'))
|
|
|
+ .backgroundColor(Color.Transparent)
|
|
|
+ .layoutWeight(1)
|
|
|
+ .onClick(() => {
|
|
|
+ if (this.onCancel) {
|
|
|
+ this.onCancel();
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ Button(this.confirmText)
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.brand'))
|
|
|
+ .backgroundColor(Color.Transparent)
|
|
|
+ .layoutWeight(1)
|
|
|
+ .onClick(() => {
|
|
|
+ if (this.onConfirm) {
|
|
|
+ this.onConfirm();
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .height(48)
|
|
|
+ }
|
|
|
+ .backgroundColor($r('app.color.backgroundPrimary'))
|
|
|
+ .borderRadius(12)
|
|
|
+ .width('80%')
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 主题适配
|
|
|
+
|
|
|
+### 主题感知组件
|
|
|
+```typescript
|
|
|
+@Component
|
|
|
+struct ThemeAwareButton {
|
|
|
+ @Prop text: string = '';
|
|
|
+ @Prop type: 'primary' | 'secondary' = 'primary';
|
|
|
+ private onClick?: () => void;
|
|
|
+
|
|
|
+ build() {
|
|
|
+ Button(this.text)
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor(this.type === 'primary' ?
|
|
|
+ $r('app.color.fontOnPrimary') :
|
|
|
+ $r('app.color.fontPrimary'))
|
|
|
+ .backgroundColor(this.type === 'primary' ?
|
|
|
+ $r('app.color.brand') :
|
|
|
+ $r('app.color.compBackgroundSecondary'))
|
|
|
+ .borderRadius(8)
|
|
|
+ .padding({ horizontal: 20, vertical: 10 })
|
|
|
+ .onClick(() => {
|
|
|
+ if (this.onClick) {
|
|
|
+ this.onClick();
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 动画效果
|
|
|
+
|
|
|
+### 页面转场动画
|
|
|
+```typescript
|
|
|
+// 页面跳转带动画
|
|
|
+router.pushUrl({
|
|
|
+ url: 'pages/DetailPage',
|
|
|
+ params: { id: this.itemId }
|
|
|
+}).then(() => {
|
|
|
+ // 页面跳转成功
|
|
|
+}).catch((err: Error) => {
|
|
|
+ Logger.error(`页面跳转失败: ${err.message}`);
|
|
|
+});
|
|
|
+
|
|
|
+// 在目标页面中
|
|
|
+@Entry
|
|
|
+@Component
|
|
|
+struct DetailPage {
|
|
|
+ // 页面转场动画
|
|
|
+ pageTransition() {
|
|
|
+ PageTransitionEnter({ duration: 300, curve: Curve.EaseInOut })
|
|
|
+ .slide(SlideEffect.Right)
|
|
|
+
|
|
|
+ PageTransitionExit({ duration: 300, curve: Curve.EaseInOut })
|
|
|
+ .slide(SlideEffect.Left)
|
|
|
+ }
|
|
|
+
|
|
|
+ build() {
|
|
|
+ // 页面内容
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 状态变化动画
|
|
|
+```typescript
|
|
|
+@Component
|
|
|
+struct AnimatedButton {
|
|
|
+ @State isPressed: boolean = false;
|
|
|
+
|
|
|
+ build() {
|
|
|
+ Button('点击我')
|
|
|
+ .scale({ x: this.isPressed ? 0.95 : 1, y: this.isPressed ? 0.95 : 1 })
|
|
|
+ .animation({
|
|
|
+ duration: 100,
|
|
|
+ curve: Curve.EaseInOut
|
|
|
+ })
|
|
|
+ .onTouch((event: TouchEvent) => {
|
|
|
+ if (event.type === TouchType.Down) {
|
|
|
+ this.isPressed = true;
|
|
|
+ } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
|
|
|
+ this.isPressed = false;
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 响应式布局
|
|
|
+
|
|
|
+### 断点适配
|
|
|
+```typescript
|
|
|
+@Component
|
|
|
+struct ResponsiveLayout {
|
|
|
+ @State currentBreakpoint: string = 'sm';
|
|
|
+
|
|
|
+ aboutToAppear() {
|
|
|
+ // 监听窗口大小变化
|
|
|
+ window.getLastWindow(getContext(this))
|
|
|
+ .then((windowClass) => {
|
|
|
+ windowClass.on('windowSizeChange', (windowSize) => {
|
|
|
+ this.updateBreakpoint(windowSize.width);
|
|
|
+ });
|
|
|
+ this.updateBreakpoint(windowClass.getWindowProperties().windowRect.width);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private updateBreakpoint(width: number) {
|
|
|
+ if (width < 600) {
|
|
|
+ this.currentBreakpoint = 'sm';
|
|
|
+ } else if (width < 840) {
|
|
|
+ this.currentBreakpoint = 'md';
|
|
|
+ } else {
|
|
|
+ this.currentBreakpoint = 'lg';
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ build() {
|
|
|
+ if (this.currentBreakpoint === 'sm') {
|
|
|
+ // 小屏幕布局
|
|
|
+ this.buildSmallLayout();
|
|
|
+ } else if (this.currentBreakpoint === 'md') {
|
|
|
+ // 中等屏幕布局
|
|
|
+ this.buildMediumLayout();
|
|
|
+ } else {
|
|
|
+ // 大屏幕布局
|
|
|
+ this.buildLargeLayout();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Builder
|
|
|
+ buildSmallLayout() {
|
|
|
+ Column() {
|
|
|
+ // 小屏幕布局内容
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Builder
|
|
|
+ buildMediumLayout() {
|
|
|
+ Row() {
|
|
|
+ // 中等屏幕布局内容
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Builder
|
|
|
+ buildLargeLayout() {
|
|
|
+ Grid() {
|
|
|
+ // 大屏幕布局内容
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|