20220112のiOSに関する記事は7件です。

DispatchGroupを使っていたら、「Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)」が発生

SwiftのDispatchなんちゃらで毎回詰まっていますが、今回もまた詰まったので 記録に残しておきます。 多分記事にしておくくらいの苦労をしないと、また数ヶ月後にまた同じことで詰まる気がとてもしているからです! (って自信持っていうことじゃないが笑) 原因は単純ミスです。 発生していたこと DispatchGroupを使用していたところ、 Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0) というエラーが発生・・・ 実際にプロジェクトで詰まったコードに似たサンプルを作りました。 Playgroundで実行できます。 Thread.sleep(forTimeInterval: 0.5)の部分は実際はAPIがリクエストされている箇所でしたが、 今回はsleepに差し替えています。 import UIKit func getItems( itemIds: [String], completionHandler: @escaping ([String]) -> Void ) { var items = [String]() let group = DispatchGroup() for id in itemIds { group.enter() getItemDetail(id) { result in defer { group.leave() } guard let result = result else { return } items.append(result) print("ループの中") } } group.notify(queue: .main) { print("ループ全部終わった\n-------------") completionHandler(items) } } func getItemDetail( _ id: String, completionHandler: @escaping (String?) -> Void ) { Thread.sleep(forTimeInterval: 0.5) print("0.5秒待機中") calcPrice(id) { result in completionHandler(id) } } func calcPrice( _ id: String, completionHandler: @escaping (String?) -> Void ) { if !id.isEmpty { Thread.sleep(forTimeInterval: 0.5) completionHandler(id) } completionHandler(nil) } // ----------------------------------- let itemIds = ["1", "2", "3", "", ""] getItems(itemIds: itemIds) { result in print(result) } どう解決したか、原因は? エラーメッセージからは何が原因かわかりませんでしたが、参考に載せたstackoverflowで、この答えを発見。 You can check number of count entered in group before leaving from any group by below Patch Work また、コメントを見ていたところ、こんな記述も発見。 My API call was sending 2 completion handlers ということではい、私もドンピシャで同じことをしておりました。 修正したのはcalcPriceメソッドです。 returnを忘れた結果、completionHandlerが2回呼ばれてしまっていました。 func calcPrice( _ id: String, completionHandler: @escaping (String?) -> Void ) { if !id.isEmpty { Thread.sleep(forTimeInterval: 0.5) completionHandler(id) return // 追加したコード } completionHandler(nil) } 公式ドキュメントにも似たようなことが書いてありました。 A call to this function must balance a call to enter(). It is invalid to call it more times than enter(), which would result in a negative count. つまりenter()とleave()の呼び出し回数が釣り合ってなかったわけです。 itemIdsの要素が空文字の場合、enter()が1回呼ばれているのに対し、leave()が2回呼ばれていました。 それが原因で例のエラーが起きていました。 コード全体はこちらに上がっています。 おわりに returnを忘れるという単純ミスでしたが、なかなか気づかなかったので 記事として記録しておきました。 Swift自体も勉強中ですが、中でも非同期処理が苦手です。 もっと良いやり方あるよ、ここ違くないなどなどありましたが、ご指摘いただけますと幸いです。 参考 Apple Developer Document leave()→公式ドキュメント Call DispatchGroup() in the right place→stackoverflow Leaving DispatchGroup causes my code to crash→stackoverflow
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

視覚拡張だけがARじゃない! -音のAR-

