メインコンテンツへ移動

円形プログレスバー

無料2016-12-10#CSS#Solution#渐变边框#CSS进度条#CSS圆形进度条#CSS圆环进度条#css circular progress bar#css gradient border

グラデーションカラーの円形プログレスバーを実装する方法

1. シナリオ

グラデーションカラーの円形プログレスバーを実装する必要があります。プログレスバーは次のような形をしています:

[caption id="attachment_1238" align="alignnone" width="200"]circle circle[/caption]

進捗が0のときは灰色の溝が表示され、読み込みプロセス中にカラーバーが時計回りに1周します。

2. 古典的な解法「二枚扉」

円形プログレスバーには古典的な解法があり、その原理は非常に興味深いものです。HTML構造は以下の通りです:

<div class="circle progress-basic">
    <div class="left">
        <div class="circle left-circle"></div>
    </div>
    <div class="right">
        <div class=" circle right-circle"></div>
    </div>
</div>

まず円のことは考えず、観音開き(両開き)のドアを想像してください。progress-basic がドアの枠、left が左側のドア、right が右側のドアです。以下のようになります:

.progress-basic {
    position: relative;
}
.left, .right {
    position: absolute;
    width: 100px; height: 200px;
    top: 0;
    overflow: hidden;
}
.left {
    left: 0;
}
.right {
    right: 0;
}

次に 0-180deg の効果をどのように実現するか考えます。プログレスバーは0時の位置から少しずつ現れ、右側の半円を満たしていく必要があります。180deg のとき、左側は灰色の溝、右側はプログレスバーの状態になります。

正方形の紙の左側に半円が描かれていると想像してください。左手で左半分を隠し、紙を時計回りに回転させます。すると、左手の指先から半円が少しずつ現れます。180度回転させたとき、ちょうど左側は空白で、右側が半円になります。これ以上回すと、不自然な部分が見えてしまいます(半円のプログレスバーがちょうど終わり、それ以上回すと途切れてしまいます)。

180-360deg の方法もこれと同様です。まず半円のプログレスバーを隠しておき、少しずつ回転させて出していきます。完全なCSSは以下の通りです:

.circle {
    width: 200px; height: 200px;
    box-sizing: border-box;
    border-radius: 50%;
    border: 10px solid silver;
}

.progress-basic {
    position: relative;
}
.left, .right {
    position: absolute;
    width: 100px; height: 200px;
    /* 親要素のborder内に収める */
    top: -10px;
    overflow: hidden;
}
.left {
    /* 親要素のborder内に収める */
    left: -10px;
}
.right {
    /* 親要素のborder内に収める */
    right: -10px;
}
.left-circle, .right-circle {
    border-color: #077df8;
}
.left-circle {
    margin-right: -100px;
    border-left-color: transparent;
    border-top-color: transparent;
    transform: rotateZ(-45deg);
}
.right-circle {
    margin-left: -100px;
    border-right-color: transparent;
    border-bottom-color: transparent;
    transform: rotateZ(-45deg);
}

ドアの枠にはあらかじめ灰色の溝があり、2枚のドアは overflow: hidden に設定されています。左側のドアは右半分のプログレスバーを隠し、右側のドアは左半分のプログレスバーを隠します。まず右側の紙を回すと、紙の左側にあった半円が少しずつ現れます。180度に達したら左側の紙を回すと、紙の右側にあった半円が少しずつ現れ、360度でちょうど完全な円環が組み上がります。以下のようになります:

var $ = document.querySelector.bind(document);

