整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

实用的 CSS动画知识—贝塞尔曲线(cubic-bezier)

在了解 cubic-bezier 之前,你需要对 CSS3 中的动画效果有所认识,它是 animation-timing-functiontransition-timing-function 中一个重要的内容。

PS:如果你用过PS的钢笔工具,应该对这个曲线也会十分熟悉。

简介

cubic-bezier 又称三次贝塞尔,主要是为了 animation 生成速度曲线的函数,规定是 cubic-bezier(<x1>, <y1>, <x2>, <y2>)

我们可以从下图中简要理解一下 cubic-bezier

从上图我们需要知道的是 cubic-bezier 的取值范围:

  • P0:默认值 (0, 0)
  • P1:动态取值 (x1, y1)
  • P2:动态取值 (x2, y2)
  • P3:默认值 (1, 1)

我们需要关注的是 P1 和 P2 两点的取值,而其中 X 轴的取值范围是 01,当取值超出范围时 cubic-bezier 将失效;Y 轴的取值没有规定,当然也毋须过大。

最直接的理解是,以一条直线放在范围只有 1 的坐标轴中,并从中间拿出两个点来拉扯(X 轴的取值区间是 [0, 1],Y 轴任意),最后形成的曲线就是动画的速度曲线

使用

在测试例子中:

<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
  <style>
    .animation {
      width: 50px;
      height: 50px;
      background-color: #ed3;
      -webkit-transition:  all 2s;
           -o-transition:  all 2s;
              transition:  all 2s;
    }
    .animation:hover {
      -webkit-transform:  translateX(100px);
          -ms-transform:  translateX(100px);
           -o-transform:  translateX(100px);
              transform:  translateX(100px);
    }
  </style>
</head>
<body>
  <div class="animation"></div>
</body>
</html>

我们可以在浏览器中看到,当鼠标移到元素上时,元素开始向右移动,开始比较慢,之后则比较快,移开时按原曲线回到原点。

在例子中,当我们不为 transition 添加 cubic-bezier 或是其他 timing-function 时,默认的速度曲线是 ease,此时的速度曲线是:

那么让我们在代码中加入 cubic-bezier(.17, .86, .73, .14)

...
.animation {
  ...
  -webkit-transition:  all 2s cubic-bezier(.17, .86, .73, .14);
       -o-transition:  all 2s cubic-bezier(.17, .86, .73, .14);
          transition:  all 2s cubic-bezier(.17, .86, .73, .14);
}
...

再刷新页面观察效果,会看到动画在执行过程中有一段很缓慢的移动,前后的速度相似,此时的运动曲线是:

几个常用的固定值对应的 cubic-bezier 值以及速度曲线

1、ease:cubic-bezier(.25, .1, .25, 1)

2、liner:cubic-bezier(0, 0, 1, 1) / cubic-bezier(1, 1, 0, 0)

3、ease-in:cubic-bezier(.42, 0, 1, 1)

4、ease-out:cubic-bezier(0, 0, .58, 1)

5、ease-in-out:cubic-bezier(.42, 0, .58, 1)

6、In Out . Back(来回的缓冲效果):cubic-bezier(0.68, -0.55, 0.27, 1.55)

附:制图工具 http://cubic-bezier.com/

塞尔曲线解析

贝塞尔曲线(Bézier curve),又称贝兹曲线或贝济埃曲线,是应用于二维图形应用程序的数学曲线。

贝塞尔曲线于1962年,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由保尔·德·卡斯特里奥于1959年运用德卡斯特里奥算法开发,以稳定数值的方法求出贝塞尔曲线。

n阶贝塞尔曲线一般化推导公式如下:

是不是被公式的长度吓了一跳。不用担心,阶贝塞尔曲线其实并没有那么复杂,只要把原理理解明白了,任何人都可以轻松画出阶贝塞尔曲线。

下面我就带大家一起拆分阶贝塞尔曲线,来揭开它的神秘面纱。

首先, 我们先来看下各阶的贝塞尔曲线动图