音をAR表現してみました。 昨年末に作ったコンテンツですが、せっかくなので共有します。 音のAR 最近AirPodsProを使い始めたのですが、ヘッドトラッキング機能に驚きました。 右側を向くと正面から聞こえていた音が左側から聞こえます。映画を観ている時、どんなに頭を動かしてもディスプレイから音が聞こえてきます。 音の定位がずっと固定されているので、"その場にいる感"があり臨場感が高まります。 (加速度センサーから頭の動きをトラッキングしているのですが、あの小ささの中に加速度センサーが載っているなんて驚きです...) 頭の回転運動を検出して音の定位を固定できますが、歩き回った時にも音の定位を固定できたら面白そうだと思い、音のARを作りました。 ちょうどショッピングモールで、クリスマスツリーのイルミネーションやライトアップがされていたのでここに仮想音源を配置し、音のARを作りました。 開発の流れ 使用した技術 Immersal 空間データの作成 スマホの自己位置推定 Immersalの概要についてはこちら ヘルシンキの「Immersal」が都市規模のARマッピングソリューションに照準! Unity AirPodsPro & iPhone AirPodsProだけでは3DoFしか出来ないので、今回はiPhoneと接続して使用することで6DoFを実現しました。 1. 空間マップ作成 今回スキャンしたいエリアの幅は約100mと広かったので3箇所に分けてスキャンし、後でそれぞれのマップを結合します。 1マップにつき約30枚程度撮影しました。 1歩進むごとに1枚撮影していたので周りから凄く見られましたが、もくもくと撮影し続けます。 こちらがスキャンした空間データ(点群データ)です。 クリスマスツリー 並木 並木道 2. マップ合成 ImmersalのDeveloperPortalで空間データを選択し、結合ボタンを押すだけで複数のマップを1つに結合されています。 各マップの境界もきれいに結合されています。 3. 音の配置 作成した空間マップをUnityに取り込み、音をそれぞれの場所に配置していきます。 空間マップをUnityで読み込み、マップ内にクリスマスソングと鈴の音の音源を配置しました。 MinDistance(上画像の内枠球)とMaxDistance(上画像の外枠球)を設定して、音源に近づくにつれ音量が大きくなり、範囲外に出ると音が聞こえなくなるようにします。 AudioSourceを以下のように設定しました。 - Spatial Blend 音源の位置によって左右から聞こえる音が変わる - 3D Sound Settings 音源からの音が減衰する距離を設定 開発Tips 夜のスキャンについて Immersalのスキャンでは、複数画像にある共通の特徴点を元に3Dデータを作成しています。 今回スキャンした場所はイルミネーションされていて色が頻繁に変わるので、特徴点が取れず3Dデータが崩れやすかったです。 対策として、近くの建物を利用しました。 今回の空間マップを見てみると、イルミネーションがされてる場所だけでなく、少し離れた場所の建物や看板もきれいにスキャン出来ています。「モールのライト+イルミネーションの光」があるので、周りの建物もある程度明るかったので認識できたようです。 3Dマップは結構粗いですが、Immersalを使う目的はスマホの自己位置推定することなので、 クリスマスツリー等の背景にある建物を認識して自己位置推定してくれれば十分目的を果たせます。 iPhone LiDARを使って補正マッピング Immersalは広い空間マップを作るには向いていますが、細かい箇所の精度はあまり高くないので、正確にオブジェクトを配置するのは難しいです。 そこでiPhone LiDARを使うこと有効だと思いました。 iPhoneのLiDARでは、数mm単位で綺麗にスキャンできます。Unity上でiPhoneでスキャンした3DモデルとImmersalの3Dモデルの位置を合わせることで、ARオブジェクトの位置を思った通りの配置できます。 デモ 今回は音のARなので、動画で伝えられないのが残念です。(体験を共有できないのが音のARの弱点かも...) ですが、やってみると結構面白いです。曲を聞きながら並木に近づくと、だんだん音がフェードアウトし別の曲や鈴の音が聞こえてきます。 スマホは、カメラが景色が写していればどんなに動かしても空間マップとの位置ずれはありませんでした。 まとめ 音のARは想像以上に面白く、今までに体験したことのないものだった ほとんどコードを書かず、簡単で高クオリティに作れるのが特徴的 視覚的なARコンテンツより没入感がありました。3Dモデルよりも音の方が現実感があって、脳が仮想世界を補ってくれる。位置づれの影響が少ないのも理由としてある。 AirPodsProのようにヘッドトラッキングすることで没入感が段違いに高まる。 SoundARというのが既にあったが、スマホの向き(姿勢)を元に音が出ているので横を向いたり、振り向いたりしても音は変化しないのでリアル感が減りそう。 観光地や美術館での音声案内など色んな場所に使えそう 建物内の別階でも、それぞれ自己位置を正しく認識できるのか試してみたい 複数の端末間で音源の再生タイミングを同期させれば、数人でも一緒に楽しめそう LiDAR搭載のiPhone欲しい
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CoreLocationのauthorizationStatus備忘録

