如何用SVG画一个特定边框(下)

简介: 如何用SVG画一个特定边框(下)

如何用SVG画一个特定边框(上):https://developer.aliyun.com/article/1480478


 问题三:SVG 如何实现滚动离屏过渡


我们先看看理想效果:


在上文中我们提到,这个问题其实可以拆解为两个子问题:

  1. tab元素的顶边在离屏过程中需要固定,border 框选区域高度不断变小
  2. 圆角如何平滑过渡到直线


  • 如何固定顶边


在元素已经有一部分离屏的时候,我们需要对点进行修正:

  1. A 点的坐标将会被强制更新,我们用 A' 点表示新坐标,y 值永远被固定为 top2
  2. B、C 点可以直接被丢弃,即数据结构中的 arc、line 值都可以为空,因为 A' 点可以直接一条线连到 D 点


同理,元素往底部离屏的时候,我们强制更新 H 点,丢弃 G、F 点即可。


  • 圆角如何平滑过渡到直线


其实有6个圆角(A、B、C、F、G、H点对应的圆角)需要过渡到直线。我们以 B、C 两点为例:

  1. 两点想距较远(> 2 * radius),弧是一个正圆弧,两半径长度都为 radius
  2. 两点想距较近(< 2 * radius),弧是一个椭圆弧,X轴半径不变,Y轴半径变为 (y1 - y2) / 2



如何通过SVG表达这种过渡曲线呢?我们可以使用圆弧命令 A 的能力,因为它支持椭圆,不过我们还有另一种方式:二次贝塞尔曲线,一个二次贝塞尔曲线由 起点、终点 和一个 控制点 组成,每个圆弧我们其实正好能拿到3与之对应的点。


二次贝塞尔曲线在 SVG path 中通过 Q 指令绘制Q x1 y1, x y,在SVG中,起点为画笔位置,因此Q指令指定 控制点 和 终点:

  1. x1 y1:控制点坐标
  2. x y:终点坐标

其他问题

到这,核心卡点问题我们都已经解决了,实际上最终也实现了一版,达到了设计师想要的效果,但还存在一些遗留问题:

 性能问题


由于要随滚动不断计算并渲染SVG边框,因此性能开销比较大。后续需要在算法上进行优化,才能真正达到高体验的标准。

 拓展性


我们的算法基本是为水平布局定制的,如果布局切换到垂直布局,很多地方需要改动,因此当前方案的通用性并不佳。


 源码


使用绘制边框SVG的源码附上,drawSVGBorder方法为入口:

/**
 * 用于绘制边框的svg的id
 */
export const Svg_Id = '____TAB_CONTAINER_BORDER_SVG_ID_MAKABAKA____';

/**
 * tab容器的id
 */
export const Container_Id = '__MKT_TAB_CONTAINER_ID__';


export interface IBorderStyle {
  /**
   * 边框颜色
   */
  color?: string;
  /**
   * 边框宽度
   */
  width?: number;
  /**
   * 边框圆角
   */
  radius?: number;
}

/**
 * 为了绘制svg边框,需要将两个dom元素的四个顶点定义出来
 * 为了方便svg最终路径生成,因此每个点还会存储两个信息:
 * 1. 经过这个点的圆弧的svg路径
 * 2. 这个点到下一个圆弧起点的svg路径
 */
interface IPoint {
  x: number;
  y: number;
  arc?: string; // 圆弧svg路径
  line?: string; // 连线svg路径
}

interface IRect {
  left: number;
  top: number;
  right: number;
  bottom: number;
}

export enum EDirection {
  column = 'column',
  row = 'row',
}

enum ESweepFlag {
  cw = 1, // 顺时针
  ccw = 0, // 逆时针
}

/**
 * 生成圆弧svg路径
 * @param endX: 圆弧终点x坐标
 * @param endY: 圆弧终点y坐标
 * @param radius: 圆弧半径
 * @param sweepFlag: 顺时针还是逆时针: 1 顺时针、0 逆时针
 */