二阶的贝塞尔曲线就是一条直线, 就不再放图了, 图从三阶开始。

图1-三阶

图2-四阶

图3-五阶

从上面画各阶贝塞尔曲线的动图我们可以看出,各阶曲线是可以向下分解的。各阶曲线都可以递归的最终分解为三阶曲线。

在下图4中点序D1,D2,M1, 都处于自身所在线段的t处

也就是在长度上 P0D1/D1P1=P1D2/D2P2=D1M1/M1D2,在t变动时,D1, D2和M1的位置也在变动,点M1的运行轨迹就是最终的贝塞尔曲线。

图4

四阶贝塞尔曲线就相当于在三阶的基础上在外面再包一层线,外层三条线的每条线的t点依次连线形成和三阶一样的结构,然后再使用和三阶一样的方法可得到线段D1D2,从而得到点M1,最终得到贝塞尔曲线。

更高阶的贝塞尔曲线原理也和四阶一样,每条线的t点依次连线形成低阶结构最终得到动点M1, 从而得到最终的贝塞尔曲线。

上代码

根据给出的点递归计算低阶曲线的对应的点,最终计算出不系列动点M1。

function drawLines(ps, color) {
    if (ps.length < 2) {
        const { x, y } = ps[0];
        finalPoints.push({ x, y });
        drawLine();
        return;
    }
    const pps = [];
    for (let i = 0; i < ps.length - 1; i++) {
        pps.push(calcMiddlePoint(ps[i], ps[i + 1], .5));
    }
    drawLines(pps);
}

计算两点连线t点坐标

// 计算两点连线t点坐标
function calcMiddlePoint(p1, p2) {
    return { x: (p2.x - p1.x) * t + p1.x, y: (p2.y - p1.y) * t + p1.y };
}

画最终曲线

function drawLine() {
    let last = {}
    ctx.strokeStyle = "red";
    ctx.lineWidth = 4;
    ctx.beginPath();
    finalPoints.forEach(({ x, y }, index) => {
        if (index === 0) {
            ctx.moveTo(x, y)
        } else {
            ctx.lineTo(x, y)
        }
    });
    ctx.stroke();
}

完整代码

<!DOCTYPE html>
<html lang="zh-hans">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>贝塞尔曲线</title>
    <style>
        html,
        body,
        #canvas {
            margin: 0;
            padding: 0;
            background: white;
        }

        html,
        body {
            height: 100%;
            width: 100%;
            background: gray;
        }

        .buttonContainer {
            position: fixed;
            right: 20px;
            bottom: 20px;
            z-index: 100;
        }
    </style>
</head>

