ArkTs版图库预览

本文基于鸿蒙系统,用600行代码实现类似图库预览效果

新建MediaDialogUtil.ets工具类,然后在工程下import 导入使用即可

scss 复制代码
import {MediaParams, MediaDialogUtil} from ./MediaDialogUtil.ets


build() {
    Stack() {
      Text('点击打开弹窗')
        .border({
          width: 10,
          color: '#000000'
        })
        .onClick(() => {
          const data: MediaParams[] = [
            new MediaParams('https://img1.baidu.com/it/u=3858873910,2096255234&fm=253&fmt=auto&app=138&f=JPEG'),
            new MediaParams('https://img2.huashi6.com/images/resource/thumbnail/2025/03/06/15612_3721456001.jpg'),
            new MediaParams('https://gips1.baidu.com/it/u=4077989092,1759249013&fm=3074&app=3074&f=JPEG'),
          ];
          MediaDialogUtil.openDialog(data);
        })
    }
    .width('100%')
    .height('100%')
  }

MediaDialogUtil.ets工具类具体代码如下:

js 复制代码
import { ComponentContent, promptAction } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit';
import { display } from '@kit.ArkUI'

const DURATION_200 = 200;
const ONE_HUNDRED_PERCENT = '100%';
const BLACK_COLOR = '#000000';
const CONST_NUMBER_40 = 40;
const CONST_NUMBER_50 = 50;
const MAX_SCALE_TIMES = 3; // 图片最大放大倍数
const TAP_SCALE_AVOID_FACTOR = 1.2; // 双击放大时出现黑边避让系数

const WHITE_COLOR = '#ffffff';

export enum MEDIA_TYPE {
  PHOTO = 1,
  VIDEO = 2
}

enum BORDER_ENUM {
  LEFT,
  RIGHT,
  TOP,
  BOTTOM,
  NORMAL
}

interface EventImage {
  width: number,
  height: number
}

function resetImage(item: MediaParams) {
  item.translateCenterX = item.centerDistanceX;
  item.translateCenterY = item.centerDistanceY;
  item.translateX = 0;
  item.translateY = 0;
  item.horizonBorder = BORDER_ENUM.NORMAL;
  item.verticalBorder = BORDER_ENUM.NORMAL;
  item.scale = 1;
  item.params?.update();
}

function tapScaleImage(item: MediaParams, event: GestureEvent, recover = false) {
  if (item.imgWidth === 0 || item.imgHeight === 0) {
    return;
  }
  animateToImmediately({
    duration: 150
  }, () => {
    if (!item.params) {
      return
    }
    if (item.scale < MAX_SCALE_TIMES && !recover) {
      const displayX = event.fingerList[0].displayX ?? item.params.screenWidth / 2;
      const displayY = event.fingerList[0].displayY ?? item.params.screenHeight / 2;
      scaleImage(item, MAX_SCALE_TIMES, displayX, displayY, item.params, false);
      // 进行中心点坐标矫正 防止出现黑边
      const realWidth = item.imgWidth * MAX_SCALE_TIMES;
      const realHeight = item.imgHeight * MAX_SCALE_TIMES;
      const screenWidth = item.params.screenWidth;
      const screenHeight = item.params.screenHeight;
      const leftBorder = item.imgWidth / 2 + item.translateCenterX + item.translateX - realWidth / 2;
      const rightBorder = leftBorder + realWidth;
      if (leftBorder > 0) {
        item.translateCenterX -= TAP_SCALE_AVOID_FACTOR * leftBorder;
      }
      if (rightBorder < screenWidth) {
        item.translateCenterX += TAP_SCALE_AVOID_FACTOR * (screenWidth - rightBorder);
      }
      const topBorder = item.imgHeight / 2 + item.translateCenterY + item.translateY - realHeight / 2;
      const bottomBorder = topBorder + realHeight;
      if (topBorder > 0) {
        item.translateCenterY -= TAP_SCALE_AVOID_FACTOR * topBorder;
      }
      if (bottomBorder < screenHeight) {
        item.translateCenterY += TAP_SCALE_AVOID_FACTOR * (screenHeight - bottomBorder);
      }
      checkBorder(item, item.params);
    } else {
      // 还原
      resetImage(item);
    }
  })
}