const generatorArc = (endX: number, endY: number, radius: number, sweepFlag: ESweepFlag = ESweepFlag.cw) => {
  return `A${radius} ${radius} 0 0 ${sweepFlag} ${endX} ${endY}`;
}

/**
 * 生成险段svg路径
 * @param endX 线段终点x坐标
 * @param endY 线段终点y坐标
 * @returns 
 */
const generatorLine = (endX: number, endY: number) => {
  return `L${endX} ${endY}`;
}

/**
 * 生成二阶贝塞尔曲线
 * @param controlPoint 贝塞尔曲线控制点
 * @param endPoint 贝塞尔曲线结束点
 * @returns 
 */
const generatorSecondOrderBezierCurve = (controlPoint: IPoint, endPoint: IPoint) => {
  return `Q${controlPoint.x} ${controlPoint.y} ${endPoint.x} ${endPoint.y}`;
}

/**
 * 判断两点是否相同
 */
const isSamePoint = (point1: IPoint, point2: IPoint) => {
  return point1.x === point2.x && point1.y === point2.y
}

/**
 * 获取元素相对于容器的DomRect
 */
const getBoundingClientRect = (id: string) => {
  const containerNode = document.getElementById(Container_Id);
  const node = document.getElementById(id);
  const containerRect = containerNode?.getBoundingClientRect();
  const rect = node?.getBoundingClientRect();

  if (!containerRect || !rect) return;

  // 获取相对于容器的 left 和 top
  const left = rect.left - containerRect.left;
  const top = rect.top - containerRect.top;

  return {
    left,
    top,
    right: left + rect.width,
    bottom: top + rect.height
  }
}

/**
 * 绘制一个圆角矩形,该函数使用场景为:
 * 1. 仅获取到一个元素时,给这个元素绘制边框
 */
function drawRectWithBorderRadius(rect: IRect, radius: number, borderStyle: IBorderStyle) {
  const svgDom = document.getElementById(Svg_Id);
  if (!svgDom) return;

  const pathdom = document.createElementNS("http://www.w3.org/2000/svg", 'rect');
  svgDom.appendChild(pathdom);

  const { left, top, right, bottom } = rect;
  const { color, width } = borderStyle || {};

  pathdom.setAttribute("x", String(left));
  pathdom.setAttribute("y", String(top));
  pathdom.setAttribute("rx", String(radius));
  pathdom.setAttribute("ry", String(radius));
  pathdom.setAttribute("width", String(right - left));
  pathdom.setAttribute("height", String(bottom - top));
  pathdom.setAttribute("fill", "none");
  pathdom.setAttribute("stroke", color || 'black');
  pathdom.setAttribute("stroke-width", `${width || 1}px`);
}

/**
 * 绘制svg路径,radius为矩形圆角半径,类似 border-radius
 */
