今天设计师给我出了个难题,结果被我反手拿下

9,413 阅读7分钟

最近做个需求,设计师画了个图,虽然UI稿看起来很简单,但如果用技术手段实现却不能无脑堆 css 了,图示如下

1.jpg

本组件基于 vue3的完整可运行代码已经上传至 Github,另外也做了个 在线体验demo

姑且称其为一种数据漏斗展示卡片吧

  • 每个卡片都是一个平行四边形,第一个平行四边形的左边斜角被切掉,最后一个平行四边形的右边斜角被切掉
  • 后一个平行四边形的高度是前一个的 x%(即高度不固定,与实际数据相关)
  • 相邻的平行四边形之间用一个悬浮的小标签元素连接,这个元素也是一个平行四边形,其倾斜角度和卡片倾斜角度相同,小标签元素在x轴上的位置位于前后两个平行四边形中间,y轴上则处于后一个较低的平行四边形的高度正中间位置
  • 相邻的平行四边形之间存在固定的间距,这块间距又被一个不规则多边形填充(用于在视觉上顺滑连接相邻的两个平行四边形),这个不规则多边形的顶部封闭线有一部分是一段圆弧,圆弧刚好将前一个平行四边形的右上端点和后一个平行四边形的和左上端点顺滑连接

拆分一下,这里共存在三种图形:

  • 平行四边形(前后斜角可以被切掉)

2.jpg

  • 悬浮小标签

4.jpg

  • 不规则圆弧顶多边形

3.jpg

平行四边形

平行四边形的实现方式很多,这里我采用 skewX 的方式

<div class="parallelogram-wrapper">
  <div class="parallelogram-box"></div>
</div>
<style lang="less" scoped>
  .parallelogram-wrapper {
    width: 400px;
    height: 200px;
  }
  .parallelogram-box {
    height: 100%;
    border-radius: 4px;
    transform: skewX(-16deg);
    background: linear-gradient(263.65deg, rgb(142, 173, 255) 11.79%, rgb(194, 211, 255) 94.63%)
  }
</style>

skewX 有一个特性,当你对一个长方形使用 skewX 的时候,得到的平行四边形的高度依旧等于原长方形的高度,即原长方形的 height400px,那么 skewX 之后,平行四边形的 height 依旧是 400px,但是相对于原长方形,平行四边形所占据的宽度却改变了

例如对于本例,.parallelogram-box是一个 div元素,其默认宽度应该是和其父元素 .parallelogram-wrapper 宽度一样的,但实际上,作为平行四边形的 .parallelogram-box,在浏览器中查看其占据的宽度是 457.35px,长方形变形为平行四边形,按照正常的逻辑,变形后的平行四边形的宽度确实会变大,且是可计算的

5.jpg

上图,蓝色边长方形表示原长方形,红色边平行四边形表示原长方形经过 skewX 后得到的平行四边形

∠ACB是平行四边形的倾斜角度,平行四边形的宽度 = 长方形宽度 + AB

其中,AB = tan∠ACB * 长方形的高,本例中,∠ACB=16,长方形的高是 200,所以可得平行四边形的宽度是:平行四边形的宽度 = 400 + tan∠ACB * 200 = 457.35,与实际相符

一般情况下,设计稿上只会给你平行四边形的长和宽(毕竟设计稿不需要从长方形变形为平行四边形,不需要这一步),但是技术上实现得需要原长方形的宽高,既然能从长方形的宽高计算得到平行四边形的宽高,那么反过来当然也可以

例如,设计稿给定平行四边形的宽/高是 457.35px/200px,那么原长方形的高不变也是 200px,宽是 平行四边形的宽度 - AB,拿到了长方形的宽高之后,再 skewX一下,就得到和设计稿中一样的平行四边形了

如何将平行四边形的左边或右边的斜角切掉呢?

6.jpg

只需要给 .parallelogram-wrapper 加个 overflow: hidden;的属性,然后将 .parallelogram-box 向左平移 1/2 * AB 的距离,则得到左边斜角被切掉的图形;将 .parallelogram-box 向右平移 1/2 * AB 的距离,则得到右边斜角被切掉的图形

悬浮小标签

