页面加载Loading效果的实现
================

#### 问题现象

页面加载时有时延，较长时会被感知到，如何解决该问题？

#### 背景知识

*   [LoadingProgress()](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-basic-components-loadingprogress)是用于显示加载动效的组件。
*   [rotate](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-universal-attributes-transformation)用于设置组件旋转。
*   [animation](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-animatorproperty)属性动画实现渐变过渡效果。

#### 解决方案

根据业务场景，有三种方案实现该效果：

1.  参考LoadingProgress()[示例](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-basic-components-loadingprogress#示例1设置颜色)。
2.  通过图片旋转模拟Loading效果。
    
    @Entry
    @Component
    struct LoadingExample {
      @State loading: boolean \= false;
    
      build() {
        Column({ space: 20 }) {
          LoadingView({ loading: this.loading })
          Row({ space: 20 }) {
            Button('开始').onClick(() \=\> {
              this.loading \= true;
            })
    
            Button('结束').onClick(() \=\> {
              this.loading \= false;
            })
          }
        }
        .width('100%')
        .height('100%')
      }
    }
    
    @Component
    struct LoadingView {
      @Prop loading: boolean \= false;
      _//图片旋转角度_
      @State angel: number \= 0;
    
      build() {
        Image($r('app.media.startIcon'))  _// 图片资源自行替换_
          .width(30)
          .height(30)
          .rotate({
            centerX: "50%",
            centerY: "50%",
            angle: this.angel,
          })
          .draggable(false)
          .visibility(this.loading ? Visibility.Visible : Visibility.None)
          .onAppear(() \=\> {
            this.getUIContext()?.animateTo({
              curve: Curve.Linear,
              playMode: PlayMode.Normal,
              iterations: -1, _//_ _设置\-1表示动画无限循环_
              onFinish: () \=\> {
              }
            }, () \=\> {
              this.angel \= 360;
            });
          })
      }
    }
    
    代码运行效果图如下：
    
    ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/9a/v3/sUiS152zR-qciaaApdArog/zh-cn_image_0000002628398440.png?HW-CC-KV=V1&HW-CC-Date=20260709T133920Z&HW-CC-Expire=86400&HW-CC-Sign=780335196ED194A7B442BA599DCE4FF8DE0286F132FCE7DC8B34840F97678FA2 "点击放大")
    

