q***@gmail.com
q***@gmail.com
  • 发布:2026-01-20 15:28
  • 更新:2026-01-20 15:28
  • 阅读:78

【报Bug】uvue页面安卓端 @touchmove 拖动时获取的event的值忽高忽低造成抖动!

分类:uni-app

产品分类: uniapp/App

PC开发环境操作系统: Mac

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

HBuilderX类型: 正式

HBuilderX版本号: 4.87

手机系统: Android

手机系统版本号: Android 11

手机厂商: 小米

手机机型: mi9 pro

页面类型: nvue

vue版本: vue3

打包方式: 云端

项目创建方式: HBuilderX

示例代码:
javascript  
<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="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中打印当前坐标的时候,坐标会忽高忽低,不是连续的值

2026-01-20 15:28 负责人:无 分享
已邀请:

要回复问题请先登录注册