💡

【Animating Views and Transit編】初心者がswiftUIチュートリアルをやって疑問に思ったことを調査して記していく

2023/09/20に公開

はじめに

グラフをアニメーション表示するチュートリアルをやったので、その中で新しく出てきたものについて調査したので記していく。

公式サイト : Animating Views and Transitions

部品部分からたどって最後にHikeDetailを見ていく流れとする。

load関数(自作)

jsonを任意の構造体にデコードするための関数について紹介します。

//引数はjsonファイルの名前
func load<T: Decodable>(_ filename: String) -> T {
    let data: Data

    //urlを取得
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            fatalError("Couldn't find \(filename) in main bundle.")
    }

    do {
        //指定されたurlからjsonをData型に変換
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }

    do {
        let decoder = JSONDecoder()
        //第一引数で指定したタイプに型変換
        //今回であればHike型の配列
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

Bundle.main
プロジェクトに格納されているファイルやファイル内の値を取得するためのクラス。
主な関数
URL : 指定されたファイル名から識別されるURLを返す
path : 指定されたファイル名から識別される絶対パスを返す

Hike.swift

jsonと同じ様式の構造体です。
デコードは自作のload関数を使用します。

Hike.swift
import Foundation

struct Hike: Codable, Hashable, Identifiable {
    var id: Int
    var name: String
    var distance: Double
    var difficulty: Int
    var observations: [Observation]

    static var formatter = LengthFormatter()

    var distanceText: String {
        Hike.formatter
            .string(fromValue: distance, unit: .kilometer)
    }

    struct Observation: Codable, Hashable {
        var distanceFromStart: Double

        var elevation: Range<Double>
        var pace: Range<Double>
        var heartRate: Range<Double>
    }
}

Range とは

@frozen //<-型の変更を制限する
struct Range<Bound> where Bound : Comparable
//Comparableは比較演算子で比較可能な型

A half-open interval from a lower bound up to, but not including, an upper bound.
下限から上限までの半開区間(<)で、上限は含まない

for文で使用する「..<」こいつのこと。

参考記事
https://qiita.com/mono0926/items/88779ceff30f8fc705c5

LengthFormatter
長さや高さの測定値など、直線距離に変換するフォーマッタ。

と公式には書かれているので、MeasurementFormatterを代わりに使用した方がよさそうですね。

HikeGraph.swift

HikeGraph.swiftの全体像
import SwiftUI

extension Animation {
    static func ripple(index: Int) -> Animation {
        Animation.spring(dampingFraction: 0.5)
            .speed(2)
            .delay(0.03 * Double(index))
    }
}

struct HikeGraph: View {
    var hike: Hike
    var path: KeyPath<Hike.Observation, Range<Double>>

    var color: Color {
        switch path {
        case \.elevation:
            return .gray
        case \.heartRate:
            return Color(hue: 0, saturation: 0.5, brightness: 0.7)
        case \.pace:
            return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
        default:
            return .black
        }
    }

    var body: some View {
        let data = hike.observations
        let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: path] })
        let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
        let heightRatio = 1 - CGFloat(maxMagnitude / magnitude(of: overallRange))

        return GeometryReader { proxy in
            HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
                ForEach(Array(data.enumerated()), id: \.offset) { index, observation in
                    GraphCapsule(
                        index: index,
                        color: color,
                        height: proxy.size.height,
                        range: observation[keyPath: path],
                        overallRange: overallRange
                    )
                    .animation(.ripple(index: index))
                }
                .offset(x: 0, y: proxy.size.height * heightRatio)
            }
        }
    }
}

func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
    where C.Element == Range<Double> {
    guard !ranges.isEmpty else { return 0..<0 }
    let low = ranges.lazy.map { $0.lowerBound }.min()!
    let high = ranges.lazy.map { $0.upperBound }.max()!
    return low..<high
}

func magnitude(of range: Range<Double>) -> Double {
    range.upperBound - range.lowerBound
}

コード内に、Viewの構造体と関数が3つあります。
さきに、関数について解説してからViewに移ります。
rangeOfRanges()

//引数はCollection型
//戻り値はRange型
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
    //引数の要素はRange型に指定
    where C.Element == Range<Double> {
    //引数が空であればreturn
    guard !ranges.isEmpty else { return 0..<0 }
    //map関数で引数要素(Range型)の下限だけの配列作成
    //min関数で作成した配列内の最小値を取得
    let low = ranges.lazy.map { $0.lowerBound }.min()!
    //こちらは上限だけの配列作成後に、最大値を取得
    let high = ranges.lazy.map { $0.upperBound }.max()!
    //引数の要素が取り得る範囲のRangeを返す
    return low..<high
}