3.  自定义LoadingView，供全局调用。
    
    *   自定义LoadingView.ets。
        
        export class LoadingTime {
          _// 显示时间为baseTime+msg.length\*wordTime_
          static baseTime: number \= 400; _// 消息显示基本时间_
          static wordTime: number \= 90; _//_ _每个字增加显示的时间_
        
         _ // 最终显示时间为上面计算结果限制到最短与最长之间_
          static minTime: number \= 1600; _// 最短显示时间_
          static maxTime: number \= 4200; _// 最长显示时间_
        };
        
        export enum LoadingType {
          Loading \= 0,
          Info,
          Success,
          Error,
          Length
        };
        
        interface LoadingParam {
          msg: string,
          cancelCallBack?: () \=\> void,
          alignment?: DialogAlignment,
          offset?: Offset,
          showInSubWindow?: boolean,
          isModal?: boolean,
          tmpHUDNum?: number, _// 缓存HUD的数量，用于多层返回HUD不显示情况_
        };
        
        @Entry
        @CustomDialog
        struct \_LoadingView {
          controller: CustomDialogController;
          close: () \=\> void \= () \=\> {
          };
          type: LoadingType \= LoadingType.Loading;
          image: ResourceStr | undefined \= undefined;
          @State angle: number \= 0;
          msg: string \= '';
        
          aboutToAppear() {
            if (this.type \=== LoadingType.Loading) {
              setTimeout(() \=\> {
                this.angle \= 360;
              }, 100);
            }
          }
        
          build() {
            Column() {
              if (this.type \=== LoadingType.Loading) {
                Row()
                  .width(this.getUIContext().px2vp(200))
                  .height(this.getUIContext().px2vp(200))
                  .sweepGradient({
                    center: \[this.getUIContext().px2vp(100), this.getUIContext().px2vp(100)\],
                    rotation: 280,
                    start: 0,
                    end: 360,
                    colors:
                    \[\['rgba(255, 255, 255, 0.0)', 0.0\], \['rgba(255, 255, 255, 0.0)', 0.18\], \['rgba(255, 255, 255, 1.0)', 1.0\]\]
                  })
                  .clipShape(new Path({
                    width: 100, height: 100, commands:
                    \`M100 10 A90 90 0 1 0 190 100 A2 8 0 1 0 182 100 A82 82 0 1 1 100 18 A4 4 0 0 0 100 10 Z\`
                  }))
                  .rotate({
                    z: 1,
                    angle: this.angle
                  })
                  .animation({
                    duration: 1000,
                    curve: Curve.Linear,
                    iterations: -1,
                    expectedFrameRateRange: {
                      min: 20,
                      max: 60,
                      expected: 60,
                    }
                  })
                  .scale({ x: 0.9, y: 0.9 })
              } else if (this.image) {
                Image(this.image)
                  .fitOriginalSize(true)
                  .objectFit(ImageFit.None)
              }
              if (this.msg) {
                Text(this.msg)
                  .fontColor(Color.White)
                  .textAlign(TextAlign.Center)
                  .padding({ top: this.type \=== LoadingType.Loading || this.image ? 10 : 0 })
              }
            }
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)
            .padding(12)
            .margin(30)
            .backgroundColor(Color.Black)
            .borderRadius(10)
            .shadow({
              radius: 10,
              color: Color.Gray,
              offsetX: 3,
              offsetY: 0
            })
          }
        }
        
        let \_dialogController: CustomDialogController | null;
        let \_cancelCallBack: (() \=\> void) | undefined;
        
        let tmpHUDArray: LoadingView\[\] \= \[\];
        
        @Component
        export struct LoadingView {
          showLoading(
            msg: string | LoadingParam \= '',
            cancelCallBack?: () \=\> void,
            alignment?: DialogAlignment,
            offset?: Offset,
            showInSubWindow?: boolean,
            isModal?: boolean,
            tmpHUDNum: number \= 1, _//_ _缓存HUD的数量，用于多层返回HUD不显示情况_
          ): void {
            if (typeof msg \=== 'string') {
              this.showType(LoadingType.Loading, undefined, msg, cancelCallBack, alignment, offset, showInSubWindow, isModal);
            } else {
              this.showType(LoadingType.Loading, undefined, msg.msg, msg.cancelCallBack, msg.alignment, msg.offset,
                msg.showInSubWindow, msg.isModal);
              tmpHUDNum \= msg.tmpHUDNum ?? 1;
            }
        
           _ // 用于解决网络返回或者其它情况下不显示的问题_
            if (tmpHUDArray.length < tmpHUDNum) {
              for (let index \= 0; index < tmpHUDNum; index++) {
                tmpHUDArray.push(new LoadingView());
              }
            }
          }
        
          showInfo(
            msg: string | LoadingParam \= '',
            cancelCallBack?: () \=\> void,
            alignment?: DialogAlignment,
            offset?: Offset,
            showInSubWindow?: boolean,
            isModal?: boolean,
          ): void {
            if (typeof msg \=== 'string') {
              this.showType(LoadingType.Info, undefined, msg, cancelCallBack, alignment, offset, showInSubWindow, isModal);
            } else {
              this.showType(LoadingType.Info, undefined, msg.msg, msg.cancelCallBack, msg.alignment, msg.offset,
                msg.showInSubWindow, msg.isModal);
            }
          }
        
          showSuccess(
            msg: string | LoadingParam \= '',
            cancelCallBack?: () \=\> void,
            alignment?: DialogAlignment,
            offset?: Offset,
            showInSubWindow?: boolean,
            isModal?: boolean,
          ): void {
            if (typeof msg \=== 'string') {
              this.showType(LoadingType.Success, $r('app.media.startIcon'), msg, cancelCallBack, alignment, offset,
                showInSubWindow, isModal);
            } else {
              this.showType(LoadingType.Success, $r('app.media.startIcon'), msg.msg, msg.cancelCallBack, msg.alignment,
                msg.offset, msg.showInSubWindow, msg.isModal);
            }
          }
        
          showError(
            msg: string | LoadingParam \= '',
            cancelCallBack?: () \=\> void,
            alignment?: DialogAlignment,
            offset?: Offset,
            showInSubWindow?: boolean,
            isModal?: boolean,
          ): void {
            if (typeof msg \=== 'string') {
              this.showType(LoadingType.Error, $r('app.media.startIcon'), msg, cancelCallBack, alignment, offset,
                showInSubWindow, isModal);
            } else {
              this.showType(LoadingType.Error, $r('app.media.startIcon'), msg.msg, msg.cancelCallBack, msg.alignment,
                msg.offset, msg.showInSubWindow, msg.isModal);
            }
          }
        
          showType(
            type: LoadingType,
            image: Resource | undefined,
            msg: string,
            cancelCallBack?: () \=\> void,
            alignment: DialogAlignment \= DialogAlignment.Center,
            offset: Offset \= { dx: 0, dy: 0 },
            showInSubWindow: boolean \= false,
            isModal: boolean \= false,
            useTmpHUD: boolean \= true _// 是否使用缓存的HUD_
          ): void {
            let self: LoadingView | undefined \= this;
            if (useTmpHUD && tmpHUDArray.length \> 0) {
              self \= tmpHUDArray.shift();
            };
            self?.showTypeReal(type, image, msg, cancelCallBack, alignment, offset, showInSubWindow, isModal);
          }
        
          private showTypeReal(
            type: LoadingType,
            image: Resource | undefined,
            msg: string,
            cancelCallBack?: () \=\> void,
            alignment: DialogAlignment \= DialogAlignment.Center,
            offset: Offset \= { dx: 0, dy: 0 },
            showInSubWindow: boolean \= false,
            isModal: boolean \= false,
          ): void {
            this.hide();
        
            \_cancelCallBack \= cancelCallBack;
        
            let animate: AnimateParam \= {
              duration: 90,
              delay: 0,
              curve: Curve.EaseInOut
            };
        
            \_dialogController \= new CustomDialogController({
              builder: \_LoadingView({ type: type, image: image, msg: msg }),
              autoCancel: false,
              cancel: () \=\> {
                \_dialogController \= null;
                if (\_cancelCallBack) {
                  \_cancelCallBack();
                };
              },
              customStyle: true,
              alignment: alignment,
              offset: offset,
              maskColor: 0x33000000,
              openAnimation: animate,
              closeAnimation: animate,
              showInSubWindow: showInSubWindow,
              isModal: isModal, _// api11是否有蒙层_
            });
            \_dialogController.open();
            if (type !== LoadingType.Loading) {
              let time: number \= LoadingTime.baseTime;
              if (msg) {
                time += msg.length \* LoadingTime.wordTime;
              }
              time \= Math.max(time, LoadingTime.minTime);
              time \= Math.min(time, LoadingTime.maxTime);
              setTimeout(() \=\> {
                this.hide();
              }, time);
            }
          }
        
          hide() {
            if (\_dialogController) {
              \_dialogController.close();
              \_dialogController \= null;
              if (\_cancelCallBack) {
                \_cancelCallBack();
              }
            }
          }
        
          build() {
          }
        }
        
    
    *   Index引入使用。
        
        import { LoadingView } from './LoadingView';
        
        @Entry
        @Component
        struct Index {
          loadingView: LoadingView \= new LoadingView();
        
          build() {
            NavDestination() {
              Column({ space: 30 }) {
                Button('Toast提示有文案')
                  .onClick(() \=\> {
                    this.loadingView.showLoading({ msg: '数据加载中' });
                    setTimeout(() \=\> {
                      this.loadingView.hide();
                    }, 1000);
                  })
                Button('loading有蒙层')
                  .onClick(() \=\> {
                    this.loadingView.showLoading({ msg: '数据加载中', isModal: true });
                    setTimeout(() \=\> {
                      this.loadingView.hide();
                    }, 1000);
                  })
                Button('信息提示')
                  .onClick(() \=\> {
                    this.loadingView.showInfo('这里是Toast提示信息');
                  })
                Button('成功')
                  .onClick(() \=\> {
                    this.loadingView.showSuccess('数据加载成功');
                  })
                Button('失败')
                  .onClick(() \=\> {
                    this.loadingView.showError('数据加载失败');
                  })
                Button('Toast')
                  .onClick(() \=\> {
                    this.loadingView.showLoading();
                    setTimeout(() \=\> {
                      this.loadingView.hide();
                    }, 1000);
                  })
              }
            }
            .height('100%')
            .width('100%')
          }
        }
        
        代码运行效果图如下：
        
        ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/73/v3/4RwYKHaHT3SdY3ptX_k2tg/zh-cn_image_0000002658797721.png?HW-CC-KV=V1&HW-CC-Date=20260709T133920Z&HW-CC-Expire=86400&HW-CC-Sign=F63EEE770AD1B554564DE1B1D4B8F6803429B80BBA245D7536343D0D78D1DC7E "点击放大")