void function() {
    var $rightCircle = $('.right-circle');
    var $leftCircle = $('.left-circle');
    var prog = 0;
    var deg = 0;
    var initialDeg = -45;
    var halfDone;
    var timer = setInterval(function() {
        prog += 0.01;
        deg = 360 * prog;
        if (deg < 180) {
            $rightCircle.style.transform = 'rotateZ(' + (initialDeg + deg) + 'deg)';
        }
        else if (deg < 360) {
            if (!halfDone) {
                console.log('half done');
                $rightCircle.style.transform = 'rotateZ(' + (initialDeg + 180) + 'deg)';
                halfDone = true;
            }
            $leftCircle.style.transform = 'rotateZ(' + (initialDeg + deg - 180) + 'deg)';
        }
        else {
            console.log('done');
            $leftCircle.style.transform = 'rotateZ(' + (initialDeg + 180) + 'deg)';
            clearInterval(timer);
        }
    }, 100);
}();

非常に巧妙な方法です。正式名称があるかは分かりませんが、ひとまず「二枚扉」と呼ぶことにします。

P.S. clip を使っても実現可能で、原理は同じです。

3. 古典的な解法を試す

「二枚扉」の原理は汎用性があるように見えるので、グラデーションのプログレスバーにそのまま適用してみます。そのためには、プログレスバーの画像を水平方向に反転させ、左右に分割する必要があります。図の通りです:

[caption id="attachment_1239" align="alignnone" width="404"]circle-left-right circle-left-right[/caption]

具体的には以下の通りです:

<div class="circle circle-image progress-basic">
    <div class="left">
        <div class="circle left-circle"></div>
    </div>
    <div class="right">
        <div class=" circle right-circle"></div>
    </div>
</div>

/* グラデーション画像を左右で組み合わせる */
.circle-image .left-circle {
    margin-right: -100px;
    border: none;
    background: url(circle-right.png) no-repeat 100px 0;
    -webkit-background-size: 100px 200px;
    background-size: 100px 200px;
    transform: rotateZ(0deg);
}
.circle-image .right-circle {
    margin-left: -100px;
    border: none;
    background: url(circle-left.png) no-repeat 0 0;
    -webkit-background-size: 100px 200px;
    background-size: 100px 200px;
    transform: rotateZ(0deg);
}

1つの致命的な問題があります。180度のときに、もう半分とうまくつながりません。単色の場合は左右の半円の色が同じなので、180-360deg の際の変化が見えず、つながらないという問題は発生しません。しかし、グラデーションの円環では、180度を過ぎた後に左側の円環が「少しずつ現れる」のではなく、「円環全体が回転しながら現れる」のがはっきりと見えてしまいます。この試みは失敗したので、別の方法を検討します。

4. 白いブロックのマスク

もう一つの異なるアプローチは、プログレスバーの円環をあらかじめ下に敷いておき、左右を白いブロックで隠す方法です。右側の白いブロックを時計回りに回転させると、下にある右半分の円環が少しずつ現れます。180度になったら左側の白いブロックを回転させ、下にある左半分の円環を少しずつ出していきます。

これなら、円環はもともと完全なものなので、左右がつながらないという問題は発生しません。バーの問題を解決したので、次は「溝」について考えます。左右の白いブロックに溝を描いておけば、ブロックの回転に合わせて溝も回転し、プログレスバーが少しずつ現れるようになります。完璧です。具体的には以下の通りです:

<div class="circle circle-block progress-basic">
    <div class="left">
        <div class="block left-block">
            <div class="circle"></div>
        </div>
    </div>
    <div class="right">
        <div class="block right-block">
            <div class="circle"></div>
        </div>
    </div>
    <div class="circleBar"></div>
</div>

/* 白いブロックで覆い隠す */
.circle-block .block {
    width: 100%; height: 100%;
    background: #fff;
}
.circle-block .right-block {
    -webkit-transform-origin: 0 50%;
    /* 露出するエッジを隠す */
    border-right: 1px solid #fff;
}
.right-block .circle {
    margin-left: -100px;
    border-left-color: transparent;
    border-top-color: transparent;
    transform: rotateZ(-45deg);
}
.circle-block .left-block {
    -webkit-transform-origin: 100% 50%;
    /* 露出するエッジを隠す */
    border-left: 1px solid #fff;
    margin-left: -1px;
}
.left-block .circle {
    border-right-color: transparent;
    border-bottom-color: transparent;
    transform: rotateZ(-45deg);
}
.circle-block .circleBar {
    width: 200px; height: 200px;
    margin-top: -10px; margin-left: -10px;
    background: url(circle.png) no-repeat 0 0;
    -webkit-background-size: 200px 200px;
    background-size: 200px 200px;
}

