自定义路径动画在 H5 编辑器中的应用

2020/08/25 offset-pathsvgcanvas

当前,稿定 H5 编辑器中的动画可分为翻页动画和元素动画。翻页动画是基于 Vue 内置的动画组件(<transition-group>)来实现。而元素动画则是基于标准的 Web Animation API(WAAPI)实现。

我们的路径动画属于元素动画,与普通元素动画的差异在于,动画的路径是用户手动绘制的。因此,可以拆分为两大项任务:路径绘制动画执行。在实现的技术方案上,我们主要考虑以下选择:

路径绘制:SVG 绘制 / Canvas 绘制

动画执行:SVG SMIL Animation / CSS Motion Path

关于路径绘制,SVG / Canvas 的方案差别不大,最后都能拿到我们想要的路径点数据。

而在动画执行方案的选择上,如果我们采用了 SVG 的方案,那么意味着在动画执行阶段我们需要新建一层 svg 路径动画执行层覆盖在画布上,和原元素动画会有很大的冲突,在和原元素动画的衔接过渡上存在难以解决的问题。因此我们选择了 CSS Motion Path,浏览器兼容性问题我们通过引入 polyfill 来解决。

综上,技术选型时我们充分考虑与原元素动画的兼容,决定采用 Canvas 绘制 + CSS Motion Path + Web Animation API 来实现路径动画。

# 路径编辑器

路径编辑器借鉴了 Method Draw (opens new window), Image Grabber (opens new window) 这两个开源编辑器。

# 编辑器尺寸

在长页作品中,我们是通过 IntersectionObserver API 监听元素 DOM 进入视口时开始执行动画。如果路径编辑器的尺寸大于画布,且用户将动画元素拖动至画布外,那么这个元素会处于视口之外,动画是永远不会执行的。同时,元素在画布外时是不可见的,用户对元素的选择和编辑都会很困难。因此,第一个版本,为了降低复杂度,避免过多的业务逻辑处理,我们规定路径编辑器的宽高与元素所在画布的宽高保持一致。

# 数据结构

路径可以是直线/曲线,一条路径又是由许多的点组成,我们可以通过存储路径上的直线点和曲线点数据来还原一条路径。

其中,曲线又可分为二阶贝塞尔曲线与三阶贝塞尔曲线。差别在于二阶贝塞尔曲线只有一个控制点,而三阶贝塞尔曲线有两个控制点。我们可以通过如下数据结构来大致描述这3中点类型:

// 直线点
{ x: 0, y: 0 }

// 二阶贝塞尔曲线点
{
    x: 0,
    y: 0,
    cp: { x: 10, y: 10 },
}

// 三阶贝塞尔曲线点
{
    x: 0,
    y: 0,
    cp0: { x: 10, y: 10 },
    cp1: { x: 20, y: 20 },
}

从以上数据结构我们可以知道,曲线点存储的数据量是要比直线点大的;如果曲线点与它的控制点为同一个点,则该点其实也是一个直线点。为了降低路径编辑器整体的复杂度,规避在点数据上冗余的逻辑判断,我们统一使用三阶贝塞尔曲线点的数据结构来存储点数据。最终的数据结构如下:

{
    animations: [{
        // ...
        customValues: {
            path: [
                {
                    x: 0,
                    y: 0,
                    cp0: { x: 0, y: 0 },
                    cp1: { x: 0, y: 0 },
                },
                {
                    x: 100,
                    y: 100,
                    cp0: { x: 100, y: 100 },
                    cp1: { x: 100, y: 100 },
                },
            ],
        },
        name: '路径移动',
        offsetRotate: 'auto',
        // ...
    }],
}

# 路径点计算

在取路径坐标点时,是以视口左上角为原点。我们要把路径点绘制到 Canvas 画布上,需要将该点转换为以 Canvas 画布左上角为原点的坐标。而最后输出的路径点数据,则是要以元素盒模型左上角为坐标原点。在路径点数据转换过程中,我们还需要将画布缩放对数据点坐标的影响考虑在我们的算法当中:

export default {
    methods: {
        // 计算转换相对于 Canvas 的位置数据([x, y] 为相对视口的坐标)
        positionToCanvas(x, y) {
            const bbox = this.canvas.getBoundingClientRect();
            return {
                x: (x - bbox.left) * this.canvas.width / bbox.width,
                y: (y - bbox.top) * this.canvas.height / bbox.height,
            };
        },

        // 计算转换相对于元素所在画布的位置数据([x, y] 为相对 Canvas 的坐标)
        positionToLayout(x, y) {
            const {
                $layout,
                editor: {
                    global: { zoom },
                },
            } = this;
            const box = $layout.getBoundingClientRect();
            const layoutPosition = this.positionToCanvas(box.left, box.top);

            return {
                x: (x - layoutPosition.x) / zoom,
                y: (y - layoutPosition.y) / zoom,
            };
        },

        // 计算转换相对于元素左上角顶点的位置数据([x, y] 为相对 Canvas 的坐标)
        positionToElement(x, y) {
            const {
                element: { left, top },
            } = this;
            const pos = this.positionToLayout(x, y);

            return {
                x: pos.x - left,
                y: pos.y - top,
            };
        },
    },
}

# 直/曲线点切换

如果有用过 PS 的钢笔工具,那么你应该知道手动操作各控制点生成曲线是很繁琐的。针对该问题,我们实现了双击路径点将该点切换为曲线点/直线点的功能,曲线点的算法参考了「平滑曲线生成:贝塞尔曲线拟合 (opens new window)」这篇文章:

贝塞尔曲线拟合

/**
 * 原理浅析:
 * 1. 假设目标点为 p0,取目标点前后各 1 个点 p1, p2
 * 2. 做 p1-p0-p2 连线夹角的角平分线
 * 3. 取角平分线的垂线为两个控制点(cp0, cp1)所在的连线
 */
function toggleSmoothCurve(ep, ratio = 0.3) {
    const { path } = this;

    if (path.length <= 2) return ep;

    const index = path.findIndex(item => item === ep);
    const prevEp = index - 1 >= 0 ? path[index - 1] : path[path.length - 1];
    const nextEp = index + 1 >= path.length ? path[0] : path[index + 1];
    const isBezierPoint = ep.x !== ep.cp0.x || ep.x !== ep.cp1.x
        || ep.y !== ep.cp0.y || ep.y !== ep.cp1.y;
    let cp0;
    let cp1;

    if (isBezierPoint) {
        cp0 = { x: ep.x, y: ep.y };
        cp1 = { x: ep.x, y: ep.y };
    } else {
        const len1 = Math.sqrt(
            ((prevEp.x - ep.x) ** 2) + ((prevEp.y - ep.y) ** 2),
        );
        const len2 = Math.sqrt(
            ((nextEp.x - ep.x) ** 2) + ((nextEp.y - ep.y) ** 2),
        );
        const v = len2 / len1;

        let delta = {};
        if (v > 1) {
            delta = {
                x: prevEp.x - ((nextEp.x - ep.x) / v + ep.x),
                y: prevEp.y - ((nextEp.y - ep.y) / v + ep.y),
            };
        } else {
            delta = {
                x: ep.x + (prevEp.x - ep.x) * v - nextEp.x,
                y: ep.y + (prevEp.y - ep.y) * v - nextEp.y,
            };
        }

        delta.x *= ratio;
        delta.y *= ratio;

        cp0 = {
            x: ep.x + delta.x,
            y: ep.y + delta.y,
        };
        cp1 = {
            x: ep.x - delta.x,
            y: ep.y - delta.y,
        };
    }

    ep.cp0 = new ControlPoint(cp0.x, cp0.y);
    ep.cp1 = new ControlPoint(cp1.x, cp1.y);

    return ep;
}

# 元素路径

根据 UI 稿,我们规定元素的中心点为一条路径的起点。路径上的所有点坐标是以该元素盒模型左上角为原点,当元素缩放时,坐标原点的位置会发生改变,相应的路径点数据也会发生改变。

但是,我们并不需要在元素缩放时实时调整路径数据(可想而知,这对性能的消耗会很大)。只有在路径动画执行时,我们才需要用到路径数据,而这时候,元素的尺寸、画布的缩放比例都是确定的,所以,我们只需要在动画执行前重算路径并且重写相关动画配置即可达成我们的目的。同时,在执行动画前,我们还需要把点数据转换为 SVG path 数据,参考 MDN (opens new window)

/**
 * 路径点数据转换为 SVG path 数据
 * @param {Object} element 运动元素 Model
 * @param {Object} path 路径点数据
 * @param {Number} zoom 页面缩放指数
 * https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d
 */