<body>
    <canvas id="canvas"></canvas>
    <div class="buttonContainer">
        <div>
            <button id="draw">画图</button>
            <button id="reDraw">重画</button>
            <button id="clear">清空</button>
            <button id="radom">随机</button>
        </div>
        <div>
            <a href="https://hefang.link">何方博客</a>
            <a href="https://zhuanlan.zhihu.com/iamhefang">何方的知乎专栏</a>
        </div>
    </div>
    <script>
        const cv = document.getElementById("canvas");
        const draw = document.getElementById("draw");
        const clear = document.getElementById("clear");
        const reDraw = document.getElementById("reDraw")
        let points = [];
        const ctx = cv.getContext("2d");
        let offset = 0;
        let timer;
        cv.height = window.innerHeight;
        cv.width = window.innerWidth;
        function calcMiddlePoint(p1, p2) {
            return { x: (p2.x - p1.x) * offset + p1.x, y: (p2.y - p1.y) * offset + p1.y };
        }
        let finalPoints = [];
        function drawLine() {
            let last = {}
            ctx.strokeStyle = "red";
            ctx.lineWidth = 4;
            ctx.beginPath();
            finalPoints.forEach(({ x, y }, index) => {
                if (index === 0) {
                    ctx.moveTo(x, y)
                } else {
                    ctx.lineTo(x, y)
                }
            });
            ctx.stroke();
        }
        function drawLines(ps, color) {
            if (ps.length < 2) {
                const { x, y } = ps[0];
                finalPoints.push({ x, y });
                drawLine();
                return;
            }
            ctx.lineWidth = 1;
            ctx.strokeStyle = color || "green";
            ctx.beginPath();
            for (let i = 0; i < ps.length; i++) {
                const { x, y } = ps[i];
                if (i === 0) {
                    ctx.moveTo(x, y);
                } else {
                    ctx.lineTo(x, y);
                }
            }
            ctx.stroke();
            const pps = [];
            for (let i = 0; i < ps.length - 1; i++) {
                pps.push(calcMiddlePoint(ps[i], ps[i + 1], .5));
            }
            drawLines(pps)
        }
        function doDraw() {
            if (points.length < 2) {
                alert(`无法画图,请至少指定2个点`);
                return;
            }
            timer && clearInterval(timer);
            drawLines(points, 'blue');
            timer = setInterval(() => {
                offset += 0.001;
                window.requestAnimationFrame(() => {
                    ctx.clearRect(0, 0, cv.width, cv.height);
                    drawLines(points, 'blue');
                });
                if (offset >= 1) {
                    clearInterval(timer);
                }
            }, 1);
        }
        reDraw.addEventListener("click", function () {
            finalPoints = [];
            offset = 0;
            ctx.clearRect(0, 0, cv.width, cv.height);
            doDraw();
        });
        draw.addEventListener("click", doDraw);
        clear.addEventListener("click", () => {
            points = [];
            finalPoints = [];
            offset = 0;
            ctx.clearRect(0, 0, cv.width, cv.height);
        });
        cv.addEventListener("click", function (e) {
            points.push({ x: e.clientX, y: e.clientY });
            console.log("point:", { x: e.clientX, y: e.clientY });
            ctx.fillStyle = "black";
            ctx.beginPath();
            ctx.arc(e.clientX, e.clientY, 2, 0, Math.PI * 2);
            ctx.fill();
        });
        radom.addEventListener("click", () => {
            points = [];
            finalPoints = [];
            offset = 0;
            ctx.clearRect(0, 0, cv.width, cv.height);
            for (let i = 0; i < 10; i++) {
                points.push({ x: Math.random() * cv.width, y: Math.random() * cv.height })
            }
            doDraw();
        });
    </script>
</body>

</html>

上面的html我已经放到了服务器上,点击可以直接使用。

  • 鼠标直接屏幕点击画点
  • 点击右下角"画图"开始画图
  • 点击"重画"可再次画图
  • 点击"清空"清空结果
  • 点击”随机“使用随机生成的20个点画图

我们的开发工程中经常会使用到各种图,所谓的图就是由节点和节点之间的连接所形成的系统,数学上专门有一个分支叫图论(Graph Theroy)。利用图我们可以做很多工具,比如思维导图,流程图,状态机,组织架构图,等等。今天我要做的是用开源的HTML5工具来快速构造一个做图的工具。

工具选择

工预善其事,必先利其器。第一件事是选择一件合适的工具,开源时代,程序员还是很幸福的,选择很多。

  • flowchart.js http://adrai.github.io/flowchart.js/ , 基于SVG创建Flow Chart
  • go.js http://www.gojs.net/latest/index.html go.js 提供一整套的JS工具 ,支持各种交互式图表的创建。有免费版和收费版
  • joint.js http://www.jointjs.com/ joint.js 是另一个创建图标库的工具,也提供免费版和商业版
  • jsPlumb http://www.jsplumb.org/ jsPlumb是一套开源的流程图创建工具 ,小巧精悍,使用简单
  • d3 http://d3js.org 在html5领域,d3可谓是最好的可视化基础库,提供方面的DOM操作,非常强大。

最终,我选择了jsPlumb,因为它完全开源,使用很简单,用D3的话可能会多花很多功夫。joint.js也不错。大家可以根据自己的需要选择。

构建静态应用