// 检测图片是否抵达屏幕边缘
function checkBorder(item: MediaParams, params: MediaDialogParams, refresh = true) {
  const realWidth = item.imgWidth * item.scale;
  const realHeight = item.imgHeight * item.scale;
  const screenWidth = params.screenWidth;
  const screenHeight = params.screenHeight;
  item.horizonBorder = BORDER_ENUM.NORMAL;
  item.verticalBorder = BORDER_ENUM.NORMAL;
  if (realWidth > screenWidth) {
    const leftBorder = item.imgWidth / 2 + item.translateCenterX + item.translateX - realWidth / 2;
    const rightBorder = leftBorder + realWidth;
    if (leftBorder >= 0) {
      item.horizonBorder = BORDER_ENUM.LEFT;
    } else if (rightBorder <= screenWidth) {
      item.horizonBorder = BORDER_ENUM.RIGHT;
    }
  }
  if (realHeight > screenHeight) {
    const topBorder = item.imgHeight / 2 + item.translateCenterY + item.translateY - realHeight / 2;
    const bottomBorder = topBorder + realHeight;
    if (topBorder >= 0) {
      item.verticalBorder = BORDER_ENUM.TOP;
    } else if (bottomBorder <= screenHeight) {
      item.verticalBorder = BORDER_ENUM.BOTTOM;
    }
  }
  if (refresh) {
    params.update();
  }
}

function pinScaleImage(item: MediaParams, event: GestureEvent) {
  if (!item.params) {
    return;
  }
  let newScale = Math.min(MAX_SCALE_TIMES, item.pinScale * event.scale);
  const displayX = (event.pinchCenterX ?? item.params.screenWidth / 2);
  const displayY = (event.pinchCenterY ?? item.params.screenHeight / 2);
  scaleImage(item, newScale, displayX, displayY, item.params);
}

function scaleImage(item: MediaParams, newScale: number, displayX: number, displayY: number,
  params: MediaDialogParams, adapt = true) {
  const realWidth = item.imgWidth * item.scale;
  const realHeight = item.imgHeight * item.scale;
  const screenWidth = params.screenWidth;
  const screenHeight = params.screenHeight;
  // 缩放中心点-不能超过图片范围
  let scaleCenterX = Math.min(item.imgWidth, Math.max(displayX - item.centerDistanceX, 0));
  let scaleCenterY = Math.min(item.imgHeight, Math.max(displayY - item.centerDistanceY, 0));
  // 调整缩放中心点 双击放大无需调整
  if (adapt) {
    if (realWidth <= screenWidth) {
      scaleCenterX = item.imgWidth / 2;
    } else if (item.horizonBorder === BORDER_ENUM.LEFT) {
      scaleCenterX = 0;
    } else if (item.horizonBorder === BORDER_ENUM.RIGHT) {
      scaleCenterX = item.imgWidth;
    }
    if (realHeight <= screenHeight) {
      scaleCenterY = item.imgHeight / 2;
    } else if (item.verticalBorder === BORDER_ENUM.TOP) {
      scaleCenterY = 0;
    } else if (item.verticalBorder === BORDER_ENUM.BOTTOM) {
      scaleCenterY = item.imgHeight;
    }
  }
  // 计算 中心点偏移量
  const moveX = (item.imgWidth / 2 - scaleCenterX) * (newScale - item.scale);
  const moveY = (item.imgHeight / 2 - scaleCenterY) * (newScale - item.scale);
  // 中心点真实坐标
  item.translateCenterX += moveX;
  item.translateCenterY += moveY;
  item.scale = newScale;
  checkBorder(item, params, adapt);
}