export const convertToSvgPath = (element, { points = [], isClose = false }, zoom = 1) => {
    let path = '';
    const numberToString = x => x.toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
    const originPosition = points[0];

    if (!originPosition) return path;

    // 路径坐标校正(起始点应与元素中心点重合)
    // 根据元素中心点与路径起始点的偏差值来重算所有路径点
    const delta = {
        x: originPosition.x - element.width / 2,
        y: originPosition.y - element.height / 2,
    };
    const positionToZoom = ({
        x, y, cp0, cp1,
    }) => {
        x = numberToString((x - delta.x) * zoom);
        y = numberToString((y - delta.y) * zoom);
        cp0 = {
            x: numberToString((cp0.x - delta.x) * zoom),
            y: numberToString((cp0.y - delta.y) * zoom),
        };
        cp1 = {
            x: numberToString((cp1.x - delta.x) * zoom),
            y: numberToString((cp1.y - delta.y) * zoom),
        };

        return {
            x, y, cp0, cp1,
        };
    };

    points.forEach((ep, i) => {
        ep = positionToZoom(ep);

        if (i === 0) {
            path += `M${ep.x},${ep.y}`;
            return;
        }

        const prevEp = positionToZoom(points[i - 1]);
        path += ` C${prevEp.cp1.x},${prevEp.cp1.y} ${ep.cp0.x},${ep.cp0.y} ${ep.x},${ep.y}`;
    });

    // 闭合路径,同样以三阶贝塞尔绘制(若以 Z 闭合则无法绘制曲线)
    if (isClose) {
        const ep = positionToZoom(points[0]);
        const prevEp = positionToZoom(points[points.length - 1]);
        path += ` C${prevEp.cp1.x},${prevEp.cp1.y} ${ep.cp0.x},${ep.cp0.y} ${ep.x},${ep.y}`;
    }

    return `path('${path}')`;
};

# 动画执行

要让元素沿着不规则路径运动,我们首先想到的可能会是 「SVG SMIL animation」(Synchronized Multimedia Integration Language 同步多媒体集成语言),除了 IE,浏览器的兼容非常不错(can i use (opens new window))。

除了 SVG 我们还可以通过 Canvas 来实现路径动画,不过需要自己封装动画 API,或者借助第三方库(如 SpritJS)。

当然,还有我们目前所选择的 CSS Motion Path 方案,使用 offset-path 属性定义路径,通过定义 @keyframes 来改变 offset-distance 属性,可以非常简单的实现路径动画。

#motion-demo {
    offset-path: path('M20,20 C20,100 200,0 200,100');
    animation: move 3000ms infinite alternate ease-in-out;
    width: 40px;
    height: 40px;
    background: cyan;
}

@keyframes move {
    0% {
        offset-distance: 0%;
    }
    100% {
        offset-distance: 100%;
    }
}

不过,这个方案目前还处于草案阶段,兼容性较差(can i use (opens new window)),但是借助 polyfill (motion-path-js (opens new window)) 我们一样可以开始在生产环境中跑起来(以下为伪代码):

/**
 * 实现原理:
 * 通过创建 SVG path element,调用 SVG 元素的 getTotalLength, getPointAtLength 函数获取到
 * 路径上的点数据,计算得出每个点的坐标及倾角,通过改变 transform 来实现元素路径运动
 */
function customizePath(path, func) {
    const pathElement = document.createElementNS('http://www.w3.org/2000/svg',"path");

	pathElement.setAttributeNS(null, 'd', path);

	const length = pathElement.getTotalLength();
    const duration = 1000;
    const interval = length / duration;
    let time = 0, step = 0;

    const timer = setInterval(function() {
        if (time <= duration) {
              const x = parseInt(pathElement.getPointAtLength(step).x);
              const y = parseInt(pathElement.getPointAtLength(step).y);
              func(x, y);
              step++;
        } else {
              clearInterval(timer)
        }
     }, interval);
}

/**
 * 如何计算路径上某个点的角度?
 * 以该点作为原点 p1(x, y),取同一路径上一定距离后的另一个点 p2(x, y),计算两点连线与正 x 轴的夹角
 */
function getRotate(a, b) {
    const k = (b.y - a.y) / (b.x - a.x);
    const rotate = Math.atan(k) * 180 / Math.PI;
    return k < 0 ? rotate + 90 : rotate - 90;
}

根据 CSS Motion Path 规范,offset-rotate 的默认值为 auto,表示元素的倾角和路径的方向与正 x 轴之间的夹角保持一致,这在实际应用场景上可能会导致元素开始执行动画时会有较大的角度旋转变换,因此我们需要做一下处理,保证元素初始倾角在 -45° ~ 45° 之间:

getKeyframes(element, layout, method, zoom) {
    const {
        offsetRotate,
        customValues: { path },
    } = this;
    const offsetPath = convertToSvgPath(element, path || {}, zoom);
    const keyframes = {
        offsetDistance: ['0%', '100%'],
        offsetPath: [offsetPath, offsetPath],
        offsetAnchor: ['auto', 'auto'],
    };

    // 保证初始角度在 -45° ~ 45° 之间
    if (offsetRotate === 'auto' && path && path.length > 1) {
        const [ep0, ep1] = path;
        const options = [0, -180, -90, 90, 180];
        const degree = Math.atan2(ep1.y - ep0.y, ep1.x - ep0.x) / (Math.PI / 180);

        let angle = 0;
        // eslint-disable-next-line
        for (let i = 0, l = options.length; i < l; i++) {
            const delta = degree + options[i];

            if (delta > -45 && delta < 45) {
                angle = options[i];
                break;
            }
        }

        keyframes.offsetRotate = new Array(2).fill(`auto ${angle}deg`);
    } else {
        keyframes.offsetRotate = new Array(2).fill('0deg');
    }

    return keyframes;
}

# 兼容性

在不支持 Web Animations API 浏览器上,我们引入 web-animations-js 这个 polyfill 库通过直接修改 CSS 的方式来实现动画效果。在支持 WAAPI 的浏览器上,web-animations-js 在特性判断之后直接 return 了,相关的 polyfill 代码并没有真正执行。但是,在支持 WAAPI 的浏览器上,有很大的可能还不支持 CSS Motion Path,因此我们还需要对该 polyfill 做二次开发,调整这个库的打包逻辑,增加相关动画特性判断。

目前,大部分的移动端浏览器都还不支持 CSS Motion Path,多数场景我们都需要通过 JS+CSS 来模拟。同时这也引入了另一个问题,我们在同一组元素动画中(入场、强调、退场),如果混合使用了原生 WAAPI 与 polyfill 特性,那么通过 polyfill 执行的动画是无法获取到原生 WAAPI 动画执行后的状态的,因此,如果一组动画中包含有路径动画,我们通过 forceJsExcute 参数来强制该元素上的所有动画都通过 polyfill 的方式来执行:

(function(scope) {
    function isPropertySupported(property) {
        if (property in document.body.style) return true;
        var prefixes = ['Moz', 'Webkit', 'O', 'ms', 'Khtml'];
        var prefProperty = property.charAt(0).toUpperCase() + property.substr(1);

        for (var i = 0; i < prefixes.length; i++) {
            if ((prefixes[i] + prefProperty) in document.body.style) return true;
        }

        return false;
    }

    function isWebAnimationsApiSupported() {
        if (document.documentElement.animate) {
            var player = document.documentElement.animate([], 0);

            if (!player) return false;

            var animProperty = [
                'play', 'currentTime', 'pause', 'reverse',
                'playbackRate', 'cancel', 'finish', 'startTime', 'playState',
            ];

            for (var i = 0, l = animProperty.length; i < l; i++) {
                if (player[animProperty[i] === undefined]) return false;
            }

            return true;
        }
    }

    var isAnimationsApiSupported = isWebAnimationsApiSupported();
    var isOffsetPathSupported = isPropertySupported('offset-path');
    var originalElementAnimate = window.Element.prototype.animate;

    window.Element.prototype.animate = function(effectInput, options) {
        var hasOffsetPathProperty = ~JSON.stringify(effectInput).indexOf('offsetPath');

        if (
            !isAnimationsApiSupported
                || (options && options.forceJsExcute)
                || (hasOffsetPathProperty && !isOffsetPathSupported)
        ) {
            var id = '';
            if (options && options.id) {
                id = options.id;
            }

            try {
                delete options.forceJsExcute;
            } catch (e) { }

            return scope.timeline._play(scope.KeyframeEffect(this, effectInput, options, id));
        }

        return originalElementAnimate.call(this, effectInput, options);
    };
})(webAnimations1);

# 注意事项

  1. 高清屏上,Canvas 路径编辑器上绘制的路径边沿会模糊,该问题的根源是初始设计时绘制路径的各尺寸数据未考虑到高清屏场景,导致现在要增加这个特性会有较大的改动调试成本
  2. 在不支持 Web Animations API 的浏览器上,会使用 web-animations-js 库来控制动画执行。但是要注意,这个 polyfill 库在动画 pause 暂停时会采用轮询的方式来查询当前的动画状态,如果画布上有多个动画同时暂停等待执行,那么页面会非常卡顿(长页作品目前采用将动画 pause 停留在第一帧的方式来使动画元素在画布上不可见)

# 参考资料:

上次更新: 2023/1/2 02:25:24