uni-app uvue页面安卓端 @touchmove 拖动时获取的event的值忽高忽低造成抖动

uni-app uvue页面安卓端 @touchmove 拖动时获取的event的值忽高忽低造成抖动

开发环境 版本号 项目创建方式
Mac 15 HBuilderX

产品分类:uniapp/App

PC开发环境操作系统:Mac

PC开发环境操作系统版本号:15

HBuilderX类型:正式

HBuilderX版本号:4.87

手机系统:Android

手机系统版本号:Android 11

手机厂商:小米

手机机型:mi9 pro

页面类型:nvue

vue版本:vue3

打包方式:云端

示例代码:

<template>  
  <!-- 页面根容器 -->  
  <view class="page">  
    <!-- 顶部屏幕指示区域 -->  
    <view class="header-area">  
      <!-- 影厅名称 -->  
      <text class="hall-name">1号 4K激光厅</text>  
      <!-- 屏幕形状模拟 -->  
      <view class="screen-shape">  
        <text class="screen-text">银幕中央</text>  
      </view>  
    </view>  

    <!-- 选座区域:左侧行号固定 + 右侧座位区支持拖拽/缩放 -->  
    <view class="seat-viewport">  
      <!-- 左侧固定行号区域 -->  
      <view class="row-axis">  
        <!-- 行号容器,跟随垂直滚动 (panY) 和缩放 (scale) -->  
        <view class="row-axis-transform" :style="{ transform: 'translateY(' + panY + 'px) scale(' + scale + ')' }">  
          <!-- 遍历渲染每一行的行号 -->  
          <view class="row-axis-item" v-for="(row, rIndex) in seatRows" :key="''+rIndex">  
            <view class="row-label">  
              <text class="row-label-text">{{row.label}}</text>  
            </view>  
          </view>  
        </view>  
      </view>  
      <!-- 右侧座位区(主要手势交互区域) -->  
      <view class="seat-area"   
            id="seat-area"  
            @touchstart="onTouchStart"   
            [@touchmove](/user/touchmove)="onTouchMove"  
            @touchend="onTouchEnd"  
            @touchcancel="onTouchEnd">  
        <!-- 座位地图容器,应用平移 (panX, panY) 和缩放 (scale) 变换 -->  
        <view class="seat-map" :style="{ transform: 'translate(' + panX + 'px,' + panY + 'px) scale(' + scale + ')' }">  
          <!-- 遍历渲染每一行座位 -->  
          <view class="row-container" v-for="(row, rIndex) in seatRows" :key="rIndex">  
            <view class="seats-row">  
              <!-- 遍历渲染每一个座位 -->  
              <view class="seat-wrapper" v-for="(seat, cIndex) in row.seats" :key="cIndex">  
                <!-- 仅当 seat.type == 1 时显示真实座位 -->  
                <view v-if="seat.type == 1"   
                      class="seat-item"   
                      :class="seat.status == 1 ? 'seat-selected' : 'seat-unselected'"  
                      @click.stop="handleSeatClick(rIndex, cIndex)">  
                </view>  
              </view>  
            </view>  
          </view>  
        </view>  
      </view>  
    </view>  

    <!-- 底部图例说明区域 -->  
    <view class="footer-area">  
      <view class="legend-item">  
        <view class="seat-item seat-unselected legend-icon"></view>  
        <text class="legend-text">未选{{scale}}</text>  
      </view>  
      <view class="legend-item">  
        <view class="seat-item seat-selected legend-icon"></view>  
        <text class="legend-text">已选</text>  
      </view>  
    </view>  
  </view>  
</template>  

<script setup>  
/**  
 * 座位数据结构定义  
 */  
type Seat = {  
  id : string    // 座位唯一ID,例如 "A1"  
  status : number // 座位状态:0表示未选中,1表示已选中  
  type : number   // 座位类型:1表示真实座位,0表示空白或过道  
}  

/**  
 * 座位行数据结构定义  
 */  
type SeatRow = {  
  label : string  // 行号标签,例如 "A", "B"  
  seats : Seat[]  // 该行包含的所有座位列表  
}  