高解像度のデバイスでは、白いブロックによる隠蔽が完全ではない場合があるため、以下が必要になります:

/* 露出するエッジを隠す */
border-right: 1px solid #fff;

効果は悪くありませんが、明らかな制限があります。単色背景にしか適用できないという点です。白いブロックの背景色をページの背景色と一致させなければならないため、そうでなければ不自然に見えてしまいます。

5. Canvasによる描画

条件が許すなら、Canvasで描画すればすべての問題が解決します:

<canvas class="canvas">Canvasをサポートしていません</canvas>

void function() {
    var options = {
        size: 200,
        lineWidth: 10,
        rotate: 0
    };

    var canvas = $('.canvas');
    var ctx = canvas.getContext('2d');
    canvas.width = canvas.height = options.size;

    // 原点を中心点にリセット
    ctx.translate(options.size / 2, options.size / 2);
    // -90度回転させ、x軸を-y方向に重ねる
    ctx.rotate((-1 / 2 + options.rotate / 180) * Math.PI);

    var radius = (options.size - options.lineWidth) / 2;

    var drawCircle = function(color, lineWidth, percent) {
            percent = Math.min(Math.max(0, percent || 1), 1);
            ctx.beginPath();
            ctx.arc(0, 0, radius, 0, Math.PI * 2 * percent, false);
            ctx.strokeStyle = color;
            // butt, round or square
            // 線の両端の形状
            // butt: デフォルト。両端に装飾を加えず、切り落としたような感じ。
            // round: 線の両端に半円を被せる。
            // square: 線の両端に半分の正方形を被せる。見た目は butt と同じですが、butt よりも lineWidth 分だけ長くなります。
            ctx.lineCap = 'butt';
            ctx.lineWidth = lineWidth;
            ctx.stroke();
    };

    // 灰色の溝
    drawCircle('#efefef', options.lineWidth, 100 / 100);

    var prog = 0;
    // グラデーションバー
    var gradient = ctx.createLinearGradient(0, 100, 0, -100);
    gradient.addColorStop(0, '#ff0000');
    gradient.addColorStop(0.15, '#ff00ff');
    gradient.addColorStop(0.33, '#0000ff');
    gradient.addColorStop(0.49, '#00ffff');
    gradient.addColorStop(0.67, '#00ff00');
    gradient.addColorStop(0.84, '#ffff00');
    gradient.addColorStop(1, '#ff0000');
    // '#077df8'
    var timer = setInterval(function() {
        prog += 1;
        // カラーバー
        drawCircle(gradient, options.lineWidth, prog / 100);
        if (prog >= 360) {
            clearInterval(timer);
        }
    }, 100);
}();

グラデーションの定義に注意してください:

var gradient = ctx.createLinearGradient(0, 100, 0, -100);

このように定義されているのは、実際には右から左への線形グラデーションです。これは座標系が2回変換されているためです:

// 原点を中心点にリセット
// 原点を(100, 100)に平行移動
ctx.translate(options.size / 2, options.size / 2);
// -90度回転させ、x軸を-y方向に重ねる
// x軸とy軸を入れ替える
ctx.rotate((-1 / 2 + options.rotate / 180) * Math.PI);

プログレスバーの先端を丸くするのも簡単です:

ctx.lineCap = 'round';
// 線の両端の形状
// butt: デフォルト。両端に装飾を加えず、切り落としたような感じ。
// round: 線の両端に半円を被せる。
// square: 線の両端に半分の正方形を被せる。見た目は butt と同じですが、butt よりも lineWidth 分だけ長くなります。

しかし、Canvasの描画は高解像度デバイスで*ジャギー(ギザギザ)*が発生する問題があるため、少しコツが必要です:

var width = canvas.width, height=canvas.height;
if (window.devicePixelRatio) {
    canvas.style.width = width + "px";
    canvas.style.height = height + "px";
    canvas.height = height * window.devicePixelRatio;
    canvas.width = width * window.devicePixelRatio;
    ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}

デバイスピクセル比に合わせたサイズで描画することで、ジャギーの問題を解決できます。

6. SVGのパスアニメーション

同様に、条件が許せば、SVGのパスアニメーションを使って実現することもできます。以下の通りです:

<div class="progress-svg">
    <!-- バー -->
    <svg viewBox="-10 -10 220 220">
    <g fill="none" stroke-width="10" transform="translate(100,100)">
    <path d="M 0,-100 A 100,100 0 0,1 86.6,-50" stroke="url(#cl1)"/>
    <path d="M 86.6,-50 A 100,100 0 0,1 86.6,50" stroke="url(#cl2)"/>
    <path d="M 86.6,50 A 100,100 0 0,1 0,100" stroke="url(#cl3)"/>
    <path d="M 0,100 A 100,100 0 0,1 -86.6,50" stroke="url(#cl4)"/>
    <path d="M -86.6,50 A 100,100 0 0,1 -86.6,-50" stroke="url(#cl5)"/>
    <path d="M -86.6,-50 A 100,100 0 0,1 0,-100" stroke="url(#cl6)"/>
    </g>
    </svg>
    <!-- 溝 -->
    <svg viewBox="-10 -10 220 220">
    <path d="M200,100 C200,44.771525 155.228475,0 100,0 C44.771525,0 0,44.771525 0,100 C0,155.228475 44.771525,200 100,200 C155.228475,200 200,155.228475 200,100 Z" stroke-dashoffset="629"></path>
    </svg>
</div>
<!--  グラデーションカラーの定義  -->
<svg width="0" height="0">
<defs>
<linearGradient id="cl1" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="1" y2="1">
    <stop stop-color="#618099"/>
    <stop offset="100%" stop-color="#8e6677"/>
</linearGradient>
<linearGradient id="cl2" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="0" y2="1">
    <stop stop-color="#8e6677"/>
    <stop offset="100%" stop-color="#9b5e67"/>
</linearGradient>
<linearGradient id="cl3" gradientUnits="objectBoundingBox" x1="1" y1="0" x2="0" y2="1">
    <stop stop-color="#9b5e67"/>
    <stop offset="100%" stop-color="#9c787a"/>
</linearGradient>
<linearGradient id="cl4" gradientUnits="objectBoundingBox" x1="1" y1="1" x2="0" y2="0">
    <stop stop-color="#9c787a"/>
    <stop offset="100%" stop-color="#817a94"/>
</linearGradient>
<linearGradient id="cl5" gradientUnits="objectBoundingBox" x1="0" y1="1" x2="0" y2="0">
    <stop stop-color="#817a94"/>
    <stop offset="100%" stop-color="#498a98"/>
</linearGradient>
<linearGradient id="cl6" gradientUnits="objectBoundingBox" x1="0" y1="1" x2="1" y2="0">
    <stop stop-color="#498a98"/>
    <stop offset="100%" stop-color="#618099"/>
</linearGradient>
</defs>
</svg>

対応するCSSは以下の通りです:

.progress-svg {
  display: inline-block;
  position: relative;
  text-align: center;
}
.progress-svg svg {
  width: 200px; height: 200px;
}
.progress-svg svg:nth-child(2) {
  position: absolute;
  left: 0;
  top: 0;
  -webkit-transform: rotate(-90deg);
          transform: rotate(-90deg);
}
.progress-svg svg:nth-child(2) path {
  fill: none;
  stroke-width: 25;
  stroke-dasharray: 629;
  stroke: #fff;
  opacity: .9;
  -webkit-animation: load 10s;
          animation: load 10s;
}
@keyframes load {
  0% {
    stroke-dashoffset: 0;
  }
}

