图片预览原来可以这么写

287 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情


现有个需求,实现聊天图片预览功能,即实现图片缩放与移动。

注:使用的是vue3和ts,基于移动端

初始图.png

面对这个需求,涌入我脑中的第一个想法就是通过改变图片宽高来实现缩放,通过改变图片的中心点x轴与y轴的坐标来实现移动,想必很多人和我一样是这样的想法吧?No!这样写就很不优雅了,我的导师给了我一个思路。


实现

关于移动缩放,transform都包括,我们可以通过绑定图片的transform样式来实现缩放和移动,具体如下:

1. 移动

首先,创建一个数据结构用于存储图片在x轴与y轴上移动的数值

import { reactive } from "vue";
const d = reactive({
  dx: 0,
  dy: 0,
});

然后,将图片style的transfrom与我们图片移动值进行绑定

<img ref="innerImg" :style="{ transform }" class="img" src="https://pic.rmb.bdstatic.com/bjh/news/93833881c80bc8a31743a6a6fd3bcfe6.jpeg"/>
const transform = computed(
  () => `translateX(${d.dx}px) translateY(${d.dy}px)`
);

然后就是监听图片移动事件,获取图片在x轴与y轴上移动的数值,关于怎么获取移动端手指触发相关事件,导师推荐我使用hammer.js。 那然后就是安装引入hammer.js了,不多赘述,监听hammer.js的pan移动事件来获取图片在x轴与y轴上移动的数值。

import Hammer from "hammerjs";

const innerImg = ref<HTMLImageElement>();
const previewImg = () => {
  const mc = new Hammer(img.value, {
    recognizers: [[Hammer.Pan], [Hammer.Pinch]],
  });
  
  //上一次移动结束时移动的x值与y值
  let finalX = 0;
  let finalY = 0;

  // 移动图片
  mc.on("pan", (ev) => {
    //是否点击打开预览图片
    //if (ready.value) {
      //是否停止移动
      if (!ev.isFinal) {
        //deltaX为单次移动改变的x值,finalX为上一次移动结束后的总的改变的x值
        //targetX为总的改变的x值,y值同理
        let targetX = ev.deltaX + finalX;
        let targetY = ev.deltaY + finalY;
      } else {
        // 处理第一次平移之后会从初始位置开始的问题
        // 用finalX保存上一次停止时移动的总x值
        finalX = d.dx;
        finalY = d.dy;
      }
    //}
  });
};

同时,要为图片添加transform移动样式,否则移动不平滑。

.img {
    width: 100%;
    transition: transform 0.1s linear;
  }

然后,现在面临一个问题,图片会移出屏幕,要给图片限制边界,我所做的处理是至少图片有一部分留在屏幕内。 在以上基础上添加边界判断:

const imgWidth = innerImg.value?.width ?? 0;
const imgHeight = innerImg.value?.height ?? 0;
const top = innerImg.value?.offsetTop ?? 0;

let targetX = ev.deltaX + finalX;
let targetY = ev.deltaY + finalY;

// 做边界限制,图片不能全部移动至屏幕外
if ((targetX <= width && targetX >= 50 - imgWidth) && (targetY <= height - top && targetY >= 50 - imgHeight - top)) {
  isBoundary = false;
  d.dx = ev.deltaX + finalX
  d.dy = ev.deltaY + finalY
  return
}

//当图片超出时,如果下一次移动时往屏幕内侧,则允许其移动,
//否则,不可移动
if (targetX > width || targetX < 50 - imgWidth) {
  if (Math.abs(ev.deltaX + d.dx) < Math.abs(d.dx)) {
    d.dx = ev.deltaX + finalX;
    d.dy = ev.deltaY + finalY;
  }
}

if (targetY > height || targetY < 50 - imgHeight - top) {
  if (Math.abs(ev.deltaY + d.dy) < Math.abs(d.dy)) {
    d.dx = ev.deltaX + finalX;
    d.dy = ev.deltaY + finalY;
  }
}

当图片移动至屏幕边界,宽或高留50px在屏幕内

边界图.png

2. 缩放

首先,绑定transform的scale属性来实现缩放

const d = reactive({
  dx: 0,
  dy: 0,
  scale: 1,
})
const transform = computed(() => `translateX(${d.dx}px) translateY(${d.dy}px) scale(${d.scale}) `)

然后,监听hammer.js的pinch双指缩放事件来获取缩放值

  mc.on("pinch", (ev) => {
    //判断缩放值是否大于0.1,太小的话忽略,否则有不必要的误触抖动
    if (ready.value && d.scale * ev.scale >= 0.1) {
      const top = innerImg.value?.offsetTop ?? 0;
      
      if (ev.srcEvent.type === "pointermove") {
        let temp = Number((finalScale * ev.scale).toFixed(2));
        if (Math.abs(temp - d.scale) / d.scale > 0.05) {
            d.scale = temp;
        }
      }
      if (ev.srcEvent.type === "pointerup") {
        // finalScale与finalX类似,存储上一次的缩放值
        //否则scale会从1重新开始变化与渲染
        finalScale = d.scale;
      }
    }
  })
缩放.jpg

处理到达边界问题,到达边界后,不允许缩小,只允许放大,否则,图片可能缩小至屏幕外

let isBoundary = false; //是否到达屏幕边界

// 移动到边界时,将isBoundary置为true
if (targetX > width || targetX < 50 - imgWidth * d.scale) {
  isBoundary = true
  ......
}

