カテゴリ機能で実装したグラフアニメーションの話

こんにちは。KyashのMobileチームでiOSアプリを開発している tamadonです。

少し前になりますが、iOS版Kyashのカテゴリ機能を開発した際にグラフのアニメーションを実装したのですが自分にとって色々と学びがあったので紹介します。

今回実装したアニメーションはこちらです。

最初デザイナーからこのアニメーションのプロトタイプを共有された時、正直実装方法が全く思いつかず絶望したのを覚えています。

開発が先行していたAndroid版のPull Requestを参考にしつつ試行錯誤した結果、このアニメーションは大きく3つのフェーズに分けると実装できそうという事がわかりました。

  1. 点をクルクル回す
  2. 各カテゴリの利用額を総額に対する割合で分割し描画する
  3. 回転を停止して中央にカテゴリのアイコン画像を配置

また、各アニメーションの実装に使用する座標の計算には三角関数を使えば良さそうだという事もわかりました。

各フェーズごとの実装について説明します

前提条件として、以下のようにObjectを配置します。各Objectの役割は以下のとおりです。

ChartView:アニメーションの描画に必要なObjectを格納するView

FirstAnimationView:1のアニメーションを表示するView

SecondAnimationView:2,3のアニメーションを表示するView

TotalAmountLabel:アニメーション終了時に利用額を表示するLabel

1. 点をクルクル回す

まず、各カテゴリの背景色を登録した点状のUIViewの配列を作成します。全カテゴリのカラーコードをcategoryColorCodesという引数で渡します。

private func setupCategoryDots(categoryColorCodes: [String] ) {
    var categoryDots: [UIView] = []
    let radius = CGFloat(10)

    for colorCode in categoryColorCodes {
        let dot = UIView()
        dot.frame = CGRect(x: 0, y: 0, width: radius, height: radius)
        dot.backgroundColor = UIColor.color(fromHexCode: colorCode)
        dot.layer.cornerRadius = radius / 2

        categoryDots.append(view)
    }
    // 円形に配置するので、座標計算に使用する半径を取得
    let chartViewHalfWidth = chartView.frame.size.width / 2
    setDotsPositionCircle(views: categoryDots, radius: chartViewHalfWidth)
}

次に、点を等間隔で配置するための座標を求めます。詳しい説明は割愛しますが、三角関数のsinとcosを使用すると円周上の点の座標を求めることができます。

要は「円の半径と角度が求められれば配置する座標は計算で求められる」ということです。 そのためのコードがこちらです。

private func setDotsPositionCircle(dots: [UIView], radius: CGFloat) {
    guard let firstDot = dots.first else { return }
    // 各点の配置間隔調整用の値
    let positionAdjustmentValue = radius - firstDot.frame.width / 2
    // 配置する座標は三角関数で求める
    let divideAngle = 360 / dots.count
    for (index, dot) in dots.enumerated() {
        let angle = divideAngle * index
        let x = cos(Double(angle) * Double.pi / 180) * Double(radius)
        let y = sin(Double(angle) * Double.pi / 180) * Double(radius)

        view.frame = CGRect(
            x: dot.frame.minX + CGFloat(roundf(Float(x))) + positionAdjustmentValue,
            y: dot.frame.minY + CGFloat(roundf(Float(y))) + positionAdjustmentValue,
            width: dot.frame.width,
            height: dot.frame.height
        )
        firstAnimationView.addSubview(dot)
    }
    firstAnimationView.transform = firstAnimationView.transform.rotated(by: -90)
}

引数には、先程のコードで作成した各カテゴリの背景色が塗られた点状のUIViewと実際に円周上に等間隔で配置するUIViewの半径を渡します。

また、点と点の間に間隔をもたせるために座標調整用の変数をpositionAdjustmentValueという値で定義し、少しずつ座標をずらしています。

配置が完了したら、配置元のUIViewをtransform.rotated(by:)で回転させて完了です。

2. 各カテゴリの利用額を総額に対する割合で分割し曲線を描画する