はじめに 記事にするまでもないけど、よく忘れてしまうのでメモ。 環境 iOS15 Xcode13.1 authorizationStatus kCLAuthorizationStatusNotDetermined 許可していない状態(初回インストール直後等) 次回確認でもこのケース この状態じゃないとOS標準ダイアログが表示されない kCLAuthorizationStatusRestricted iPadとかiPod等でGPS機能がついていない端末はこれが返される(らしい) kCLAuthorizationStatusDenied 設定→各アプリ→位置情報で「なし」を設定した時に返される 設定→プライバシー→位置情報サービスをOFFにした場合も返される どっちの設定起因で不許可になるか判別ができない 余談だがアプリからプライバシーまで一気に飛ぶスキームは無い(はず) kCLAuthorizationStatusAuthorizedAlways 常に許可をしている状態 実際の動きを見たわけではないけどバックグラウンドでも動き続けるような設定の場合はこれかと kCLAuthorizationStatusAuthorizedWhenInUse アプリ使用中のみ許可の状態 以上
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

authorizationStatusの備忘録

はじめに 記事にするまでもないけど、よく忘れてしまうのでメモ。 環境 iOS15 Xcode13.1 authorizationStatus kCLAuthorizationStatusNotDetermined 許可していない状態(初回インストール直後等) 次回確認でもこのケース この状態じゃないとOS標準ダイアログが表示されない kCLAuthorizationStatusRestricted iPadとかiPod等でGPS機能がついていない端末はこれが返される(らしい) kCLAuthorizationStatusDenied 設定→各アプリ→位置情報で「なし」を設定した時に返される 設定→プライバシー→位置情報サービスをOFFにした場合も返される どっちの設定起因で不許可になるか判別ができない 余談だがアプリからプライバシーまで一気に飛ぶスキームは無い(はず) kCLAuthorizationStatusAuthorizedAlways 常に許可をしている状態 実際の動きを見たわけではないけどバックグラウンドでも動き続けるような設定の場合はこれかと kCLAuthorizationStatusAuthorizedWhenInUse アプリ使用中のみ許可の状態 以上
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

データセット用の顔画像をすぐに用意したい iPhoneの写真から、顔だけを数秒で100枚切り出す

iPhoneのフォトライブラリから、顔だけを切り出す方法です データセットを用意するのは大変 たとえば機械学習などで、データセットとして顔写真や人体の写真が数千枚必要なケースがあります。 しかし、手動で顔写真を切り出すのは大変です。 手元のiPhoneの写真が使えたら便利 たいていの人のiPhoneには、人の写っている写真が数百〜数千枚はあるでしょう。 それらの写真の顔だけを抽出できれば、迅速なデータセット作成に活かせそうです。 SemanticImageでiPhoneの写真を高速処理できる SemanticImageというライブラリを使えば、高速で写真から顔だけを切り抜けます。 手順 SwiftPackageManagerでSemanticImageをインストール。 let semanticImage = SemanticImage() PhPickerでiPhoneのフォトライブラリから、複数写真を選択します。 Personで検索すると、人の画像だけ切り抜きにかけられて便利です。 var configuration = PHPickerConfiguration() configuration.selectionLimit = 0 // 選択する写真の数を無制限にする configuration.filter = .images let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) 取得した写真をSemanticImageの顔切り抜き機能にかけます。 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) for result in results { // フォトライブラリから取得した写真を順次処理 if result.itemProvider.canLoadObject(ofClass: UIImage.self) { result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in if let image = image as? UIImage, let safeSelf = self { DispatchQueue.main.async { if let faceImage = safeSelf.sematicImage.faceRectangle(uiImage: image) { // 顔を切り抜く UIImageWriteToSavedPhotosAlbum(maskImage, nil, nil, nil) // フォトライブラリに保存 } } } } } } } これで、切り抜かれた顔の画像がフォトライブラリに保存されます。 100枚程度だと、14秒で処理でき、メモリとCPU使用量も安全です(iPhone11)。 (数千枚一気に処理すると、画像データを保持しきれず、メモリがオーバーするので、数百枚ずつ処理するのが良さそうです) 添付画像は全部マスクしてデータとしては意味ない感じですが、著者は照れ屋なので、すみません。 全身データや、犬、猫のデータにも SemanticImageは、全身やペットを検出して切り抜く機能もあるので、さまざまなデータ作成に活かせます。 ? フリーランスエンジニアです。 お仕事のご相談こちらまで rockyshikoku@gmail.com Core MLやARKitを使ったアプリを作っています。 機械学習/AR関連の情報を発信しています。 Twitter Medium
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift]MKLocalSearchCompleterをPOIフィルタリングして改善してみる!

