円形のプログレスの仕組み

CSSに関しては手っ取り早くライブラリを使おうという考え方が多くて、これまでどのような仕組みでそれが作られているかをあまり考えたことがなかった。 第一にテーマの統一感がある。 逆に言えばあるフレームワーク、例えばBootstrapでは円形のプログレスは長らく存在しないように思える。 そういったときに他所のCSSを組み合わせるのは抵抗がある。

今開発しているアプリケーションではたまたまCSSのフレームワークを採用せずに始めた。 良くも悪くも柔軟にいろいろなコードを取り入れることができる。 その上で円形のプログレスを作ろうとしたら目からウロコだったので今回はブログに残そうと思う。

線形のプログレス”バー”はよく目にする。 たとえば昔RailsでTurbolinksが実装され始めた頃に目にしたものだ。 他にもGitHubではnprogressというライブラリも存在している。 言うまでもなくBootstrapでも利用することができる。

円形のプログレスでも同様だけれども、線形よりはレイアウトしやすい。 一覧表示するよりも、単独あるいは詳細ページに置くとそれっぽい。 似たようなコンポーネントでスピナーというものもあるのだが、スピナーはただ読み込み中であるという状態に対して文字通り進捗を表示するためのもので別物である。

今回はPure CSS Circular Progress Bar (experimental)のCSSを拝借してみようと思う。

興味深かったのはconic-gradientというCSSの関数だ。 当たり前のようにCSSで関数という単語が出てくるのはCSS2.0の頃から私はついていけなくなったのだが、CANVASタグでなくともCSSだけでグラデーション関数が使えるわけである。 ただこのグラデーションはPhotoshopやGimpなどではよく目にするが、円形のプログレスに使う発想はなかった。


図1: グラデーションを指定する

const fig1 = document.getElementById("figure-1");
const ctx = fig1.getContext("2d");
      ctx.beginPath();
      ctx.arc(150, 150, 100, 0, 2 * Math.PI);
      ctx.clip();

const gradient = ctx.createConicGradient(-0.5 * Math.PI, 150, 150);
      gradient.addColorStop(0, "black");
      gradient.addColorStop(1, "white");

      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 300, 300);

イメージとしてはまずよく見かけるグラデーションを再現してみる。 大まかにこのコードでは円形のグラデーションを表示して、指定した円の形で切り取るという感じである。


図2: addColorStopを追加する

const fig2 = document.getElementById("figure-2");
const ctx = fig2.getContext("2d");
      ctx.beginPath();
      ctx.arc(150, 150, 100, 0, 2 * Math.PI);
      ctx.clip();

const gradient = ctx.createConicGradient(-0.5 * Math.PI, 150, 150);
      gradient.addColorStop(0, "dodgerblue");
      gradient.addColorStop(0.3, "dodgerblue"); // 30%
      gradient.addColorStop(0.3, "lightgrey"); // 30%
      gradient.addColorStop(1, "lightgrey");

      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 300, 300);

続いてaddColorStopという関数を使って指定した場所に色を追加する。 ここでのポイントはaddColorStop(0.3, color)を2度記載しているが、こうすることでグラデーションというよりは円グラフのような表示に変わる。 CSSのconic-gradientであらわすとconic-gradient(dodgerblue 30%, lightgrey 0)といったところだろうか。


図3: 背景と同じ色の円を追加する

const fig3 = document.getElementById("figure-3");
const ctx = fig3.getContext("2d");
      ctx.beginPath();
      ctx.arc(150, 150, 100, 0, 2 * Math.PI);
      ctx.clip();

const gradient = ctx.createConicGradient(-0.5 * Math.PI, 150, 150);
      gradient.addColorStop(0, "dodgerblue");
      gradient.addColorStop(0.3, "dodgerblue"); // 30%
      gradient.addColorStop(0.3, "lightgrey"); // 30%
      gradient.addColorStop(1, "lightgrey");

      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 300, 300);

      // second circle
      ctx.beginPath();
      ctx.arc(150, 150, 80, 0, 2 * Math.PI);
      ctx.fillStyle = "white";
      ctx.fill();

あとは背景と同色の円でくり抜いてドーナツ状にすれば完成である。 CSSのアプローチもposition: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);して中心に配置しているので少々冗長ではあるが、わざわざ上のようにCanvasタグを使わなくても描画できるのはすごいと思う。 あとはReactなどでコンポーネントにしてあげてaddColorStopの値を動的に変更してあげればアニメーションにもできる。 今回は学習目的でCanvasにおこしてみたが、実用性で言えばリンクにあるCodePenのようなCSSを利用するのがよさそうな気がする。

おまけ

もともと今回の投稿はCSS側を話題にするつもりだったのだが、CanavasがメインになってしまったのでおまけとしてReactのコンポーネント化したものを残そうと思う。

import classes from './ProgressCircle.module.css'

export const ProgressCircle = ({ progress = 0 }) => {
  const style = { '--progress': parseInt(progress) }
  return (
    <div
      className={classes.ProgressCircle}
      style={style}
    >
      <div className={classes.ProgressCircleInner}>
        {`${progress}%`}
      </div>
    </div>
  )
}
.ProgressCircle {
  --progress: 0%;

  position: relative;
  width: 150px;
  height: 150px;
  margin: 0.5rem;
  border-radius: 50%;
  background: #ffcdb2;
  overflow: hidden;
  background-image: conic-gradient(
    #b5838d calc(var(--progress) * 1%),
    #ffcdb2 0
  );
}

.ProgressCircleInner {
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 115px;
  height: 115px;
  background: #fff;
  border-radius: 50%;
  font-size: 1.85em;
  font-weight: 300;
  color: rgba(0, 0, 0, 0.75);
}

このコードもCSSの変数を直接JSで変更しているのは興味深いのだが、これだけで投稿にするのは少々ネタが足りなかった気がするのでやはりおまけでよかったと思う。 実際に動かしてみるとアニメーションがコマ送りなのでまだ改良の余地はあるのだけれども、今回はここまでにしておこうと思う。