function panMoveImage(item: MediaParams, event: GestureEvent) {
  if (!item || !item.params) {
    return;
  }
  const deltaX = event.offsetX; // X轴移动的距离
  const deltaY = event.offsetY; // Y轴移动的距离
  const realWidth = item.imgWidth * item.scale;
  const realHeight = item.imgHeight * item.scale;
  const screenWidth = item.params.screenWidth;
  const screenHeight = item.params.screenHeight;
  // 判断边界
  if (realWidth > screenWidth) { // 宽度小于不移动
    const leftBorder = item.imgWidth / 2 + item.translateCenterX + item.panStartX - realWidth / 2;
    const rightBorder = leftBorder + realWidth;
    item.translateX = item.panStartX + Math.min(-leftBorder, Math.max(deltaX, screenWidth - rightBorder));
  }
  if (realHeight > screenHeight) {
    const topBorder = item.imgHeight / 2 + item.translateCenterY + item.panStartY - realHeight / 2;
    const bottomBorder = topBorder + realHeight;
    item.translateY = item.panStartY + Math.min(-topBorder, Math.max(deltaY, screenHeight - bottomBorder));
  }
  checkBorder(item, item.params);
}

function generateCommonGesture(item: MediaParams, params: MediaDialogParams) {
  return [
    new TapGestureHandler({ count: 2 })
      .onAction((event: GestureEvent) => {
        tapScaleImage(item, event);
      }),
    new TapGestureHandler({ count: 1 })
      .onAction(() => {
        params.close();
      }),
    new PinchGestureHandler({ distance: 1 })
      .onActionStart(() => {
        item.pinScale = item.scale;
        params.disabledSwiper = true;
        params.update();
      })
      .onActionUpdate((event: GestureEvent) => {
        if (!item) {
          return;
        }
        pinScaleImage(item, event);
      })
      .onActionEnd((event: GestureEvent) => {
        if (item.scale < 1) {
          tapScaleImage(item, event, true);
        }
        params.disabledSwiper = false;
        params.update();
      })
      .onActionCancel(() => {
        params.disabledSwiper = false;
        params.update();
      })
  ];
}

class EmptyModifierGlobal implements GestureModifier {
  applyGesture(event: UIGestureEvent): void {
    event.clearGestures();
  }
}

class ImageGestureModifierGlobal implements GestureModifier {
  item: MediaParams | null = null;

  constructor(item: MediaParams) {
    this.item = item;
  }

  applyGesture(event: UIGestureEvent): void {
    if (!this.item || !this.item.params) {
      return;
    }
    if (this.item.horizonBorder !== BORDER_ENUM.NORMAL) { // 当到达边界的时候、添加手势、代理
      const normalGesture = generateCommonGesture(this.item, this.item.params);
      normalGesture.push(new PanGestureHandler({ fingers: 1 })
        .onActionStart(() => {
          if (!this.item || !this.item.params || this.item.params.isDetermined) {
            return;
          }
          this.item.panStartX = this.item.translateX;
          this.item.panStartY = this.item.translateY;
        })
        .onActionUpdate((event: GestureEvent) => {
          if (!this.item || !this.item.params) {
            return;
          }
          const params = this.item.params;
          if (!params.isDetermined) {
            params.isDetermined = true;
            const deltaX = event.offsetX; // X轴移动的距离
            const deltaY = event.offsetY; // Y轴移动的距离
            if ((this.item.horizonBorder === BORDER_ENUM.LEFT && deltaX < 0) ||
              (this.item.horizonBorder === BORDER_ENUM.RIGHT && deltaX > 0) ||
              (deltaX == 0 && deltaY !== 0)) { // 触发位移逻辑
              params.shouldMovePic = true;
              this.item.params.disabledSwiper = true;
              params.update();
            }
          }
          if (params.shouldMovePic) {
            panMoveImage(this.item, event);
          }
        })
        .onActionEnd(() => {
          if (!this.item || !this.item.params) {
            return;
          }
          // 还原swiper
          this.item.params.disabledSwiper = false;
          this.item.params.isDetermined = false;
          this.item.params.shouldMovePic = false;
          this.item.params.update();
        })
        .onActionCancel(() => {
          if (!this.item || !this.item.params) {
            return;
          }
          // 还原swiper
          this.item.params.disabledSwiper = false;
          this.item.params.isDetermined = false;
          this.item.params.shouldMovePic = false;
          this.item.params.update();
        }))
      event.addParallelGesture(new GestureGroupHandler({
        mode: GestureMode.Exclusive,
        gestures: normalGesture
      }))
    } else {
      event.clearGestures();
    }
  }
}

