第10章 - 相机手势与交互

Native 的相机模型建立在两个结构上:CameraOptions(描述”我要去哪”)与 Transform / TransformState(描述”现在在哪”)。手势不直接改相机,而是把触摸事件翻译成 moveBy、scaleBy、rotateBy、pitchBy 这类增量调用。

这一章讲清楚四件事:相机状态的字段与单位、三种视图切换(jumpTo/easeTo/flyTo)的差别、手势在平台层与 core 之间的边界、以及渲染帧是如何被动画驱动的。

10.1 CameraOptions:九个可选字段

include/mln/map/camera.hpp 定义了两个核心结构。CameraOptions 的字段全部是 std::optional:

字段 单位 说明
center LatLng 相机中心;与 anchor 互斥,同时给时 center 优先
centerAltitude 米 中心点海拔
padding EdgeInsets 视口内边距,影响 center 的参考系
anchor ScreenCoordinate zoom 与旋转的参考点(左上角为原点)
zoom 级别 会被夹到 min/max zoom
bearing 度 相对真北,内部会归一化到 [0, 360) 再转弧度
pitch 度 0 为俯视,上限 pi/3
roll 度 相机横滚
fov 度 相机垂直视场角

“全部可选”意味着调用方可以只写关心的字段:

CameraOptions options;
options.withCenter(LatLng{31.2304, 121.4737}).withZoom(14);
map.jumpTo(options);

withXxx() 系列返回自身的引用,所以可以链式调用。未设置的字段在 easeTo / jumpTo 里保持当前值——这也是”只转相机不动位置”能工作的原因。

AnimationOptions 描述动画过程:

struct AnimationOptions {
    std::optional<Duration> duration;
    std::optional<double> velocity;      // flyTo 的屏/秒
    std::optional<double> minZoom;       // flyTo 飞行路径上的最低层级
    std::optional<mln::util::UnitBezier> easing;
    std::function<void(double)> transitionFrameFn;
    std::function<void()> transitionFinishFn;
};

velocity 的单位是”每秒多少屏”,不是像素每秒——它随 zoom 变化,所以同一个值在不同缩放级别下的视觉速度相近。

10.2 Transform:状态的唯一持有者

src/mln/map/transform.hpp 的 Transform 持有 TransformState,后者是纯数据。读接口很直观:

double getZoom() const;
double getBearing() const;
double getPitch() const;
double getRoll() const;
double getFieldOfView() const;
LatLng getLatLng(LatLng::WrapMode = LatLng::Wrapped) const;
const TransformState& getState() const;

写接口分成两类:绝对定位与增量。

// 绝对
void jumpTo(const CameraOptions&);
void easeTo(const CameraOptions&, const AnimationOptions& = {});
void flyTo(const CameraOptions&, const AnimationOptions& = {});

// 增量
void moveBy(const ScreenCoordinate&, const AnimationOptions& = {});
void rotateBy(const ScreenCoordinate& first, const ScreenCoordinate& second, const AnimationOptions& = {});

手势用的是第二类。Map 层在 include/mln/map/map.hpp 里又暴露了更贴近手势的封装:

void moveBy(const ScreenCoordinate&, const AnimationOptions& = {});
void scaleBy(double scale, const std::optional<ScreenCoordinate>& anchor, const AnimationOptions& animation = {});
void pitchBy(double pitch, const AnimationOptions& animation = {});
void rotateBy(const ScreenCoordinate& first, const ScreenCoordinate& second, const AnimationOptions& = {});

scaleBy 需要 anchor 是因为缩放必须以某个屏幕点为基准——双指缩放时是两指中心,滚轮缩放时是鼠标位置。缺了 anchor,缩放会以视口中心为基准,手感完全不同。

10.2.1 单位:度与弧度

这是最容易写错的地方。CameraOptions 里的 bearing / pitch / roll / fov 都是度,而 TransformState 内部存的是弧度。转换发生在 Transform::easeTo 里:

double bearing = camera.bearing ? util::deg2rad(-*camera.bearing) : getBearing();
double pitch = camera.pitch ? util::deg2rad(*camera.pitch) : getPitch();
double fov = camera.fov ? util::deg2rad(*camera.fov) : getFieldOfView();
double roll = camera.roll ? util::deg2rad(*camera.roll) : getRoll();

注意 bearing 前面那个负号:bearing 表示”相对北顺时针转了多少度”,而内部角度是逆时针为正的数学约定,两者符号相反。自己算投影矩阵时如果直接拿 TransformState::getBearing() 当”度”用,会转错方向。

