以鼠标为锚点缩放

07/291 浏览综合

核心数学原理

缩放前后,鼠标指向的那个世界坐标点必须保持在同一屏幕位置。
已知:旧缩放 oldZoom、新缩放 newZoom、鼠标锚点屏幕坐标 (ax, ay)、当前偏移 (panX, panY)

步骤1:算出旧缩放下鼠标指向的世界坐标
  wx = (ax - panX) / oldZoom
  wy = (ay - panY) / oldZoom

步骤2:新缩放后反解新偏移,让 wx/wy 仍落在 (ax, ay)
  newPanX = ax - wx * newZoom
  newPanY = ay - wy * newZoom

关键代码:缩放函数

lua
-- 校园地图.lua 版本(参考实现,手感好) local function ApplyZoom(delta, anchorX, anchorY) local newZoom = zoomLevel_ + delta newZoom = math.max(ZOOM_MIN, math.min(ZOOM_MAX, newZoom)) if newZoom == zoomLevel_ then return end -- 锚点:鼠标缓存坐标,无则视口中心 local ax = anchorX local ay = anchorY if not ax then local viewRect = UI.GetVisualRect(mapView) ax = viewRect.w * 0.5 ay = viewRect.h * 0.5 end local oldZoom = zoomLevel_ local wx = (ax - worldOffsetX_) / oldZoom local wy = (ay - worldOffsetY_) / oldZoom zoomLevel_ = newZoom worldOffsetX_ = ax - wx * newZoom worldOffsetY_ = ay - wy * newZoom ApplyTransformInPlace() end

鼠标坐标缓存(OnWheel 不带坐标,必须缓存)

lua
-- 在 onPointerMove 中缓存(每帧鼠标移动时更新) onPointerMove = function(event, widget) if event and event.x and event.y then lastMouseX_ = event.x -- 已是 widget 本地坐标 lastMouseY_ = event.y end -- ... 其他逻辑 end, -- OnWheel 回调读缓存 function mapView:OnWheel(dx, dy) if dy == 0 then return end local step = dy > 0 and ZOOM_STEP or -ZOOM_STEP ApplyZoom(step, lastMouseX_, lastMouseY_) end

坐标空间注意事项(地图编辑器踩的坑)

关键:缓存的坐标和 pan 偏移必须在同一坐标空间!
场景event.x 坐标空间panX 坐标空间是否一致
直接在 viewport 上缓存viewport 本地坐标viewport 本地 直接用
在 transform 子节点上缓存子节点本地坐标(已逆变换)viewport 本地 需转换
子节点上的转换公式:
lua
-- mapSurface 有 scale+left/top transform,event.x 是 surface 本地坐标 -- 需要转回 viewport 坐标才能和 panX 同空间 cs.lastMouseX = event.x * cs.zoom + cs.panX cs.lastMouseY = event.y * cs.zoom + cs.panY

偏移应用方式:Yoga left/top 优于 CSS transform

lua
-- ✅ 推荐:Yoga 布局属性,更稳定 local function ApplyTransformInPlace() worldLayerRef_:SetStyle({ scale = zoomLevel_, left = worldOffsetX_, top = worldOffsetY_, }) end -- ❌ 避免:直接写 props.translateX/translateY(CSS transform 属性,在 Yoga 中不稳定) self.mapSurface.props.translateX = cs.panX self.mapSurface.props.translateY = cs.panY
构造时必须声明 transformOrigin:
lua
UI.Panel { position = "absolute", left = 0, top = 0, transformOrigin = "top-left", -- 必须!scale 从左上角扩散,与 left/top 公式一致 -- ... }

线性步进 vs 乘法缩放

lua
-- ✅ 线性加法:全程手感一致,步长均匀 local ZOOM_STEP = 0.03 -- 校园地图 local CANVAS_ZOOM_STEP = 0.05 -- 编辑器(稍大) newZoom = oldZoom + step -- ❌ 乘法指数:zoom 小时步长太小(几乎不动),zoom 大时步长太大(跳变) local zoomFactor = deltaY > 0 and 1.12 or 0.89 newZoom = oldZoom * zoomFactor

完整常量配置参考

lua
-- 校园地图(运行时) local ZOOM_MIN = 0.05 local ZOOM_MAX = 0.6 local ZOOM_STEP = 0.03 local zoomLevel_ = 0.13 -- 默认 -- 地图编辑器 local CANVAS_MIN_ZOOM = 0.05 local CANVAS_MAX_ZOOM = 0.8 local CANVAS_ZOOM_STEP = 0.05 local CANVAS_DEFAULT_ZOOM = 0.18 -- 默认

避坑清单

  1. OnWheel 不带坐标 — 必须在 onPointerMove/onPointerDown 中缓存 event.x/event.y
  2. 坐标空间必须一致 — event.x 是 widget 本地坐标,pan 偏移也必须是同一父容器的本地坐标;在 transform 子节点上缓存要乘 zoom+加 pan 转换
  3. transformOrigin = "top-left" — scale 默认从中心缩放会导致锚点公式完全错误
  4. 用 SetStyle({left, top, scale}) — 不要直接写 props.translateX/Y
  5. 线性步进 — 乘法缩放手感不均匀
  6. 锚点回退视口中心 — +/− 按钮不传锚点时,用 viewRect.w/h × 0.5 作为中心锚点
1
4