话不多说,先给大家看看实际效果图

。。。。gif 最大 5M,4s 的都 18M 了

算了,发个静态图吧,后面视频审批过了再加进来。

HarmonyOS 鸿蒙照片上墙功能分享

背景:

这个是最近正好做项目需求写的东西,感觉可能别的地方也用得到,又挺独立的就分享一下。

实现思路:

这是用四个 Image 来实现的,背景图和前景图各两个,如果都用一个来实现会切换的时候比较卡顿,不像现在这么流畅。

背景图就比较简单的透明度变换的动画,前景图是做了好多个动画组,然后随机抽出来下一张图用什么动画来展示。整体配合形成的这么个效果。

至于怎么做的切换,主要就是靠存储前台获得焦点的是哪个来切换的。位置其实没变,主要看哪个显示在前台。

完整代码:

这是组件本体,不包含导入代码,另外我是为了测试效果才写成了可跳转的页面,实际上是当组件用的。

下面我会把用得到的工具类也发出来。包能运行的。对了数据源得自己弄奥。

/**
 * @文件名称: PhotoWayAnimationView.ets
 * @路径: entry/src/main/ets/playsongs/photosway
 * @作者: 魔眼天王
 * @创建时间: 2026/1/16 14:31
 * @描述: 照片上墙播放照片动画的组件
 */
@HMRouter({ pageUrl: PageConstant.PHOTOS_ON_THE_WAY1 })
@Component
export struct PhotoWayAnimationView {
  @State animate: boolean = false;
  //背景层
  @State imgBGUrl1: string | null = null
  @State imgBGUrl2: string | null = null
  @State activeBg: '1' | '2' = '1' // 控制背景的淡入淡出

  //前景层
  @State imgUrl1: string | null = null
  @State imgUrl2: string | null = null
  @State foreUrl1: string = ''
  @State foreUrl2: string = ''
  @State animConfig1: PhotoAnimConfig | null = null//动画配置
  @State animConfig2: PhotoAnimConfig | null = null
  @State activeFore: '1' | '2' = '1' // 当前显示哪一个
  private timer: number = -1

  aboutToAppear() {
    //走跳转方式的,从 TV 照片上墙页过来的  拿数据
    const currentParam = HMRouterMgr.getCurrentParam()
    const paramsMap = currentParam as Record<string, Object>
    if (paramsMap && paramsMap['urls']) {
      const items = paramsMap['urls'] as proto_kg_tv.photoUrlItem[]
      const urls = items.map(item => item.fileUrl) // 提取 url 字符串
      this.addPhotos(urls)
    }

    this.timer = setInterval(() => {
      this.nextPhoto()
    }, 2500)
  }

  aboutToDisappear() {
    clearInterval(this.timer)
    photoQueueManager.clear()
  }

  build() {
    Stack() {
      Image(this.imgBGUrl1)
        .width('100%')
        .height('100%')
        .blur(20)
        .opacity(this.activeBg === '1' ? 1 : 0) // 状态驱动
        .animation({ duration: 1500 }) // 1.5秒淡入淡出

      Image(this.imgBGUrl2)
        .width('100%')
        .height('100%')
        .blur(20)
        .opacity(this.activeBg === '2' ? 1 : 0)
        .animation({ duration: 1500 })

      // 前景  1
      PhotoWayItem({
        imgUrl: this.foreUrl1,
        config: this.animConfig1,
        isActive: this.activeFore === '1' // 传入激活状态
      })

      // 前景 2
      PhotoWayItem({
        imgUrl: this.foreUrl2,
        config: this.animConfig2,
        isActive: this.activeFore === '2'
      })
    }
    .width('100%')
    .height('100%')
  }


  // 添加照片数据
  public addPhotos(urls: string[]) {
    urls.forEach(url => photoQueueManager.addPhotoUrl(url))

    // 只要都没有 URL,就说明是初始状态,立即开始
    if (!this.foreUrl1 && !this.foreUrl2) {
      this.nextPhoto()
    }
  }

  nextPhoto() {
    let newUrl = photoQueueManager.getNextUrl()
    if (!newUrl) return
    //  处理背景 (简单的淡入淡出)
    if (this.activeBg === '1') {
      this.imgBGUrl2 = newUrl
      this.activeBg = '2' // 2 淡入,1 淡出
    } else {
      this.imgBGUrl1 = newUrl
      this.activeBg = '1' // 1 淡入,2 淡出
    }

    //  处理前景 (复杂的漫游动画)
    let newConfig = animHelper.getRandomAnimation()
    if (this.activeFore === '1') {
      // 准备 2 (加载新图,设置新动画)
      this.foreUrl2 = newUrl
      this.animConfig2 = newConfig
      //  切换焦点到 2 (触发 2 淡入,1 淡出)
      this.activeFore = '2'
    } else {
      //  准备 1
      this.foreUrl1 = newUrl
      this.animConfig1 = newConfig
      // 切换焦点到 1
      this.activeFore = '1'
    }
  }
}

动画管理类,用于管理动画形式

/**
 * @文件名称: PhotoAnimHelper.ets
 * @路径: entry/src/main/ets/playsongs/photosway
 * @作者: 魔眼天王
 * @创建时间: 2026/1/16 16:43
 * @描述: 照片上墙动画工具类
 */
export class PhotoAnimHelper {
  private lastRandom: number = 0; // 记录上一次的随机值,防止重复

  // 默认初始状态
  private static readonly DEFAULT_TRANSFORM: AnimTransform = {
    scaleX: 1.0,
    scaleY: 1.0,
    rotateAngle: 0,
    translateX: '0%',
    translateY: '0%',
    transformOriginX: '50%',
    transformOriginY: '50%'
  }

  private static copyTransform(source: AnimTransform): AnimTransform {
    return {
      scaleX: source.scaleX,
      scaleY: source.scaleY,
      rotateAngle: source.rotateAngle,
      translateX: source.translateX,
      translateY: source.translateY,
      transformOriginX: source.transformOriginX,
      transformOriginY: source.transformOriginY
    };
  }

