NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

【Swift】ChartとGeometryReaderで動的に動くグラフを作成する

概要

ChartにGeometryReaderを使用することで以下のようにタップなどの動作で特定のグラフの値を抽出したり、範囲選択して、値をリスト表示させたりすることができます。

この記事では上記のように

  • 範囲選択から値をリスト表示する
  • 特定のグラフを選択した時に値を抽出して表示する

ことをGeometryReaderを利用しておこなっていきたいと思います。

環境

この記事は以下のバージョン環境のもと作成されたものです。
【Xcode】14.0
【iOS】16.0
【macOS】Monterey バージョン 12.5

参考サンプル

今回ご紹介する2種類の表示方法についてはAppleより公開されているサンプルコードを元に作成しています。
以下サンプルコードのリンクです。

developer.apple.com

共通して使用するサンプルのデータとして、以下の架空のサンプルデータを自作して使用していますので適時置き換えていただければと思います。

範囲選択から値をリスト表示する

以下のように範囲選択を行うとその期間の表示やグラフ上で選択範囲の可視化、また選択範囲の情報をリスト表示することができます。

上記サンプルデータを型として、選択範囲を返すメソッドを作成します。
こちらのメソッドに関しては使用したいデータの型を渡すだけになっていますので、SkinTempertureとskinTemperatureSampleDataの部分を適時変更していただくことでほぼサンプルコードのまま使い回すことができます。

var rows: [SkinTemperture] {
    if let range = range {
        if range.0 < range.1 {
            return skinTemperatureSampleData.filter { $0.day >= range.0 && $0.day <= range.1 }
        } else {
            return skinTemperatureSampleData.filter { $0.day >= range.1 && $0.day <= range.0 }
        }
    } else {
        return skinTemperatureSampleData
    }
}

つづいてchartOverlayを使用してChart上で選択した範囲を取得していきます。
座標の取得にはGeometryReaderを使用していきます。

.chartOverlay { proxy in
    GeometryReader { geometry in
        Rectangle().fill(.clear).contentShape(Rectangle())
            .gesture(DragGesture()
                .onChanged {value in
                    let startX = value.startLocation.x - geometry[proxy.plotAreaFrame].origin.x
                    let cuurentX = value.location.x - geometry[proxy.plotAreaFrame].origin.x
                    
                    if let startDate: Date = proxy.value(atX: startX),
                       let cuurentDate: Date = proxy.value(atX: cuurentX) {
                        range = (startDate, cuurentDate)
                    }
                }
                .onEnded { _ in range = nil}
            )
    }
}

以上です。
ここで行っていることはドラッグでの範囲をGeometryReaderで取得しているだけです。
複雑な計算をしているように見てますが、ここもサンプルコードをそのまま使用することができます。
あとはグラフ上でドラッグしている時に選択範囲を可視化するためにRectangleMarkを重ねていきます。

if let (start, end) = range {
    RectangleMark(
        xStart: .value("Range Start", start),
        xEnd: .value("Range End", end)
    )
    .foregroundStyle(.gray.opacity(0.02))
}

xStartにはchartOverlayで取得したstartDateを、xEndにはcuurentDateをrangeを通して渡しているだけです。
後はxStartとxEndの範囲からRectangleMarkが表示されます。
以上でgifのようなグラフを作成することができます。

特定のグラフを選択した時に値を抽出して表示する

以下のようにタップなどで特定のグラフを選択した時にそのグラフの値や日付などの情報を表示させることができます。

最初に選択したグラフの日付からSkinTempertureを返すメソッドを作成します。
このメソッドに関しては使用したいデータの型を返り値に渡してあげるだけでその他のサンプルコードはほぼ使い回しすることができます。

func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> (SkinTemperture)? {
    let relativeXPosition = location.x - geometry[proxy.plotAreaFrame].origin.x
    if let date = proxy.value(atX: relativeXPosition) as Date? {
        var minDistance: TimeInterval = .infinity
        var index: Int? = nil
        for tempertureDataIndex in skinTemperatureSampleData.indices {
            let nthTempertureDataDistance = skinTemperatureSampleData[tempertureDataIndex].day.distance(to: date)
            if abs(nthTempertureDataDistance) < minDistance {
                minDistance = abs(nthTempertureDataDistance)
                index = tempertureDataIndex
            }
        }
        if let index = index {
            return skinTemperatureSampleData[index]
        }
    }
    return nil
}