class ImageGestureModifier implements GestureModifier {
  item: MediaParams | null = null;

  constructor(item: MediaParams) {
    this.item = item;
  }

  applyGesture(event: UIGestureEvent): void {
    if (!this.item || !this.item.params) {
      return;
    }
    if (this.item.horizonBorder !== BORDER_ENUM.NORMAL) {
      event.clearGestures();
      return;
    }
    const normalGesture = generateCommonGesture(this.item, this.item.params);
    if (this.item.scale > 1) { // 图片放大了、添加panGesture、预览
      normalGesture.push(
        new PanGestureHandler({
          fingers: 1,
          direction: PanDirection.All
        })
          .onActionStart(() => {
            if (!this.item || !this.item.params) {
              return;
            }
            this.item.panStartX = this.item.translateX;
            this.item.panStartY = this.item.translateY;
            this.item.params.disabledSwiper = true;
            this.item.params.update();
          })
          .onActionUpdate((event: GestureEvent) => {
            if (!this.item) {
              return;
            }
            panMoveImage(this.item, event);
          })
          .onActionEnd(() => {
            if (!this.item || !this.item.params) {
              return;
            }
            this.item.params.disabledSwiper = false;
            this.item.params.update();
          })
          .onActionCancel(() => {
            if (!this.item || !this.item.params) {
              return;
            }
            this.item.params.disabledSwiper = false;
            this.item.params.update();
          })
      )
    }
    event.addGesture(new GestureGroupHandler({
      mode: GestureMode.Exclusive,
      gestures: normalGesture
    }))
  }
}

export class MediaParams {
  type = MEDIA_TYPE.PHOTO;
  url = '';
  scale = 1;
  pinScale = 1; // 记录pinGesture开始时的scale
  centerDistanceX = 0; // 图片中心点与手机屏幕中心点的距离
  centerDistanceY = 0;
  translateX = 0;
  translateY = 0;
  translateCenterX = 0; // 图片translate (本质上是中心点的位移动)
  translateCenterY = 0;
  panStartX = 0; // 记录panGesture开始的translateCenterX
  panStartY = 0; // 记录panGesture开始的translateCenterY
  imgWidth: number = 0;
  imgHeight: number = 0;
  params: MediaDialogParams | null = null;
  horizonBorder = BORDER_ENUM.NORMAL;
  verticalBorder = BORDER_ENUM.NORMAL;
  modifier: ImageGestureModifier | null = null;

  constructor(url: string) {
    this.url = url;
  }
}

export class MediaDialogParams {
  isShow = true;
  mediaData: MediaParams[] = [];
  isFullScreen: boolean = false;
  index: number = 0;
  close: (immediate?: boolean) => void; // 关闭方法
  swiperController: SwiperController = new SwiperController();
  update: (param?: MediaDialogParams) => void; // 更新节点方法
  screenWidth: number = 0; // 屏幕宽度、单位vp
  screenHeight: number = 0;
  disabledSwiper = false;
  isDetermined = false; // 决定是图片位移还是swiper移动
  shouldMovePic = false;