  /**
   * 生成随机动画配置
   * @param isLongImage 是否是长图
   */
  public getRandomAnimation(isLongImage: boolean = false): PhotoAnimConfig {
    let r = Math.random();

    // 防重复逻辑 如果随机数和上次太接近,或者长图遇到了纵向移动的随机段(0.3~0.4),则重随
    let retryCount = 0;
    while (retryCount < 5 && ((Math.abs(r - this.lastRandom) < 0.1) || (isLongImage && r <= 0.4 && r > 0.3))) {
      r = Math.random();
      retryCount++;
    }

    // 保留一位小数作为下次比较的基准
    this.lastRandom = Math.floor(r * 10) / 10;

    const duration = 2000; //动画时长
    let fromState: AnimTransform = PhotoAnimHelper.copyTransform(PhotoAnimHelper.DEFAULT_TRANSFORM);
    let toState: AnimTransform = PhotoAnimHelper.copyTransform(PhotoAnimHelper.DEFAULT_TRANSFORM);


    if (r > 0.9) {
      // 组合1: 右旋 + 放大
      fromState.rotateAngle = 0;
      toState.rotateAngle = 10;
      fromState.scaleX = 1.0;
      toState.scaleX = 1.05;
      fromState.scaleY = 1.0;
      toState.scaleY = 1.05;
    } else if (r > 0.8) {
      // 组合2: 左旋 + 缩小
      fromState.rotateAngle = 0;
      toState.rotateAngle = -10;
      fromState.scaleX = 1.05;
      toState.scaleX = 1.0;
      fromState.scaleY = 1.05;
      toState.scaleY = 1.0;
    } else if (r > 0.7) {
      // 组合3: 中心点偏上的放大 (Pivot Y=40%)
      fromState.transformOriginY = '40%';
      toState.transformOriginY = '40%';
      fromState.scaleX = 1.0;
      toState.scaleX = 1.05;
      fromState.scaleY = 1.0;
      toState.scaleY = 1.05;
    } else if (r > 0.6) {
      // 组合4: 放大 + 右移漫游 (X: -3% -> 3%)
      fromState.scaleX = 1.0;
      toState.scaleX = 1.05;
      fromState.scaleY = 1.0;
      toState.scaleY = 1.05;
      fromState.translateX = '-3%';
      toState.translateX = '3%';
    } else if (r > 0.5) {
      // 组合5: 中心点偏下的放大 (Pivot=60%)
      fromState.transformOriginX = '60%';
      toState.transformOriginX = '60%';
      fromState.transformOriginY = '60%';
      toState.transformOriginY = '60%';
      fromState.scaleX = 1.0;
      toState.scaleX = 1.05;
      fromState.scaleY = 1.0;
      toState.scaleY = 1.05;
    } else if (r > 0.4) {
      // 组合6: 放大 + 上移漫游 (Y: 10% -> -10%)
      fromState.scaleX = 1.0;
      toState.scaleX = 1.05;
      fromState.scaleY = 1.0;
      toState.scaleY = 1.05;
      fromState.translateY = '10%';
      toState.translateY = '-10%';
    } else if (r > 0.3) {
      // 组合7: 放大 + 下移漫游 (Y: -10% -> 10%)
      fromState.scaleX = 1.0;
      toState.scaleX = 1.05;
      fromState.scaleY = 1.0;
      toState.scaleY = 1.05;
      fromState.translateY = '-10%';
      toState.translateY = '10%';
    } else if (r > 0.1) {
      // 组合8 & 9 (0.1~0.3): 右旋 + 放大 (重复高频效果)
      fromState.rotateAngle = 0;
      toState.rotateAngle = 10;
      fromState.scaleX = 1.0;
      toState.scaleX = 1.05;
      fromState.scaleY = 1.0;
      toState.scaleY = 1.05;
    } else {
      // 兜底: 放大 + 右移
      fromState.scaleX = 1.0;
      toState.scaleX = 1.05;
      fromState.scaleY = 1.0;
      toState.scaleY = 1.05;
      fromState.translateX = '-3%';
      toState.translateX = '3%';
    }

    return {
      duration: duration,
      from: fromState,
      to: toState
    };
  }
}
export const animHelper = new PhotoAnimHelper()

// 定义动画属性接口
export interface AnimTransform {
  scaleX: number;
  scaleY: number;
  rotateAngle: number;
  translateX: string; // 使用百分比字符串,如 '10%'
  translateY: string;
  transformOriginX: string; // 旋转/缩放中心点 X
  transformOriginY: string; // 旋转/缩放中心点 Y
}

// 动画配置对象(包含开始和结束状态)
export interface PhotoAnimConfig {
  duration: number; // 动画时长 (ms)
  from: AnimTransform; // 起始状态
  to: AnimTransform; // 结束状态
}

组件前景图的那个控件,写在一起代码太多了,就给摘出来了。

/**
 * @文件名称: PhotoWayItem.ets
 * @路径: entry/src/main/ets/playsongs/photosway
 * @作者: 魔眼天王
 * @创建时间: 2026/1/16 16:57
 * @描述: 照片上墙模式下录唱页的那个带动画的图片展示控件
 */
@Component
export struct PhotoWayItem {
  @Prop imgUrl: string
  @Prop config: PhotoAnimConfig|null
  @Prop @Watch('onActiveChange')isActive: boolean = false // 控制显隐和动画触发

  @State currentTransform: AnimTransform = {
    scaleX: 1, scaleY: 1, rotateAngle: 0,
    translateX: '0%', translateY: '0%',
    transformOriginX: '50%', transformOriginY: '50%'
  }