lazy
such as map and filter, are implemented lazily.
mapやfilter などの一部の操作が遅延して実装されます。
処理が重たいものに対して、必要な段階になった時にようやく初期化処理が行われるようにすう。
パフォーマンス向上や初期化状態に依存するものに対して使用する。

magnitude()

func magnitude(of range: Range<Double>) -> Double {
    //引数の上限と下限の差を返す
    range.upperBound - range.lowerBound
}

ripple()

//Animationクラスにripple関数を追加
extension Animation {
    static func ripple(index: Int) -> Animation {
        //バネのアニメーション
        Animation.spring(dampingFraction: 0.5)
	    //スピード指定
            .speed(2)
	    //遅らせる
            .delay(0.03 * Double(index))
    }
}

次にViewの構造体を見ていきます。

struct HikeGraph: View {
    var hike: Hike
    var path: KeyPath<Hike.Observation, Range<Double>>

    //「pathに指定されたkeypathに応じて色を返す」というgetter
    var color: Color {
        switch path {
        case \.elevation:
            return .gray
        case \.heartRate:
            return Color(hue: 0, saturation: 0.5, brightness: 0.7)
        case \.pace:
            return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
        default:
            return .black
        }
    }

    var body: some View {
        let data = hike.observations
        //mapで引数で指定された要素だけの配列を作成し返す
        //「\Hike.Observation.elevation」の時はelevationのRangeだけの配列
        //作成された配列からrangeOfRangesで最大範囲のRangeを返す
        let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: path] })
        //Rangeの差を要素とする配列から最大値を取得(!でnil出ないことを保証)
        let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
        // 1 - (配列内の取りうる範囲から、1番大きな差を持つ棒が占める割合)
        //結果、1番大きな棒の上下の隙間が取得される
        let heightRatio = 1 - CGFloat(maxMagnitude / magnitude(of: overallRange))

        return GeometryReader { proxy in
            //横幅を120等分した幅が、棒と棒の隙間となる
            HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
                //enumerated()は配列から
		//「0から始まる整数」と「要素」のペアをもつ配列を作る
                ForEach(Array(data.enumerated()), id: \.offset) { index, observation in
                    //縦棒を作成
                    GraphCapsule(
                        index: index,
                        color: color,
                        height: proxy.size.height,
                        range: observation[keyPath: path],
                        overallRange: overallRange
                    )
                    //縦棒の表示時に自作のAnimationを適用
                    .animation(.ripple(index: index))
                }
                .offset(x: 0, y: proxy.size.height * heightRatio)
            }
        }
    }
}

enumerated
Returns a sequence of pairs (n, x), where n represents a consecutive integer starting at zero and x represents an element of the sequence.

ペア ( n , x )のシーケンスを返します。ここで、 n はゼロから始まる連続した整数を表し、xはシーケンスの要素を表します。

//公式内サンプルコード
for (n, c) in "Swift".enumerated() {
   print("\(n): '\(c)'")
}
// Prints "0: 'S'"
// Prints "1: 'w'"
// Prints "2: 'i'"
// Prints "3: 'f'"
// Prints "4: 't'"

ただ、公式内には、配列のインデックスを使用したい場合は zip関数 を使用するようにしましょうと書いてあります。
適宜、使い分けましょう。

HikeDetail.swift

表示するグラフの切り替えを行う

import SwiftUI

struct HikeDetail: View {
    let hike: Hike
    @State var dataToShow = \Hike.Observation.elevation

    var buttons = [
        ("Elevation", \Hike.Observation.elevation),
        ("Heart Rate", \Hike.Observation.heartRate),
        ("Pace", \Hike.Observation.pace)
    ]

    var body: some View {
        VStack {
            //指定されたkeypathによってグラフ更新
            HikeGraph(hike: hike, path: dataToShow)
                .frame(height: 200)
            //押したボタンによってdataToShow更新
            HStack(spacing: 25) {
                ForEach(buttons, id: \.0) { value in
                    Button {
                        dataToShow = value.1
                    } label: {
                        Text(value.0)
                            .font(.system(size: 15))
                            //三項演算子を使用して、
			    //選ばれたbuttonの色がgrayになる
                            //選ばれてないのは青
                            .foregroundColor(value.1 == dataToShow
                                ? .gray
                                : .accentColor)
                            //ButtonAnimationを無効化
                            .animation(nil)
                    }
                }
            }
        }
    }
}

まとめ

まだまだ説明不足な部分が多いので、おいおい記事を追加していきます。

今度は、ジェネリクスやクロージャ、キーパス等細かい部分についてもまとめたいですね。

最後までお付き合いいただきありがとうございました!

Discussion