はじめに 唐突ですが、あなたはパン屋を検索できるアプリを作っているとします。 オートコンプリートで簡単に検索できる機能を実装しようとしますが、関係のないお店が出てきてしまうかもしれません。 今回はパン屋のみを検索できるように改善してみようと思います。 参考:WWDC19 What’s New in MapKit and MapKit JS https://developer.apple.com/videos/play/wwdc2019/236/ 開発環境 Xcode 13.0 Swift 5.5 オートコンプリートとは 文字を途中まで入力すると、候補を出してくれる機能のことです。 MapkitのMKLocalSearchCompleterで簡単に実装できます。 POIとは Point Of Interestの略で、学校や動物園などの場所のことを示します。 Mapkitの場合、MKPointOfInterestCategoryのようにカテゴリーが用意されていて、40種類以上あります。 基本の実装 まずは簡単に検索機能を実装してみます。 StoryBoard TextFieldとTableViewのシンプルなものにします。 TableViewCellのCellStyleをsubtitleにし、Identifierはcellとします。 オートコンプリートを実装 import UIKit import MapKit class ViewController: UIViewController { @IBOutlet weak var textField: UITextField! @IBOutlet weak var tableView: UITableView! var searchCompleter = MKLocalSearchCompleter() override func viewDidLoad() { super.viewDidLoad() tableView.delegate = self tableView.dataSource = self searchCompleter.delegate = self } @IBAction func textEditingChanged(_ sender: Any) { searchCompleter.queryFragment = textField.text ?? "" } } extension ViewController: MKLocalSearchCompleterDelegate { func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { tableView.reloadData() } func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { print("error: \(error)") } } extension ViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {![Something went wrong]() return searchCompleter.results.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "cell") else { return UITableViewCell() } return cell } } とても簡単に実装できました。 これでTextFieldに入力するとパン屋が検索できるはずです! 試しに僕の好きな『パンのトラ』を検索してみます。 検索はできてますね。 でも、パン屋以外の検索結果も表示されてしまいます。 パン屋を検索するアプリとしてはダメです。 POIフィルタリングする ここからが本題です。 pointOfInterestFilterとResultTypeを使って改善していきます。 let pointOfInterestFilter = MKPointOfInterestFilter(including: [.bakery]) まずはMKPointOfInterestFilterのカテゴリーを設定します。今回はベーカリーです。 override func viewDidLoad() { super.viewDidLoad() searchCompleter.pointOfInterestFilter = pointOfInterestFilter searchCompleter.resultTypes = .pointOfInterest } viewDidLoad()にこの二行を追加します。 searchCompleter.pointOfInterestFilter = pointOfInterestFilter これでsearchComleterにpointOfInterestFilterを適用しています。 searchCompleter.resultTypes = .pointOfInterest こちらは、searchCompleterのresultTypesをPOIに制限しています。 ちなみに、POIに制限しないと... このように、地名なども検索に引っかかってしまいます。 では、実際に動かしてみましょう。 ご覧の通り、パン屋だけが検索結果として返ってきています。 うまくいきました。 これらの検索結果を利用してLocalSearchにかけてあげれば、緯度経度なども取得できるようになります。 また別でその記事を書いてみようかなと思います。 では、良きパンライフを!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUI ScrollView でスナップ動作を実装