  aboutToAppear() {
    // 首次加载如果就是激活状态,也要触发
    if (this.isActive) {
      this.onActiveChange()
    }
  }


  build() {
    Image(this.imgUrl)
      .width('100%')
      .height('100%')
      .objectFit(ImageFit.Contain)
      .alt($r('app.media.page_bg'))
      // --- 绑定动画属性 ---
      .scale({
        x: this.currentTransform.scaleX,
        y: this.currentTransform.scaleY,
        centerX: this.currentTransform.transformOriginX,
        centerY: this.currentTransform.transformOriginY
      })
      .rotate({
        angle: this.currentTransform.rotateAngle,
        centerX: this.currentTransform.transformOriginX,
        centerY: this.currentTransform.transformOriginY
      })
      .translate({
        x: this.currentTransform.translateX,
        y: this.currentTransform.translateY
      })
      .opacity(this.isActive ? 1 : 0)
      .animation({ duration: 2000, curve: Curve.Linear })

  }


  // 监听 isActive 变化
  onActiveChange() {
    if (this.isActive&& this.config) {
      // === 进场逻辑 ===
      //  立即重置到起始状态
      this.currentTransform = this.config.from

      //  执行漫游动画 (2秒)
      animateTo({
        duration: this.config?.duration,
        curve: Curve.FastOutSlowIn,
        playMode: PlayMode.Normal,
      }, () => {
        if (this.config) {
          this.currentTransform = this.config.to
        }
      })
    }
  }
}

队列管理器,用于管理前后展示逻辑。

/**
 * @文件名称: PhotoQueueManager.ets
 * @路径: entry/src/main/ets/playsongs/photosway
 * @作者: 魔眼天王
 * @创建时间: 2026/1/16 16:40
 * @描述: 照片墙队列管理器
 */
export class PhotoQueueManager {
  private waitUrls: string[] = []; // 等待展示的队列 (urls)
  private shownUrls: string[] = []; // 已经展示过的历史池 (oldUrls)

  /**
   * 添加新照片
   * 逻辑:50%概率插队头,50%插队尾
   */
  public addPhotoUrl(url: string): void {
    if (!url) return;

    // 简单防重 如果已经在等待队列里,就不加了
    if (this.waitUrls.includes(url)) return;

    if (Math.random() > 0.5) {
      this.waitUrls.unshift(url); // 插队头
    } else {
      this.waitUrls.push(url);    // 插队尾
    }
  }

  /**
   * 批量移除照片(比如用户撤回)
   */
  public removeBatchUrls(urls: string[]): void {
    if (!urls || urls.length === 0) return;

    // 从两个队列里都删掉
    this.waitUrls = this.waitUrls.filter(u => !urls.includes(u));
    this.shownUrls = this.shownUrls.filter(u => !urls.includes(u));
  }

  /**
   * 获取下一张要展示的照片
   * @returns 图片url,如果没有图片则返回 null
   */
  public getNextUrl(): string | null {
    //如果等待队列空了,但历史池有货 -> 把历史池倒回来 (实现循环)
    if (this.waitUrls.length === 0 && this.shownUrls.length > 0) {

      this.waitUrls = [...this.shownUrls];
      this.shownUrls = []; // 清空历史池
    }

    //还没货?那就是真没图了
    if (this.waitUrls.length === 0) {
      return null;
    }

    // 取出第一张
    const url = this.waitUrls.shift();

    // 放入历史池   以此实现无限循环
    if (url) {
      this.shownUrls.push(url);
    }

    return url || null;
  }

  /**
   * 重置/清空
   */
  public clear(): void {
    this.waitUrls = [];
    this.shownUrls = [];
  }

  /**
   * 是否有图
   */
  public hasPhotos(): boolean {
    return this.waitUrls.length > 0 || this.shownUrls.length > 0;
  }
}
export const photoQueueManager = new PhotoQueueManager

Logo

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

更多推荐