- 投稿日:2022-02-24T23:42:42+09:00
【RxSwift】コードで学ぶObservableインスタンスの生成と要素の操作方法
参考 ReactiveX/RxSwift(GitHub) RxSwift Reference サンプルコード b150005/RxSwiftSample Observableインスタンスの生成 // Observable.just(_:)は単一の要素で構成 let singleElementObservable: Observable<Int> = Observable.just(1) // Observable.of(_:)は複数の要素で構成 let multipleElementsObservable1: Observable<Int> = Observable.of(1, 2, 3) let multipleElementsObservable2: Observable<[Int]> = Observable.of([1, 2, 3]) // Observable.from(_:)は配列の要素で構成 let arrayElementObservable: Observable<Int> = Observable.from([1, 2, 3]) // ユーザ定義 let manuallyCreatedObservable: Observable<Int> = Observable<Int>.create { (observer: AnyObserver<Int>) -> Disposable in observer.onNext(1) observer.onCompleted() return Disposables.create() } Observableインスタンスの購読 // next(1) -> completed // Element: 1 singleElementObservable.subscribe { (event: Event) in print(event) if let element = event.element { print("Element: \(element)") } } // next(1) -> next(2) -> next(3) -> completed // Element: 1 -> 2 -> 3 multipleElementsObservable1.subscribe { (event: Event) in print(event) if let element = event.element { print("Element: \(element)") } } // next([1, 2, 3]) -> completed // Element: [1, 2, 3] multipleElementsObservable2.subscribe { (event: Event) in print(event) if let element = event.element { print("Element: \(element)") } } // next(1) -> next(2) -> next(3) -> completed // Element: 1 -> 2 -> 3 arrayElementObservable.subscribe { (event: Event) in print(event) if let element = event.element { print("Element: \(element)") } } // next(1) -> completed // Element: 1 manuallyCreatedObservable.subscribe { (event: Event) in print(event) if let element = event.element { print("Element: \(element)") } } Observableインスタンスの購読解除 // 明示的な購読解除(非推奨) let disposableSubscription: Disposable = arrayElementObservable.subscribe(onNext: { (element: Int) in // Element: 1 -> 2 -> 3 print("Element: \(element)") }) disposableSubscription.dispose() // 終了イベント(Completed or Error)の発生 または ARC参照カウントが0 の場合に自動で購読解除 let disposeBag: DisposeBag = DisposeBag() arrayElementObservable.subscribe { // next(1) -> next(2) -> next(3) -> completed print($0) }.disposed(by: disposeBag) Subjectインスタンスの生成 // PublishSubjectは購読後に発行されたイベントのみ購読可能であり、初期値は与えられない // next(2) -> completed let publishSubject: PublishSubject<Int> = PublishSubject<Int>() publishSubject.onNext(1) publishSubject.subscribe { (event: Event) in print(event) } publishSubject.onNext(2) publishSubject.onCompleted() publishSubject.dispose() // BehaviorSubjectは購読直前に発行された最新のイベントから購読可能であり、初期値が与えられる // next(2) -> next(3) -> completed let behaviorSubject: BehaviorSubject<Int> = BehaviorSubject<Int>(value: 1) behaviorSubject.onNext(2) behaviorSubject.subscribe { (event: Event) in print(event) } behaviorSubject.onNext(3) behaviorSubject.onCompleted() behaviorSubject.dispose() // ReplaySubjectは購読直前に発行されたn個のNextイベントから購読可能であり、初期値は与えられない // next(2) -> next(3) -> completed let replaySubject: ReplaySubject<Int> = ReplaySubject<Int>.create(bufferSize: 2) replaySubject.onNext(1) replaySubject.onNext(2) replaySubject.onNext(3) replaySubject.onCompleted() replaySubject.subscribe { (event: Event) in print(event) } // BehaviorRelayは購読直前に発行された最新のイベントのみ購読可能で、CompleteとErrorイベントを発行しない // next([1, 2, 3]) let behaviorRelay: BehaviorRelay<[Int]> = BehaviorRelay(value: [1]) let behaviorRelayObservable: Observable<[Int]> = behaviorRelay.asObservable() // ①+演算子を用いた値の追加 behaviorRelay.accept(behaviorRelay.value + [2]) // ②append(_:)を用いた値の追加 var behaviorRelayValue: [Int] = behaviorRelay.value behaviorRelayValue.append(3) behaviorRelay.accept(behaviorRelayValue) behaviorRelayObservable.subscribe { (event: Event) in print(event) } Observableの要素をフィルタリングするOperator 以降、本記事での要素は「Nextイベントおよびその値」を指します。 // ObservableType#ignoreElements()は全ての要素が排除されたCompleteイベントのみを発行するObservable<Never>を返却する // completed let ignoredObservable: Observable<Never> = Observable<Int>.of(1, 2, 3).ignoreElements() ignoredObservable.subscribe { (event: Event) in print(event) }.disposed(by: disposeBag) // ObservableType#element(at:)は指定したindexの要素のみをもつObservable<Element>を返却する // next(1) -> completed let specificElementObservable: Observable<Int> = Observable<Int>.of(1, 2, 3).element(at: 0) specificElementObservable.subscribe { (event: Event) in print(event) }.disposed(by: disposeBag) // ObservableType#filter(_:)は指定した条件を満たす要素群を発行するObservable<Element>を返却する // next(2) -> completed let filteredElementsObservable: Observable<Int> = Observable<Int>.of(1, 2, 3) .filter { (i: Int) -> Bool in return i > 1 } .filter { $0 < 3 } filteredElementsObservable.subscribe { (event: Event) in print(event) }.disposed(by: disposeBag) // ObservableType#skip(_:)は先頭要素から指定した要素数を排除した要素群を発行するObservable<Element>を返却する // next(3) -> completed let skippedElementsObservable: Observable<Int> = Observable<Int>.of(1, 2, 3).skip(2) skippedElementsObservable.subscribe { print($0) }.disposed(by: disposeBag) // ObservableType#skip(while:)は先頭要素から指定した条件を満たす間の要素を排除した要素群を発行するObservable<Element>を返却する // next(3) -> next(1) -> next(2) -> completed let skippedWhileConditionIsMetObservable: Observable<Int> = Observable<Int>.of(1, 2, 3, 1, 2).skip(while: { $0 < 3 }) skippedWhileConditionIsMetObservable.subscribe{ print($0) }.disposed(by: disposeBag) // ObservableType#skip(until:)は引数のObservableがNextイベントを発行するまでの要素を排除したObservable<Element>を返却する // -> 引数のObservableがNextイベントを発行しない場合は、全ての要素が排除され、Completeイベントを発行するObservable<Element>が返却される // Trigger: next(1) -> Trigger: completed -> Triggered: next(3) -> Triggered: completed let triggerPublishSubject: PublishSubject<Int> = PublishSubject<Int>() triggerPublishSubject.subscribe { print("Trigger: \($0)") }.disposed(by: disposeBag) let skippedUntilTriggeredPublishSubject: PublishSubject<Int> = PublishSubject<Int>() skippedUntilTriggeredPublishSubject.skip(until: triggerPublishSubject).subscribe { print("Triggered: \($0)") }.disposed(by: disposeBag) skippedUntilTriggeredPublishSubject.onNext(1) skippedUntilTriggeredPublishSubject.onNext(2) triggerPublishSubject.onNext(1) triggerPublishSubject.onCompleted() skippedUntilTriggeredPublishSubject.onNext(3) skippedUntilTriggeredPublishSubject.onCompleted() // ObservableType#take(_:)は先頭要素から指定した要素数の要素群を発行するObservable<Element>を返却する // next(1) -> next(2) -> completed let takenObservable: Observable<Int> = Observable<Int>.of(1, 2, 3).take(2) takenObservable.subscribe { print($0) }.disposed(by: disposeBag) // ObservableType#take(while:)は先頭要素から指定した条件を満たす間の要素群を発行するObservable<Element>を返却する // -> 先頭要素がすでに条件を満たさない場合は、全ての要素が排除され、Completeイベントを発行するObservable<Element>が返却される // next(1) -> completed let takenWhileConditionIsMetObservable: Observable<Int> = Observable<Int>.of(1, 2, 3, 4, 5).take(while: { $0 < 2 }) takenWhileConditionIsMetObservable.subscribe{ print($0) }.disposed(by: disposeBag) // ObservableType#take(until:)は先頭要素から指定した条件を初めて満たすまでの要素群を発行するObservable<Element>を返却する // -> 先頭要素がすでに条件を満たす場合は、全ての要素が排除され、Completeイベントを発行するObservable<Element>を返却する // next(1) -> next(2) -> completed let takenUntilConditionIsMetObservable: Observable<Int> = Observable<Int>.of(1, 2, 3, 4, 5).take(until: { $0 > 2 }) takenUntilConditionIsMetObservable.subscribe{ print($0) }.disposed(by: disposeBag) Observableの要素を変換するOperator // ObservableType#toArray()は各要素を1つの配列に格納した要素を発行するSingle<Element>を返却する // next([1, 2, 3]) -> completed let arrayedObservable: Observable<[Int]> = Observable<Int>.of(1, 2, 3).toArray().asObservable() arrayedObservable.subscribe { print($0) }.disposed(by: disposeBag) // ObservableType#map(_:)は各要素に対して演算が行われた要素群を発行するObservable<Element>(厳密にはObservable<Result>)を返却する // next(6) -> next(12) -> next(18) -> completed let mappedObservable: Observable<Int> = Observable<Int>.of(1, 2, 3) .map { (i: Int) -> Int in return i * 2 } .map { $0 * 3 } mappedObservable.subscribe { print($0) }.disposed(by: disposeBag) // ObservableType#flatMap(_:)は各要素に対して演算が行われた要素群を発行するObservable<Observable<Element>>を非同期的に生成し、 // 購読可能な全てのObservable<Element>がマージされた要素群を発行するObservable<Element>を返却する // next(10) -> next(100) -> next(20) -> next(1000) -> next(200) -> next(30) // -> next(2000) -> next(300) -> next(3000) -> completed let flatMappedObservable: Observable<Int> = Observable<Int>.of(1, 2, 3) .flatMap { (i: Int) -> Observable<Int> in return Observable.just(i * 2) } .flatMap { Observable.of($0 * 5, $0 * 50, $0 * 500) } flatMappedObservable.subscribe { print($0) }.disposed(by: disposeBag) // ObservableType#flatMapLatest(_:)は各要素に対して演算が行われた要素群を発行するObservable<Observable<Element>>を非同期的に生成し、 // 任意の時点ごとに購読可能な最新のObservable<Element>のみがマージされた要素群を発行するObservable<Element>を返却する // next(10) -> next(20) -> next(30) -> next(300) -> next(3000) -> completed let flatMappedLatestObservable: Observable<Int> = Observable<Int>.of(1, 2, 3) .flatMapLatest { (i: Int) -> Observable<Int> in return Observable.just(i * 2) } .flatMapLatest { Observable.of($0 * 5, $0 * 50, $0 * 500)} flatMappedLatestObservable.subscribe { print($0) }.disposed(by: disposeBag) Observableの要素を結合するOperator // ObservableType#startWith(_:)は先頭要素の前に引数の要素群を追加して発行するObservable<Element>を返却する // next(0) -> next(1) -> next(2) -> next(3) -> completed let startedWithAddedElementObservable: Observable<Int> = Observable<Int>.of(1, 2, 3).startWith(0) startedWithAddedElementObservable.subscribe { print($0) }.disposed(by: disposeBag) // ObservableType#concat(_:), ObservableType#concat(), ObservableType.concate(_:)はあるObservable<Element>の要素に // 他のObservable<Element>の要素を同期的(=直列的)に連結(concatenate)した要素群を発行するObservable<Element>を返却する // next(1) -> next(2) -> next(3) -> next(4) -> completed let firstConcatenatedObservable: Observable<Int> = Observable<Int>.just(1).concat(Observable<Int>.just(2)) let secondConcatenatedObservable: Observable<Int> = Observable.of(firstConcatenatedObservable, Observable<Int>.just(3)).concat() let lastConcatenatedObservable: Observable<Int> = Observable.concat([secondConcatenatedObservable, Observable<Int>.just(4)]) lastConcatenatedObservable.subscribe { print($0) }.disposed(by: disposeBag) // ObservableType#merge()はあるObservable<Element>の要素に // 他のObservable<Element>の要素を非同期的(=並列的)に統合(merge)した要素群を発行するObservable<Element>を返却する // -> 統合が完了するまでに発生したCompleteイベントは、統合段階で除外される // next(1) -> next(1) -> next(2) -> next(2) -> next(3) -> next(3) -> completed let firstMergedPublishSubject: PublishSubject<Int> = PublishSubject<Int>() let secondMergedPublishSubject: PublishSubject<Int> = PublishSubject<Int>() let mergedObservable: Observable<Int> = Observable.of(firstMergedPublishSubject.asObservable(), secondMergedPublishSubject.asObservable()).merge() mergedObservable.subscribe { print($0) }.disposed(by: disposeBag) firstMergedPublishSubject.onNext(1) secondMergedPublishSubject.onNext(1) secondMergedPublishSubject.onNext(2) firstMergedPublishSubject.onNext(2) firstMergedPublishSubject.onNext(3) firstMergedPublishSubject.onCompleted() secondMergedPublishSubject.onNext(3) secondMergedPublishSubject.onCompleted() // ObservableType.combineLatest(_:_:resultSelector:)は引数の全てのObservableで要素が追加されてから、 // いずれかのObservableで要素が追加されるたびに、その時点での各Observableの最新の要素群を単一の要素として発行するObservableを返却する // -> 返却する // next(first: 2, second: 10) -> next(first: 2, second: 11) -> next(first: 3, second: 11) // -> next(first: 3, second: 12) -> completed let firstCombinedLatestPublishSubject: PublishSubject<Int> = PublishSubject<Int>() let secondCombinedLatestPublishSubject: PublishSubject<Int> = PublishSubject<Int>() let combinedLatestObservable: Observable<String> = Observable.combineLatest(firstCombinedLatestPublishSubject, secondCombinedLatestPublishSubject, resultSelector: { (firstElement: Int, secondElement: Int) -> String in return "first: \(firstElement), second: \(secondElement)" }) combinedLatestObservable.subscribe { print($0) }.disposed(by: disposeBag) firstCombinedLatestPublishSubject.onNext(1) firstCombinedLatestPublishSubject.onNext(2) secondCombinedLatestPublishSubject.onNext(10) secondCombinedLatestPublishSubject.onNext(11) firstCombinedLatestPublishSubject.onNext(3) firstCombinedLatestPublishSubject.onCompleted() secondCombinedLatestPublishSubject.onNext(12) secondCombinedLatestPublishSubject.onCompleted() // ObservableType#withLatestFrom(_:)は引数のSourceに要素が追加されてから、 // トリガーとなるObservable<Void>で要素が追加されるたびに、その時点でのSourceの最新の単一の要素を発行するObservable<Source.Element>を返却する // next(2) -> next(3) let triggerredWithLatestFromPublishSubject: PublishSubject<Int> = PublishSubject<Int>() let triggerWithLatestFromPublishSubject: PublishSubject<Void> = PublishSubject<Void>() let withLatestFromObservable: Observable<Int> = triggerWithLatestFromPublishSubject.withLatestFrom(triggerredWithLatestFromPublishSubject) withLatestFromObservable.subscribe{ print($0) }.disposed(by: disposeBag) triggerWithLatestFromPublishSubject.onNext(()) triggerredWithLatestFromPublishSubject.onNext(1) triggerredWithLatestFromPublishSubject.onNext(2) triggerWithLatestFromPublishSubject.onNext(()) triggerredWithLatestFromPublishSubject.onNext(3) triggerredWithLatestFromPublishSubject.onCompleted() triggerWithLatestFromPublishSubject.onNext(()) // ObservableType#reduce(_:accumulator:)は、第一引数の初期値と全ての要素の総和を演算した要素を発行するObservable<Element>を返却する // next(36) -> completed // old: 20, new: 16 let reducedObservable: Observable<Int> = Observable<Int>.of(1, 2, 3) .reduce(10, accumulator: +) .reduce(20, accumulator: { (oldValue: Int, newValue: Int) -> Int in print("[old: \(oldValue), new: \(newValue)]") return oldValue + newValue }) reducedObservable.subscribe { print($0) }.disposed(by: disposeBag) // ObservableType#scan(_:accumulator:)は、第一引数の初期値に各要素の値を演算した要素群を発行するObservable<Element>を返却する // next(31) -> next(44) -> next(60) -> completed // [old: 20, new: 11] -> [old: 31, new: 13] -> [old: 44, new: 16] let scannedObservable: Observable<Int> = Observable<Int>.of(1, 2, 3) .scan(10, accumulator: +) .scan(20, accumulator: { (oldValue: Int, newValue: Int) -> Int in print("[old: \(oldValue), new: \(newValue)]") return oldValue + newValue }) scannedObservable.subscribe { print($0) }.disposed(by: disposeBag)
- 投稿日:2022-02-24T20:40:01+09:00
WidgetKitで天気予報アプリ作ってみた〜View実装編〜
投稿の経緯 この記事は、前回投稿したWidgetKitで天気予報アプリ作ってみたシリーズ~タイムライン作成編~の続編です。 今回はいよいよViewを実装していきます。 前回の記事を見てない人は先に↓こちら↓を確認してください。 環境 Swift 5.5 Xcode 13.2.1 サンプルプロジェクト GitHubにPushしています。気になる方はご覧ください。 https://github.com/ken-sasaki-222/WeatherWidget Viewで表示する内容 画像のように各ブロックごとに分割して開発していきます。周りの余白は16ではみ出す場合はclipped()します。 Viewで表示する内容は以下の通り。 青ブロック 現在の気温 現在時刻 地点名 赤ブロック 気圧グラフ 緑ブロック 時刻テキスト 天気アイコン 気温 表示するデータは前回作成した「EntryModel」から取得して使います。 MediumWidgetEntryModel.swift import WidgetKit import SwiftUI struct MediumWidgetEntryModel: TimelineEntry { let date: Date var hourlyWeathers: [Hourly] var currentLocation: String? var weatherIcons: [String] var timePeriodTexts: [String] var temperatureTexts: [String] var hourlyPressures: [Double] init(currentDate: Date, hourlyWeathers: [Hourly], currentLocation: String?) { self.date = currentDate self.currentLocation = currentLocation self.hourlyWeathers = [] self.weatherIcons = [] self.timePeriodTexts = [] self.temperatureTexts = [] self.hourlyPressures = [] for index in 0..<24 { self.hourlyWeathers.append(hourlyWeathers[index]) self.hourlyPressures.append(hourlyWeathers[index].pressure) let timePeriodText = getTimePeriodText(hourlyWeather: hourlyWeathers[index]) self.timePeriodTexts.append(timePeriodText) let weather = hourlyWeathers[index].weather[0] if let weatherIconName = getWeatherIconName(weather: weather) { self.weatherIcons.append(weatherIconName) } let temp = String(format: "%0.0f", hourlyWeathers[index].temp) self.temperatureTexts.append(temp) } } func getTimePeriodText(hourlyWeather: Hourly) -> String { let date = Date(timeIntervalSince1970: hourlyWeather.dt) let dateString = DateFormatHelper.shared.formatToHHmm(date: date) var timePeriodText: String if dateString == "00:00" { timePeriodText = "0" } else if dateString == "03:00" { timePeriodText = "3" } else if dateString == "06:00" { timePeriodText = "6" } else if dateString == "09:00" { timePeriodText = "9" } else if dateString == "12:00" { timePeriodText = "12" } else if dateString == "15:00" { timePeriodText = "15" } else if dateString == "18:00" { timePeriodText = "18" } else if dateString == "21:00" { timePeriodText = "21" } else { timePeriodText = "・" } return timePeriodText } func getWeatherIconName(weather: Weather) -> String? { var wetherName: String switch weather.main { case "Clear": wetherName = WeatherTypeTranslator.translate(type: .clear) case "Clouds": wetherName = WeatherTypeTranslator.translate(type: .clouds) case "Rain": wetherName = WeatherTypeTranslator.translate(type: .rain) case "Snow": wetherName = WeatherTypeTranslator.translate(type: .snow) default: return nil } return wetherName } } このタイミングで「EntryModel」の値をインスタンス化しておきましょう。 MediumWidgetView.swift struct MediumWidgetView: View { @Environment(\.colorScheme) var colorScheme var entry: MediumWidgetProvider.Entry var body: some View { let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date) let hourlyWeathers = entry.hourlyWeathers let timePeriodTexts = entry.timePeriodTexts let weatherIcons = entry.weatherIcons let temperatureTexts = entry.temperatureTexts let hourlyPressures = entry.hourlyPressures GeometryReader { geometry in let geometryWidth = geometry.size.width let geometryHeight = geometry.size.height let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth VStack { Spacer().frame(height: 16) VStack(spacing: 0) { // 現在気温、現在時刻、地点名(青ブロック) HStack { Spacer() HStack(spacing: 0) { } .frame(width: geometryWidth - 32, height: geometryHeight / 6) .background(Color.blue) Spacer() } // グラフ(赤ブロック) HStack { Spacer() HStack { } .frame(width: geometryWidth - 32, height: geometryHeight / 3) .background(Color.red) Spacer() } // 時刻、天気アイコン、気温(緑ブロック) HStack { Spacer() HStack(alignment: .top, spacing: 0) { } .frame(width: geometryWidth - 32, height: geometryHeight / 3) .background(Color.green) Spacer() } } } } .background(ColorManager.background) } } struct MediumWidgetView_Previews: PreviewProvider { static var previews: some View { let entry = MediumWidgetEntryModel( currentDate: Date(timeIntervalSince1970: 1644048000), hourlyWeathers: MockHourly.data, currentLocation: "世田谷区" ) Group { MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .light) MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .dark) } } } currentDate -> 現在時刻 hourlyWeathers -> 24時間分の天気データ timePeriodTexts -> 時刻テキスト weatherIcons -> 天気アイコン temperatureTexts -> 気温 hourlyPressures -> 気圧 Viewのwidth、heightはGeometryReaderで取得しており、こちらもインスタンス化してViewの実装を進めます。 DateFormatHelperの中身はこちら。 DateFormatHelper.swift import Foundation final class DateFormatHelper { static let shared = DateFormatHelper() private let hourAndMinutesFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "Asia/Tokyo") formatter.dateFormat = "HH:mm" return formatter }() private let hourFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "Asia/Tokyo") formatter.dateFormat = "HH" return formatter }() func formatToHHmm(date: Date) -> String { return hourAndMinutesFormatter.string(from: date) } func formatToHH(date: Date) -> String { return hourFormatter.string(from: date) } } View完成イメージ 現在の気温、現在時、地点名の実装(青ブロック) MediumWidgetView.swift struct MediumWidgetView: View { @Environment(\.colorScheme) var colorScheme var entry: MediumWidgetProvider.Entry var body: some View { let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date) let hourlyWeathers = entry.hourlyWeathers let timePeriodTexts = entry.timePeriodTexts let weatherIcons = entry.weatherIcons let temperatureTexts = entry.temperatureTexts let hourlyPressures = entry.hourlyPressures GeometryReader { geometry in let geometryWidth = geometry.size.width let geometryHeight = geometry.size.height let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth VStack { Spacer().frame(height: 16) VStack(spacing: 0) { // 現在気温、現在時刻、地点名(青ブロック) HStack { Spacer() HStack(spacing: 0) { HStack(spacing: 0) { Text(String(format: "%0.0f", hourlyWeathers[0].temp)) .foregroundColor(ColorManager.font) .font(.system(size: 30, weight: .semibold)) .offset(x: 5) .fixedSize(horizontal: true, vertical: true) Text("℃" + " \(currentDate):00" + "現在") .foregroundColor(ColorManager.font) .font(.system(size: 14, weight: .medium)) .offset(x: 5) .fixedSize(horizontal: true, vertical: true) .frame(height: geometryHeight / 6, alignment: .bottom) } .frame(width: (geometryWidth - 32) / 2, height: geometryHeight / 6, alignment: .leading) if let location = entry.currentLocation { Text(location) .foregroundColor(ColorManager.font) .font(.system(size: 14, weight: .medium)) .offset(x: -5) .frame(width: (geometryWidth - 32) / 2, height: geometryHeight / 6, alignment: .bottomTrailing) } } .frame(width: geometryWidth - 32, height: geometryHeight / 6) Spacer() } // グラフ(赤ブロック) HStack { Spacer() HStack { } .frame(width: geometryWidth - 32, height: geometryHeight / 3) .background(Color.red) Spacer() } // 時刻、天気アイコン、気温(緑ブロック) HStack { Spacer() HStack(alignment: .top, spacing: 0) { } .frame(width: geometryWidth - 32, height: geometryHeight / 3) .background(Color.green) Spacer() } } } } .background(ColorManager.background) } } struct MediumWidgetView_Previews: PreviewProvider { static var previews: some View { let entry = MediumWidgetEntryModel( currentDate: Date(timeIntervalSince1970: 1644048000), hourlyWeathers: MockHourly.data, currentLocation: "世田谷区" ) Group { MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .light) MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .dark) } } } 色はColorManagerで管理して、ダークモードにも対応しています。 ColorManager.swift struct ColorManager { static let font = Color("font_color") static let background = Color("background_color") static let graph = Color("graph_color") static let graphBackground = Color("graphbackground_color") } これで、現在の気温、現在時、地点名の実装ができました。特に難しいことはしていないと思うので説明はしません(笑) 現在の気温、現在時、地点名を実装後のプレビュー 気圧グラフの実装(赤ブロック) MediumWidgetView.swift import WidgetKit import SwiftUI struct PressureGraphPoint: Identifiable { var id = UUID() var points: [CGPoint] = [] } struct MediumWidgetView: View { @Environment(\.colorScheme) var colorScheme var entry: MediumWidgetProvider.Entry var body: some View { let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date) let hourlyWeathers = entry.hourlyWeathers let timePeriodTexts = entry.timePeriodTexts let weatherIcons = entry.weatherIcons let temperatureTexts = entry.temperatureTexts let hourlyPressures = entry.hourlyPressures GeometryReader { geometry in let geometryWidth = geometry.size.width let geometryHeight = geometry.size.height let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth VStack { Spacer().frame(height: 16) VStack(spacing: 0) { // 現在気温、現在時刻、地点名(青ブロック) HStack { Spacer() HStack(spacing: 0) { // 省略 } .frame(width: geometryWidth - 32, height: geometryHeight / 6) Spacer() } // グラフ(赤ブロック) HStack { Spacer() HStack { GeometryReader { graphGeometry in let graphGeometryWidth = graphGeometry.size.width let graphGeometryHeight = graphGeometry.size.height let graphBackLineStartPoint = (widthPerHour * 0.5) let graphBackLineEndPoint = graphGeometryWidth - graphBackLineStartPoint let pressureGraphPoints = getPressureGraphPoints(hourlyPressures: hourlyPressures, width: graphGeometryWidth, height: graphGeometryHeight) ZStack { // 背景ライン Path { path in path.move(to: CGPoint(x: graphBackLineStartPoint + widthPerHour, y:3)) path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: 3)) path.move(to: CGPoint(x: graphBackLineStartPoint, y: graphGeometryHeight / 2)) path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: graphGeometryHeight / 2)) path.move(to: CGPoint(x: graphBackLineStartPoint + widthPerHour, y: graphGeometryHeight - 3)) path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: graphGeometryHeight - 3)) } .stroke(ColorManager.graphBackground, lineWidth: 1) // 気圧グラフ ForEach(pressureGraphPoints) { pressureGraphPoint in Path { path in path.move(to: pressureGraphPoint.points[0]) for index in 1..<pressureGraphPoint.points.count { path.addLine(to: pressureGraphPoint.points[index]) } } .stroke(ColorManager.graph, lineWidth: 3) .offset(x: widthPerHour * 0.5) .clipped() } Text(String(format: "%0.0f", hourlyPressures[0] + 15)) .font(.system(size: 6, weight: .regular)) .foregroundColor(ColorManager.font) .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .topLeading) Text(String(format: "%0.0f", hourlyPressures[0])) .font(.system(size: 6, weight: .regular)) .foregroundColor(ColorManager.font) .offset(y: 5) .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .leading) Text(String(format: "%0.0f", hourlyPressures[0] - 15)) .font(.system(size: 6, weight: .regular)) .foregroundColor(ColorManager.font) .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .bottomLeading) Text("hpa") .font(.system(size: 8, weight: .regular)) .foregroundColor(ColorManager.font) .offset(x: -4, y: -4) .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .bottomTrailing) } } } .frame(width: geometryWidth - 32, height: geometryHeight / 3) Spacer() } // 時刻、天気アイコン、気温(緑ブロック) HStack { Spacer() HStack(alignment: .top, spacing: 0) { } .frame(width: geometryWidth - 32, height: geometryHeight / 3) .background(Color.green) Spacer() } } } } .background(ColorManager.background) } private func getPressureGraphPoints(hourlyPressures: [Double], width: CGFloat, height: CGFloat) -> [PressureGraphPoint] { let currentPressure = hourlyPressures[0] var pressureGraphPoints: [PressureGraphPoint] = [] var tempPressurePoint = PressureGraphPoint() hourlyPressures.enumerated().forEach { index, hourlyPressure in let pressureGraphPointWidth = (width / 24) * CGFloat(index) // 各時刻のグラフ描画のx軸 let heightPerHpa = height / 30 let maxHpa = currentPressure + 15 let diffPressure = maxHpa - hourlyPressure let pressureGraphPointHeight = diffPressure * heightPerHpa// 各時刻のグラフ描画のy軸 let points = CGPoint(x: pressureGraphPointWidth, y: pressureGraphPointHeight) tempPressurePoint.points.append(points) } pressureGraphPoints.append(tempPressurePoint) return pressureGraphPoints } } struct MediumWidgetView_Previews: PreviewProvider { static var previews: some View { let entry = MediumWidgetEntryModel( currentDate: Date(timeIntervalSince1970: 1644048000), hourlyWeathers: MockHourly.data, currentLocation: "世田谷区" ) Group { MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .light) MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .dark) } } } 気圧グラフの背景ラインと気圧グラフはPathを用いて描画します。Pathで描画するコードをあまり複雑にしたくなかったので、getPressureGraphPointsに、新たに追加したGeometryReaderで取得したwidthとheightを渡し、PressureGraphPoint型のCGPintの配列を返しています。 getPressureGraphPointsの以下の部分で気圧グラフ24時間分の描画ポイントを求めています。 MediumWidgetView.swift let pressureGraphPointWidth = (width / 24) * CGFloat(index) // 各時刻のグラフ描画のx軸 let heightPerHpa = height / 30 let maxHpa = currentPressure + 15 let diffPressure = maxHpa - hourlyPressure let pressureGraphPointHeight = diffPressure * heightPerHpa// 各時刻のグラフ描画のy軸 気圧グラフ描画のx軸は、描画範囲を24分割してindexと積すれば求められます。 y軸は少し複雑ですが、グラフ描画範囲を30で割った高さを1hpaあたりの高さとし、現在時刻の気圧に15を足した値を描画範囲の上限値としています。その上限値から各時間の気圧との差分を求めて、1hpaあたりの高さと積すれば各時間の気圧グラフ描画のy軸が求められます。今回のパターンだと気圧グラフ描画範囲の上限下限は現在時刻の気圧から15hpaということになります。 この計算が気圧グラフ描画の肝になると思いますが、結構苦労しました、、、 うまく伝われば幸いです。 ちなみに今回は「Open Weather API」のレスポンスで気圧の欠測値が確認できなかったので、欠測値を意識したコードは書いていません。(探せばありそう) 気圧グラフ実装後のプレビュー 気圧の値はモックで設定しています。実際にビルドすると小数点が影響してもう少し滑らかな描画になります。 時刻テキスト、天気アイコン、気温の実装(赤ブロック) MediumWidgetView.swift import WidgetKit import SwiftUI struct PressureGraphPoint: Identifiable { var id = UUID() var points: [CGPoint] = [] } struct MediumWidgetView: View { @Environment(\.colorScheme) var colorScheme var entry: MediumWidgetProvider.Entry var body: some View { let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date) let hourlyWeathers = entry.hourlyWeathers let timePeriodTexts = entry.timePeriodTexts let weatherIcons = entry.weatherIcons let temperatureTexts = entry.temperatureTexts let hourlyPressures = entry.hourlyPressures GeometryReader { geometry in let geometryWidth = geometry.size.width let geometryHeight = geometry.size.height let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth VStack { Spacer().frame(height: 16) VStack(spacing: 0) { // 現在気温、現在時刻、地点名(青ブロック) HStack { Spacer() HStack(spacing: 0) { // 省略 } .frame(width: geometryWidth - 32, height: geometryHeight / 6) Spacer() } // グラフ(赤ブロック) HStack { Spacer() HStack { // 省略 } .frame(width: geometryWidth - 32, height: geometryHeight / 3) Spacer() } // 時刻、天気アイコン、気温(緑ブロック) HStack { Spacer() HStack(alignment: .top, spacing: 0) { ForEach(0..<hourlyWeathers.count) { index in VStack(alignment: .center, spacing: 0) { Text(timePeriodTexts[index]) .foregroundColor(ColorManager.font) .font(.system(size: 14, weight: .medium)) .fixedSize(horizontal: true, vertical: true) .frame(width: widthPerHour, alignment: .center) if isMultipleOfThree(hourlyWeather: hourlyWeathers[index]) { Image(weatherIcons[index]) .resizable() .scaledToFill() .frame(width: widthPerHour, height: 27) .fixedSize(horizontal: true, vertical: true) Text("\(temperatureTexts[index])℃") .foregroundColor(ColorManager.font) .font(.system(size: 12, weight: .medium)) .fixedSize(horizontal: true, vertical: true) .frame(width: widthPerHour, alignment: .center) } } } } .frame(width: geometryWidth - 32, height: geometryHeight / 3) Spacer() } } } } .background(ColorManager.background) } private func isMultipleOfThree(hourlyWeather: Hourly) -> Bool { let hourDate = hourlyWeather.dt let date = Date(timeIntervalSince1970: hourDate) guard let dateInt = Int(DateFormatHelper.shared.formatToHH(date: date)) else { return false } if dateInt % 3 == 0 { return true } else { return false } } // 省略 } struct MediumWidgetView_Previews: PreviewProvider { static var previews: some View { let entry = MediumWidgetEntryModel( currentDate: Date(timeIntervalSince1970: 1644048000), hourlyWeathers: MockHourly.data, currentLocation: "世田谷区" ) Group { MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .light) MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .dark) } } } 24時間分の時刻テキストと、天気アイコンと、気温を表示しています。新たに追加したisMultipleOfThreeで3の倍数の時間のみ天気アイコンと気温を表示するように判断しています。 時刻テキスト、天気アイコン、気温の実装後のプレビュー 天気アイコンはモックで設定しています。これで完成です。 コード全体 MediumWidgetView.swift import WidgetKit import SwiftUI struct PressureGraphPoint: Identifiable { var id = UUID() var points: [CGPoint] = [] } struct MediumWidgetView: View { @Environment(\.colorScheme) var colorScheme var entry: MediumWidgetProvider.Entry var body: some View { let currentDate = DateFormatHelper.shared.formatToHH(date: entry.date) let hourlyWeathers = entry.hourlyWeathers let timePeriodTexts = entry.timePeriodTexts let weatherIcons = entry.weatherIcons let temperatureTexts = entry.temperatureTexts let hourlyPressures = entry.hourlyPressures GeometryReader { geometry in let geometryWidth = geometry.size.width let geometryHeight = geometry.size.height let widthPerHour = (geometryWidth - 32) / 24 // 1時間あたりのwidth VStack { Spacer().frame(height: 16) VStack(spacing: 0) { // 現在気温、現在時刻、地点名 HStack { Spacer() HStack(spacing: 0) { HStack(spacing: 0) { Text(String(format: "%0.0f", hourlyWeathers[0].temp)) .foregroundColor(ColorManager.font) .font(.system(size: 30, weight: .semibold)) .offset(x: 5) .fixedSize(horizontal: true, vertical: true) Text("℃" + " \(currentDate):00" + "現在") .foregroundColor(ColorManager.font) .font(.system(size: 14, weight: .medium)) .offset(x: 5) .fixedSize(horizontal: true, vertical: true) .frame(height: geometryHeight / 6, alignment: .bottom) } .frame(width: (geometryWidth - 32) / 2, height: geometryHeight / 6, alignment: .leading) if let location = entry.currentLocation { Text(location) .foregroundColor(ColorManager.font) .font(.system(size: 14, weight: .medium)) .offset(x: -5) .frame(width: (geometryWidth - 32) / 2, height: geometryHeight / 6, alignment: .bottomTrailing) } } .frame(width: geometryWidth - 32, height: geometryHeight / 6) Spacer() } // グラフ HStack { Spacer() HStack { GeometryReader { graphGeometry in let graphGeometryWidth = graphGeometry.size.width let graphGeometryHeight = graphGeometry.size.height let graphBackLineStartPoint = (widthPerHour * 0.5) let graphBackLineEndPoint = graphGeometryWidth - graphBackLineStartPoint let pressureGraphPoints = getPressureGraphPoints(hourlyPressures: hourlyPressures, width: graphGeometryWidth, height: graphGeometryHeight) ZStack { // 背景ライン Path { path in path.move(to: CGPoint(x: graphBackLineStartPoint + widthPerHour, y:3)) path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: 3)) path.move(to: CGPoint(x: graphBackLineStartPoint, y: graphGeometryHeight / 2)) path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: graphGeometryHeight / 2)) path.move(to: CGPoint(x: graphBackLineStartPoint + widthPerHour, y: graphGeometryHeight - 3)) path.addLine(to: CGPoint(x: graphBackLineEndPoint, y: graphGeometryHeight - 3)) } .stroke(ColorManager.graphBackground, lineWidth: 1) // 気圧グラフ ForEach(pressureGraphPoints) { pressureGraphPoint in Path { path in path.move(to: pressureGraphPoint.points[0]) for index in 1..<pressureGraphPoint.points.count { path.addLine(to: pressureGraphPoint.points[index]) } } .stroke(ColorManager.graph, lineWidth: 3) .offset(x: widthPerHour * 0.5) // timePeriodTextsのx軸と合わせて描画 .clipped() } Text(String(format: "%0.0f", hourlyPressures[0] + 15)) .font(.system(size: 6, weight: .regular)) .foregroundColor(ColorManager.font) .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .topLeading) Text(String(format: "%0.0f", hourlyPressures[0])) .font(.system(size: 6, weight: .regular)) .foregroundColor(ColorManager.font) .offset(y: 5) .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .leading) Text(String(format: "%0.0f", hourlyPressures[0] - 15)) .font(.system(size: 6, weight: .regular)) .foregroundColor(ColorManager.font) .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .bottomLeading) Text("hpa") .font(.system(size: 8, weight: .regular)) .foregroundColor(ColorManager.font) .offset(x: -4, y: -4) .frame(width: graphBackLineEndPoint, height: graphGeometryHeight, alignment: .bottomTrailing) } } } .frame(width: geometryWidth - 32, height: geometryHeight / 3) Spacer() } // 時刻、天気アイコン、気温 HStack { Spacer() HStack(alignment: .top, spacing: 0) { ForEach(0..<hourlyWeathers.count) { index in VStack(alignment: .center, spacing: 0) { Text(timePeriodTexts[index]) .foregroundColor(ColorManager.font) .font(.system(size: 14, weight: .medium)) .fixedSize(horizontal: true, vertical: true) .frame(width: widthPerHour, alignment: .center) if isMultipleOfThree(hourlyWeather: hourlyWeathers[index]) { Image(weatherIcons[index]) .resizable() .scaledToFill() .frame(width: widthPerHour, height: 27) .fixedSize(horizontal: true, vertical: true) Text("\(temperatureTexts[index])℃") .foregroundColor(ColorManager.font) .font(.system(size: 12, weight: .medium)) .fixedSize(horizontal: true, vertical: true) .frame(width: widthPerHour, alignment: .center) } } } } .frame(width: geometryWidth - 32, height: geometryHeight / 3) Spacer() } } } } .background(ColorManager.background) } private func isMultipleOfThree(hourlyWeather: Hourly) -> Bool { let hourDate = hourlyWeather.dt let date = Date(timeIntervalSince1970: hourDate) guard let dateInt = Int(DateFormatHelper.shared.formatToHH(date: date)) else { return false } if dateInt % 3 == 0 { return true } else { return false } } private func getPressureGraphPoints(hourlyPressures: [Double], width: CGFloat, height: CGFloat) -> [PressureGraphPoint] { let currentPressure = hourlyPressures[0] var pressureGraphPoints: [PressureGraphPoint] = [] var tempPressurePoint = PressureGraphPoint() hourlyPressures.enumerated().forEach { index, hourlyPressure in let pressureGraphPointWidth = (width / 24) * CGFloat(index) let heightPerHpa = height / 30 let maxHpa = currentPressure + 15 let diffPressure = maxHpa - hourlyPressure let pressureGraphPointHeight = diffPressure * heightPerHpa let points = CGPoint(x: pressureGraphPointWidth, y: pressureGraphPointHeight) tempPressurePoint.points.append(points) } pressureGraphPoints.append(tempPressurePoint) return pressureGraphPoints } } struct MediumWidgetView_Previews: PreviewProvider { static var previews: some View { let entry = MediumWidgetEntryModel( currentDate: Date(timeIntervalSince1970: 1644048000), hourlyWeathers: MockHourly.data, currentLocation: "世田谷区" ) Group { MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .light) MediumWidgetView(entry: entry) .previewContext(WidgetPreviewContext(family: .systemMedium)) .environment(\.colorScheme, .dark) } } } おわりに 今回でWidgetKitで天気予報アプリ作ってみたシリーズの完結です。 Viewの記事を書くのはなかなか難しかったですが、誰かの役に立てば幸いです。 一通り「Widget Extension」でアプリを開発してみた感想としては、「Provider」と「EntryModel」でタイムラインを作る箇所の理解に苦戦しました。ただ、タイムライン作成の流れと、正確なModelを作ることさえできれば残りはModel(Entry)からデータを取得してViewを書くだけなので、慣れてしまえばそこまで複雑には感じなくなりました。 ご覧いただきありがとうございました。 こうしたほうがいいや、ここはちょっと違うなど気になる箇所があった場合、ご教示いただけると幸いです。質問も受け付けています。 お知らせ 現在副業でiOSアプリ開発を募集しています。 Twitter DMでご依頼お待ちしております? ↓活動リンクはこちら↓ https://linktr.ee/sasaki.ken
- 投稿日:2022-02-24T18:09:44+09:00
SwiftUIでテーブル形式のビュー(Listなど)でローディング中のスケルトンを適用する方法
はじめに ListやLazyVGridなどの画面初期化時にリスト数が決まっていないViewに対し、YoutubeやFacebookで使われているような、ロード中のスケルトンを表示する方法を書いてみました。 ちなみにスケルトンスクリーンとは、このようなやつです。 実装イメージは下記です。 結論 当たり前っちゃ当たり前なのですが、 リスト形式の場合、画面表示時には、表示数が0件なので、スケルトンを適用しても効果がありません。 なので、適当なDummyデータを作っておき、APIなどで本来表示すべき値が取得できたら、Dummyデータと置き換えて表示してあげればうまくいきます。 実装 結論で書いたことが、全てですので、簡単な実装だけご紹介します。 実際にAPIなどへリクエストすることを想定し、Combineを利用していますが、ここではそれについては触れません。 Combineについては、以前この記事で書かせていただきましたのでもし良かったら、ご覧いただければと思います。 まず、ロード中にスケルトンを表示するためには、redacted(reason:)が標準で用意されているので、それを使います。 ContentViewModel.swift struct UserInfo: Identifiable { let id: Int let name: String let age: Int let email: String } class ContentViewModel: ObservableObject { var cancellables = Set<AnyCancellable>() @Published var userInfoList = dummyData @Published var isFirstLoading = true let fetchUserSubject = PassthroughSubject<Void, Never>() static var dummyData: [UserInfo] { return (1...10).map { UserInfo(id: $0, name: "DummyName", age: 99, email: "dummy@test.com") } } init() { fetchUserSubject .flatMap({ _ -> AnyPublisher<[UserInfo], Never> in let userInfoList = (1...3).map { UserInfo(id: $0, name: "RealName", age: 10, email: "real@real.com") } return Just(userInfoList).eraseToAnyPublisher() }) .delay(for: 2, scheduler: DispatchQueue.main) .sink(receiveValue: { userInfoList in self.userInfoList = userInfoList self.isFirstLoading = false }) .store(in: &cancellables) } } static var dummyData: [UserInfo]を定義して、@Published var userInfoList = dummyDataのようにして、初期化時はダミーデータが入るようにします。 ViewModelでは、let fetchUserSubject = PassthroughSubject<Void, Never>()を購読していますので、View側から値がsend()されたら、リアルデータをクラス変数にセットする処理が走ります。 本来は、APIによってデータを取得しますが、今回は、2秒後に固定値のリアルデータが帰ってくるように、処理を書いています。 ContentView.swift struct ContentView: View { @ObservedObject var viewModel = ContentViewModel() var body: some View { VStack { List(viewModel.userInfoList) { user in HStack { Text(user.name) Spacer() Text("\(user.age)歳") Spacer() Text(user.email) } } .redacted(reason: viewModel.isFirstLoading ? .placeholder : []) } .onAppear { viewModel.fetchUserSubject.send(()) } } } .redacted(reason: viewModel.isFirstLoading ? .placeholder : []) をListに対して適用することで、Listの{}で囲まれたViewがすべてスケルトン適用されます。 viewModel.isFirstLoadingは、リアルデータが取得できた時にfalseになるようにしているので、データが取れたら、自動的にスケルトンが解除されます。 以上となります。 List形式のViewで、ロード中のスケルトンを実装したい方の参考になれば幸いです。 参考にさせていただいた記事
- 投稿日:2022-02-24T03:09:48+09:00
四角形を検出する
画像内の四角形を検出する方法です 書類や標識の検出に 四角形の人工物を検出できれば、場面に応じて様々な用途に活かせそうです。 例えば、ドキュメント内の四角形の検出など。 Visionの四角形検出が使える 。 方法 画像にVNDetectRectangleRequestをするだけです。 let request = VNDetectRectanglesRequest() let handler = VNImageRequestHandler(ciImage: ciImage!, options: [:]) try! handler.perform([request]) guard let results = request.results else { return } for result in results { print("topLeft: \(result.topLeft)") print("topRight: \(result.topRight)") print("bottomLeft: \(result.bottomLeft)") print("bottomRight: \(result.bottomRight)") print("boundingBox: \(result.boundingBox)") } topLeft: (0.37335851788520813, 0.5885436534881592) topRight: (0.5980302095413208, 0.6653665900230408) bottomLeft: (0.45727139711380005, 0.16498719155788422) bottomRight: (0.7020156383514404, 0.26323217153549194) boundingBox: (0.37335851788520813, 0.16498719155788422, 0.3286571204662323, 0.5003793984651566) 画像内の正規化座標を取得できます。 Y座標は、画像の下が基準になっています。 ? フリーランスエンジニアです。 お仕事のご相談こちらまで rockyshikoku@gmail.com Core MLやARKitを使ったアプリを作っています。 機械学習/AR関連の情報を発信しています。 Twitter Medium