Event

SwiftGarden 4.iOSアプリでデータを確認する – Swift Chartsの実装

4.iOSアプリでデータを確認する – Swift Chartsの実装

この記事はiOSDC2023で発表したセッション、Swiftでりんごを育ててみたの発表の第4部の内容をまとめた記事になります。

「3.自動給水を行う – SwiftでラズパイのGPIOを操作する」の記事の続編です。もしまだ読んでいない方は読んでいただけると!

SwiftGarden 3.自動給水を行う - SwiftでラズパイのGPIOを操作するこの記事は、「Swiftでりんごを育ててみた」の3部「自動給水を行う - SwiftでラズパイのGPIOを操作する」の内容をまとめた記事になります!...

今回は⑤のアプリ側の実装内容をまとめます!

アプリでFirebaseのデータを確認できるようにする

今回はアプリで以下の実装を行いました

  1. 1日の1時間おきの室温と湿度をグラフで表示(Chartsを使用)
  2. 1日の1時間おきの写真をカルーセルUIで表示(詳細はリポジトリ)
  3. 指定期間の写真をタイムラプス風に表示

順番に実装の詳細をまとめていきます

グラフ表示の実装

グラフの実装はiOS16~で利用できるChartsを利用します(触ったことなかったので、ちょうどいい機会でした!)

今回実装しようとしているグラフはこのようなものです

  1. x軸
    • 3時間ごとに時刻を表示
  2. Y軸
    • 左側は温度(最大値は50(℃))
    • 右側は湿度(最大値は100(%))
  3. 現在選択している時刻に赤い縦線を表示
    • このグラフの下に表示する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つ取得できるので、それをLineMarkx/yの値にセットしていきます
xには時刻、yには湿度や温度の値を渡します

最後に重要なのは、湿度と温度を1つのグラフではなく、別々のグラフとして表示するための設定です
foregroundStyleという関数で、valuedata.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軸の位置に移動するかを判定する共通関数を実装します
GeometryReaderChartProxyを利用して、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を実装します
今回はTabViewPageTabViewStyleを使用して、実装します

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部のグラフの実装などの内容をまとめました
次の記事は、まとめの記事です!

SwiftGarden まとめ – SwiftGardenの実装を終えてこの記事は、「Swiftでりんごを育ててみた」のまとめの内容をまとめた記事になります!...
+1

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA