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>
- 发布:2026-01-20 15:28
- 更新:2026-01-20 15:28
- 阅读:78
产品分类: uniapp/App
PC开发环境操作系统: Mac
PC开发环境操作系统版本号: 15
HBuilderX类型: 正式
HBuilderX版本号: 4.87
手机系统: Android
手机系统版本号: Android 11
手机厂商: 小米
手机机型: mi9 pro
页面类型: nvue
vue版本: vue3
打包方式: 云端
项目创建方式: HBuilderX
示例代码:
操作步骤:
将代码复制到一个空白的uvue文件中,并且用安卓测试手机打开页面,用两根手指放大缩小中间的座位图,重复多试几次就能看到页面会抖动,原因是因为:touchmove给我的x和y的坐标不对,我需要拿x和y的值计算当前的缩放倍数
将代码复制到一个空白的uvue文件中,并且用安卓测试手机打开页面,用两根手指放大缩小中间的座位图,重复多试几次就能看到页面会抖动,原因是因为:touchmove给我的x和y的坐标不对,我需要拿x和y的值计算当前的缩放倍数
预期结果:
希望正确给我x和y的值
希望正确给我x和y的值
实际结果:
不正确的x和y值
不正确的x和y值
bug描述:
当我在touchmove中打印当前坐标的时候,坐标会忽高忽低,不是连续的值