Leaflet源码解析系列(六-6):Map 对象解读——处理地图状态改变的动画和事件

247 阅读5分钟

地图的交互动画是很重要的一部分,优化了map中要素位置、大小改变时候的效果,保证了地图操作的平滑性。

Leaflet 里的动画主要是通过 css 的 transform 来实现的,没有直接改变dom元素的位置,可以提升一定的性能,地图上要素位置的改变都是通过计算地理坐标和投影仿射坐标之间的换算,最终体现在 leaflet-map-pane 这个dom 元素上。可以详细阅读CRS.JS了解这一套换算关系,使用simple这个坐标系辅助理解验证(因为simple坐标系的仿射变换算系数都是1,且坐标原点也是[0,0])。

地图的交互事件其实是在map-panelayer-pane上触发的,平移改变了map-panetransformXtransformY,缩放改变了layer-panetransformXtransformY

地图有平移(pan)和缩放(zoom)事件,拖拽、双指捏放、鼠标滚轮缩放都有可能会触发这些事件。 leaflet 中有movezoom 两个事件,平移和缩放都会触发,通过在_tryAnimatedZoom_tryAnimatedPan方法中进行动画和位置改变。

在 leaflet 中,平移和缩放动画以及 leaflet 内部事件完成之后,才会触发用户注册的事件。

以下是地图状态改变的几个事件

  • viewreset:当地图需要重绘其内容时触发(地图缩放或加载时触发),对于创建自定义叠层加非常有用;
  • load:初始化地图时触发(首次设置其中心和缩放比例);
  • unload: 调用remove方法销毁地图时候触发;
  • zoom:在缩放层级改变的时候反复触发;including zoom and fly animations.
  • move:在地图move(pan 和 fly)的时候反复触发;
  • zoomstart:在地图缩放“开始更改”时触发 (e.g. before zoom animation);
  • movestart:在地图视图“开始更改”时触发 (e.g. user starts dragging the map);
  • zoomend:在地图缩放动画结束后时触发;
  • moveend:在地图center“结束更改”时触发;map 本身监听了 moveend,会设置新的 view
