4.iOSアプリでデータを確認する – Swift Chartsの実装
この記事はiOSDC2023で発表したセッション、Swiftでりんごを育ててみたの発表の第4部の内容をまとめた記事になります。
「3.自動給水を行う – SwiftでラズパイのGPIOを操作する」の記事の続編です。もしまだ読んでいない方は読んでいただけると!
今回は⑤のアプリ側の実装内容をまとめます!
アプリでFirebaseのデータを確認できるようにする
今回はアプリで以下の実装を行いました
- 1日の1時間おきの室温と湿度をグラフで表示(Chartsを使用)
- 1日の1時間おきの写真をカルーセルUIで表示(詳細はリポジトリ)
- 指定期間の写真をタイムラプス風に表示
順番に実装の詳細をまとめていきます
グラフ表示の実装
グラフの実装はiOS16~で利用できるChartsを利用します(触ったことなかったので、ちょうどいい機会でした!)
今回実装しようとしているグラフはこのようなものです
- x軸
- 3時間ごとに時刻を表示
- Y軸
- 左側は温度(最大値は50(℃))
- 右側は湿度(最大値は100(%))
- 現在選択している時刻に赤い縦線を表示
- このグラフの下に表示する1時間おきの写真のうち、何時の写真を表示しているかを示す線
- この線はグラフ内をタップしたり、左右のドラッグで移動できる
ではさっそく実装していきましょう
ではまずは表示するのに必要なデータモデル用のstructを用意します
今回温度と湿度を表示しますが、struct自体は共通のもので問題なく、それらを区別できるようにするenumのプロパティを1つ設定しています
enum DataType: String {
case humidity
case temperature
}
struct DataModel: Identifiable {
var id: String { type.rawValue + xValue.description + yValue.description }
let type: DataType
let xValue: Date
let yValue: Int
}
次にFirestoreからのデータを保持しているモデルの配列を上記のDataModel
の配列に変換しますdataList
というDataModel
の配列に湿度と温度の配列を共通の配列に定義します
let postModels: [FirestoreDataModel] // document of Firestore data
private var dataList: [DataModel] {
postModels // for humidity
.map({ DataModel(type: .humidity, xValue: $0.date, yValue: $0.humidity) })
+ postModels // for temperature
.map({ DataModel(type: .temperature, xValue: $0.date, yValue: Int($0.temperature)) })
}
そしてLineグラフの実装をしますChart
のViewに先ほど用意したdataList
の配列を渡します
そうするとクロージャー内でその要素を1つ取得できるので、それをLineMarkのx
/y
の値にセットしていきます
xには時刻、yには湿度や温度の値を渡します
最後に重要なのは、湿度と温度を1つのグラフではなく、別々のグラフとして表示するための設定です
foregroundStyleという関数で、value
にdata.type.rawValue
を指定すると、先ほど実装したDataType
のenumのcaseごとにグラフを表示することができるようになります!
Chart(dataList) { data in
LineMark(
x: .value("Time", data.xValue, unit: .hour), // Set the X-axis unit to time
y: .value("Value", data.yValue)
)
.foregroundStyle(by: .value("Data Type", data.type.rawValue)) // Separate graphs for each data
}
これでグラフ自体は表示できました!
あとはグラフのX軸やY軸などのレイアウトの調整とジェスチャーの実装です
まずはY軸の設定です
以下のように.chartYAxis
のmodifierを使うと、Y軸のカスタマイズをすることができます
AxisMarksに表示するY軸の位置や値をセットし、クロージャー内でそのような形式で、今回の場合は値の後ろに%
をつけて表示するように指定しています
Chart(dataList) { data in
// ・・・
}
.chartYAxis {
let humidityValues = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
AxisMarks(
position: .trailing, // Y-axis display position (left or right)
values: humidityValues
) {
AxisValueLabel("\(humidityValues[$0.index])%", centered: false) // Y-axis value display format
}
// ---Set the temperature as above---
}
次はY軸と平行な赤線の実装です
縦線を引くには、RuleMarkをChart内に書けばOKです
今回の場合は、選択中の日付であるselectedDate
がある場合、その時刻の箇所に線を表示しますforegroundStyle
で線の色を指定しています
@Binding var selectedHourIndex: Int
let postModels: [FirestoreDataModel]
private var selectedDate: Date? {
if postModels.isEmpty { return nil }
return postModels[min(selectedHourIndex, postModels.count - 1)].date
}
Chart(dataList) { data in
// ----Line chart code above----
if let selectedDate { // If there is a date in the current selection
RuleMark(x: .value("Selected date", selectedDate, unit: .hour)) // display vertical line
.foregroundStyle(Color.red) // change color to red
}
}
最後にジェスチャーの実装です
まずはタップやドラッグのジェスチャーのlocationから、どのX軸の位置に移動するかを判定する共通関数を実装します
GeometryReaderやChartProxyを利用して、Xの値を計算しますxPosition
はView全体のlocationからグラフの左上のlocationを引くことで、グラフの左上の基準にしたグラフ内のLocationを計算することができます
そしてproxy.value(atX: )
を使うことで、locationのXの値から、グラフのxの値を返却してくれます
またselectedHourIndex
の変更にともなう赤い縦線の移動をアニメーション付きにしたかったので、withAnimation
内で更新しています
func updateSelectedDate(at location: CGPoint,
proxy: ChartProxy, geometry: GeometryProxy) {
let xPosition = location.x - geometry[proxy.plotAreaFrame].origin.x // tap coordinate - upper left coordinate of graph
guard let date: Date = proxy.value(atX: xPosition) else { return } // Find x value from tap coordinates
withAnimation {
selectedHourIndex = postModels
.map(\.date)
.firstIndex(where: { $0.hour == date.hour }) ?? 0
}
}
そして上記のメソッドを呼び出すジェスチャーの実装をします
chartOverlayを使用すると、グラフにoverlayのviewを追加できます。
これを使い、透明なViewを配置し、そのviewに対してタップとドラッグジェスチャーを実装し、その中で先ほど実装したupdateSelectedDate
を呼び出します
.chartOverlay { proxy in
GeometryReader { geometry in
Rectangle().fill(.clear).contentShape(Rectangle())
.gesture(DragGesture()
.onChanged { value in
updateSelectedDate(at: value.location,
proxy: proxy,
geometry: geometry)
})
.onTapGesture { location in
updateSelectedDate(at: location,
proxy: proxy,
geometry: geometry)
}
}
1日の1時間おきの写真をカルーセルUIで表示する実装
次に1日の1時間おきの写真、最大24枚の写真をカルーセル形式で表示できるUIを実装します
表示する画像ですが、firestoreに保存したCloudStorageのURLには認証用のトークンがついていないため、画像のダウンロード直前にFirebaseのSDKを使用し、ファイル名を渡して、トークン付きのURLを発行します
static func getImageURL(pathName: String) async throws -> URL {
let storage = Storage.storage()
let storageRef = storage.reference()
let starsRef = storageRef.child(pathName)
return try await starsRef.downloadURL()
}
次に画像表示用のコンポーネントの実装です
上記のURL発行メソッドを呼び出しつつ、キャッシュなども利用するため、SDWebImageSwiftUIを利用します
struct AsyncImageView: View {
@State var url: URL?
@State var error: Error?
let imageName: String
var body: some View {
Group {
if error != nil {
Image(systemName: "photo")
} else {
WebImage(url: url)
.resizable()
.placeholder(content: {
ProgressView()
.font(.largeTitle)
})
.indicator(.progress)
.scaledToFit()
.transition(.fade(duration: 0.5))
}
}
.task {
do {
url = try await FirebaseManager.getImageURL(pathName: imageName)
} catch {
print(error)
self.error = error
}
}
}
}
最後にこのAsyncImageView
を使って、画像一覧のUIを実装します
今回はTabView
のPageTabViewStyleを使用して、実装します
struct ImageCarouselView: View {
@Binding var selectedIndex: Int
let imageNameList: [String]
var body: some View {
TabView(selection: $selectedIndex.animation(.spring())) {
ForEach(imageNameList.indexed(), id: \.element) { (index, name) in
AsyncImageView(imageName: name)
.tag(index) // to get selected index
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
}
}
先ほどのグラフと合わせて表示すると、以下のような表示になります
実装としてはそこまで複雑でない反面、グラフのドラッグジェスチャーでの移動時にリアルタイムでカルーセルもページが切り替わってしまうため、ここはインクリメンタルサーチのように、ジェスチャー完了時、もしくは値の変更があってから一定時間経ってから、ページを切り替えるようにするなどのリファクタリングをする余地はありそうです
指定期間の写真をタイムラプス風に表示する実装
最後に1日の写真をタイムラプス風に表示する実装です
この仕様の実現も色々な実装方法があると思いますが、今回はUIImageViewのanimationImagesを利用します
ここで実装したViewをUIViewRepresentable
でラップして表示します
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.animationImages = images
imageView.clipsToBounds = true
// Display an image every 0.3 seconds
imageView.animationDuration = TimeInterval(floatLiteral: Double(images.count) * 0.3)
if !images.isEmpty {
imageView.startAnimating()
}
また利用にはUIImage
の配列が必要なので、表示前にまとめてCloudStorageから取得します
今回この実装にはSwiftConcurrencyのGroupの仕組みを使って実装しています
これでのダウンロード用のURLの発行とそのダウンロードを並列に実行します
private func fetchList() async throws {
Task {
do {
let results: [FirestoreDataModel]
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
results = FirestoreDataModel.mocks
} else {
results = try await FirebaseManager.fetchList(fromDate: date., endDate: date.offsetDays(offset: 1) ?? Date())
}
let imageList = try await withThrowingTaskGroup(of: UIImage?.self) { group in
results.map(\.imageName).forEach { imageName in
group.addTask {
return try await FirebaseManager.getImageURL(pathName: imageName).asUIImage()
}
}
var images: [UIImage] = []
for try await image in group {
guard let image else { break }
images.append(image)
}
return images
}
let images = imageList.compactMap { $0 }
self.images = images
} catch {
print(error)
}
}
}
これで1日の最大24枚の写真をカルーセル形式で表示することができるようになりました!
ただ今は画像サイズが大きいため、ダウンロードにもかなり時間がかかってしまうので、ラズパイ側で保存する時にリサイズ処理を追加でしたいです
まとめ
この記事では、SwiftGardenの発表の4部のグラフの実装などの内容をまとめました
次の記事は、まとめの記事です!