下面我们一步一步的来使用jsPlumb来创建我们的流程图工具。

第一步是等待DOM和jsPlumb初始化完毕,类似document.ready()和jquery.ready(), 要使用jsPlumb, 需要把代码放在这个函数里:

jsPlumb.ready(function() {
    // ... your code goes here ...
}


创建一个jsPlumb的实例,并初始化jsPlumb的配置参数:

//Initialize JsPlumb
var color = "#E8C870";
var instance = jsPlumb.getInstance({
    // notice the 'curviness' argument to this Bezier curve.  the curves on this page are far smoother
    // than the curves on the first demo, which use the default curviness value.      
    Connector : [ "Bezier", { curviness:50 } ],
    DragOptions : { cursor: "pointer", zIndex:2000 },
    PaintStyle : { strokeStyle:color, lineWidth:2 },
    EndpointStyle : { radius:5, fillStyle:color },
    HoverPaintStyle : {strokeStyle:"#7073EB" },
    EndpointHoverStyle : {fillStyle:"#7073EB" },
    Container:"container-id"
 });


这里给给出了一些配置包括,连接线(这里配置了一个贝塞尔曲线),线的风格,连接点得风格。Container需要配置一个对应的DIV容器的id。(这里也可以使用setContainer的方法)

下面我们要创建一个节点(node),每一个节点可以用一个DIV来实现。我这里提供了一个函数来创建节点。

function addNode(parentId, nodeId, nodeLable, position) {
  var panel = d3.select("#" + parentId);
  panel.append('div').style('width','120px').style('height','50px')
    .style('position','absolute')
    .style('top',position.y).style('left',position.x)
    .style('border','2px #9DFFCA solid').attr('align','center')
    .attr('id',nodeId).classed('node',true)
    .text(nodeLable);

  return jsPlumb.getSelector('#' + nodeId)[0];
}


这里做的事情就是创建了一个DIV元素,并放在对应的容器的制定位置上,注意为了支持拖拽的功能,必须使用position:absolute 。

我使用D3来操作DOM,大家可能会更习惯JQuery,这纯属个人喜好的问题。

最后返回创建节点的实例引用,这是的selector使用了jsPlumb.getSelector()方法,它和JQuery的selector是一样的,这样用的好处是你可以使用不同的DOM操作库,例如Vanilla

下面我使用一个函数来创建端点/锚点(anchor),锚点就是节点上的连接点,用于连接不同的节点。

function addPorts(instance, node, ports, type) {
  //Assume horizental layout
  var number_of_ports = ports.length;
  var i = 0;
  var height = $(node).height();  //Note, jquery does not include border for height
  var y_offset = 1 / ( number_of_ports + 1);
  var y = 0;

  for ( ; i < number_of_ports; i++ ) {
    var anchor = [0,0,0,0];
    var paintStyle = { radius:5, fillStyle:'#FF8891' };
    var isSource = false, isTarget = false;
    if ( type === 'output' ) {
      anchor[0] = 1;
      paintStyle.fillStyle = '#D4FFD6';
      isSource = true;
    } else {
      isTarget =true;
    }

    anchor[1] = y + y_offset;
    y = anchor[1];

    instance.addEndpoint(node, {
      uuid:node.getAttribute("id") + "-" + ports[i],
      paintStyle: paintStyle,
      anchor:anchor,
      maxConnections:-1,
      isSource:isSource,
      isTarget:isTarget
    });
  }
}


instance是jsPlumb的实例

node是我们用addNode方法创建的Node实例

ports,是一个string的数组,指定端点的个数和名字

type,可能是output或者input,指定端点的种类,一个节点的输出端口可以连接另一个节点的输入端口。

这里anchor是一个四维数组,0维和1维分别是锚点在节点x轴和y轴的偏移百分比。我这里希望把端口画在节点的左右两侧,并按照端口的数量均匀分布。

最后使用instance.addEndpoint来创建端点。注意这里只要指定isSource和isTarget就可以用drag&drop的方式来连接端点,非常方便。

下面一步我们提供一个函数来连接端点:

function connectPorts(instance, node1, port1, node2 , port2) {
  // declare some common values:
  var color = "gray";
  var arrowCommon = { foldback:0.8, fillStyle:color, width:5 },
  // use three-arg spec to create two different arrows with the common values:
  overlays = [
    [ "Arrow", { location:0.8 }, arrowCommon ],
    [ "Arrow", { location:0.2, direction:-1 }, arrowCommon ]
  ];

  var uuid_source = node1.getAttribute("id") + "-" + port1;
  var uuid_target = node2.getAttribute("id") + "-" + port2;

  instance.connect({uuids:[uuid_source, uuid_target]});
}


node1和node2是源节点和目标节点的引用,port1和port2是源端口和目标端口的名字。

使用instance.connect方法来创建连接。 overlays用来添加连接线的箭头效果或者其他风格,我这里没有使用,因为觉得都不是很好看。大家如果要用,只要把overlays加入到instance.connect的方法参数就可以了。

调用以上方法来创建节点,端点和连接线。

var node1 = addNode('container-id','node1', 'node1', {x:'80px',y:'20px'});
var node2 = addNode('container-id','node2', 'node2', {x:'280px',y:'20px'});

addPorts(instance, node1, ['out1','out2'],'output');
addPorts(instance, node2, ['in','in1','in2'],'input');

connectPorts(instance, node1, 'out2', node2, 'in');


这里我们创建了两个节点,第一个节点有两个输出端口,第二个节点有三个输入端口,然后把第一个节点的out2端口连接到第二个端点的in端口。效果如下:

最后我们给节点增加drag&drop的功能,这样我们就可以拖动这些节点来改变图的布局了。

instance.draggable($('.node'));


这里似乎依赖于JQuery-UI,我还不是很清楚。

交互式创建节点

我们已经初步具有了创建图的功能,可是节点的创建必须通过程序,我们希望用交互的方式来创建节点。

通常我们希望有一个tree view的控件,让后通过拖拽来创建对应类型的节点。这里我使用了这个开源的tree view,基于bootstrap https://github.com/jonmiles/bootstrap-treeview

我们先创建一个tree view:

function getTreeData() {
  var tree = [
    {
      text: "Nodes",
      nodes: [
        {
          text: "Node1",
        },
        {
          text: "Node2"
        }
      ]
    }
  ]; 

  return tree;
}
//Initialize Control Tree View
$('#control-panel').treeview({data: getTreeData()});


树上有两个节点:

然后我实现从树上拖拽对应的节点,到流程图上的逻辑。

//Handle drag and drop
$('.list-group-item').attr('draggable','true').on('dragstart', function(ev){
  //ev.dataTransfer.setData("text", ev.target.id);
  ev.originalEvent.dataTransfer.setData('text',ev.target.textContent);
  console.log('drag start');
});

$('#container-id').on('drop', function(ev){
  //avoid event conlict for jsPlumb
  if (ev.target.className.indexOf('_jsPlumb') >= 0 ) {
    return;
  }

  ev.preventDefault();
  var mx = '' + ev.originalEvent.offsetX + 'px';
  var my = '' + ev.originalEvent.offsetY + 'px';

  console.log('on drop : ' + ev.originalEvent.dataTransfer.getData('text'));
  var uid = new Date().getTime();
  var node = addNode('flow-panel','node' + uid, 'node', {x:mx,y:my});
  addPorts(instance, node, ['out'],'output');
  addPorts(instance, node, ['in1','in2'],'input');
  instance.draggable($(node));
}).on('dragover', function(ev){
  ev.preventDefault();
  console.log('on drag over');
});


这里要注意的是要避免和jsPlumb拖拽端点的逻辑冲突,当检测到target是jsPlumb对象是需要直接从drop方法中退出以执行对应的jsPlumb的drop逻辑。

好了,一个绘制流程图的软件工具初步完工。

我把代码放在oschina的代码托管服务上了, 大家有兴趣可以去试试。