// 响应式变量:存储所有行的座位数据  
const seatRows = ref<SeatRow[]>([])  

// 地图内容的实际宽度和高度(未缩放时的基准尺寸)  
const mapWidth = ref(0)  
const mapHeight = ref(0)  

// 缩放限制常量配置  
const MIN_SCALE = 0.5 // 允许缩小的最小倍数  
const MAX_SCALE = 1.2 // 允许放大的最大倍数  
const INIT_SCALE = 0.8 // 初始显示的缩放倍数  

// 平移和缩放的状态变量  
// panX, panY: 记录地图相对于容器左上角的偏移量(单位:像素)  
// scale: 记录当前的缩放比例  
const panX = ref(0)  
const panY = ref(0)  
const scale = ref(INIT_SCALE)  

// 手势交互过程中的临时状态变量  
const lastX = ref(0) // 上一次触摸点的X坐标(用于计算拖拽距离)  
const lastY = ref(0) // 上一次触摸点的Y坐标  
const pinchLastDist = ref(0) // 双指缩放时,上一次两指之间的距离  

/**  
 * 活跃触摸点追踪 Map  
 * 用于解决多指触摸不同子元素时,原生 e.touches 可能不准确的问题。  
 * key: touch.identifier (唯一标识)  
 * value: Point 对象 {x, y}  
 */  
type Point = { x: number, y: number }  
const activeTouches = new Map<number, Point>()  

// 手势区域容器的尺寸和屏幕位置(用于计算相对坐标和边界限制)  
const areaWidth = ref(0)  
const areaHeight = ref(0)  
const areaLeft = ref(0)  
const areaTop = ref(0)  

/**  
 * 限制平移范围函数 (clampPan)  
 * 作用:确保座位图在缩放或拖拽后,不会完全跑出可视区域。  
 * 逻辑:  
 * 1. 计算当前缩放下的内容实际宽高等于:基准宽高 * 当前缩放比  
 * 2. 如果内容小于容器尺寸,则强制居中显示  
 * 3. 如果内容大于容器尺寸,则限制 panX/panY 不能超过边界(不能出现过大的留白)  
 */  
function clampPan() {  
  // 1. 计算当前缩放下的内容实际尺寸  
  const contentW = mapWidth.value * scale.value  
  const contentH = mapHeight.value * scale.value  

  // 2. X轴边界处理  
  if (contentW <= areaWidth.value) {  
    // 内容宽度小于容器宽度 -> 居中显示  
    panX.value = (areaWidth.value - contentW) / 2  
  } else {  
    // 内容宽度大于容器宽度 -> 限制拖拽范围  
    const minX = areaWidth.value - contentW  
    // 不能向右拖出边界 (panX 不能大于 0)  
    if (panX.value > 0) panX.value = 0   
    // 不能向左拖出边界 (panX 不能小于 minX)  
    if (panX.value < minX) panX.value = minX   
  }  

  // 3. Y轴边界处理  
  if (contentH <= areaHeight.value) {  
    // 内容高度小于容器高度 -> 居中显示  
    panY.value = (areaHeight.value - contentH) / 2  
  } else {  
    // 内容高度大于容器高度 -> 限制拖拽范围  
    const minY = areaHeight.value - contentH  
    // 不能向下拖出边界  
    if (panY.value > 0) panY.value = 0   
    // 不能向上拖出边界  
    if (panY.value < minY) panY.value = minY   
  }  
}  

/**  
 * 初始化座位数据函数 (initSeats)  
 * 作用:生成 A-N 行,每行 16 列的模拟座位数据  
 */  