背景と宣伝 諸般の事情でカルーセルやスライドショー的なパーツを SwiftUI で実装する必要があったのですが、思ったより手軽に実装できなかったので、対応内容をまとめてみました。 今回の対応をベースに ScrollView + スナップ動作を簡単に実現するためのライブラリも作成しているので、とりあえずお試しといった場合は、こちらをどうぞ。 ゴール のようなスナップ動作を SwiftUI の ScrollView で実現します。 動作環境については、 iOS 15.0 以上の想定で進めます。 前準備 スナップ動作を実装する前に、横方向にスクロール可能な View 実装を用意します。 前準備 struct CarouselContentView: View { var body: some View { LazyHStack { ForEach(Array(1...100), id: \.self) { index in ZStack { Color(hue: Double(index * 11 % 100) / 100, saturation: 0.5, brightness: 1.0) Text("\(index)") } .frame(width: 280, height: 200) } } } } struct ContentView: View { var body: some View { ScrollView(.horizontal, showsIndicators: false) { CarouselContentView() } } } 表示するアイテムは固定サイズで 100 個 並び順に依存する背景色とテキスト表示 HSL で背景色の色味だけ揃える といった実装で、動きとしては以下のような状態です。 前準備段階での動作 これをベースとしてスナップ動作の実装を進めます。 対応方針 スナップ動作を実現するにあたって、必要な要素を以下のように分割、それぞれ対応していくことにしました。 スナップ開始タイミングの検知 スナップ先座標の取得 スナップ先座標へのスクロール (スナップ動作) 要求されるスナップ動作によって、それぞれの指定が微妙に異なるのですが、今回は以下の条件で対応します。 (冒頭の gif 画像通りの動作です) 指を離した瞬間 (ドラッグ終了) のタイミングでスナップ開始 Center-to-Center で ScrollView の中央位置に最も近いアイテムをスナップ先に指定 対応内容 1. スナップ開始タイミングの検知 いきなり大問題なのですが、 現在 (Xcode 13 / iOS 15) の SwiftUI では UIScrollViewDelegate 相当の処理が I/F として提供されていない ScrollView + DragGesture の併用ができない といった状況のため、 指を離した瞬間 (ドラッグ終了) のタイミングでスナップ開始 の実現にハードルがあります。 非常に悩んだのですが、 Introspect for SwiftUI を導入し、 ScrollView が背後で利用している UIScrollView のインスタンスを強引に取得して、 UIScrollViewDelegate を利用してドラッグ終了を検知することとしました。 具体的な対応手順としては、 UIScrollViewDelegate に適合した NSObject な class を用意 SwiftUI View 側で 1. の class のインスタンスを保持 UIScrollViewDelegate を利用してドラッグ終了を検知した場合、クロージャ経由で View 側に伝達 といった流れで、対応コードと動作確認は以下のような感じとなります。 対応コード UIScrollViewDelegate の実装 class DragDetector: NSObject, UIScrollViewDelegate { var didEndScroll: (() -> Void)? func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { // decelerate の場合は、 `scrollViewDidEndDecelerating(_:)` に処理を任せる didEndScroll?() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { didEndScroll?() } } 動作確認 struct ContentView: View { @State private var didEndScroll: Bool = false // 動作確認用 private let dragDetector = DragDetector() var body: some View { VStack { ScrollView(.horizontal, showsIndicators: false) { CarouselContentView() } // `Introspect for SwiftUI` で UIScrollView のインスタンスを取得 .introspectScrollView { scrollView in scrollView.delegate = dragDetector } // 動作確認用 if didEndScroll { Text("ScrollView didEndScroll") } else { Text(" ") } } .onAppear { dragDetector.didEndScroll = { self.didEndScroll = true } } } } 動作確認 2. スナップ先座標の取得 Center-to-Center で ScrollView の中央位置に最も近いアイテムをスナップ先に指定 を実現するために、 ScrollView 自体の x 軸中央座標と、 ScrollView (+ LazyHStack) 中で管理している子 View の x 軸中央座標を算出。 各子 View と ScrollView の間で距離を求めて、最も距離が短くなる子 View を取得します。 ScrollView の座標については、 GeometryReader を利用してそのまま算出できるのですが、子 View については、 LazyHStack で管理しており、 100 個すべてが必ず存在するわけでもないため、 PreferenceKey + anchorPreference の仕組みを利用して、座標を取得更新していきます。 参考: 対応手順としては、 子 View 識別用のインデックス値 (id) と座標を格納する PreferenceKey を定義 子 View に anchorPreference の ViewModifier を追加して、 親 View に PreferenceKey を伝達 親 View 側のプロパティで ScrollView と PreferenceKey 経由で取得した子 View の座標を保持 特定タイミングでプロパティで保持している座標を元に、スナップ先の子 View を算出 といった流れで、とりあえずは 4.特定タイミングでプロパティで保持している座標を元に、スナップ先の子 View を算出 を暫定的なボタンタップとすることで、動作確認を実施しました。 対応コードと動作確認結果は次のようになります。 対応コード PreferenceKey の用意 class SnapAnchorPreferenceKey: PreferenceKey { // Dictionary で子 View の インデックス値 (id) と 座標のペアを保持 static var defaultValue: [Int: Anchor<CGPoint>] = [:] static func reduce( value: inout [Int: Anchor<CGPoint>], nextValue: () -> [Int: Anchor<CGPoint>] ) { for (index, anchor) in nextValue() { value[index] = anchor } } } 子 View 側の対応 struct CarouselContentView: View { var body: some View { LazyHStack { ForEach(Array(1...100), id: \.self) { index in ZStack { Color(hue: Double(index * 11 % 100) / 100, saturation: 0.5, brightness: 1.0) Text("\(index)") } .frame(width: 280, height: 200) // 各表示アイテムの中心位置を PreferenceKey で親 View に伝達 .anchorPreference( key: SnapAnchorPreferenceKey.self, value: .center ) { [index: $0] } } } } } 動作確認 struct ContentView: View { @State private var scrollViewAnchor: CGFloat? @State private var anchors: [Int: CGFloat] = [:] @State private var snappingIndex: Int? var body: some View { VStack { GeometryReader { proxy in ScrollView(.horizontal, showsIndicators: false) { CarouselContentView() } // 子 View の座標変化に合わせてプロパティに座標を保存 .onPreferenceChange(SnapAnchorPreferenceKey.self) { preference in anchors = preference.mapValues { proxy[$0].x } } .onAppear { scrollViewAnchor = proxy.size.width / 2 } } // 動作確認用 HStack { Spacer() Button("Check") { guard let scrollViewAnchor = scrollViewAnchor else { return } // 最短距離の子 View を探索 snappingIndex = anchors.min { leftAnchor, rightAnchor in let leftDistance = abs(scrollViewAnchor - leftAnchor.value) let rightDistance = abs(scrollViewAnchor - rightAnchor.value) return leftDistance < rightDistance }?.key } if let snappingIndex = snappingIndex { Text("\(snappingIndex)") } else { Text(" ") } Spacer() } Spacer() } } } 動作確認 この対応において、 iOS 15.0 未満では Anchor<Value> が Equatable に準拠していない 問題から、 iOS 15.0 以上の Deployment Target が要求されてしまいます。 iOS 15.0 未満での動作も考慮する場合は、 Anchor<Value> ではなく直接 CGPoint を保持するような実装が必要となります。 3. スナップ先座標へのスクロール (スナップ動作) スクロール動作については、 iOS 14 から実装された ScrollViewReader と ScrollViewProxy によって、シンプルに実装可能です。 対応としては、 子 View ごとにユニークとなるような id(_:) を設定 ScrollViewReader + ScrollViewProxy で対象の子 View までスクロール という形となります。 対応コード 動作確認 struct CarouselContentView: View { var body: some View { LazyHStack { ForEach(Array(1...100), id: \.self) { index in ZStack { Color(hue: Double(index * 11 % 100) / 100, saturation: 0.5, brightness: 1.0) Text("\(index)") } .frame(width: 280, height: 200) // スナップ動作のスクロールで利用するためにユニークな id を付与 .id(index) } } } } struct ContentView: View { @State private var scrollTo: String = "" var body: some View { ScrollViewReader { proxy in VStack { ScrollView(.horizontal, showsIndicators: false) { CarouselContentView() } // 動作確認用 HStack { Spacer() TextField("Scroll to", text: $scrollTo) .frame(width: 100) Button("Scroll") { guard let index = Int(scrollTo) else { return } // アニメーション付きでスクロール withAnimation { proxy.scrollTo(index, anchor: .center) } } Spacer() } Spacer() } } } } 動作確認 まとめ これまでの 3 つの対応要素をまとめることによって、本記事冒頭の gif 画像動作が実装できます。 まとめた際の最終形のコードは以下の通りです。 最終形 // ドラッグ終了検知用の UIScrollViewDelegate に適合した NSObject class DragDetector: NSObject, UIScrollViewDelegate { var didEndScroll: (() -> Void)? func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { // decelerate の場合は、 `scrollViewDidEndDecelerating(_:)` に処理を任せる didEndScroll?() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { didEndScroll?() } } // 子 View の座標伝達で利用する PreferenceKey class SnapAnchorPreferenceKey: PreferenceKey { // Dictionary で子 View の インデックス値 (id) と 座標のペアを保持 static var defaultValue: [Int: Anchor<CGPoint>] = [:] static func reduce( value: inout [Int: Anchor<CGPoint>], nextValue: () -> [Int: Anchor<CGPoint>] ) { for (index, anchor) in nextValue() { value[index] = anchor } } } struct CarouselContentView: View { var body: some View { LazyHStack { ForEach(Array(1...100), id: \.self) { index in ZStack { Color(hue: Double(index * 11 % 100) / 100, saturation: 0.5, brightness: 1.0) Text("\(index)") } .frame(width: 280, height: 200) // スナップ動作のスクロールで利用するためにユニークな id を付与 .id(index) // 各表示アイテムの中心位置を PreferenceKey で親 View に伝達 .anchorPreference( key: SnapAnchorPreferenceKey.self, value: .center ) { [index: $0] } } } } } struct ContentView: View { private let dragDetector = DragDetector() @State private var anchors: [Int: CGFloat] = [:] var body: some View { ScrollViewReader { scrollViewProxy in GeometryReader { geometryProxy in ScrollView(.horizontal, showsIndicators: false) { CarouselContentView() } // `Introspect for SwiftUI` で UIScrollView のインスタンスを取得 .introspectScrollView { scrollView in scrollView.delegate = dragDetector } // 子 View の座標変化に合わせてプロパティに座標を保存 .onPreferenceChange(SnapAnchorPreferenceKey.self) { preference in anchors = preference.mapValues { geometryProxy[$0].x } } .onAppear { dragDetector.didEndScroll = { let scrollViewAnchor = geometryProxy.size.width / 2 // 最短距離の子 View を探索 let snappingIndex = anchors.min { leftAnchor, rightAnchor in let leftDistance = abs(scrollViewAnchor - leftAnchor.value) let rightDistance = abs(scrollViewAnchor - rightAnchor.value) return leftDistance < rightDistance }?.key // アニメーション付きでスクロール withAnimation { scrollViewProxy.scrollTo(snappingIndex, anchor: .center) } } } } } } } その他対応方法との比較、参考記事 今回は ScrollView を生かしたままスナップ動作を実現しましたが、類似の実装を検索すると ScrollView を利用せずに、独自でスクロール + スナップ動作を実装 スナップ開始タイミングを ScrollView の offset 変化で検知 といったパターンでの実装もありました。 それぞれの対応でのメリット / デメリットは以下のようになると思われます。 対応方法 メリット デメリット 今回の対応(ドラッグ終了検知に UIScrollView を利用) ScrollView の動作を生かせる 背後に UIScrollView があるという暗黙の実装に依存 独自でスクロール + スナップ動作実装 DragGesture によって、 SwiftUI ベースでのドラッグ終了検知が可能 ScrollView と独自スクロールの間で動作差異が生まれる ScrollView の offset 変化検知 ScrollView の動作を生かしつつ、SwiftUI ベースでのドラッグ終了検知が可能 ドラッグしたまま静止された場合に留まれない 独自でスクロール + スナップ動作を実装 スナップ開始タイミングを ScrollView の offset 変化で検知
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む