前端开发者学堂 - fedev.cn

使用圆锥渐变和CSS自定义属性创建一个Range Input控制的环形图

发布于 大漠

特别声明:此篇文章内容来源于@Vicky.Ye翻译的《使用圆锥渐变和CSS变量创建一个Range Input控制的环形图》一文。英文原文来自于@Ana Tudor的《Using Conic Gradients and CSS Variables to Create a Doughnut Chart Output for a Range Input》一文。

最近我在 Codepen 上看到了一个例子,我的第一个想法是这个案例可以只用三个元素完成:一个容器,一个 range 类型的 input 和一个 output 。在 CSS 方面,涉及到使用一个把 CSS 自定义属性作为范围渲染参数的圆锥渐变函数 conic-gradient()

在2015年年中,@Lea Verou 在一次会议演讲中发布了一个 conic-gradient()的 polyfill,并演示了如何将它们用于创建饼图。这个 polyfill 非常适合 conic-gradient() 的入门学习,因为它使我们能够更全面的使用这个函数来构建我们想要的东西。不幸的是,它不适用于 CSS自定义属性,而 CSS自定义属性现在已成为编写高效代码的关键组成部分。

但好消息是,在过去的两年半时间里,情况有所转变。 一般来说,Chrome 浏览器和使用暴露标志的 Blink 引擎浏览器(例如 Opera )现在都支持原生的 conic-gradient(),这意味着已经有可能尝试以 CSS自定义属性作为 conic-gradient()的范围值 。 我们所需要做的就是在 chrome://flags 启用 Experimental Web Platform Features 标志(或者,如果您使用 Opera ,opera://flags ):

好吧,现在我们可以开始了!

初始结构

一开始我们需要一个容器和一个 range 类型的 input

<div class="wrap">
    <input id="r" type="range"/>
</div>

请注意,我们没有 output 元素。 因为当 JavaScript 由于某种原因被禁用或加载失败时,未更新元素就会出现在页面里,所以我们需要通过 JavaScript 来动态更新 output 标签的值。同样的也需要通过 JavaScript 来判断浏览器是否支持 conic-gradient(),并在容器上动态添加一个 class 作为标识。

如果我们的浏览器支持 conic-gradient() ,则容器将获得一个 .full 样式, .full 下的 output 样式将会显示到图表上。 否则,我们只有一个没有图表的简单滑动条, output 位于滑动条按钮上。

浏览器支持 conic-gradient()(上边)和浏览器不支持时的兜底方案(下边)

基本样式

在着手之前,我们希望滑动条在所有浏览器上显示都是没问题的。

我们先从最基本的样式重置开始,并设置 bodybackground

$bg: #3d3d4a;
* { 
    margin: 0 
}
body { 
    background: $bg 
}

第二步是准备滑动条在 WebKit 浏览器中的样式,这里我们要通过设置 -webkit-appearance: none 和其按钮样式(因为某种原因系统设置了该轨道的默认样式),为避免不同浏览器中默认属性的不一致,如 paddingbackgroundfont ,我们要给出明确值:

[type='range'] {
    &, &::-webkit-slider-thumb { 
        -webkit-appearance: none 
    }

    display: block;   
    padding: 0;
    background: transparent;
    font: inherit
}

如果您需要了解滑动条及其组件在各种浏览器中的工作方式,请查看我以前写的一篇有关于Rang Input的文章

现在我们可以进入更有趣的部分了。 设置轨道和按钮的尺寸,并通过相应的@mixin指令将它们绑到滑动条组件上。通过添加 background 让其在屏幕上可见,设置 border-radius 来对其进行美化。为了与预期的效果一致,我们将这两个元素的 border 设为 none

$k: .1;
$track-w: 25em;
$track-h: .02*$track-w;
$thumb-d: $k*$track-w;

@mixin track() {
    border: none;
    width: $track-w; 
    height: $track-h;
    border-radius: .5*$track-h;
    background: #343440
}

@mixin thumb() {
    border: none;
    width: $thumb-d; 
    height: $thumb-d;
    border-radius: 50%;
    background: #e6323e
}

[type='range'] {
    /* same styles as before */
    width: $track-w; 
    height: $thumb-d;
        
    &::-webkit-slider-runnable-track { 
        @include track 
    }
    &::-moz-range-track { 
        @include track 
    }
    &::-ms-track { 
        @include track 
    }
        
    &::-webkit-slider-thumb {
        margin-top: .5*($track-h - $thumb-d);
        @include thumb
    }
    &::-moz-range-thumb { 
        @include thumb 
    }
    &::-ms-thumb {
        margin-top: 0;
        @include thumb
    }
}

