HarmonyOS Web 加载骨架屏 + Web 淡入动画模板(可直接用)

HarmonyOS Web 加载骨架屏 + Web 淡入动画模板(可直接用)

鸿蒙第四期开发者活动

在真实项目里,Web 页面加载体验好不好,不取决于页面最终长什么样,而取决于:
👉 用户点进去后的前 1~3 秒你给了什么反馈

如果这段时间是白屏、卡住、没反应,哪怕页面最后加载得再漂亮,体验分也已经掉了。

这篇文章我给你一套可以直接复制进工程使用的方案,实现这几个目标:

  • 页面一进来 立刻显示原生骨架屏
  • Web 在后台加载,不抢视觉
  • Web 可展示时 淡入
  • 骨架屏 淡出
  • 全程无白屏、无闪烁、无突兀跳变

一、整体思路(先讲清楚,不然容易写歪)

一句话概括这套方案:

Web 始终存在,但一开始是透明的;
骨架屏覆盖在上面;
Web 准备好后淡入,骨架屏淡出。

关键点有 3 个:

  1. Web 不要等加载完才创建(否则必闪)
  2. 骨架屏是原生组件,不是 Web 里的 loading
  3. 动画只控制 opacity,不做 layout 变更

二、页面级完整模板(直接可用)

这是一个完整的 ArkTS 页面,你可以直接新建
WebSkeletonFadePage.ets 使用。

import { webview } from '@kit.ArkWeb';

@Entry
@Component
struct WebSkeletonFadePage {
  private controller: webview.WebviewController = new webview.WebviewController();

  @State isSkeletonVisible: boolean = true;
  @State skeletonOpacity: number = 1;

  @State webOpacity: number = 0;
  @State progress: number = 0;

  @State showError: boolean = false;

  private fadeInWebAndHideSkeleton() {
    // 避免重复触发
    if (!this.isSkeletonVisible && this.webOpacity >= 1) return;

    // Web 淡入
    animateTo({ duration: 240, curve: Curve.EaseOut }, () => {
      this.webOpacity = 1;
    });

    // 骨架屏淡出
    animateTo({ duration: 220, curve: Curve.EaseIn }, () => {
      this.skeletonOpacity = 0;
    });

    // 动画结束后移除骨架屏
    setTimeout(() => {
      this.isSkeletonVisible = false;
    }, 240);
  }

  build() {
    Stack() {
      // Web 组件(始终存在,只是透明度变化)
      Web({
        src: 'https://example.com', // 换成你的地址或 rawfile
        controller: this.controller
      })
        .javaScriptAccess(true)
        .onPageBegin(() => {
          this.showError = false;
          this.progress = 0;
          this.isSkeletonVisible = true;
          this.skeletonOpacity = 1;
          this.webOpacity = 0;
        })
        .onProgressChange((p: number) => {
          this.progress = p;
          // 经验值:80% 基本可见首屏
          if (p >= 80) {
            this.fadeInWebAndHideSkeleton();
          }
        })
        .onPageEnd(() => {
          // 兜底,确保一定淡入
          this.fadeInWebAndHideSkeleton();
        })
        .onRenderExited(() => {
          this.showError = true;
          this.isSkeletonVisible = false;
          this.webOpacity = 1;
        })
        .opacity(this.webOpacity)
        .width('100%')
        .height('100%')

      // 骨架屏
      if (this.isSkeletonVisible) {
        SkeletonLayer({
          progress: this.progress,
          opacity: this.skeletonOpacity
        })
      }

      // 错误态(可选)
      if (this.showError) {
        Column({ space: 12 }) {
          Text('加载失败')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
          Text('请检查网络后重试')
            .opacity(0.7)
          Button('重试')
            .onClick(() => {
              this.showError = false;
              this.isSkeletonVisible = true;
              this.skeletonOpacity = 1;
              this.webOpacity = 0;
              this.controller.refresh();
            })
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
  }
}

三、骨架屏组件(轻量但“像样”)

1️⃣ 骨架屏整体结构

@Component
struct SkeletonLayer {
  progress: number;
  opacity: number;

  build() {
    Column({ space: 14 }) {
      SkeletonBlock({ w: '70%', h: 18, r: 8 })
      SkeletonBlock({ w: '40%', h: 14, r: 8 })

      Column({ space: 12 }) {
        ForEach([1, 2, 3, 4, 5], () => {
          Column({ space: 10 }) {
            SkeletonBlock({ w: '100%', h: 120, r: 12 })
            Row({ space: 10 }) {
              SkeletonBlock({ w: '22%', h: 14, r: 8 })
              SkeletonBlock({ w: '30%', h: 14, r: 8 })
              SkeletonBlock({ w: '18%', h: 14, r: 8 })
            }
          }
        })
      }

      Text(`加载中… ${this.progress}%`)
        .fontSize(12)
        .opacity(0.6)
    }
    .padding(16)
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .opacity(this.opacity)
  }
}

2️⃣ 单个骨架块(带轻微流光)

@Component
struct SkeletonBlock {
  w: string;
  h: number;
  r: number;

  @State shimmerOffset: number = -60;

  aboutToAppear() {
    setInterval(() => {
      animateTo({ duration: 900, curve: Curve.Linear }, () => {
        this.shimmerOffset =
          this.shimmerOffset >= 260 ? -60 : this.shimmerOffset + 80;
      });
    }, 950);
  }

  build() {
    Stack() {
      Rect()
        .width(this.w)
        .height(this.h)
        .radius(this.r)
        .opacity(0.12)

      Rect()
        .width('30%')
        .height(this.h)
        .radius(this.r)
        .translate({ x: this.shimmerOffset })
        .opacity(0.08)
    }
  }
}

四、为什么这套方案“看起来就高级”

这是我在项目里反复打磨出来的结论:

  • 骨架屏是“页面结构的预告”,不是转圈圈
  • 淡入动画是心理缓冲,让内容出现得“理所当然”
  • Web 不重排、不重建,性能稳定
  • 动画只改 opacity,最安全、最不容易出问题

五、我踩过的坑,你可以直接避开

❌ 骨架屏用 Web 自己的 loading

→ Web 没加载前你根本看不到它,白屏依旧。

❌ onPageEnd 才显示 Web

→ 页面其实早就能看了,被你硬生生挡住。

❌ 动画期间改布局

→ 闪、抖、性能下降,一堆莫名其妙的问题。


六、推荐的实际使用策略

  • 首屏 Web 页面:用这套方案
  • 页面内跳转 URL:只用淡入,不用骨架
  • 协议页 / 轻页面:直接 loading 即可

七、一句话总结

Web 页面加载不是“等它加载完”,
而是“在它加载的这段时间,你给用户什么感觉”。

posted @ 2025-12-18 16:28  骑老爷爷过马路  阅读(1)  评论(0)    收藏  举报