function initSeats() {  
  const rows = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N']  
  const cols = 16  
  const newRows : SeatRow[] = []  

  // 基于样式的尺寸常量(与 CSS 保持一致,用于计算总宽高)  
  const STEP_X = 40 // 座位宽度 36px + 右间距 4px  
  const STEP_Y = 46 // 座位高度 36px + 行间距 10px  
  const PAD = 20    // 容器内边距 20px  

  // 计算整个座位图的内容尺寸(基准尺寸)  
  mapWidth.value = cols * STEP_X + PAD * 2  
  mapHeight.value = rows.length * STEP_Y + PAD * 2  

  // 遍历生成每一行的座位数据  
  rows.forEach((rowLabel) => {  
    const seats : Seat[] = []  
    for (let i = 1; i <= cols; i++) {  
      seats.push({  
        id: rowLabel + i,  
        status: 0,  
        type: 1  
      } as Seat)  
    }  
    newRows.push({  
      label: rowLabel,  
      seats: seats  
    } as SeatRow)  
  })  
  seatRows.value = newRows  
}  

// 页面加载生命周期:初始化座位数据  
onLoad(() => {  
  initSeats()  
})  

/**  
 * 重置视图函数 (resetView)  
 * 作用:将缩放比例恢复到初始值,并重新计算位置使其居中  
 */  
function resetView() {  
  scale.value = INIT_SCALE  
  clampPan() // 立即应用边界限制(会自动触发居中逻辑)  
}  

// 页面就绪生命周期:获取容器尺寸并初始化视图  
onReady(() => {  
  const el = uni.getElementById('seat-area')  
  if (el != null) {  
    const rect = el.getBoundingClientRect()  
    areaWidth.value = rect.width  
    areaHeight.value = rect.height  
    areaLeft.value = rect.left  
    areaTop.value = rect.top  
    resetView() // 初始化视图位置  
  }  
})  

/**  
 * 限制缩放比例函数 (clampScale)  
 * @param v 目标缩放值  
 * @returns 限制在 [MIN_SCALE, MAX_SCALE] 区间内的值  
 */  
function clampScale(v: number) : number {  
  if (v < MIN_SCALE) return MIN_SCALE  
  if (v > MAX_SCALE) return MAX_SCALE  
  return v  
}  

/**  
 * 辅助函数:计算两点之间的距离  
 * 使用勾股定理:sqrt((x2-x1)^2 + (y2-y1)^2)  
 */  
function getDistanceByPoints(p1: Point, p2: Point) : number {  
  const x = p2.x - p1.x  
  const y = p2.y - p1.y  
  return Math.sqrt(x * x + y * y)  
}  

/**  
 * 触摸开始事件处理 (onTouchStart)  
 * 作用:记录触摸点初始位置,判断是单指拖拽还是双指缩放的开始  
 */  
function onTouchStart(e: TouchEvent) {  
  // 1. 遍历 changedTouches,更新活跃触摸点 Map  
  // changedTouches 包含了本次事件中状态发生改变的触摸点  
  for (let i = 0; i < e.changedTouches.length; i++) {  
    const t = e.changedTouches[i]  
    activeTouches.set(t.identifier, { x: t.screenX, y: t.screenY } as Point)  
  }  

  // 2. 将 Map 中的触摸点转换为数组,方便后续处理  
  const points : Point[] = []  
  activeTouches.forEach((value, key) => {  
    points.push(value)  
  })  

  // 3. 根据屏幕上当前的触摸点数量决定逻辑  
  if (activeTouches.size == 1) {  
    // 情况 A:单指模式  
    // 记录当前触摸点坐标,作为拖拽的起始参照点  
    lastX.value = points[0].x  
    lastY.value = points[0].y  
    // 重置双指距离,确保不会误触发缩放逻辑  
    pinchLastDist.value = 0  
  } else if (activeTouches.size == 2) {  
    // 情况 B:双指模式  
    // 计算两指之间的初始距离,作为缩放的基准距离  
    pinchLastDist.value = getDistanceByPoints(points[0], points[1])  
  }  
}  

/**  
 * 触摸移动事件处理 (onTouchMove)  
 * 作用:处理拖拽(平移)和双指缩放逻辑  
 */  
