ArkTs版图库预览

135 阅读7分钟

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

图片的放大缩小预览本质上就是两个属性:translatescalescaleCenter 可选可不选。这里涉及到的主要难点是:沿着双击点缩放、双指捏合放大缩小以及放大后还要支持手指滑动预览不同区域

首先我们需要明确沿着双击点缩放、双指捏合放大缩小本质上是一样的,就是改变图片的 scaleCenter(css 也叫 transform-origin、缩放锚点)

241f74036047e038bd7b5372cc3945c8.jpg

那么如果依赖 scaleCenter , 有什么好处与坏处? 设置scaleCenter ,本质上是系统底层帮我们进行缩放。好处就是我们不需要进行一系列计算,坏处是我们就无法得知缩放后图片的位置信息,我们只能通过系统api去获取图片到屏幕边界的距离。考虑到该 api 的可靠性和耗时性,本文将不设置scaleCenter、手动计算。

scaleCenter 到底改变了什么? 如果图片不放大或者缩小,那么scaleCenter 将毫无作用,一旦图片放大or 缩小了,那么不论scaleCenter 如何改变,图片的最终尺寸是不会改变的、因此 scaleCenter 仅仅是改变了图片的位置、也就是偏移量。 由于图片默认 scaleCenter 是 图片的中心点 ,因此只需要算出 scaleCenter 与 图片的中心点 的距离差、 再 * 放大倍数 就是图片的偏移量

// 计算 中心点偏移量
 const moveX = (item.imgWidth / 2 - scaleCenterX) * (newScale - item.scaleX); 
 const moveY = (item.imgHeight / 2 - scaleCenterY) * (newScale - item.scaleY); 
 // 中心点真实坐标 
 item.translateCenterX += moveX;
 item.translateCenterY += moveY; 

注:imgWidth / 2 为图片中心点、也就是默认的scaleCenter、这里的scaleCenterX-Y 需要将event的x-y转化为图片上的位置,具体实现可以参考如下代码

function displayPointToImage(item: MediaParams, x: number, y: number): number[] { // 将屏幕上的坐标转化到图片上的坐标, 即scaleCenter
  const realWidth = item.imgWidth * item.scale;
  const realHeight = item.imgHeight * item.scale;
  const leftBorder = item.imgWidth / 2 + item.translateCenterX + item.translateX - realWidth / 2;
  x -= leftBorder;
  x = Math.min(x, realWidth);
  const topBorder = item.imgHeight / 2 + item.translateCenterY + item.translateY - realHeight / 2;
  y -= topBorder;
  y = Math.min(y, realHeight);
  return [x / item.scale, y / item.scale];
}

到这里就解决了缩放的偏移量,还有滑动的偏移量。由于两者其实没有本质联系,直接分开计算,最终相加即可。

.translate({ 
    x: item.translateX + item.translateCenterX, 
    y: item.translateY + item.translateCenterY 
}) 
.scale({
    x: item.scaleX
    y: item.scaleY
})

滑动偏移量很简单,监听手势移动距离进行相加减即可,问题是如何判断是否滑动到边界。 本文采用的方案为直接计算:

    leftBorder = imgWidth / 2 + translateX + translateCenterX - realWidth / 2; 
    rightBorder = leftBorder + realWidth;

注:realWidth为图片缩放后的大小(imgWidth * scale )、判断 leftBorder <= 0 和 rightBorder >= screenWidth 即可判断是否抵达左右边界

那么接下来会引入一个新的问题、抵达边界后进行缩小,要如何缩小

d3a1c52ac21be8037a7eb7948c46193e.jpg

如果抵达边界后不调整scaleCenter,那么它会沿着中心点(or 手指位置)缩小、这样会出现黑边,不符合预期。

为了不出现黑边、我们在抵达边界需要调整scaleCenter,具体如下:

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 = 6; // 图片最大放大倍数
const TAP_SCALE_AVOID_FACTOR = 1.2; // 双击放大时出现黑边避让系数

const WHITE_COLOR = '#ffffff';

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 displayPointToImage(item: MediaParams, x: number, y: number): number[] { // 将屏幕上的坐标转化到图片上的坐标, scaleCenter
  const realWidth = item.imgWidth * item.scale;
  const realHeight = item.imgHeight * item.scale;
  const leftBorder = item.imgWidth / 2 + item.translateCenterX + item.translateX - realWidth / 2;
  x -= leftBorder;
  x = Math.max(0, Math.min(x, realWidth));
  const topBorder = item.imgHeight / 2 + item.translateCenterY + item.translateY - realHeight / 2;
  y -= topBorder;
  y = Math.max(0, Math.min(y, realHeight));
  return [x / item.scale, y / item.scale];
}