我们添加一些属性,如在容器上设置 margin ,给定明确的 widthfont

.wrap {
    margin: 2em auto;
    width: $track-w;
    font: 2vmin trebuchet ms, arial, sans-serif
}

我们不想让它变得太小或太大,所以我们限制了font-size

.wrap {
    @media (max-width: 500px), (max-height: 500px) { 
        font-size: 10px 
    }
    @media (min-width: 1600px), (min-height: 1600px) { 
        font-size: 32px 
    }
}

然后,现在我们有了一个不错的跨浏览器滑动条:

JavaScript

首先我们要获取到滑动条、容器和创建的 output 元素。

const _R = document.getElementById('r'), 
      _W = _R.parentNode, 
      _O = document.createElement('output');

创建一个变量 val ,用于存储 range 类型的 input 的当前值:

let val = null;

接下来,我们创建一个 update() 函数,用于检查当前滑块值是否等于已存的值。 如果不是,则更新 JavaScript 里 val变量、 output的文本内容和外框上的 CSS自定义属性 --val

function update() {
    let newval = +_R.value;

    if(val !== newval)
        _W.style.setProperty('--val', _O.value = val = newval)
};

在我们继续编写JavaScript之前,在 output 的 CSS 中设置一个 conic-gradient()

output {
    background: conic-gradient(#e64c65 calc(var(--val)*1%), #41a8ab 0%)
}

我们通过调用 update() 函数,将 output 作为子 DOM 元素添加到容器上,然后测试 outputbackground-image 是否可设置 conic-gradient() (注意,这步需要确定 DOM 元素添加之后,才可这么做)。

如果测试出的 background-image 不是 none (如果是 none ,则没有原生 conic-gradient() 支持),则在容器上添加一个 full 样式。 并通过 for 属性将 outputrange类型的input 绑在一起 。

通过事件监听器,我们确保每次移动滑块时都能调用 update() 函数。

_O.setAttribute('for', _R.id);
update();
_W.appendChild(_O);

if(getComputedStyle(_O).backgroundImage !== 'none')
    _W.classList.add('full');

_R.addEventListener('input', update, false);
_R.addEventListener('change', update, false);

现在我们有了一个滑动条和一个 output (如果我们的浏览器支持原生 conic-gradient() ,可以查看到它的值显示在 conic-gradient() 背景上)。 虽然在这个阶段仍然很丑,但它的功能基本实现——当我们拖动滑块时, output 的值会随着变化:

我们给 output 加了一个浅色值,以便我们可以更好地看到它,并通过 ::after 伪类在末尾添加 % 。 还需要 display 设置为 none 来隐藏 Edge 中的工具提示(::-ms-tooltip)。

没有图表的情况

当我们没有 conic-gradient() 支持时,就会出现没有图表这种情况。 我们想要实现的效果如下图:

美化输出样式

在上面的基础上,我们给 output 设置绝对定位,获取滑块按钮的尺寸并将输出的文本居中显示:

.wrap:not(.full) {
    position: relative;
        
    output {    
        position: absolute;
        top: 0;
        width: $thumb-d; 
        height: $thumb-d
    }
}

output {
    display: flex;
    align-items: center;
    justify-content: center;
}

如果您需要进一步了解 align-itemsjustify-content ,请参阅 @Patrick Brosset 的 《揭开 CSS 对齐的神秘面纱》或者查阅W3cplus上有关于Flexbox布局相关的资料

结果可以在下面的 Codepen 中看到,我们依然增加了一个 outline 以便清楚地看到 output 的边框:

这乍看起来像是那么回事儿,但是我们的 output 的文字不随着滑块按钮移动。

使输出文字移动

为了解决这个问题,我们首先要清楚滑块按钮是如何运动的。 在 Chrome 中,滑块按钮的 border-boxinput 中的滑动轨道 content-box 的范围内移动,而在 Firefox 和 Edge 中,滑块按钮的 border-box 在实际 input 滑动条 content-box 的范围内移动。

虽然这种差异可能会在某些情况下出现问题,但我们的用例很简单, 并没有在滑动条或其组件上设置 marginpaddingborder ,所以滑动条本身、滑动轨道、滑动按钮的这三个属性 (content-boxpadding-boxborder-box )是重合的。 此外,实际 input 的这三个属性的宽度与其轨道的三个属性的宽度重合。

这意味着当滑块值最小时(我们没有明确设置,因此它最小值默认是 0 ),滑动按钮框的左边缘与 input 的左边缘(和轨道的左边缘)重合 。

同样,当滑块值达到其最大值(未明确设置时,它最大值默认 100 )时,滑动按钮框的右边缘与 input 的右边缘(以及轨道的右边缘)重合。将按钮的左边缘放在滑块的右边缘之前,向左大概一个按钮宽度( $thumb-d )。

下图显示了输入框的宽度 width$track-w )—— 显示为 1 。按钮宽 width$thumb-d )设为 k (因为我们已将它设置为 $thumb-d: $k * $track-w ),长度为输入框的宽度的 一个分数 。