function onTouchMove(e: TouchEvent) {  
  // 1. 更新活跃触摸点 Map 中对应的坐标信息  
  for (let i = 0; i < e.changedTouches.length; i++) {  
    const t = e.changedTouches[i]  
    // 只有 Map 中存在的点才更新(防止意外)  
    if (activeTouches.has(t.identifier)) {  
      activeTouches.set(t.identifier, { x: t.screenX, y: t.screenY } as Point)  
    }  
  }  

  // 2. 获取所有活跃点数组  
  const points : Point[] = []  
  activeTouches.forEach((value, key) => {  
    points.push(value)  
  })  
  console.log(activeTouches,"activeTouches");  
  // 情况1:单指拖拽逻辑  
  // 条件:屏幕上只有1个手指,且没有记录双指距离(非缩放状态)  
  if (activeTouches.size == 1 && pinchLastDist.value == 0) {  
    const point = points[0]  
    // 计算移动增量 (当前坐标 - 上一次坐标)  
    const dx = point.x - lastX.value  
    const dy = point.y - lastY.value  

    // 更新平移量 (累加增量)  
    panX.value = panX.value + dx  
    panY.value = panY.value + dy  

    // 应用边界限制,防止拖出屏幕  
    clampPan()   

    // 更新上一次坐标,为下一次 move 做准备  
    lastX.value = point.x  
    lastY.value = point.y  
    return  
  }  

  // 情况2:双指缩放逻辑  
  // 条件:屏幕上有2个手指  
  if (activeTouches.size == 2) {  
    const p1 = points[0]  
    const p2 = points[1]  

    // 计算当前两指距离  
    const newDist = getDistanceByPoints(p1, p2)  

    // 如果是刚进入双指状态(pinchLastDist <= 0),先记录当前距离并退出,等待下一次 move 计算差值  
    if (pinchLastDist.value <= 0) {  
      pinchLastDist.value = newDist  
      return  
    }  

    // 缩放核心计算:  
    // 1. 计算缩放比率 (当前距离 / 上一次距离)  
    const oldScale = scale.value  
    const ratio = newDist / pinchLastDist.value   
    // 2. 计算新的缩放值 (旧缩放值 * 比率),并限制在 min/max 范围内  
    const newScale = clampScale(oldScale * ratio)   

    // 焦点缩放(Focal Point Zoom)核心算法:  
    // 目标:让双指中心点下方的地图内容,在缩放前后保持在屏幕同一位置,避免画面跳动。  

    // A. 计算双指中心点在屏幕上的坐标(相对于容器左上角)  
    const centerX = ((p1.x + p2.x) / 2) - areaLeft.value  
    const centerY = ((p1.y + p2.y) / 2) - areaTop.value  

    // B. 计算中心点在"世界坐标系"(未缩放的内容坐标系)中的位置  
    // 公式:世界坐标 = (屏幕相对坐标 - 当前平移量) / 当前缩放比例  
    const worldX = (centerX - panX.value) / oldScale  
    const worldY = (centerY - panY.value) / oldScale  

    // C. 更新全局缩放比例  
    scale.value = newScale  

    // D. 反推新的平移量,使得该世界坐标点在屏幕上的位置保持不变  
    // 公式:新平移量 = 屏幕相对坐标 - (世界坐标 * 新缩放比例)  
    panX.value = centerX - worldX * newScale  
    panY.value = centerY - worldY * newScale  

    // 更新上一次的距离,为下一次 move 做准备  
    pinchLastDist.value = newDist   
    // 应用边界限制  
    clampPan()   
  }  
}  

/**  
 * 触摸结束/取消事件处理 (onTouchEnd)  
 * 作用:清理活跃触摸点,处理手势状态切换  
 */  
function onTouchEnd(e: TouchEvent) {  
  // 1. 从活跃 Map 中移除已经离开屏幕的触摸点  
  for (let i = 0; i < e.changedTouches.length; i++) {  
    const t = e.changedTouches[i]  
    activeTouches.delete(t.identifier)  
  }  

  // 2. 获取剩余活跃点  
  const points : Point[] = []  
  activeTouches.forEach((value, key) => {  
    points.push(value)  
  })  

  // 情况:如果松开一指变成单指  
  // 需要重新记录单指的坐标作为 lastX/Y,防止下一次 move 时产生巨大的 dx/dy 跳动  
  if (activeTouches.size == 1) {  
    lastX.value = points[0].x  
    lastY.value = points[0].y  
    pinchLastDist.value = 0 // 重置缩放距离  
    return  
  }  

  // 情况:如果全部手指松开  
  if (activeTouches.size == 0) {  
    pinchLastDist.value = 0  
  }  
}  