function tapScaleImage(item: MediaParams, event: GestureEvent, recover = false) {
  if (item.imgWidth === 0 || item.imgHeight === 0) {
    return;
  }
  animateToImmediately({
    duration: 150
  }, () => {
    if (item.scale < MAX_SCALE_TIMES && !recover) {
      const point = displayPointToImage(item, event.fingerList[0].displayX, event.fingerList[0].displayY);
      const scale = item.scale < MAX_SCALE_TIMES / 2 ? MAX_SCALE_TIMES / 2 : MAX_SCALE_TIMES;
      scaleImage(item, scale, point[0], point[1],
        item.params, false);
      // 进行中心点坐标矫正 防止出现黑边
      const realWidth = item.imgWidth * scale;
      const realHeight = item.imgHeight * scale;
      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 && rightBorder > screenWidth) {
        item.translateCenterX -= Math.min(TAP_SCALE_AVOID_FACTOR * leftBorder, rightBorder - screenWidth);
      }
      if (rightBorder < screenWidth && leftBorder < 0) {
        item.translateCenterX += Math.min(TAP_SCALE_AVOID_FACTOR * (screenWidth - rightBorder), -leftBorder);
      }
      const topBorder = item.imgHeight / 2 + item.translateCenterY + item.translateY - realHeight / 2;
      const bottomBorder = topBorder + realHeight;
      if (topBorder > 0 && bottomBorder > screenHeight) {
        item.translateCenterY -= Math.min(TAP_SCALE_AVOID_FACTOR * topBorder, bottomBorder - screenHeight);
      }
      if (bottomBorder < screenHeight && topBorder < 0) {
        item.translateCenterY += Math.min(TAP_SCALE_AVOID_FACTOR * (screenHeight - bottomBorder), -topBorder);
      }
      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) {
  let newScale = Math.min(MAX_SCALE_TIMES, item.pinScale * event.scale);
  const point = displayPointToImage(item, event.pinchCenterX, event.pinchCenterY);
  scaleImage(item, newScale, point[0], point[1], item.params);
}

function scaleImage(item: MediaParams, newScale: number, scaleCenterX: number, scaleCenterY: 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;
  // 缩放中心点-不能超过图片范围
  // 调整缩放中心点 双击放大无需调整
  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;
    }
  } else { // 宽高不够,强制沿着中心点缩放
    if (item.imgWidth * newScale <= screenWidth) {
      scaleCenterX = item.imgWidth / 2;
    }
    if (item.imgHeight * newScale <= screenHeight) {
      scaleCenterY = item.imgHeight / 2;
    }
  }
  // 计算 中心点偏移量
  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) {
  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) => {
        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 ImageGestureModifierGlobal implements GestureModifier {
  item: MediaParams;

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

  applyGesture(event: UIGestureEvent): void {
    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.params.isDetermined) {
            return;
          }
          this.item.panStartX = this.item.translateX;
          this.item.panStartY = this.item.translateY;
        })
        .onActionUpdate((event: GestureEvent) => {
          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(() => {
          // 还原swiper
          this.item.params.disabledSwiper = false;
          this.item.params.isDetermined = false;
          this.item.params.shouldMovePic = false;
          this.item.params.update();
        })
        .onActionCancel(() => {
          // 还原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;

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

  applyGesture(event: UIGestureEvent): void {
    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(() => {
            this.item.panStartX = this.item.translateX;
            this.item.panStartY = this.item.translateY;
            this.item.params.disabledSwiper = true;
            this.item.params.update();
          })
          .onActionUpdate((event: GestureEvent) => {
            panMoveImage(this.item, event);
          })
          .onActionEnd(() => {
            this.item.params.disabledSwiper = false;
            this.item.params.update();
          })
          .onActionCancel(() => {
            this.item.params.disabledSwiper = false;
            this.item.params.update();
          })
      )
    }
    event.addGesture(new GestureGroupHandler({
      mode: GestureMode.Exclusive,
      gestures: normalGesture
    }))
  }
}

export class MediaParams {
  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;
  horizonBorder = BORDER_ENUM.NORMAL;
  verticalBorder = BORDER_ENUM.NORMAL;
  modifier: ImageGestureModifier | null = null;

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

export class MediaDialogParams {
  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) {
    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) {
  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)
  .gestureModifier(new ImageGestureModifierGlobal(params.mediaData[params.index] as MediaParams))
}

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,
      () => {
        promptAction.closeCustomDialog(contentNode);
      },
      (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.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
        }),
      )
    }
    promptAction.openCustomDialog(contentNode, options);
  }
}