SVGの描画アニメーションは、pathの2つの要素に関係しています:

  • stroke-dasharray:破線の各セグメントの長さ

  • stroke-dashoffset:破線の初期位置のオフセット(左方向へのオフセット

左方向へのオフセットが重要です。そうでないと、なぜ0から増加させるのではなく、0に向かって減少させるのか理解しにくいでしょう:

A dashed stroke with a non-zero dash offset. The dashing pattern is 20,10 and the dash offset is 15. The red line shows the actual path that is stroked.

[caption id="attachment_1240" align="alignnone" width="307"]stroke-dashoffset stroke-dashoffset[/caption]

詳細は SVG仕様 を参照してください。

原理としては、stroke-dasharray の長さを path よりも大きくし、stroke-dashoffsetstroke-dasharray と等しくします。この時点では描画されません(左へのオフセットによって破線の最初の実線部分が隠れているため)。そしてオフセットを0まで減少させると、視覚的には破線の最初の実線部分が左側から少しずつ現れ、0になったときにちょうど path を覆うようになります。

逆に考えて、stroke-dashoffset を0にし、stroke-dasharray を極小値から path と等しくなるまで増加させた場合、確かに破線の最初のセグメントは少しずつ長くなりますが、後続のセグメントを隠すことができないため、描画アニメーションの実装には使えません。

しかし、SVGのグラデーション描画にはつなぎ目の問題があります。よく見ると円環上に4箇所の切れ目があるのが分かります。これは2つのグラデーションがうまくつながらないためです。定義したグラデーションの色値に問題はなく、理論上は完璧につながるはずですが、実際の効果は理想的ではありません。

同様にCSSでグラデーションボーダーを作成する場合も、左右のつなぎ目の問題が発生します。以下の通りです:

<div class="colorfulBorder"></div>

.colorfulBorder {
    width:100px; height:100px; -webkit-transform:rotate(90deg);
}
.colorfulBorder:before {
    content:"";
    display:block;
    width:100px; height:50px;
    margin-top:10px; padding:10px; padding-bottom:0; box-sizing:border-box;
    border-top-left-radius:50px;
    border-top-right-radius:50px;
    background:-webkit-gradient(
        linear,
        left top,
        right top,
        color-stop(0,#fff),
        color-stop(1,#fff)
    ),-webkit-gradient(
        linear,
        left top,
        right top,
        color-stop(0,#077df8),
        color-stop(1,#74baff)
    );
    background-clip:content-box,padding-box;
}
.colorfulBorder:after {
    content:"";
    display:block;
    width:100px; height:50px;
    padding:10px; padding-top:0;
    box-sizing:border-box;
    border-bottom-left-radius:50px;
    border-bottom-right-radius:50px;
    background:-webkit-gradient(
        linear,
        left top,
        right top,
        color-stop(0,#fff),
        color-stop(1,#fff)
    ),-webkit-gradient(
        linear,
        left top,
        right top,
        color-stop(0,#fff),
        color-stop(1,#74baff)
    );
    background-clip:content-box,padding-box;
}

このような欠点があるため、このプランは採用できません。

7. オンラインデモ

デモのアドレス:http://www.ayqy.net/temp/progress/circle.html

8. まとめ

単色の円形プログレスバーについては、単色以外の背景にも対応できる古典的な「二枚扉」の解法をお勧めします。

グラデーションの円形プログレスバーの場合、選択肢は以下の通りです:

  • 条件が許すなら、Canvasが最適です。単色以外の背景にも対応できます。

  • それが難しい場合は白いブロックのマスクを使用しますが、単色背景にのみ適しています

SVGの描画アニメーションは、曲がりくねった小川のようなカスタム形状のプログレスバーを実現できますが、グラデーション描画には適していません(グラデーションの接合部に隙間ができてしまうため)。

複数の background を使用してCSSでグラデーションボーダーを実現する方法は、接合部に明らかな隙間ができるため現実的ではありません。

参考資料

コメント

コメントはまだありません

コメントを書く