  constructor(mediaData: MediaParams[], close: (immediate?: boolean) => void,
    update: (param?: MediaDialogParams) => void, screenWidth: number, screenHeight: number) {
    mediaData.forEach((item) => {
      item.params = this;
      if (item instanceof MediaParams) {
        item.modifier = new ImageGestureModifier(item);
      }
      this.mediaData.push(item);
    })
    this.close = close;
    this.update = update;
    this.screenWidth = screenWidth;
    this.screenHeight = screenHeight;
  }
}

@Builder
function MediaNavigation(params: MediaDialogParams) {
  Row() {
    Text('').width(CONST_NUMBER_40).height(CONST_NUMBER_40) // 布局占位节点
    Text(`${params.index + 1}/${params.mediaData.length}`).fontColor(WHITE_COLOR).fontSize(20)
    Image('').width(CONST_NUMBER_40)
      .height(CONST_NUMBER_40)
      .onClick(() => {
        params.close();
      })
  }
  .width(ONE_HUNDRED_PERCENT)
  .backgroundColor(Color.Transparent)
  .justifyContent(FlexAlign.SpaceBetween)
  .margin({
    top: CONST_NUMBER_50,
    left: CONST_NUMBER_40,
    right: CONST_NUMBER_40
  })
  .hitTestBehavior(HitTestMode.None)
}

// 图片加载完成更新图片info
function updateImageInfo(item: MediaParams, event?: EventImage) {
  item.imgWidth = px2vp(event?.width ?? 0);
  item.imgHeight = px2vp(event?.height ?? 0);
  if (item.imgWidth === 0 || item.imgHeight === 0 || !item.params) {
    return;
  }
  // 根据ImageFit计算图片的真实宽高
  let aspectRatio = item.imgWidth / item.imgHeight;
  let screenAspectRation = item.params.screenWidth / item.params.screenHeight;
  if (aspectRatio >= screenAspectRation) { // 宽度铺满
    item.imgWidth = item.params.screenWidth ?? 0;
    item.imgHeight = (item.params.screenWidth ?? 0) / aspectRatio;
  } else { // 高度铺满
    item.imgWidth = (item.params.screenHeight ?? 0) * aspectRatio;
    item.imgHeight = item.params.screenHeight ?? 0
  }
  // 计算中心点距离
  item.centerDistanceX = (item.params.screenWidth - item.imgWidth) / 2;
  item.centerDistanceY = (item.params.screenHeight - item.imgHeight) / 2;
  item.translateCenterX = item.centerDistanceX;
  item.translateCenterY = item.centerDistanceY;
  item.translateX = 0;
  item.translateY = 0;
  item.params.update();
}