function drawSvgPath(rect1: IRect, rect2: IRect, radius: number, borderStyle: IBorderStyle) {
  let { left: left1, top: top1, right: right1, bottom: bottom1 } = rect1;
  let { left: left2, top: top2, right: right2, bottom: bottom2 } = rect2;

  // tab标题元素顶点
  const dotMap1: Record<string, IPoint> = {
    leftTop: { x: left1, y: top1, },
    leftBottom: { x: left1, y: bottom1 },
    rightTop: { x: right1, y: top1 },
    rightBottom: { x: right1, y: bottom1 },
  }

  // 内容区元素顶点
  const dotMap2: Record<string, IPoint> = {
    leftTop: { x: left2, y: top2 },
    leftBottom: { x: left2, y: bottom2 },
    rightTop: { x: right2, y: top2 },
    rightBottom: { x: right2, y: bottom2 },
  }

  // 当前tab顶边是否和内容区对齐,若对齐,tab标题的右上角顶点 和 tab内容的左上角顶点,在绘制path时,可以不考虑其svg路径
  const isTopTab = isSamePoint(dotMap1.rightTop, dotMap2.leftTop);

  // 当前tab底边是否和内容区对齐,若对齐,tab标题的右下角顶点 和 tab内容的左下角顶点,在绘制path时,可以不考虑其svg路径
  const isBottomTab = isSamePoint(dotMap1.rightBottom, dotMap2.leftBottom);

  // 当前tab标题右下角的圆弧和tab内容区左下角的圆弧,相交了
  const isBottomRadiusConnect = (bottom2 - bottom1) < (radius * 2);

  // 当前tab标题右上角的圆弧和tab内容区左上角的圆弧,相交了
  const isTopRadiusConnect = (top1 - top2) < (radius * 2);

  // 当前tab标题的边框高度,已经无法容纳两个圆弧了
  const isTabTitleShort = (bottom1 - top1) < (radius * 2);

  dotMap1.leftTop = {
    ...dotMap1.leftTop,
    arc: isTabTitleShort
      ? generatorSecondOrderBezierCurve(dotMap1.leftTop, { x: left1 + radius, y: top1 })
      : generatorArc(left1 + radius, top1, radius),
    line: isTopTab ? generatorLine(right2 - radius, top2) : generatorLine(right1 - radius, top1),
  }

  dotMap1.rightTop = {
    ...dotMap1.rightTop,
    arc: isTopTab
      ? ''
      : isTopRadiusConnect
        ? generatorSecondOrderBezierCurve(dotMap1.rightTop, { x: right1, y: top1 - ((top1 - top2) / 2) })
        : generatorArc(right1, top1 - radius, radius, ESweepFlag.ccw),
    line: (isTopTab || isTopRadiusConnect) ? '' : generatorLine(left2, top2 + radius)
  }

  dotMap2.leftTop = {
    ...dotMap2.leftTop,
    arc: isTopTab
      ? ''
      : isTopRadiusConnect
        ? generatorSecondOrderBezierCurve(dotMap2.leftTop, { x: left2 + radius, y: top2 })
        : generatorArc(left2 + radius, top2, radius),
    line: isTopTab ? '' : generatorLine(right2 - radius, top2),
  }

  dotMap2.rightTop = {
    ...dotMap1.rightTop,
    arc: generatorArc(right2, top2 + radius, radius),
    line: generatorLine(right2, bottom2 - radius),
  }

  dotMap2.rightBottom = {
    ...dotMap2.rightBottom,
    arc: generatorArc(right2 - radius, bottom2, radius),
    line: isBottomTab ? generatorLine(left1 + radius, bottom2) : generatorLine(left2 + radius, bottom2),
  }

  dotMap2.leftBottom = {
    ...dotMap2.leftBottom,
    arc: isBottomTab
      ? ''
      : isBottomRadiusConnect 
        ? generatorSecondOrderBezierCurve(dotMap2.leftBottom, { x: left2, y: bottom2 - ((bottom2 - bottom1) / 2) })
        : generatorArc(left2, bottom2 - radius, radius),
    line: (isBottomTab || isBottomRadiusConnect) ? '' : generatorLine(right1, bottom1 + radius)
  }

  dotMap1.rightBottom = {
    ...dotMap1.rightBottom,
    arc: isBottomTab
      ? ''
      : isBottomRadiusConnect
        ? generatorSecondOrderBezierCurve(dotMap1.rightBottom, { x: right1 - radius, y: bottom1 })
        : generatorArc(right1 - radius, bottom1, radius, ESweepFlag.ccw),
    line: isBottomTab ? '' : generatorLine(left1 + radius, bottom1)
  }

  dotMap1.leftBottom = {
    ...dotMap1.leftBottom,
    arc: isTabTitleShort
      ? generatorSecondOrderBezierCurve(dotMap1.leftBottom, { x: left1, y: bottom1 - ((bottom1 - top1) / 2) })
      : generatorArc(left1, bottom1 - radius, radius),
    line: 'Z' // 该点是绘制的结束点
  }

  // 按path数组点的顺序,依次绘制path
  const path = [
    dotMap1.leftTop,
    dotMap1.rightTop,
    dotMap2.leftTop,
    dotMap2.rightTop,
    dotMap2.rightBottom,
    dotMap2.leftBottom,
    dotMap1.rightBottom,
    dotMap1.leftBottom
  ];

  const pathString = path.map((item) => `${item.arc} ${item.line}`)

  // SVG 路径的绘制起点
  const startPoint = {
    x: isTabTitleShort ? left1 : path[0].x, 
    y: isTabTitleShort ? top1 + ((bottom1 - top1) / 2) : (path[0].y + radius)
  }
  /**
   * 绘制的起点为:
   * {
   *   x: dotMap1.leftTop.x,
   *   y: dotMap1.leftTop.y + radius
   * }
   */
  const svgPath = `M${startPoint.x} ${startPoint.y} ${pathString.join(' ')}`;

  const svgDom = document.getElementById(Svg_Id);
  if (!svgDom) return;
  const pathDom = document.createElementNS("http://www.w3.org/2000/svg", 'path');
  svgDom.appendChild(pathDom);

  const { color, width } = borderStyle || {};
  pathDom.setAttribute("d", svgPath);
  pathDom.setAttribute("fill", "none");
  pathDom.setAttribute("stroke", color || 'black');
  pathDom.setAttribute("stroke-width", `${width || 1}px`);
}

