概要
ChartにGeometryReaderを使用することで以下のようにタップなどの動作で特定のグラフの値を抽出したり、範囲選択して、値をリスト表示させたりすることができます。
この記事では上記のように
- 範囲選択から値をリスト表示する
- 特定のグラフを選択した時に値を抽出して表示する
ことをGeometryReaderを利用しておこなっていきたいと思います。
環境
この記事は以下のバージョン環境のもと作成されたものです。
【Xcode】14.0
【iOS】16.0
【macOS】Monterey バージョン 12.5
参考サンプル
今回ご紹介する2種類の表示方法についてはAppleより公開されているサンプルコードを元に作成しています。
以下サンプルコードのリンクです。
共通して使用するサンプルのデータとして、以下の架空のサンプルデータを自作して使用していますので適時置き換えていただければと思います。
範囲選択から値をリスト表示する
以下のように範囲選択を行うとその期間の表示やグラフ上で選択範囲の可視化、また選択範囲の情報をリスト表示することができます。
上記サンプルデータを型として、選択範囲を返すメソッドを作成します。
こちらのメソッドに関しては使用したいデータの型を渡すだけになっていますので、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フレームワークに関しては終えたいと思いますが、今後触る時はユースケースを絞ってサンプル作成するか、実際に作成したサンプルを紹介したいと思います!