つづいてchartOverlayとchartBackgroundを使用してグラフに重ねたViewや上部に表示するViewを作っていきます。

@State private var selectedElement: (SkinTemperture)? = nil
@Environment(\.layoutDirection) var layoutDirection
@State var range: (Date, Date)? = nil
var body: some View {
    Chart {
        ...
    }
    .chartOverlay { proxy in
        GeometryReader { nthGeometryItem in
            Rectangle().fill(.clear).contentShape(Rectangle())
                .gesture(
                    SpatialTapGesture()
                        .onEnded { value in
                            let element = findElement(location: value.location, proxy: proxy, geometry: nthGeometryItem)
                            if selectedElement?.day == element?.day {
                                selectedElement = nil
                            } else {
                                selectedElement = element
                            }
                        }
                        .exclusively(
                            before: DragGesture()
                                .onChanged { value in
                                    selectedElement = findElement(location: value.location, proxy: proxy, geometry: nthGeometryItem)
                                }
                        )
                )
        }
    }
    .chartBackground { proxy in
        ZStack(alignment: .topLeading) {
            GeometryReader { nthGeoItem in
                if let selectedElement = selectedElement {
                    let dateInterval = Calendar.current.dateInterval(of: .day, for: selectedElement.day)!
                    let startPositionX1 = proxy.position(forX: dateInterval.start) ?? 0
                    let startPositionX2 = proxy.position(forX: dateInterval.end) ?? 0
                    let midStartPositionX = (startPositionX1 + startPositionX2) / 2 + nthGeoItem[proxy.plotAreaFrame].origin.x
                    let lineX = layoutDirection == .rightToLeft ? nthGeoItem.size.width - midStartPositionX : midStartPositionX
                    let lineHeight = nthGeoItem[proxy.plotAreaFrame].maxY
                    let boxWidth: CGFloat = 150
                    let boxOffset = max(0, min(nthGeoItem.size.width - boxWidth, lineX - boxWidth / 2))
                    
                    Rectangle()
                        .fill(.quaternary)
                        .frame(width: 2, height: lineHeight)
                        .position(x: lineX, y: lineHeight / 2)
                    
                    VStack(alignment: .leading) {
                        Text("\(selectedElement.day, format: .dateTime.year().month().day())")
                            .font(.callout)
                            .foregroundStyle(.secondary)
                        Text("Skin Temperture")
                        Text("\(String(format: "%.02f", selectedElement.temperture))°")
                            .font(.title2.bold())
                            .foregroundColor(.primary)
                    }
                    .frame(width: boxWidth, alignment: .leading)
                    .background {
                        ZStack {
                            RoundedRectangle(cornerRadius: 8)
                                .fill(.background)
                            RoundedRectangle(cornerRadius: 8)
                                .fill(.quaternary.opacity(0.7))
                        }
                        .padding([.leading, .trailing], -8)
                        .padding([.top, .bottom], -4)
                    }
                    .offset(x: boxOffset, y: -80)
                }
            }
        }
    }
}

以上です
一見複雑に見えますがchartOverlayとchartBackgroundを使用しているだけです。
chartOverlayではproxyを使用することでグラフのスケールとプロットエリアにアクセスすることができ、GeometryReaderのnthGeometryItemに渡してあげることで現在選択している位置を特定したりすることができます。
またchartBackgroundによって選択している部分にラインを表示したり、上部に詳細情報として値などを表示させたりすることができます。
選択している位置に関してはboxOffsetで取得していますのでoffsetに渡すことで、タップなどの動作で選択しているグラフ状に重ねて、ラインや詳細情報を表示させることができます。
GeometryReader内のプロパティは変更せず、Rectangle以降のViewやViewに渡す値を変更するだけで使い回すことができ、独自のDBから表示させたい情報などに変更して表示させることができます。

まとめ

動的に変化するグラフが作れると、より効果的に分析や解析に役立つグラフが作れそうだなと感じました。
また一見複雑に見えますが、chartOverlayの中でGeometryReaderとgestureを使用してタップやドラッグの範囲を取得しているだけで、chartBackgroundでは選択位置で表示するViewを作っているだけで表現できることがわかり、ChartとGeometryReaderの相性がいいと感じました!
一旦Chartsフレームワークに関しては終えたいと思いますが、今後触る時はユースケースを絞ってサンプル作成するか、実際に作成したサンプルを紹介したいと思います!

執筆者岡優志

iOSエンジニア
iOSを専門とし、モバイルアプリの開発を行なっています。

Twitter