function mergeRectSideAndGetNewRect(rect1: IRect, rect2: IRect, direction: EDirection, radius: number) {
  let newRect1: IRect = { top: rect1.top, left: rect1.left, bottom: rect1.bottom, right: rect1.right };
  let newRect2: IRect = { top: rect2.top, left: rect2.left, bottom: rect2.bottom, right: rect2.right };

  let isOversize = false; // 两元素是否水平/垂直平移不相交(垂直布局中,水平平移;水平布局中,垂直平移)

  if (direction === EDirection.column) {

    /**
     * 水平布局,固定tab在左边,后续的代码逻辑中,我们将 rect1 视为左边标题区,rect2 视为右边内容区
     * 如果发现实际位置是相反的,那么需要对变量进行交换,确保 rect1 在左,rect2 在右
     */
    if (newRect1.left > newRect2.left) {
      const tempRect = newRect1;
      newRect1 = newRect2;
      newRect2 = tempRect;
    }

    newRect1.right = newRect2.left;
    if (newRect1.top < newRect2.top) newRect1.top = newRect2.top;
    if (newRect1.bottom > newRect2.bottom) newRect1.bottom = newRect2.bottom;
    if (
      // 如果 tab标题 已经无法通过水平平移,和内容区相交了,那也不用给tab标题加border了
      newRect1.bottom < newRect2.top ||
      newRect1.top > newRect2.bottom
      // 如果tab标题的border框高度,已经不足以容纳两倍的圆角,那也不用给tab标题加border了
      // (newRect2.bottom - newRect1.top) <= (radius * 2 ) ||
      // (newRect1.bottom - newRect2.top) <= (radius * 2)
    ) {
      isOversize = true;
    };
  } else if (direction === EDirection.row) {
    // TODO: 后续增加水平布局
  }

  return {
    rect1: newRect1,
    rect2: newRect2,
    isOversize
  }
}

function updateSvgBorder() {
  const svgDom = document.getElementById(Svg_Id);
  if (!svgDom.children[0]) return;
  svgDom.removeChild(svgDom.children[0]);
}

/**
 * 使用SVG绘制边框
 * @param elementId1 tab元素ID
 * @param elementId2 内容区元素ID
 * @param direction tab布局(水平或垂直)
 * @param borderStyle 边框样式
 */