pitch 的上限来自 include/mln/util/constants.hpp:

constexpr double DEFAULT_PITCH_MAX = M_PI / 3;   // 60 度
constexpr double DEFAULT_FOV = 0.6435011087932844; // 约 36.87 度

DEFAULT_PITCH_MAX 是 60 度。这个值和 Android 侧常量一致(MAXIMUM_PITCH = 60.0f),但引擎允许通过 setMaxPitch 放宽到 PITCH_MAX = M_PI(180 度)——不过那已经不是正常地图视角了。

10.3 三种视图切换的差别

10.3.1 jumpTo 不是特例

src/mln/map/transform.cpp 里第一行就打破直觉:

void Transform::jumpTo(const CameraOptions& camera) {
    easeTo(camera);
}

jumpTo 就是”不带 AnimationOptions 的 easeTo”。因为 AnimationOptions 默认 duration 为空,easeTo 里取 Duration::zero(),最终 startTransition 里 isAnimated = false,第一帧即取 t=1,直接落到终点。所以”瞬间跳转”和”动画第一帧”走的是同一段代码——这也意味着 jumpTo 同样会触发 onCameraWillChange 等回调。

10.3.2 easeTo 的线性地面速度

easeTo 的注释说明了它和普通线性的区别:

Change any combination of center, zoom, bearing, pitch and edgeInsets, with a smooth animation between old and new values.

但实现里有一个分支值得注意:

if (state.getLatLngBounds() == LatLngBounds() && !isGestureInProgress() && duration != Duration::zero()) {
    // reuse flyTo, without exaggerated animation, to achieve constant ground speed.
    flyTo(camera, animation, true);
    return;
}

在没有 bounds、非手势中、且时长不为 0 时,easeTo 直接转调 flyTo(..., linearZoomInterpolation=true)。原因是简单插值经纬度会让”长距离平移”的视觉速度忽快忽慢——地面速度恒定才符合直觉。第三个参数 true 表示不做额外的拉远再拉近。

10.3.3 flyTo 的飞行路径

flyTo 在 AnimationOptions 里多了两个专用字段:

  • velocity:平均速度,单位”屏/秒”,用来按距离推算时长;
  • minZoom:飞行路径上的最低层级,用来制造”拉远—平移—拉近”的效果。

不给 duration 时,flyTo 会根据 velocity 与起止点的距离自动算时长。手动给 duration 则速度被忽略。

10.4 手势在平台层与 core 的边界

这是读源码时最容易迷路的一段。core 不认识任何手势:没有 pinch、pan、rotate 的概念,只有 moveBy / scaleBy / rotateBy / pitchBy 这类几何增量。手势识别全部在 platform 层完成。

10.4.1 边界

Android: MapGestureDetector.java
  ↳ onMoveEnd / onScaleEnd / onRotateEnd / onShoveEnd / onLongPress
  ↳ 转成 Map::moveBy / scaleBy / rotateBy / pitchBy
iOS: MLNMapView + 一组 UIGestureRecognizer
  ↳ 同样的四个增量调用
core: Transform::moveBy / scaleBy / rotateBy / pitchBy
  ↳ 改 TransformState

core 只提供了一个协作信号:setGestureInProgress(bool)。

void setGestureInProgress(bool);
bool isGestureInProgress() const;
bool isRotating() const;   // state.isRotating()
bool isScaling() const;
bool isPanning() const;

这个标志有两个实际作用:

  1. 动画期间的手势行为不同。easeTo 里判断 !isGestureInProgress() 才走 flyTo 分支;手势进行中保持普通插值,避免用户手指还在动时相机自己计算飞行路径。
  2. 世界环绕的处理不同。还是在 easeTo 里:
if (isGestureInProgress()) {
    // 手势中:把 wrap 圈数从终点移到起点,保持滚动方向
    const double wrap = unwrappedLatLng.longitude() - latLng.longitude();
    startLatLng = LatLng(startLatLng.latitude(), startLatLng.longitude() - wrap);
} else {
    // 否则:走最短路径
    startLatLng.unwrapForShortestPath(latLng);
}

非手势时走”最短路径”,所以程序调 easeTo 横跨 300 度经度会走 60 度那次短边;手势拖动时则必须保留用户拖动的方向,否则手指往右拖地图会突然反向绕地球一圈。

10.4.2 Android 侧的手势常量

platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/constants/MapLibreConstants.java 里定义了手势动画的调参值:

常量 值 用途
ANIMATION_DURATION 300 默认动画时长(毫秒)
ANIMATION_DURATION_SHORT 150 短动画,用于 fling
VELOCITY_THRESHOLD_IGNORE_FLING 1000 低于此速度不触发甩动
MINIMUM_ANGULAR_VELOCITY 1.5 旋转动画的最小角速度
MAXIMUM_ANGULAR_VELOCITY 30 旋转动画的最大角速度
SCALE_VELOCITY_RATIO_THRESHOLD 4e-3 判定是否触发缩放动画的阈值
ROTATE_VELOCITY_RATIO_THRESHOLD 2.2e-4 判定是否触发旋转动画的阈值
QUICK_ZOOM_MAX_ZOOM_CHANGE 4.0 双击缩放的最大级数
ZOOM_RATE 0.65 缩放动画的衰减率
MAXIMUM_PITCH / MINIMUM_PITCH 60 / 0 俯仰范围(度)
MAXIMUM_ZOOM / MINIMUM_ZOOM 25.5 / 0 缩放范围

这些值同时出现在 iOS 侧的等价实现里(手势识别器代理里以常量形式存在),所以跨平台手感基本一致。改手势手感时要注意:这些是平台层常量,改它们不影响 core,也不影响程序化的 easeTo。

10.4.3 手势进行中的状态查询

Map 层暴露了三个查询方法,用来在自己的渲染或业务逻辑里判断当前在做什么:

bool isRotating() const;
bool isScaling() const;
bool isPanning() const;

它们直接转发 TransformState 的同名方法。典型用法是:手势进行中降低标注碰撞的检测频率或暂停淡入,松手后再恢复。

10.5 动画如何驱动渲染帧

10.5.1 每帧入口

src/mln/map/map_impl.cpp 的 Map::Impl::onUpdate() 是每帧的起点:

void Map::Impl::onUpdate() {
    if (mode != MapMode::Continuous && !stillImageRequest) { return; }
    TimePoint timePoint = mode == MapMode::Continuous ? Clock::now() : Clock::time_point::max();
    transform.updateTransitions(timePoint);
    UpdateParameters params = { ... };
    rendererFrontend.update(std::make_shared<UpdateParameters>(std::move(params)));
}

注意第一行:静态出图模式(MapMode::Static)下如果没有出图请求,直接 return。这意味着静态模式不会自己刷新,必须由外部触发。

transform.updateTransitions(timePoint) 推动动画进度。它的实现说明了两点:

  • 动画只用”当前时间戳”,不依赖固定的帧间隔,所以掉帧不会导致动画变慢;
  • 传进来的 timePoint 在连续模式下是 Clock::now(),静态模式下是 Clock::time_point::max()——后者会让动画一步到位。

10.5.2 缓动

startTransition 里对进度 t 应用缓动:

if (t >= 1.0) {
    frame(1.0);
} else {
    util::UnitBezier ease = animation.easing ? *animation.easing : util::DEFAULT_TRANSITION_EASE;
    frame(ease.solve(t, 0.001));
}

默认曲线来自 include/mln/util/constants.hpp:

constexpr UnitBezier DEFAULT_TRANSITION_EASE = {0, 0, 0.25, 1};
constexpr Duration DEFAULT_TRANSITION_DURATION = Milliseconds(300);

即 CSS 的 ease 曲线,300 ms。传入自定义 easing 时替换的是曲线本身,时长仍由 duration 决定。

10.5.3 谁决定”还要不要下一帧”

Map::Impl::onDidFinishRenderingFrame 里有一条关键判断(逐字摘自源码结构):

if (needsRepaint || transform.inTransition()) {
    onUpdate();
}

}

也就是说:只要还有动画在进行,或者渲染器报告还需要重绘,就会自动继续下一帧。反过来,动画结束后如果没有任何变化,引擎就停下来等事件——这是”地图静置时不耗电”的底层机制。

needsRepaint 由渲染器在完成一帧后算出(例如瓦片刚好加载完、标注透明度还在变化)。这也是为什么”标注淡入到一半停住”通常意味着 needsRepaint 判断出错,而不是动画时间算错。

10.6 边界与约束

10.6.1 ConstrainMode 与北向

Transform 构造时可以指定三个模式:

Transform(TransformObserver& = TransformObserver::nullObserver(),
          ConstrainMode = ConstrainMode::HeightOnly,
          ViewportMode = ViewportMode::Default);