/**  
 * 座位点击处理函数  
 * @param rowIndex 行索引  
 * @param colIndex 列索引  
 */  
function handleSeatClick(rowIndex : number, colIndex : number) {  
  const seat = seatRows.value[rowIndex].seats[colIndex]  
  // 只有类型为1(真实座位)才能被点击  
  if (seat.type == 1) {  
    // 切换状态: 0(未选) -> 1(已选), 1 -> 0  
    seat.status = seat.status == 0 ? 1 : 0  
  }  
}  
</script>  

<style>  
.page {  
  display: flex;  
  flex-direction: column;  
  height: 100%;  
  background-color: #f5f5f5;  
}  

.header-area {  
  display: flex;  
  flex-direction: column;  
  align-items: center;  
  padding-top: 10px;  
  background-color: #f5f5f5;  
  z-index: 10;  
}  

.hall-name {  
  font-size: 14px;  
  color: #666;  
  margin-bottom: 5px;  
}  

.screen-shape {  
  width: 200px;  
  height: 20px;  
  background-color: #e0e0e0;  
  border-radius: 0 0 20px 20px;  
  display: flex;  
  justify-content: center;  
  align-items: center;  
  margin-bottom: 20px;  
  box-shadow: 0 2px 4px rgba(0,0,0,0.05);  
}  

.screen-text {  
  font-size: 10px;  
  color: #999;  
}  

.seat-viewport {  
  flex: 1;  
  width: 100%;  
  display: flex;  
  flex-direction: row;  
  overflow: hidden;  
  background-color: #f5f5f5;  
}  

.row-axis {  
  width: 50px;  
  overflow: hidden;  
  position: relative;  
  background-color: rgba(0,0,0,0.12);  
}  

.row-axis-transform {  
  padding-top: 20px;  
  padding-bottom: 20px;  
  transform-origin: 0 0;  
}  

.row-axis-item {  
  height: 46px;  
  display: flex;  
  align-items: flex-start;  
}  

.seat-area {  
  flex: 1;  
  overflow: hidden;  
  position: relative;  
}  

.seat-map {  
  position: absolute;  
  top: 0;  
  left: 0;  
  padding: 20px;  
  transform-origin: 0 0;  
  background:red;  
}  

.row-container {  
  display: flex;  
  flex-direction: row;  
  align-items: center;  
  margin-bottom: 10px;  
}  

.row-label {  
  width: 30px;  
  height: 36px;  
  display: flex;  
  justify-content: center;  
  align-items: center;  
  background-color: rgba(0,0,0,0.5);  
  border-radius: 8px;  
  margin-left: 10px;  
}  

.row-label-text {  
  color: #fff;  
  font-size: 12px;  
}  

.seats-row {  
  display: flex;  
  flex-direction: row;  
  flex-wrap: nowrap;  
}  

.seat-wrapper {  
  width: 36px;  
  height: 36px;  
  display: flex;  
  justify-content: center;  
  align-items: center;  
  margin-right: 4px;  
}  

.seat-item {  
  width: 30px;  
  height: 30px;  
  border-radius: 8px 8px 4px 4px;  
  border-width: 1px;  
  border-style: solid;  
}  

/* 红色是未选 - Red Border, White Background */  
.seat-unselected {  
  border-color: #ff0000;  
  background-color: #ffffff;  
}  

/* 蓝色是已选 - Blue Fill */  
.seat-selected {  
  border-color: #007aff;  
  background-color: #007aff;  
}  

.footer-area {  
  height: 80px;  
  display: flex;  
  flex-direction: row;  
  justify-content: center;  
  align-items: center;  
  background-color: #ffffff;  
  border-top: 1px solid #eee;  
  padding-bottom: 20px;  
}  

.legend-item {  
  display: flex;  
  flex-direction: row;  
  align-items: center;  
  margin: 0 15px;  
}  

.legend-icon {  
  width: 20px;  
  height: 20px;  
  margin-right: 6px;  
  border-radius: 4px;  
}  