if (targetY > height || targetY < 50 - imgHeight * d.scale - top) {
  isBoundary = true
  ......
}
if (!isBoundary) {
    canScale = true;
} else {
    if (ev.scale < 1) {
      canScale = true;
    } else {
      canScale = false;
    }
}
mc.on("pinch", (ev) => {
//判断缩放值是否大于0.1,太小的话忽略,否则有不必要的误触抖动
if (ready.value && d.scale * ev.scale >= 0.1) {
  const top = innerImg.value?.offsetTop ?? 0;

  if (ev.srcEvent.type === "pointermove") {
    let temp = Number((finalScale * ev.scale).toFixed(2));
    if (Math.abs(temp - d.scale) / d.scale > 0.05) {
      if (canScale) {
        d.scale = temp;
      }
    }
  }
  if (ev.srcEvent.type === "pointerup") {
    // finalScale与finalX类似,存储上一次的缩放值
    //否则scale会从1重新开始变化与渲染
    finalScale = d.scale;
  }
}
})

解决缩放与移动互相影响的问题

然后,我们就迎来了最重要的问题,移动与缩放会相互影响,当我们先缩放再移动,会发现我们可能会移出边界;或者缩放后再次缩放会移动位置,不是我们想要的效果。

1.移出边界

那先解决第一种,先缩放再移动会移出边界,问题是怎么造成的呢? 当我们缩放后,图片的宽高就会改变,但我们的用来判断边界的图片宽高值没有更新,我们需要根据图片的缩放值来设置边界值,即将图片宽高变为缩放后的图片宽高imgWidth * d.scale

const imgWidth = innerImg.value?.width ?? 0;
const imgHeight = innerImg.value?.height ?? 0;
const top = innerImg.value?.offsetTop ?? 0;

let targetX = ev.deltaX + finalX;
let targetY = ev.deltaY + finalY;

// 做边界限制,图片不能全部移动至屏幕外
if ((targetX <= width && targetX >= 50 - imgWidth * d.scale) && (targetY <= height - top && targetY >= 50 - imgHeight * d.scale - top)) {
  isBoundary = false;
  d.dx = ev.deltaX + finalX
  d.dy = ev.deltaY + finalY
  return
}

//当图片超出时,如果下一次移动时往屏幕内侧,则允许其移动,
//否则,不可移动
if (targetX > width || targetX < 50 - imgWidth * d.scale) {
  if (Math.abs(ev.deltaX + d.dx) < Math.abs(d.dx)) {
    d.dx = ev.deltaX + finalX;
    d.dy = ev.deltaY + finalY;
  }
}

if (targetY > height || targetY < 50 - imgHeight * d.scale - top) {
  if (Math.abs(ev.deltaY + d.dy) < Math.abs(d.dy)) {
    d.dx = ev.deltaX + finalX;
    d.dy = ev.deltaY + finalY;
  }
}

2.缩放后位置改变

缩放后位置改变是因为缩放后没有更新d.dx与d.dy值,那想正确更新d.dx值与d.dy值,需要知道其改变的x轴与y轴的值,为此,进行了一番推演(不知道是不是弄复杂了,欢迎提出更简单的方法):

为了方便,同时也是为了实现手指放哪就以哪里为中心缩放哪里,将图片transform中心点设为(0, 0)(其默认transfrom中心点为图片中心点)。

<img ref="innerImg" :style="{ transform,transformOrigin:origin
     }" class="img" src="https://pic.rmb.bdstatic.com/bjh/news/93833881c80bc8a31743a6a6fd3bcfe6.jpeg" />
const origin = computed(() => `0px 0px`)

缩放图解.png

如图,初始时图片宽度占满屏幕,点1是我们手指放下时初始中心点,hammer.js的pinch事件可以获取到该中心点数值(cx, cy),当我们放大时,该点就会移到点2,而为了让图片缩放不移动,我们要做的就是将图片根据从点2到点1的路径移回去,以此种方式实现手指放哪就以哪里为中心缩放哪里,所以在缩放结束后我们要在x轴上移动dx,在y轴上移动dy。

由图可知:
cx/(cx+dx)= (cx*s1)/(cx*s2)
s1为原缩放的scale,s2为放大后的scale
则dx=cx(s2-s1)/s1
然后,处理上一次可能移动过的情况,
cx = cx - d.dx(上一次移动的值)

然后我们就得到了缩放后应该移动的x轴的距离,

注意,y轴上距离上侧的距离包括图片距离屏幕上侧的offsetTop。

熊猫头哭.webp

果然,数学是编程的基础,哭。

最后,图片缩放完成。

// 放大/缩小图片
mc.on("pinch", (ev) => {
if (ready.value && d.scale * ev.scale >= 0.1) {
  const top = innerImg.value?.offsetTop ?? 0;

  if (!isBoundary) {
    canScale = true;
  } else {
    if (ev.scale < 1) {
      canScale = true;
    } else {
      canScale = false;
    }
  }
  if (ev.srcEvent.type === "pointermove") {
    let temp = Number((finalScale * ev.scale).toFixed(2));
    if (Math.abs(temp - d.scale) / d.scale > 0.05) {
      const lx = ev.center.x - d.dx;
      const ly = ev.center.y - top - d.dy;
      const changeScaleRatio = (temp - d.scale) / d.scale;
      // canScale限制其超出边界不可缩放
      if (canScale) {
        d.scale = temp;
        d.dx -= lx * changeScaleRatio;
        d.dy -= ly * changeScaleRatio;
        // 解决缩放后立刻移动后产生的抖动
        finalX = d.dx 
        finalY = d.dy
      }
    }
  }
  if (ev.srcEvent.type === "pointerup") {
    finalScale = d.scale;
  }
}
})

至此为止,图片预览已全部完成,代码有存在码上掘金。

最后,感谢导师的指导,如有问题,欢迎指出。