在HarmonyOS 6购物比价或电商类应用中,优惠券页加载网络数据时常有300~800ms延迟,直接白屏或突然闪出内容会使用户感觉"卡顿"。骨架屏(Skeleton Screen)是在数据返回前展示页面骨架占位符,通过微光扫过动画暗示内容正在加载,数据就绪后平滑替换为真实列表,显著提升感知流畅度。

本文将基于官方行业实践示例,用 List组件 + linearGradient占位 + animateTo显隐过渡​ 完整实现一个优惠券页面的骨架屏效果,同样适用于商品列表、消息记录等场景。


一、需求拆解与设计

1. 页面状态机

状态

表现

loading = true

显示3~4条灰色骨架条(模仿优惠券卡片高宽比例),带左侧亮色渐变位移动画

loading = false

隐藏骨架,显示真实 ForEach优惠券列表

2. 骨架卡片视觉规格(模仿真实券)

  • 高度 ≈ 真实券卡片高度(88vp)

  • 左侧圆形占位(金额区)+ 右侧两行矩形占位(标题/有效期)

  • 整体 borderRadius+ 浅灰 #E8E8E8背景

  • 微光渐变:宽度30%、透明度白→透→白,水平位移动画循环


二、骨架屏子组件(可复用)

// components/SkeletonCouponItem.ets
@Component
export struct SkeletonCouponItem {
  @State gradientX: number = -120; // 渐变起始X(驱动动画)

  aboutToAppear() {
    // 启动微光扫过动画(循环)
    this.startShimmer();
  }

  startShimmer() {
    animateTo(
      { duration: 1200, iterations: -1 /* 无限循环 */, curve: Curve.Linear },
      () => { this.gradientX = this.calcMaxX(); }
    );
  }

  // 计算渐变最大偏移(略大于组件宽使扫过完整)
  calcMaxX(): number {
    // 在onAreaChange中取实际宽,此处先用估算
    return 360;
  }

  build() {
    Row({ space: 14 }) {
      // 左侧圆形成分占位
      Column()
        .width(56)
        .height(56)
        .borderRadius(28)
        .backgroundColor('#E0E0E0')

      // 右侧文本行占位
      Column({ space: 8 }) {
        Row()
          .width(140)
          .height(14)
          .borderRadius(7)
          .backgroundColor('#E0E0E0')
        Row()
          .width(90)
          .height(12)
          .borderRadius(6)
          .backgroundColor('#E8E8E8')
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .padding(14)
    .width('100%')
    .height(88)
    .borderRadius(12)
    .clip(true) // 关键:限制渐变在卡片内
    .backgroundColor('#F5F5F5')
    // 微光覆盖层——线性渐变做扫光效果
    .overlay(
      Row()
        .width('100%')
        .height('100%')
        .linearGradient({
          angle: 90,
          colors: [
            ['rgba(255,255,255,0)', this.gradientX - 60],
            ['rgba(255,255,255,0.45)', this.gradientX],
            ['rgba(255,255,255,0)', this.gradientX + 60]
          ]
        }),
      { align: Alignment.TopStart }
    )
    .onAreaChange((_, newValue) => {
      // 精确计算最大偏移(组件渲染后)
      const w = newValue.width as number;
      if (w > 0) this.calcMaxX = () => w + 120;
    })
  }
}

原理overlay叠一层半透白色线性渐变,通过 animateTo不断改变渐变色停止位置(gradientX),产生"光从左到右扫过"的效果。


三、优惠券页面(骨架↔真实切换)

// pages/CouponListPage.ets
import { SkeletonCouponItem } from '../components/SkeletonCouponItem';
import { hilog } from '@kit.PerformanceAnalysisKit';

// 模拟真实优惠券数据
interface Coupon {
  id: number;
  title: string;
  discount: string;
  expire: string;
}

@Entry
@Component
struct CouponListPage {
  @State loading: boolean = true;
  @State coupons: Coupon[] = [];

  // 模拟网络请求
  fetchCoupons() {
    this.loading = true;
    setTimeout(() => {
      this.coupons = [
        { id: 1, title: '满199减50', discount: '¥50', expire: '2026-12-31' },
        { id: 2, title: '满99减20',  discount: '¥20', expire: '2026-10-15' },
        { id: 3, title: '新人专享减30', discount: '¥30', expire: '2026-08-01' }
      ];
      // 关闭骨架(带动画过渡)
      animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
        this.loading = false;
      });
    }, 1500); // 模拟1.5s延迟
  }

  aboutToAppear() {
    this.fetchCoupons();
  }

  build() {
    Column() {
      // 标题
      Text('我的优惠券')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .padding({ top: 16, bottom: 12, left: 16 })

      // ===== 内容区 =====
      if (this.loading) {
        // --- 骨架屏 ---
        Column({ space: 10 }) {
          ForEach([1, 2, 3], (_) => {
            SkeletonCouponItem()
          }, (i: number) => i.toString())
        }
        .padding({ horizontal: 16 })
        .layoutWeight(1)
      } else {
        // --- 真实列表 ---
        List() {
          ForEach(this.coupons, (item: Coupon) => {
            ListItem() {
              Row({ space: 14 }) {
                // 左侧金额
                Column() {
                  Text(item.discount)
                    .fontSize(20)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FF5722')
                  Text('满减券')
                    .fontSize(10)
                    .fontColor('#888')
                }
                .width(56)
                .alignItems(HorizontalAlign.Center)

                // 右侧信息
                Column() {
                  Text(item.title).fontSize(15).fontColor('#333')
                  Text(`有效期至 ${item.expire}`).fontSize(11).fontColor('#AAA').margin({ top: 4 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
              }
              .padding(14)
              .backgroundColor(Color.White)
              .borderRadius(12)
              .shadow({ radius: 4, color: 'rgba(0,0,0,0.05)', offsetX: 0, offsetY: 2 })
            }
          }, (item: Coupon) => item.id.toString())
        }
        .padding({ horizontal: 16 })
        .layoutWeight(1)
      }

      // 重新加载按钮(演示用)
      if (!this.loading) {
        Button('重新模拟加载')
          .margin(16)
          .onClick(() => this.fetchCoupons())
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F6F8')
  }
}

四、避坑指南

问题

原因

修复

微光不移动

gradientX初值与终值相同或没包 animateTo

确保 animateTo中修改 gradientX,且 iterations: -1

渐变溢出卡片边缘

overlay未裁剪

给卡片 .clip(true)限制渐变绘制区域

骨架与列表闪切生硬

直接切换 if/else无过渡

animateTo包裹 loading=false赋值(或配合 visibility+ 透明度动画)

骨架高度与真实不符

占位高度/圆角未对齐真实卡片

按真实券卡片尺寸(此处88vp)设骨架高度

列表首次渲染跳动

网络极快(<50ms)时骨架一闪而过

可设最小展示时间 minShowMs=300setTimeoutmax(网络耗时, minShowMs)


五、总结:骨架屏实现SOP

  1. 抽象骨架组件​ → 用 Row/Column+ 灰色背景模拟页面布局结构

  2. 微光动画​ → overlaylinearGradientanimateTo循环平移渐变色停止位

  3. 状态切换​ → 网络开始时 loading=true显示骨架;数据返回后 animateTo(()=>loading=false)

  4. 复用​ → 同组件略改高宽可套用到商品列表、消息列表等场景

核心法则:在 HarmonyOS 6 商城页面中,"骨架屏 = 布局占位 + 扫光渐变动画 + 状态驱动显隐"*,List 承载、animateTo 控制过渡,简单可靠。

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