ConstrainMode::HeightOnly 表示只约束纬度(防止拖出 ±85.05°),经度可以无限环绕。改成 ConstrainMode::WidthAndHeight 会把经度也锁住,适合”不想要世界副本”的场景。运行时通过 setConstrainMode() 切换。

北向由 NorthOrientation 控制,setNorthOrientation() 可以让地图以北、以东或其他方向为正上方。这是移动设备横屏时”让地图正上方对准设备正上方”的实现位置。

10.6.2 边界约束:setBounds

Map::setBounds(const BoundOptions&) 与 CameraOptions 是两套东西:前者是”允许的相机范围”(min/max zoom、bounds、pitch 范围),后者是”这一次要去的位置”。约束在 easeTo 里统一应用:

state.constrainCameraAndZoomToBounds(camera, zoom);
...
zoom = util::clamp(zoom, state.getMinZoom(), state.getMaxZoom());
pitch = util::clamp(pitch, state.getMinPitch(), state.getMaxPitch());
fov = util::clamp(fov, state.getMinFieldOfView(), state.getMaxFieldOfView());

所以即使调用方传了越界的 zoom,jumpTo 之后也不会真的越界——它被静默夹取,不报错。业务上要感知”被夹取了”只能自己先比较。

10.7 排错清单

现象 根因 定位方法
bearing 转反了 CameraOptions::bearing 是”度 + 顺时针”,TransformState 是”弧度 + 逆时针为正” 检查是否直接拿 getState().getBearing() 当度用
pitch 设置超过 60 度无效 DEFAULT_PITCH_MAX = M_PI / 3 先 setMaxPitch() 放宽,再设置 pitch
手势拖动时跨 180 度经线方向乱 手势与程序化调用的 wrap 处理不同 确认平台层在手势开始/结束调了 setGestureInProgress()
jumpTo 之后仍有动画 jumpTo 走 easeTo,duration 默认 0 才瞬变 检查是否传了带 duration 的 AnimationOptions
静态模式地图不刷新 onUpdate() 在非 Continuous 且无出图请求时直接返回 显式触发一次出图/刷新请求
缩放不以双指中心为基准 scaleBy 没传 anchor 传手指中心的 ScreenCoordinate
动画卡在中途不再推进 needsRepaint 与 inTransition() 都为 false 检查渲染器是否真的报告了需要重绘
自定义相机动画与手势互相打架 动画进行中未调 cancelTransitions() 手势开始时先取消进行中的 transition

10.8 生产建议

手势层不要直接改 TransformState。 平台层的正确做法是调 Map::moveBy / scaleBy / rotateBy / pitchBy,让 core 处理夹取、wrap、observer 回调。直接改 TransformState 会绕过 setBounds 与 onCameraWillChange 等通知。

动画与手势互斥要显式处理。 用户开始拖动时调 cancelTransitions(),否则程序化的 easeTo 会和手指争夺相机。

时长从 300 ms 起步。 core 的 DEFAULT_TRANSITION_DURATION 与 Android 的 ANIMATION_DURATION 都是 300 ms。低于 150 ms 会显得生硬,高于 500 ms 在低端机上会因为丢帧而看起来卡顿。

飞行效果用 flyTo,不要用 easeTo 拉远再拉近。 easeTo 内部在”无 bounds、非手势、有时长”时会自动转调 flyTo(linear),手写的三段式动画既多余又容易在衔接处抖动。

静态出图要显式驱动。 MapMode::Static 下引擎不会自己刷新;批量出图时按”设置相机 → 触发一次渲染 → 读像素”的顺序走,不要在两次设置之间等”下一帧”。

10.9 本章小结

要点 事实(main 分支)
相机描述 CameraOptions 九个可选字段:center/centerAltitude/padding/anchor/zoom/bearing/pitch/roll/fov
单位 CameraOptions 用度;TransformState 用弧度;bearing 符号相反
上限 DEFAULT_PITCH_MAX = M_PI / 3(60°)、DEFAULT_FOV ≈ 0.6435 rad
三种切换 jumpTo = easeTo(duration=0);easeTo 无 bounds 时转 flyTo(linear);flyTo 用 velocity/minZoom
手势归属 平台层识别手势 → 调 moveBy/scaleBy/rotateBy/pitchBy;core 只认几何增量 + setGestureInProgress()
动画默认 300 ms、DEFAULT_TRANSITION_EASE = {0, 0, 0.25, 1}
帧调度 每帧 updateTransitions(timePoint);needsRepaint\|\|inTransition() 时续帧
静态模式 MapMode::Static 无请求时 onUpdate() 直接返回,需外部驱动