滑块按钮处于最小值和最大值(实例)。

至此,我们得到左边按钮在 input 上的可滑动范围为 inputwidth$track-w )减去按钮宽度( $thumb-d ),这就是它最小值到最大值的距离。

为了以相同的方式移动 output ,我们用一个 translation 过渡按钮位置来说明。 当滑动条值处于最小位置时,output 的初始位置位于按钮的最左边,所以此时 transformtranslate(0)output 为最大值时的位置,就是滑动条值达到最大值的位置,我们需要将它转换为 $track-w - $thumb-d = $track-w * (1 - $k)

按钮的运动范围以及 output实例)的范围

好吧,但是它们之间的值呢?

那么,记住每次更新滑动条值时,我们不仅要更新 output 输出的文本内容,还要将绑在容器上的 CSS自定义属性 --val 进行更新。 这个 CSS自定义属性在 0(当滑块值最小时为 0 )到 100(当滑块值最大时为100)之间。

所以如果我们通过 calc(var(--val) / 100 * #{$track-w - $thumb-d}) 沿水平轴(x轴)平移 output ,它会随着滑动按钮移动,而不需要我们做任何事:

需要注意的是,如果点击轨道上的其他位置,上述方法会工作,但如果我们尝试拖动按钮 ,则不会有反应。 这是因为 output 现在位于 input 滑动按钮之上,滑动按钮捕捉不到点击事件。

我们通过在 output 设置 pointer-events: none 解决这个问题。

在上面的演示中,删除了 output 元素上的 outline 辅助线,因为我们不再需要它了。

现在我们对不支持 conic-gradient() 浏览器有了很好的兜底方案,可以继续构建我们想要的结果了(有启用标志的 Chrome / Opera)。

有图表的情况

绘制所需布局

在开始编写代码之前,我们需要清楚地知道我们想要实现的目标。为了明确这点,我们做了一个尺寸等于轨道 width$track-w )的布局草图,这也是 input 的宽度和容器 content-box 的边长(容器 padding 不包含在内)。

这意味着我们容器的 content-box 是边长为1的正方形(等于轨道 width ),input 是一个边长等于容器边长的长方形,且另一个边长是容器边长的分数 k ,则滑动按钮是一个 k * k 的方块。

有图表情况下所需的布局 (实例)

该图表是边长为 1 - 2 * k 的正方形,容器中图表距滑动条有 k 间隙,与滑动条相对方向的容器边缘没有间隙。考虑到容器的边长是 1 ,图表的边长是 1 - 2 * k ,所以容器距图表上下边缘之间有k间隙。

调整我们的元素

获得这种布局的第一步是使容器为正方形,并将 output 的尺寸设置为 (1 - 2 * $k) * 100%

$k: .1;
$track-w: 25em;
$chart-d: (1 - 2*$k)*100%;

.wrap.full {
    width: $track-w;

    output {
        width: $chart-d; 
        height: $chart-d
    }
}

结果可以在下面看到,我们还添加了一些辅助线以更好地看到事物:

第一阶段的结果( 实例 ,只有支持原生conic-gradient()浏览器可见)

这是一个好的开始,因为 output 已经在我们想要的位置上了。

制作垂直滑块

WebKit 浏览器的“官方”方式是在 rangeinput 上设置 -webkit-appearance: vertical 。 但是,这会破坏自定义样式,因为它们要求我们将 -webkit-appearance 设置为 none ,而我们不能给 -webkit-appearance 同时设置两个不同的值。

所以我们只能使用简便的解决方案 transform 。我们想要的是在容器的底部有最小值,在容器的顶部有最大值。 但实际上,在容器的左端是滑块是最小值,最右端是最大值的。

滑块的初始位置与我们预期达到的最终位置(实例

这看起来像是在右上角(以水平方向 100% 和垂直 0% 的中心点 transform-origin )沿负方向旋转 90°(因为顺时针方向是正方向)

这是一个好的开始,但现在我们的滑块在容器边界之外。 为了让滑动条旋转到期望的位置,我们需要了解这个旋转做了什么。 它不仅旋转了 input 元素,而且还旋转了它自身的坐标系。 现在它的 x 轴向上, y 轴向右。

因此,为了将其放入容器右侧内部,我们需要在旋转之后将其沿着其y轴的负方向平移自身 height 的距离。 这意味着我们应用的最终 transform 链是 rotate(-90deg) translatey(-100%) 。 (请记住, translate() 函数中使用的 % 值与被翻转元素的尺寸有关。)

.wrap.full {
    input {
        transform-origin: 100% 0;
        transform: rotate(-90deg) translatey(-100%)
    }
}

通过上述操作,我们得到所需布局:

第二阶段的结果( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

设计图表的样式

当然,第一步是 border-radius 使图表变圆,并调整 colorfont-sizefont-weight 属性。

.wrap.full {
    output {
        border-radius: 50%;
        color: #7a7a7a;
        font-size: 4.25em;
        font-weight: 700
    }
}

您可能已经注意到我们已经将图表的尺寸设置为 (1 - 2 * $k) * 100% 而不是 (1 - 2 * $k) * $track-w 。 这是因为 $track-wem 值,这意味着计算出相等的像素值取决于该元素的 font-size 属性 。

但是,我们希望能够通过增加 font-size 控制,而不必调整 em 值。 这是并不复杂,但与仅将尺寸设置为不依赖于 font-size% 值相比,它仍然有点额外的工作。

第三阶段的结果( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

从饼图到环形图

在文字中间模拟一个洞的最简单方法是在 conic-gradient() 上添加另一个 background 层。 我们也可以通过添加混合模式来完成这个目标,这需要有背景图片,但没这个必要。要做一个实心的 background ,一个简单的遮罩层就可以做到。

$p: 39%;
background: radial-gradient($bg $p, transparent $p + .5%),
    conic-gradient(#e64c65 calc(var(--val)*1%), #41a8ab 0%);

好的,按以上这么做图表就完成了!

第四阶段的结果( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

在按钮上显示数值

在容器上用一个绝对定位的 ::after 伪类来完成此操作。 设置这个伪类尺寸为按钮大小,并将它定位在容器的右下角,滑块值最小时按钮所在的位置。

.wrap.full {
    position: relative;
    
    &::after {
        position: absolute;
        right: 0; 
        bottom: 0;
        width: $thumb-d; 
        height: $thumb-d;
        content: '';
    }
}

我们也给它一个线框,以便我们可以看到它。

第五阶段的结果( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

将它与按钮一起移动,与没有图表的情况下类似,只是这次移动是沿y轴在负方向(而不是沿x轴正方向)移动。

transform: translatey(calc(var(--val)/-100*#{$track-w - $thumb-d}))

为了能够拖动伪类下的按钮,我们还必须在这个伪元素上设置 pointer-events: none 。 结果可以在下图看到 —— 拖动按钮还会移动容器的 ::before 伪类。

第六阶段的结果( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

看起来不错,但我们真正想要的是使用这个伪类显示当前值。 如果将其 content 属性设置为 var (--val),则不会执行任何操作,因为 --val 是数字,而不是字符串。 如果我们将它设置为字符串,则它可成为 content 的值,但就不能再将它用于 calc()

幸运的是,我们可以通过使用CSS计数器的方法来解决这个问题:

counter-reset: val var(--val);
content: counter(val)'%';

现在所有功能已完成,耶!

第七阶段的结果( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

接下来,让我们继续添加一些属性来优化它。 我们把文本放在滑动按钮的中间,字体颜色设为白色,去掉所有辅助线,并在 input 上设置 cursor: pointer

.wrap.full {
    &::after {
        line-height: $thumb-d;
        color: #fff;
        text-align: center
    }
}

[type='range'] {
    cursor: pointer
}

优化后的效果:

图表情况的最终效果( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

清除重复样式代码

代码中还有要优化的地方,在没有图表情况下的 output 样式和有图表的 :after 伪类中存在一堆重复的样式。

在无图表情况下 output 的样式与有图表情况下的 .wrap:after 样式

我们可以对此做些优化, 然后我们使用一个简短的扩展样式

%thumb-val {
    position: absolute;
    width: $thumb-d; 
    height: $thumb-d;
    color: #fff;
    pointer-events: none
}

.wrap {
    &:not(.full) output {
        @extend %thumb-val;
    }
    
    &:after {
        @extend %thumb-val;
    }
}

不错的焦点样式

比方说,我们不想在 :focus 时出现 outline ,但又希望在视觉上清楚地区分获焦这种状态。 那我们该如何做? 我们可以在 input 没有获焦时,缩小滑动按钮,降低色彩饱和度,并且隐藏输出的文字。

这听起来像个很酷……但是,由于我们没有父选择器,所以当滑动条获焦或失焦时,我们无法在改变滑动条父级的 ::after 属性。 额….

但可以做的是使用 output 的其他伪元素(::before)来显示按钮上的值。 这并不难做到,稍后我们会讨论,它允许我们做如下操作:

[type='range']:focus + output:before { 
    /* focus styles */ 
}

采取这种方法的问题在于,我们如何放大 output 的字体 font ,但又不改变容器 ::before 伪类的大小和粗细。

我们可以通过设置Sass变量来解决这个问题,将相对字体大小定义为变量 $fsr ,然后使该值在实际 output 上放大字体 font ,在 output:before 伪类上将其恢复为之前的大小,如下 。

$fsr: 4;

.wrap {
    color: $fg;
        
    &.full {
        output {
            font-size: $fsr*1em;
            
            &:before {
                font-size: 1em/$fsr;
                font-weight: 200;
            }
        }
    }
}

除此之外,我们只需要移动我们在 .wrap:after 上的 CSS自定义属性到 output:before 上 。

容器伪元素上的样式与 output 伪元素上的样式

好的,现在我们可以进入区分正常和聚焦效果的最后一步。

当滑块没有被聚焦时,我们首先隐藏丑陋的 :focus 默认 outline 状态和按钮上的值:

%thumb-val {
    opacity: 0;
}

[type='range']:focus {
    outline: none;

    .wrap:not(.full) & + output, 
    .wrap.full & + output:before { 
        opacity: 1 
    }
}

只有当滑动条获得焦点时,按钮上的值才可见( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

接下来,我们为滑块按钮的正常和聚焦状态设置不同的样式:

@mixin thumb() {
    transform: scale(.7);
    filter: saturate(.7)
}

@mixin thumb-focus() {
    transform: none;
    filter: none
}

[type='range']:focus {
    &::-webkit-slider-thumb { 
        @include thumb-focus 
    }
    &::-moz-range-thumb { 
        @include thumb-focus 
    }
    &::-ms-thumb { 
        @include thumb-focus 
    }
}

只要滑块没有聚焦,按钮才缩小和去饱和( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

最后一步是添加这些状态之间的转换:

$t: .5s;

@mixin thumb() {
    transition: transform $t linear, filter $t
}

%thumb-val {
    transition: opacity $t ease-in-out
}

该例子显示正常状态和聚焦状态之间的转换( 实例 ,只有支持原生 conic-gradient() 浏览器可见)

什么是屏幕读取?

由于屏幕读取最近生成的内容,因此在这种情况下,我们会将 % 值读取两次。 所以我们通过在 output 上设置 role='img' 来解决这个问题,然后把我们想要读取的当前值放在 aria-label 属性中:

let conic = false;

function update() {
    let newval = +_R.value;

    if(val !== newval) {
        _W.style.setProperty('--val', _O.value = val = newval);
        if(conic) _O.setAttribute('aria-label', `${val}%`)
    }
};

update();

_O.setAttribute('for', _R.id);
_W.appendChild(_O);

if(getComputedStyle(_O).backgroundImage !== 'none') {
    conic = true;
    _W.classList.add('full');
    _O.setAttribute('role', 'img');
    _O.setAttribute('aria-label', `${val}%`)
}

最后的演示可以在下面链接中找到。 请注意,如果您的浏览器没有原生 conic-gradient() 支持,你只会看到兜底样式。

最后的话

尽管浏览器对 conic-gradient() 的支持仍然很差,但情况将会有所改变。 目前只有暴露标志的 Blink 浏览器支持,但 Safari 将 conic-gradient() 列为正在开发中 ,所以事情已经越来越好了。

如果您希望跨浏览器支持早日成为现实,您可以通过在 Edge 中投票实现 conic-gradient() 或通过对 Firefox 错误发表评论来说明为什么您认为这很重要或是什么让您使用它。 这里是我发表的作品。

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/css/using-conic-gradients-css-variables-create-doughnut-chart-output-range-input.htmlnike air max 1 red