export default function drawSVGBorder(
  elementId1: string = '',
  elementId2: string = '',
  direction = EDirection.column,
  borderStyle: IBorderStyle
) {
  updateSvgBorder();
  if (!elementId1 || !elementId2) return; // 传入的元素id为空时,什么都不做

  const radius = borderStyle.radius || 6;

  // let rect1 = document.getElementById(elementId1)?.getBoundingClientRect?.();
  // let rect2 = document.getElementById(elementId2)?.getBoundingClientRect?.();
  let rect1 = getBoundingClientRect(elementId1);
  let rect2 = getBoundingClientRect(elementId2);

  if (!rect1 && !rect2) return; // 两个元素都没拿到时,什么都不做

  /**
   * 只能获取到一个元素时,这个场景有两种:
   * 1. 获取不到的元素是tab标题,标题列表滚动后,这个元素已经不在视口内,由于虚拟滚动,元素不会渲染,因此获取不到
   * 2. 元素tab标题能获取到,但是获取不到内容区
   */
  if (
    (!rect1 && rect2) ||
    (!rect2 && rect1)
  ) {
    // 给仅剩的dom元素画边框,一个圆角矩形
    drawRectWithBorderRadius(rect1 || rect2, radius, borderStyle);
    return;
  }

  const { rect1: newRect1, rect2: newRect2, isOversize } = mergeRectSideAndGetNewRect(rect1, rect2, direction, radius);

  if (isOversize) {
    drawRectWithBorderRadius(newRect2, radius, borderStyle); // 两元素平移不相交,则仅对内容区画边框
  } else {
    drawSvgPath(newRect1, newRect2, radius, borderStyle);
  }
}


团队介绍


我们是淘天集团-营销中后台前端团队,负责核心的淘宝&天猫营销业务,搭建千级运营小二、百万级商家和亿级消费者三方之间的连接通道,在这里将有机会参与到618、双十一等大型营销活动,与上下游伙伴协同作战,参与百万级流量后台场景的前端基础能力建设,通过标准化、归一化、模块化、低代码化的架构设计,保障商家与运营的经营体验和效率;参与面向亿级消费者的万级活动页面快速生产背后的架构设计、交付手段和协作方式。

目录
相关文章
|
5月前
|
前端开发 JavaScript
前端 CSS 经典:文字描边
前端 CSS 经典:文字描边
223 0
|
7月前
|
前端开发
如何用SVG画一个特定边框(上)
如何用SVG画一个特定边框(上)
80 1
|
7月前
|
前端开发
前端 CSS 经典:SVG 描边动画
前端 CSS 经典:SVG 描边动画
188 0
|
7月前
|
前端开发
【CSS进阶】使用CSS gradient制作绚丽渐变纹理背景效果(下)
【CSS进阶】使用CSS gradient制作绚丽渐变纹理背景效果
185 1
|
7月前
|
前端开发
【CSS进阶】使用CSS gradient制作绚丽渐变纹理背景效果(中)
【CSS进阶】使用CSS gradient制作绚丽渐变纹理背景效果
172 1
|
7月前
|
前端开发 容器
【CSS进阶】使用CSS gradient制作绚丽渐变纹理背景效果(上)
【CSS进阶】使用CSS gradient制作绚丽渐变纹理背景效果
160 1
|
7月前
|
前端开发
CSS圆角大杀器,使用滤镜构建圆角及波浪效果
CSS圆角大杀器,使用滤镜构建圆角及波浪效果
71 0
|
前端开发
【前端切图】CSS文字渐变和背景渐变
【前端切图】CSS文字渐变和背景渐变
74 0
|
前端开发
通过构建背景图学习CSS径向渐变
通过构建背景图学习CSS径向渐变
66 0
|
前端开发
前端 SVG 与 Canvas 框架案例 (画线、矩形、箭头、文字 ....)
前端 SVG 与 Canvas 框架案例 (画线、矩形、箭头、文字 ....)
139 0