第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;
这个标志有两个实际作用:
- 动画期间的手势行为不同。
easeTo里判断!isGestureInProgress()才走 flyTo 分支;手势进行中保持普通插值,避免用户手指还在动时相机自己计算飞行路径。 - 世界环绕的处理不同。还是在
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() 直接返回,需外部驱动 |