这是个平行四边形,所以还是用 skewX属性进行设置,其高度是固定的,但宽度跟随其内部文案的长度进行变动,想要找准其在x轴上位置,肯定是要得到其精确宽度的,这个用 js 测量一下就行了

7.jpg

o点是相邻两平行四边形中左边那个平行四边形的右下端点,设其坐标为(x1, y)p是右边那个平行四边形的左下端点,,设其坐标为(x2, y),根据上面一节的理论,这两个端点的位置都是可以计算得到的,那么op的中心点q的坐标也就可以得到了,设为 (x3, y),即 ((x1 + x2) / 2, y)

通过 js测量得到其宽度 width后,现在只需要将悬浮小标签的 left 属性设置为 x3 - width / 2,即可让此元素在水平方向上的中心点位于 x3了,至于其在 y轴上的位置(y3)也一样,这样一来就确定好了悬浮小标签的位置

但这个时候如果你仔细看的话,就会发现在 x轴上,悬浮小标签似乎并不是完美地位于相邻两个平行四边形的中间位置

8.jpg

实际上,其确实不在中间位置,上面的 x3 - width / 2x坐标值,如果是在 op这条线上,那么其实 op的中间点,但是由于悬浮小标签的参照物是左右两个平行四边形,平行四边形在 y方向上的边并不是完全与 y轴重合的,而是存在一定的倾角,那么随一旦悬浮小标签不在 op这条线上了,那么其中心点也就不再是 x3 - width / 2了,还需要在此基础上,再减掉偏移的距离,这段偏移距离 mn 也是可以计算出来的,即 mn = tan∠mqn * y3,其中 ∠mqn 也就是平行四边形的倾斜角度,是已知的

不规则圆弧顶多边形

9.jpg

可以将其看做是一个长方形内部的一个图形,即上图中粉红色长方形,这个长方形的长、宽以及四个端点的坐标都是可以确定的(与平行四边形端点相关),只需要让这个粉红长方形的 z-index 小于相邻两个平行四边形的 z-index,那么粉红长方形左右两块三角形就被遮掉了

10.jpg

现在还剩下顶部的那块圆弧要稍微复杂点,圆弧就是圆的一部分,肯定存在一个圆,其一部分(也就是扇形)遮在粉红长方形上,刚好可以形成一种粉红长方形被切掉一块扇形的结果,也就是我们想要的结果

假设这个圆的圆心位于k点,坐标是 (a, b)w点是左平行四边形右上端点,坐标为 (x4, y4)e是右平行四边形左上端点,坐标为 (x5, y5),这两个点坐标是可以计算得到的,圆的半径可以按照我们自己对弧度的要求进行主观调整,也就是我们可以自行决定,那么现在问题就转变为:已知圆的半径 R 和圆上两点的坐标,求圆心坐标

11.jpg

这是个数学问题,可以列出两个方程式:

(x4-a)^2 + (y4-b)^2 = R^2
(x5-a)^2 + (y5-b)^2 = R^2

两个方程式两个未知数,所以方程可解,即可得到 (a, b),用 js函数表达的话,如下:

/**
 * 计算圆心坐标
 * @param x1 圆上一点的x坐标
 * @param y1 圆上一点的y坐标
 * @param x2 圆上另一点的x坐标
 * @param y2 圆上另一点的y坐标
 * @param r 圆半径
 */
const genCircleCenter = (x1: number, y1: number, x2: number, y2: number, r: number) => {
  const c1 = (x2 * x2 - x1 * x1 + y2 * y2 - y1 * y1) / (2 * (x2 - x1))
  const c2 = (y2 - y1) / (x2 - x1)
  const A = c2 * c2 + 1
  const B = (2 * x1 * c2 - 2 * c1 * c2 - 2 * y1)
  const C = x1 * x1 - 2 * x1 * c1 + c1 * c1 + y1 * y1 - r * r
  // const y = (-B + Math.sqrt(B * B - 4 * A * C)) / (2 * A)
  const y = (-B - Math.sqrt(B * B - 4 * A * C)) / (2 * A)
  return { x: c1 - c2 * y, y }
}

上面方程其实可以得到两组解,分别对应两个圆心坐标,我们根据实际情况取其中一个解就行了

小结

组件的实现思路倒是不复杂,实现步骤稍显繁琐,但多花点时间一步步解决最后还是可以完成的