.legend-text {  
  font-size: 14px;  
  color: #333;  
}  
</style>

操作步骤:

将代码复制到一个空白的uvue文件中,并且用安卓测试手机打开页面,用两根手指放大缩小中间的座位图,重复多试几次就能看到页面会抖动,原因是因为:touchmove给我的x和y的坐标不对,我需要拿x和y的值计算当前的缩放倍数

预期结果:

希望正确给我x和y的值

实际结果:

不正确的x和y值

bug描述:

当我在touchmove中打印当前坐标的时候,坐标会忽高忽低,不是连续的值


更多关于uni-app uvue页面安卓端 @touchmove 拖动时获取的event的值忽高忽低造成抖动的实战教程也可以访问 https://www.itying.com/category-93-b0.html

1 回复

更多关于uni-app uvue页面安卓端 @touchmove 拖动时获取的event的值忽高忽低造成抖动的实战教程也可以访问 https://www.itying.com/category-93-b0.html


这是一个典型的Android触摸事件精度问题。在Android平台上,touchmove事件的坐标值确实可能出现抖动,特别是在快速或复杂手势操作时。

问题分析:

  1. 坐标精度问题:Android触摸屏的采样率有限,快速移动时坐标值可能不连续
  2. 多指触摸干扰:双指缩放时,系统需要同时处理两个触摸点,可能导致坐标更新不同步
  3. 事件频率限制touchmove事件触发频率有限,可能导致坐标跳跃

解决方案:

方案一:使用防抖处理(推荐)

// 添加防抖函数
function debounce(func, wait) {
  let timeout;
  return function(...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => func.apply(this, args), wait);
  };
}

// 修改onTouchMove
const onTouchMove = debounce(function(e: TouchEvent) {
  // 原有逻辑...
}, 8); // 8ms防抖

方案二:使用坐标平滑算法

// 添加平滑处理
const touchHistory = ref<Point[]>([]);
const SMOOTH_WINDOW = 3; // 取最近3个点平均

function getSmoothedPoint(points: Point[]): Point {
  if (points.length < SMOOTH_WINDOW) return points[0];
  
  const recent = points.slice(-SMOOTH_WINDOW);
  const avgX = recent.reduce((sum, p) => sum + p.x, 0) / SMOOTH_WINDOW;
  const avgY = recent.reduce((sum, p) => sum + p.y, 0) / SMOOTH_WINDOW;
  
  return { x: avgX, y: avgY };
}

// 在onTouchMove中使用
function onTouchMove(e: TouchEvent) {
  // ...更新坐标后
  const currentPoint = { x: t.screenX, y: t.screenY };
  touchHistory.value.push(currentPoint);
  
  // 保持历史记录长度
  if (touchHistory.value.length > SMOOTH_WINDOW * 2) {
    touchHistory.value.shift();
  }
  
  // 使用平滑后的坐标
  const smoothed = getSmoothedPoint(touchHistory.value);
  // 用smoothed.x, smoothed.y代替原始坐标
}

方案三:使用uni-app的gesture插件

// 安装手势插件或使用uni.createGestureContext
// 这能提供更稳定的手势识别

方案四:降低对坐标精度的依赖

// 修改缩放逻辑,减少对绝对坐标的依赖
function onTouchMove(e: TouchEvent) {
  // 使用相对变化量而不是绝对坐标
  const dx = e.touches[0].clientX - lastX.value;
  const dy = e.touches[0].clientY - lastY.value;
  
  // 直接使用变化量更新位置
  panX.value += dx * 0.5; // 添加阻尼系数
  panY.value += dy * 0.5;
  
  // 更新记录
  lastX.value = e.touches[0].clientX;
  lastY.value = e.touches[0].clientY;
}

方案五:使用requestAnimationFrame优化

let isAnimating = false;

function onTouchMove(e: TouchEvent) {
  if (isAnimating) return;
  
  isAnimating = true;
  requestAnimationFrame(() => {
    // 在这里处理坐标逻辑
    // ...
    isAnimating = false;
  });
}
回到顶部