@Builder
function MediaDialog(params: MediaDialogParams) {
  if (params.isShow) {
    Stack({ alignContent: Alignment.Top }) {
      Swiper(params.swiperController) {
        ForEach(params.mediaData, (item: MediaParams) => {
          Stack({ alignContent: Alignment.TopStart }) {
            Image(item.url)
              .onComplete((event?: EventImage) => {
                updateImageInfo(item, event);
              })
              .syncLoad(true)
              .objectFit(ImageFit.Fill)
              .width(item.imgWidth)
              .height(item.imgHeight)
              .scale({
                x: item.scale,
                y: item.scale,
              })
              .translate({
                x: item.translateX + item.translateCenterX,
                y: item.translateY + item.translateCenterY
              })
          }
          .width(ONE_HUNDRED_PERCENT)
          .height(ONE_HUNDRED_PERCENT)
          .gestureModifier(item.modifier)
          .clip(true)
        }, (item: MediaParams, index: number) => {
          return item.url + '_' + index + '_' + item.imgWidth + '_' + item.imgHeight;
        })
      }
      .index(params.index)
      .loop(false)
      .indicator(false)
      .autoPlay(false)
      .width(ONE_HUNDRED_PERCENT)
      .height(ONE_HUNDRED_PERCENT)
      .disableSwipe(params.disabledSwiper)
      .onChange((index: number) => {
        const current = params.mediaData[params.index];
        resetImage(current);
        params.index = index;
        params.isFullScreen = false;
        params.disabledSwiper = false;
        params.isDetermined = false;
        params.shouldMovePic = false;
        params.update();
      })

      if (!params.isFullScreen) {
        MediaNavigation(params)
      }
    }
    .width(ONE_HUNDRED_PERCENT)
    .height(ONE_HUNDRED_PERCENT)
    .backgroundColor(BLACK_COLOR)
    .transition(
      TransitionEffect.asymmetric(
        TransitionEffect.opacity(1).combine(TransitionEffect.scale({
          x: 0,
          y: 0
        })).animation({
          duration: DURATION_200
        }),
        TransitionEffect.opacity(0).combine(TransitionEffect.scale({
          x: 0,
          y: 0
        })).animation({
          duration: DURATION_200
        }),
      ))
    .gestureModifier(
      params.mediaData[params.index] instanceof MediaParams ?
        new ImageGestureModifierGlobal(params.mediaData[params.index] as MediaParams) : new EmptyModifierGlobal()
    )
  } else {
    Column() {

    }.onAppear(() => {
      // TransitionEffect 的退场动销无法触发onFinish、使用animateTo触发
      animateTo({
        duration: DURATION_200,
        onFinish: () => {
          params.close(true);
        }
      }, () => {
      })
    })
  }
}

export class MediaDialogUtil {
  private constructor() {
  }

  public static openDialog(data: MediaParams[]): void {
    const context = (getContext()) as common.UIAbilityContext
    const mainWin = context.windowStage.getMainWindowSync()
    const uiContext = mainWin.getUIContext()
    const promptAction = uiContext.getPromptAction();
    const screenWidth = px2vp(display.getDefaultDisplaySync().width)
    const screenHeight = px2vp(display.getDefaultDisplaySync().height)
    const dialogParams = new MediaDialogParams(
      data,
      (immediate = false) => {
        if (immediate) { // 立马关闭
          promptAction.closeCustomDialog(contentNode);
        } else {
          dialogParams.isShow = false;
          contentNode.update(dialogParams);
        }
      },
      (params?: MediaDialogParams) => {
        contentNode.update(params ?? dialogParams);
      },
      screenWidth,
      screenHeight
    );
    // 创建弹窗组件
    const contentNode = new ComponentContent(
      uiContext,
      wrapBuilder(MediaDialog),
      dialogParams
    );
    const options: promptAction.BaseDialogOptions = {
      alignment: DialogAlignment.Bottom,
      isModal: true,
      autoCancel: false,
      maskColor: Color.Transparent,
      transition: TransitionEffect.IDENTITY
    }
    promptAction.openCustomDialog(contentNode, options);
  }
}
相关推荐
萧曵 丶9 小时前
Vue 中父子组件之间最常用的业务交互场景
javascript·vue.js·交互
Amumu1213810 小时前
Vue3扩展(二)
前端·javascript·vue.js
NEXT0610 小时前
JavaScript进阶:深度剖析函数柯里化及其在面试中的底层逻辑
前端·javascript·面试
牛奶11 小时前
你不知道的 JS(上):原型与行为委托
前端·javascript·编译原理
牛奶12 小时前
你不知道的JS(上):this指向与对象基础
前端·javascript·编译原理
牛奶12 小时前
你不知道的JS(上):作用域与闭包
前端·javascript·电子书
pas13613 小时前
45-mini-vue 实现代码生成三种联合类型
前端·javascript·vue.js
颜酱14 小时前
数组双指针部分指南 (快慢·左右·倒序)
javascript·后端·算法
兆子龙14 小时前
我成了🤡, 因为不想看广告,花了40美元自己写了个鸡肋挂机脚本
android·javascript
SuperEugene14 小时前
枚举不理解?一文让你醍醐灌顶
前端·javascript