export const Map = Evented.extend({
  // ......
  // 停止当前正在进行的 `panTo` 和 `flyTo` 动画
  stop() {
    this.setZoom(this._limitZoom(this._zoom));
    if (!this.options.zoomSnap) {
      this.fire("viewreset");
    }
    return this._stop();
  },

  /** 停止 fly 和 pan 动画 */
  _stop() {
    Util.cancelAnimFrame(this._flyToFrame);
    if (this._panAnim) {
      this._panAnim.stop();
    }
    return this;
  },

  // 根据传入的 center 和 zoom 设置 map 的视野。一般会提前过去当前的center 和 zoom
  _resetView(center, zoom, noMoveStart) {
    // 重置 map pane 的 Transform 位置
    DomUtil.setPosition(this._mapPane, new Point(0, 0));

    const loading = !this._loaded; // 如果灭有loaded,就表示是在loading
    this._loaded = true;
    zoom = this._limitZoom(zoom);

    this.fire("viewprereset");

    const zoomChanged = this._zoom !== zoom;
    // 触发事件,执行改变
    this._moveStart(zoomChanged, noMoveStart)._move(center, zoom)._moveEnd(zoomChanged);

    // 当地图需要重绘其内容时触发(地图缩放或加载时触发),对于创建自定义叠层加非常有用;
    this.fire("viewreset");

    // 初始化地图时触发(首次设置其中心和缩放比例);
    if (loading) {
      this.fire("load");
    }
  },

  /** 触发 zoomstart、movestart 事件 */
  _moveStart(zoomChanged, noMoveStart) {
    // 在 map 将要 zoom(zoom 之前)触发 (e.g. before zoom animation).
    if (zoomChanged) {
      this.fire("zoomstart");
    }
    // 当 map 正在 zoom 时候触发 (e.g. user starts dragging the map).
    if (!noMoveStart) {
      this.fire("movestart");
    }
    return this;
  },

  /** 触发 zoom、move 事件 */
  _move(center, zoom, data, supressEvent) {
    if (zoom === undefined) {
      zoom = this._zoom;
    }
    const zoomChanged = this._zoom !== zoom;

    this._zoom = zoom;
    this._lastCenter = center;
    this._pixelOrigin = this._getNewPixelOrigin(center);

    if (!supressEvent) {
      // 在缩放层级改变的时候反复触发;including zoom and fly animations.
      if (zoomChanged || (data && data.pinch)) {
        // Always fire 'zoom' if pinching because #3530
        this.fire("zoom", data);
      }
      // 在地图move(pan 和 fly)的时候反复触发
      this.fire("move", data);
    } else if (data && data.pinch) {
      // Always fire 'zoom' if pinching because #3530
      this.fire("zoom", data);
    }
    return this;
  },

  /** 触发 zoomend、moveend , map 本身监听了 moveend,会设置新的 view */
  _moveEnd(zoomChanged) {
    // 如果 zoom 也变了,同时触发 zoomend
    if (zoomChanged) {
      this.fire("zoomend");
    }

    // map 的 center 改变结束后,触发 moveend,设置地图视野 (e.g. user stopped dragging the map or after non-centered zoom).
    return this.fire("moveend");
  },

  // 创建动画代理元素,注册 zoomanim 事件(在zoom的时候会触发)和 moveend 事件(在move结束调用)
  _createAnimProxy() {
    const proxy = (this._proxy = DomUtil.create("div", "leaflet-proxy leaflet-zoom-animated"));
    this._panes.mapPane.appendChild(proxy);

    this.on(
      "zoomanim",
      function (e) {
        const transform = this._proxy.style.transform;

        DomUtil.setTransform(this._proxy, this.project(e.center, e.zoom), this.getZoomScale(e.zoom, 1));

        // workaround for case when transform is the same and so transitionend event is not fired
        if (transform === this._proxy.style.transform && this._animatingZoom) {
          this._onZoomTransitionEnd();
        }
      },
      this
    );

    this.on("load moveend", this._animMoveEnd, this);

    this._on("unload", this._destroyAnimProxy, this);
  },

  // 移除动画代理 dom 元素和事件
  _destroyAnimProxy() {
    this._proxy.remove();
    this.off("load moveend", this._animMoveEnd, this);
    delete this._proxy;
  },
 
  /** 动画结束,设置动画代理元素的transform */
  _animMoveEnd() {
    const c = this.getCenter(),
      z = this.getZoom();
    DomUtil.setTransform(this._proxy, this.project(c, z), this.getZoomScale(z, 1));
  }, 

  /** 执行 pan 动画 */
  _tryAnimatedPan(center, options) {
    // difference between the new and current centers in pixels
    const offset = this._getCenterOffset(center)._trunc();

    // don't animate too far unless animate: true specified in options
    if ((options && options.animate) !== true && !this.getSize().contains(offset)) {
      return false;
    }

    this.panBy(offset, options);

    return true;
  },

  /** 执行 zoom 动画 */
  _tryAnimatedZoom(center, zoom, options) {
    // 如果还在继续执行另一个动画,return
    if (this._animatingZoom) {
      return true;
    }

    options = options || {};

    // 如果没有配置zoom动画、或者不支持zoom动画或者两次缩放间隔过大就不执行动画效果
    if (!this._zoomAnimated || options.animate === false || this._nothingToAnimate() || Math.abs(zoom - this._zoom) > this.options.zoomAnimationThreshold) {
      return false;
    }

    // offset is the pixel coords of the zoom origin relative to the current center
    // offset 是 zoom origin 相对于当前视野中心的像素距离
    const scale = this.getZoomScale(zoom),
      offset = this._getCenterOffset(center)._divideBy(1 - 1 / scale);

    // don't animate if the zoom origin isn't within one screen from the current center, unless forced
    // 如果 zoom origin 超出了当前 view,则不进行 zoom 动画(通过计算zoom origin和当前view中心的距离,与当前view的大小比较可以判断出是否超出)
    if (options.animate !== true && !this.getSize().contains(offset)) {
      return false;
    }

    Util.requestAnimFrame(function () {
      this._moveStart(true, false)._animateZoom(center, zoom, true);
    }, this);

    return true;
  },

  /** 触发 zoom 动画相关的事件 */
  _animateZoom(center, zoom, startAnim, noUpdate) {
    if (!this._mapPane) {
      return;
    }

    if (startAnim) {
      this._animatingZoom = true;

      // remember what center/zoom to set after animation
      this._animateToCenter = center;
      this._animateToZoom = zoom;

      this._mapPane.classList.add("leaflet-zoom-anim");
    }

    // 每个缩放动画至少发射一次。对于连续变焦,如捏变焦,在变焦期间每帧发射一次。
    this.fire("zoomanim", {
      center,
      zoom,
      noUpdate
    });

    if (!this._tempFireZoomEvent) {
      this._tempFireZoomEvent = this._zoom !== this._animateToZoom;
    }

    this._move(this._animateToCenter, this._animateToZoom, undefined, true);

    // Work around webkit not firing 'transitionend', see https://github.com/Leaflet/Leaflet/issues/3689, 2693
    setTimeout(this._onZoomTransitionEnd.bind(this), 250);
  },

  /** css transform 过度动画完成的后执行 */
  _onZoomTransitionEnd() {
    if (!this._animatingZoom) {
      return;
    }

    if (this._mapPane) {
      this._mapPane.classList.remove("leaflet-zoom-anim");
    }

    this._animatingZoom = false;

    this._move(this._animateToCenter, this._animateToZoom, undefined, true);

    if (this._tempFireZoomEvent) {
      this.fire("zoom");
    }
    delete this._tempFireZoomEvent;

    this.fire("move");

    this._moveEnd(true);
  },

  /** css transform 过度动画完成的事件 */
  _catchTransitionEnd(e) {
    if (this._animatingZoom && e.propertyName.includes("transform")) {
      this._onZoomTransitionEnd();
    }
  },

  /** 是否不进行zoom动画(如果获取到的元素是0个,表示不进行zoom动画) */
  _nothingToAnimate() {
    return !this._container.getElementsByClassName("leaflet-zoom-animated").length;
  } 
  // ......
})