こちらも基本的な考え方は一緒です。開始位置と終了位置の角度を計算してベジェ曲線を描き、UIViewに配置後回転させれば完成です。

まずは各カテゴリの利用額に対する割合を計算し、角度を求めます。

let amounts = [5000, 4000, 3000, 2000] // 各カテゴリの利用額
let sumAmount = amounts.reduce(0) { $0 + $1 } // 利用額合計値を計算
// 全体に対する割合を計算しグラフ開始位置の角度を求める
let angles = amounts.map { Double($0) / Double(sumAmount) * 360.0 }

次に、グラフ間のスペースを計算します。ここは試行錯誤した結果10°にするとちょうど良かったので今回は10に設定しました。

それに伴い、描画するグラフのサイズを少し縮小する必要があるので以下の式で割合を求めています。

let sizeAdjustmentScale = 360.0 / (Double(amounts.count) * 10.0 + 360.0)

最後に今まで取得した値を元にベジェ曲線を描画し、曲線の中央にアニメーション3で使用するカテゴリアイコンを配置します。カテゴリアイコン配置ロジックはアニメーション1と同様にsinとcosを求めて配置するだけなので割愛します。

var angle = 0 // グラフ開始位置の角度
let radius = secondAnimationView.frame.width / 2.0 // グラフの半径

for (index, amounts) in amounts.enumerated() {
    // グラフの描画
    let shapeLayer = CAShapeLayer()
    shapeLayer.frame = secondAnimationView.frame
    shapeLayer.lineCap = CAShapeLayerLineCap.round
    // 輪郭の色
    shapeLayer.strokeColor = UIColor.blue.cgColor
    // 円の中の色
    shapeLayer.fillColor = UIColor.clear.cgColor
    // 輪郭の太さ
    shapeLayer.lineWidth = 10
    // カテゴリアイコンをグラフの中央に配置するための角度計算
    let iconAngle = (angle + angles[index] / 2.0) * sizeAdjustmentScale
    // グラフ開始位置の角度
    let startAngle = calculateAngleOfGraph(angle: angle, scale: sizeAdjustmentScale)
    angle += angles[index]
    // グラフ終了位置の角度
    let endAngle = calculateAngleOfGraph(angle: angle, scale: sizeAdjustmentScale)
    // 円弧の間のスペースを角度10.0で調整
    angle += 10.0
    // 円弧を描画
    shapeLayer.path = UIBezierPath(
        arcCenter: CGPoint(x: radius, y: radius),
        radius: radius,
        startAngle: startAngle,
        endAngle: endAngle,
        clockwise: true
    ).cgPath
    // 1のアニメーション同様、iconAngleからsinとcosを求めて
    // カテゴリのアイコン画像を配置
    // 後ほど表示しつつ拡大したいので、isHidden = true
    // width, height共にCGFload(1)にしておく
}

3. 回転を停止して中央にカテゴリのアイコン画像を配置

すでにアイコンの配置は完了しているので、ここでisHiddenプロパティをfalseにして拡大アニメーションを実行して完了です。

totalAmountLabel.isHidden = false
icon.isHidden = false
icon.transform = icon.transform.scaledBy(x: 40, y: 40)

おわりに

以上、カテゴリ機能で実装したグラフアニメーションについての紹介でした。

こういった計算を伴うアニメーションの実装には苦手意識があったのですが、今回の経験を活かしてユーザーがわくわくするような実装していきたいなと思いました。

このアニメーションを実装するにあたり、前職でお世話になったNatsuko Nishikataさんに紹介してもらった書籍が非常に役立ちました。

www.sbcr.jp

すでに絶版なので新刊を購入することは難しいのですが、中古で比較的簡単に購入可能なので数学や物理学を使用してアニメーションを実装する際には参考にしてみてはいかがでしょうか。


Kyash ではエンジニアを募集しています。カジュアル面談もやっていますので、もし興味がありましたらぜひ以下のリンクをご確認ください。