- 投稿日:2020-11-23T23:14:38+09:00
Code coverageをオンにした状態でSwiftUIのXcode previewを有効化する方法
概要
CocoaPodsからインポートしているDynamic frameworkで以下のコンパイルエラーが出る場合がある。
SwiftUi canvas preview compile error: Undefined symbols for architecture x86_64
そこでアプリケーション(SwiftUIのpreviewを利用するターゲット)のスキームにある
Test/Options/Code coverage
のフラグを無効化するとコンパイルエラーが消える場合がある解消策
Code coverageを切る。
or
ビルド対象であるターゲットの
Build settings/Linking/Other linker flags
に-fprofile-instr-generate
を追加する。まとめ
とりあえず正常にpreviewも確認できたので一安心
llvm関連のflagで特に知見もないのでとりあえずはDebug buildのみにflagを追加することで対応しました
参考リンク
- 投稿日:2020-11-23T22:56:05+09:00
[SwiftUI] Viewを強制再読み込みする賢くない方法
どうしてもViewを再読み込みしたいが、どうやってもうまくいかない、という場合、賢くないですがこの方法でいけます。
SwiftUIのお気持ちに沿って作っていればそもそも強制再読み込みは必要ないはずなので、対処法として正しいのは設計の見直しです。対処
struct HogeView: View { var body: some View { Hoge() } }を強制再読み込みしたい場合、
struct HogeView: View { @State private var flag = true func refresh(){ flag.toggle() } var mainView: some View { Hoge() } var body: some View { Group{ if flag{ mainView }else{ mainView } } } }としてあげれば、flagを切り替えるたびにmainViewが計算しなおされるので実質再読み込みになります。
自戒
本当に賢くないのでSwiftUIをちゃんと勉強します。
- 投稿日:2020-11-23T22:10:36+09:00
SwiftUIだけど画面遷移はUIKitでやる
画面遷移処理を各画面から切り離したり、カスタムURLスキームなどを使って任意の画面に遷移できるようにする処理をSwiftUIでもやりたい。でもSwiftUIだけで実現する方法がわからない。。。
難しそうなところは今後のSwiftUIの進化に期待するということで、画面遷移は無理せずUIKitベースでやってしまえば良さそうだなと思い始めました。環境
- Xcode 12.0
実装
各画面のレイアウトはSwiftUIでさくっと作ってしまって画面遷移に関連する部分はUIKitベースで処理するために、遷移先はUIHostingControllerを使う。ViewControllerを見つける処理は従来通り。
var window: UIWindow? { guard let window = (UIApplication.shared.connectedScenes.first?.delegate as? UIWindowSceneDelegate)?.window else { return nil } return window } /// 全面に表示されているViewControllerを見つける func topViewController(_ vc: UIViewController? = nil) -> UIViewController? { guard let vc = vc ?? window?.rootViewController else { return nil } if let presented = vc.presentedViewController { return topViewController(presented) } return vc } /// NavigationControllerを見つける func navigationController(_ vc: UIViewController) -> UINavigationController? { if let result = vc as? UINavigationController { return result } for child in vc.children { if let result = navigationController(child) { return result } } return nil } @main struct MainApp: App { var body: some Scene { WindowGroup { ContentView() .onOpenURL(perform: { _ in guard let topVC = topViewController() else { return } if let navVC = navigationController(topVC) { navVC.show(UIHostingController(rootView: TestView()), sender: nil) } else { topVC.present(UIHostingController(rootView: NavigationView(content: { TestView() })), animated: true, completion: nil) } }) } } }SwiftUIで作った画面をXcodeのDebug View Hierarchyで見てみると、ViewControllerらしきコンポーネントがたくさん使われているようだったので、上記の実装は「NavigationViewを使えばUINavigationControllerが内部的には使われているかもしれない」とか、「sheetでモーダル表示したらpresentedViewControllerで遷移先のViewControllerを見つけられるかもしれない」という思い込みで実装してみました。
SwiftUIのNavigationViewを使っている場合にUINavigationControllerを探索可能かどうか不明でしたが、UINavigationControllerを継承していそうなクラスが使われているようでした。
- 投稿日:2020-11-23T20:20:16+09:00
no such module '✖️✖️✖️✖️✖️'というエラーの解決法
- 投稿日:2020-11-23T18:36:17+09:00
AVPlayerViewControllerのコントロールバーを監視する
概要
画面をタップすると表示・非表示が切り替わるコントロールバーを監視します。
あまりニーズがなさそうな情報ですが、つい最近コントロールバーの表示に合わせて自作UIを表示するという要件が実際にありましたのでメモを兼ねて投稿します。開発環境
Xcode 12.1
Swift 5AVPlayerViewControllerのレイヤー構成
AVPlayerViewControllerの動画再生時の画面レイヤーはこのようになっています。
目的のバーはAVViewの配下にあります。監視対象
バーの表示・非表示の切り替えはAVViewのisHiddenプロパティではなく親のAVTouchIgnoringViewのisHiddenプロパティで行われているのでこいつを監視します。
※Objectice-Cの場合は"isHidden"ではなく"hidden"キーになるようです実装
ViewController.swift@IBAction func pressedMoviePlayButton() { let playerViewController = CustomAVPlayerViewController() self.present(playerViewController, animated: true) { playerViewController.player?.play() playerViewController.find(view: playerViewController.view) } }CustomAVPlayerViewControllerprivate var observers = [NSKeyValueObservation]() override func viewDidLoad() { super.viewDidLoad() let path = Bundle.main.path(forResource: "sample", ofType: "mp4")! let url: URL = .init(fileURLWithPath: path) let item: AVPlayerItem = .init(url: url) let player: AVPlayer = .init(playerItem: item) self.player = player } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) // 監視を解除する observers.forEach { $0.invalidate() } observers.removeAll() } // AVTouchIgnoringViewを探す func find(view: UIView) { let targetViewName = "AVTouchIgnoringView" view.subviews.forEach { if !self.observers.isEmpty { return } // isHiddenプロパティをobserveする if String(describing: type(of: $0)).isEqual(targetViewName) { self.observers.append($0.observe(\.isHidden, options: .new, changeHandler: { (_, change) in print("\(change.newValue)") })) return } self.find(view: $0) } }
- 投稿日:2020-11-23T17:35:06+09:00
SwiftUIの多言語化
概要
LocalizedStringKey
を使ってSwiftUIの多言語化を行います。3行まとめ
Text
に文字列定数を渡すと、文字列定数をキーにしてローカライズが行われます。XLIFFのエクスポートもサポートされます。LocalizedStringKey
を使うと、引数をキーにして各種コンポーネントのローカライズ対応ができます。が、XLIFFのエクスポートはサポートされません。
NSLocalizedString
をコメントの形としてつけておくとXLIFFのエクスポートに対応することが可能です。Textの多言語化
Textのパラメータに、ダブルクォーテーションで囲われた文字列定数を設定します。
Export for localization
を行うと、Textの文字列を定数にしたものが全てXLIFFに出力されます。これをもって翻訳に使ったり、SwiftUIのプレビューから各言語の表示を確認できたりできます。コード
SwiftUI
import SwiftUI struct ContentView: View { var body: some View { Text("Hello, world!") } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() .environment(\.locale, Locale(identifier: "ja")) } }XLIFF
<?xml version="1.0" encoding="UTF-8"?> <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd"> <file original="l18/en.lproj/InfoPlist.strings" datatype="plaintext" source-language="en" target-language="ja"> <header> <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="12.2" build-num="12B45b"/> </header> <body> <trans-unit id="CFBundleName" xml:space="preserve"> <source>l18</source> <note>Bundle name</note> </trans-unit> </body> </file> <file original="l18/en.lproj/Localizable.strings" datatype="plaintext" source-language="en" target-language="ja"> <header> <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="12.2" build-num="12B45b"/> </header> <body> <trans-unit id="Hello, world!" xml:space="preserve"> <source>Hello, world!</source> <note>No comment provided by engineer.</note> </trans-unit> </body> </file> </xliff>Textに定数を設定する場合以外
例えば
Label
を使う場合などは、文字列の定数を設定したとしてもその定数をソースとしたXLIFFは出力されません。この場合、従来のコードで使っていたNSLocalizedString
を使って対応することになると思います。Labelのコード
SwiftUI
import SwiftUI struct ContentView: View { let text = NSLocalizedString("Hello, world!", comment: "Hello, world!") var body: some View { Label(text, systemImage: "arrow.uturn.up") } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() .environment(\.locale, Locale(identifier: "ja")) } }SwiftUIで言語のプレビューが効かない問題と解消法
しかし、NSLocalizedStringで対応を行なった場合、SwiftUIのプレビューでlocaleのenvironmentを切り替えたとしても言語の確認ができないという問題が発生します。
そこでLocalizedStringKey
を使います。LocalizedStringKeyにキーを指定すると、SwiftUIのプレビューで確認ができるようになります。
ただし、LocalizedStringKeyもXLIFFのエクスポートに対応していないので、コメントの形としてNSLocalizedStringを書く形にするとXLIFFへのエクスポートにも対応できます。SwiftUI
import SwiftUI struct ContentView: View { // NSLocalizedString("Hello, world!", comment: "Hello, world!") let text = LocalizedStringKey("Hello, world!") var body: some View { Label(text, systemImage: "arrow.uturn.up") } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() .environment(\.locale, Locale(identifier: "ja")) } }追記
LocalizedStringKeyのキーは、ローカライズされていない時にデフォルトで表示されるテキストになるので、多くの場合は表示される英語のテキストをキー名にしたほうがいいんじゃないかと思います。
OK
LocalizedStringKey("Hello, world!")NG
LocalizedStringKey("hello_world") LocalizedStringKey("helloWorld")
- 投稿日:2020-11-23T17:06:34+09:00
FlutterでSwiftPackageManager利用時のパッケージ依存関係エラー
はじめに
こんにちは趣味でアプリ開発をしている@glassmonkeyです。
今回はFlutterでiOSアプリの開発中にSwift Package Managerを利用したときにハマった点があったので記録に残しておこうと思います。補足などあれば遠慮なくコメントなどでご指摘ください。
環境
Xcode古いけど許して$ flutter doctor [✓] Flutter (Channel stable, 1.22.3, on Mac OS X 10.15.7 19H15, locale ja-JP) [✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2) [✓] Xcode - develop for iOS and macOS (Xcode 11.4.1) [✓] Android Studio (version 4.0) (以下略)発生したエラーについて
Swift Package Manager](https://swift.org/package-manager/)を利用して、依存関係を増やしたところ、`Xcode`では通常通りビルドできるが、Android Strido経由での
flutter build ios
が以下のエラーで通らないようになりました。Oops; flutter has exited unexpectedly: "ProcessException: Process exited abnormally: Command line invocation: /Applications/Xcode-11.4.1.app/Contents/Developer/usr/bin/xcodebuild -list xcodebuild: error: Could not resolve package dependencies: Packages are not supported when using legacy build locations, but the current project has them enabled. Command: /usr/bin/xcodebuild -list".原因
修正のPRによると、もともとのFlutterで作成したプロジェクト設定がレガシービルド設定になっていたようです。
Xcode 10のリリースノート
によるとIf you need it, the legacy build system is still available in Xcode 10. To use the legacy build system, select it in the File > Project/Workspace Settings sheet. Projects configured to use the legacy build system will display an orange hammer icon in the Activity View.
と言及している程度ですが、Swift Package Managerが利用できないって認識で良さそうっぽいですね。
https://github.com/flutter/flutter/pull/59009
https://github.com/flutter/flutter/pull/68361
にて追加対応があったので、今後のバージョンでは治ってる可能性が高いそうです。ありがたや。修正方法
同じ現象が報告されているissue
に記載されている対応をします。
- プロジェクトを開く(ワークスペースではないことが重要)
$ open ios/Runner.xcodepro
これでFlutterコマンドからもビルドが通るようになったはずです。
- もし上記の方法でもだめだったら、workspaceも確認してみる
Procject SettingがWorkSpaceSettingになってる以外は同様の項目なので、もしかしたら確認してみても良いかもしれません。
$ open ios/Runner.xcworkspace
感想
Xcodeむずかしい。
- 投稿日:2020-11-23T17:06:34+09:00
FlutterでSwiftPackageManager利用時のパッケージ依存関係エラーを解消する方法
はじめに
こんにちは趣味でアプリ開発をしている@glassmonkeyです。
今回はFlutterでiOSアプリの開発中にSwift Package Managerを利用したときにハマった点があったので記録に残しておこうと思います。補足などあれば遠慮なくコメントなどでご指摘ください。
環境
Xcode古いけど許して$ flutter doctor [✓] Flutter (Channel stable, 1.22.3, on Mac OS X 10.15.7 19H15, locale ja-JP) [✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2) [✓] Xcode - develop for iOS and macOS (Xcode 11.4.1) [✓] Android Studio (version 4.0) (以下略)発生したエラーについて
Swift Package Manager](https://swift.org/package-manager/)を利用して、依存関係を増やしたところ、`Xcode`では通常通りビルドできるが、Android Strido経由での
flutter build ios
が以下のエラーで通らないようになりました。Oops; flutter has exited unexpectedly: "ProcessException: Process exited abnormally: Command line invocation: /Applications/Xcode-11.4.1.app/Contents/Developer/usr/bin/xcodebuild -list xcodebuild: error: Could not resolve package dependencies: Packages are not supported when using legacy build locations, but the current project has them enabled. Command: /usr/bin/xcodebuild -list".原因
修正のPRによると、もともとのFlutterで作成したプロジェクト設定がレガシービルド設定になっていたようです。
Xcode 10のリリースノート
によるとIf you need it, the legacy build system is still available in Xcode 10. To use the legacy build system, select it in the File > Project/Workspace Settings sheet. Projects configured to use the legacy build system will display an orange hammer icon in the Activity View.
と言及している程度ですが、Swift Package Managerが利用できないって認識で良さそうっぽいですね。
https://github.com/flutter/flutter/pull/59009
https://github.com/flutter/flutter/pull/68361
にて追加対応があったので、今後のバージョンでは治ってる可能性が高いそうです。ありがたや。修正方法
同じ現象が報告されているissue
に記載されている対応をします。
- プロジェクトを開く(ワークスペースではないことが重要)
$ open ios/Runner.xcodeproject
これでFlutterコマンドからもビルドが通るようになったはずです。
- もし上記の方法でもだめだったら、workspaceも確認してみる
Procject SettingがWorkSpaceSettingになってる以外は同様の項目なので、もしかしたら確認してみても良いかもしれません。
$ open ios/Runner.xcworkspace
感想
Xcodeむずかしい。
- 投稿日:2020-11-23T15:23:41+09:00
Auto LayoutのStack Viewの便利さ
はじめに
Auto Layoutを勉強していてStack Viewの便利さを知ったので記事を書こうと思います。
また、Auto Layoutはエンジニア必須スキルで、ちゃんと理解できれば他のエンジニアと差をつけられる要素になるみたいです。
しっかり使いこなせるようになりたいですね。Stack Viewとは何か
公式ドキュメントにはこのように書かれています。
Stack Viewは、複雑な制約を導入することなく、自動レイアウトの機能を活用する簡単な方法を提供します。
要するに、本来なら複雑な制約を書いて実装するけど、Stack Viewを使えば簡単に実装できるようになります!って感じです。
使ってみた
今回はボタンを並べて、後からボタンを追加する作業にStack Viewを使っていきます。
Stack ViewをViewに追加
HorizontalとVerticalがあります。
Horizontalは水平なので横並び、Verticalは垂直なので縦並びで設定したい時に使います。
今回はHorizontalを使います。
Stack Viewにボタンを上から載せて追加
配置を設定
topとcenterの制約を設定します。
右下にあるAdd New Constraintsでtopの制約を追加します。
次に、Stack Viewをコントロール押しながらドラックしてViewで離します。
すると画像のような選択肢が出てくるので、Center Horizontallyを選択肢します。
これで配置が完了しました。
それぞれのボタンのスペースを8に設定してみました。
ボタンを追加する
さて、ここでボタン追加の変更が入ったとします。Stack Viewでなければボタンを追加して、配置なども1からやり直しでしたが。
Stack Viewを使っているので、Stack Viewのなかにボタンをドロップするだけで配置することができます。
もちろん、さっき設定したスペースの8も自動で設定されますので、設定し直す必要がありません。
最後に
レイアウトを管理することができるのがStack Viewの役割です。
これはかなり便利な機能だなと思いました。
まだこのくらいしか使い方知りませんが、また便利な使い方があれば更新していきます。参考サイト
- 投稿日:2020-11-23T14:11:38+09:00
Swiftで小数の扱いに疲れたので分数を扱ってみる
小数の扱いに悩まされることが多い今日この頃
DoubleやFloatだと正確な計算はできない。var a = Double(1.1) var b = Double(1) print(a - b) // 0.10000000000000009上の例だとDecimalを使えば解決できます。
足し算引き算などで悩まされることはなくなりました。let a = Decimal(1.1) let b = Decimal(1) print(a - b) // 0.1しかし割り切れない割り算については
let a = Decimal(2) let b = Decimal(3) let c = a / b print(c) // 0.66666666666666666666666666666666666666 print(c * b) // 1.99999999999999999999999999999999999998これは自然な結果なのです。小数の桁数は有限なのだから。
cに関しては桁を決めて丸めればいい。だがc × bに関しては
2 ÷ 3 × 3 = 2 になって欲しい
内部的には正確な値で保持しておきたい。。。
そもそも小数でデータを保持することがそもそもの間違いな気がしてきました。
そこで気付きました
「あ、分数だ」と。しかしSwfitで分数を扱ってるクラスや構造体を探してみたが見つからないので
作ってみました。※探し足りない可能性は大いにあるので良い方法があればどなたかご教授いただきたいです。
import Foundation class Fraction { var child: Int var mother: Int { didSet{ if mother == 0 { fatalError("no mother no child") } } } init(child: Int, mother: Int) { self.child = child self.mother = mother yakubun() } convenience init(_ child: Int){ self.init(child: child, mother: 1) } // 出力 var double: Double { return Double(child) / Double(mother) } var decimal: Decimal { return Decimal(child) / Decimal(mother) } // 各演算 static func + (r0: Fraction, r1: Fraction) -> Fraction { let newChild = r0.child * r1.mother + r1.child * r0.mother let newMother = r0.mother * r1.mother return Fraction(child: newChild, mother: newMother) } static func - (r0: Fraction, r1: Fraction) -> Fraction { let newChild = r0.child * r1.mother - r1.child * r0.mother let newMother = r0.mother * r1.mother return Fraction(child: newChild, mother: newMother) } static func * (r0: Fraction, r1: Fraction) -> Fraction { let newChild = r0.child * r1.child let newMother = r0.mother * r1.mother return Fraction(child: newChild, mother: newMother) } static func / (r0: Fraction, r1: Fraction) -> Fraction { let newChild = r0.child * r1.mother let newMother = r0.mother * r1.child return Fraction(child: newChild, mother: newMother) } // 約分 private func yakubun() { let c = _yucrid(x: child, y: mother) child /= c mother /= c } // ユークリッドで最大公約数 private func _yucrid(x: Int, y: Int) -> Int { if y == 0 { return x } else { return _yucrid(x: y, y: x % y) } } }内部で保持しているのは
分子と分母のInt型だけなので、Realmなどデータベースでの保管も楽かと。もっと実用できたら他の演算子も増やして行きます!!
- 投稿日:2020-11-23T11:26:43+09:00
UIPickerViewと辞書型で筋トレの内容をlabelに表示させる
せっかくなのでメモ。Swift5 Xcode12.2
UIPickerViewで部位と種目をそれぞれ選択します。
辞書型を使い、選択した部位によって種目のpickerが変わります。辞書の宣言
let menuDataList: [String: [String]] = [ "脚": ["スクワット","レッグプレス","レッグエクステンション","レッグカール"], "背中": ["デッドリフト","ベントオーバーローイング","チンニング","ラットプルダウン"], "胸": ["バーベルベンチプレス","ダンベルベンチプレス","インクラインダンベルベンチプレス","ペックフライ"], "肩": ["サイドレイズ","フロントレイズ","リアレイズ","ショルダープレス"], "三頭": ["ナローベンチプレス","トライセプスエクステンション","ケーブルプレスダウン","ダンベルキックバック"], "二頭": ["ダンベルカール","インクラインダンベルカール","プリチャーカール","ハンマーカール"], "腹": ["クランチ","インクラインクランチ","レッグレイズ"] ] var partsDataList: [String] = [ "脚","背中","胸","肩","三頭","二頭","腹" ] var selectedParts = ""2つのpickerにタグを振り分ける
override func viewDidLoad() { super.viewDidLoad() partsPickerView.delegate = self partsPickerView.dataSource = self menuPickerView.delegate = self menuPickerView.dataSource = self partsPickerView.tag = 1 menuPickerView.tag = 2 selectedParts = partsDataList[0] }PickerViewの個数を返す
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { if pickerView.tag == 1{ return partsDataList.count } else if pickerView.tag == 2{ //menuDataListのオプショナル型にアンラップされたselectedPartsの要素数が空だったら0を返す return menuDataList[selectedParts]?.count ?? 0 } else { return 0 } }表示内容
func pickerView(_ picker: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { if picker.tag == 1 { return partsDataList[row] } else if picker.tag == 2 { //menuDataListのオプショナル型にアンラップされたselectedPartsのrow番目が空だったら空文字列を返す return menuDataList[selectedParts]?[row] ?? "" } else { return "" } }UIPickerViewのRowが選択された時の挙動
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { if pickerView.tag == 1 { partsLabel.text = partsDataList[row] selectedParts = partsDataList[row] menuPickerView.reloadAllComponents() } else if pickerView.tag == 2 { //menuDataListのオプショナル型にアンラップされたselectedPartsのrow番目が空だったら空文字列をmenuLabelのtextへ返す menuLabel.text = menuDataList[selectedParts]?[row] ?? "" } else { return } }
- 投稿日:2020-11-23T10:40:44+09:00
ARKit+Vision+iOS14 で らくがき のジオメトリ化②【物理判定付き3Dモデル生成】
前回のつづきで検出した輪郭からジオメトリを生成する。
<完成イメージ>
※作成したジオメトリにテクスチャを貼る手順は次の記事で記載します(これから作成)。
ジオメトリ化の手順
検出した輪郭をジオメトリを表す
SCNShape
に設定して3Dモデルとして扱えるようにする。
SCNShape
には輪郭情報をUIBezierPath
にして渡す必要があるので、VNContour
から取得したCGPath
を変換する。手順は次の通り。①〜⑤は前回の記事と同じなので、そちらを参照ください。
①キャプチャ画像からスクリーンに表示されている範囲を切り出す
②輪郭検出の前に①の画像を加工し輪郭検出しやすくする
③輪郭を検出する
④画像にある輪郭は複数検出されるので、着目したい輪郭のみ選択する
⑤④を表示する
〜今回はここから〜
⑥CGPathの輪郭情報をUIBezierPathに変換
⑦⑥の情報からSCNNodeを作成
⑧⑦をシーンに追加以下、詳細を説明します。
⑥CGPathの輪郭情報をUIBezierPathに変換
検出された輪郭は2Dなので、これを3Dにする必要がある。
取得した2Dのパスは近くも遠くも関係がない大きさであり、これを遠近のある3D水平上のスケールに合わせて変換しなければならない。カメラが斜めならこれも考慮する必要がある。レンダリングで行うModel-View-Projection変換の逆行列で位置は取得できるはずだが手元にあるのはX、Yの情報であり、Z軸の情報がない。どうやってやるのか。。。むづかしい数学はわからない。。。この記事ではお手軽にそれらしく変換する方法として以下を行った。
1) 輪郭検出範囲の四隅のワールド座標をレイキャストで取得
2) 上記で取得したワールド座標をもとにパスの各座標を重心座標を用いてワールド座標に変換⑥-1) 輪郭検出範囲の四隅のワールド座標をレイキャストで取得
ARKit(ARSCNView)にはスクリーン座標(2D)→ワールド座標(3D)の変換方法として
ARSCNView
にraycastQuery(from:allowing:alignment:)
が用意されている。Z軸にレイを飛ばすことで物体と交差した座標を取得できるので、これを使って輪郭検出対象の四隅のワールド座標を取得する。guard let query = self.scnView.raycastQuery(from: from, allowing: .existingPlaneGeometry, alignment: .horizontal), let result = self.scnView.session.raycast(query).first else { return nil } let p = result.worldTransform.columns.3 return SCNVector3(p.x, p.y, p.z)今回作成したサンプルでは取得したワールド座標に小さな赤い球ノードを置いている(シーン内に配置)。輪郭検出の枠とぴったり一致した場所にあることがわかる(=ワールド座標を正確に取得できている)。
そもそも、こんな便利な仕組みがあるなら、これを使ってパス上の全ての座標をワールド座標に変換することも可能では?と考える。可能かもしれないがこの記事の方法で輪郭検出すると、輪郭上の座標数は数千になることもあるため、計算速度が心配。座標数を減らせば心配ないかもしれないし、そもそもiPhoneは高速に処理してくれるかもしれない。が、今回はより負荷が軽いと思われる方式として、次の重心座標による変換を採用した。
⑥-2) 上記で取得したワールド座標をもとにパスの各座標を重心座標を用いてワールド座標に変換
パス上の各座標をワールド座標のスケールに合わせて変換する。
四隅のワールド座標はわかっているので、これを使ってVNContourの結果であるCGPathの座標(左下が(0, 0)、右上が(1, 1))をワールド座標のスケールに変換する。
この変換をそれっぽく実現するため『重心座標系での線形補間』 を行う。この線形補間を行うと「三角形内のある点Pの座標を、対応する別の三角形の座標に変換する」ことができる。まず、手元には次の情報がある。
- 輪郭検出で得られたパスの点の集まり
- 輪郭検出対象の画像(スクリーン座標のスケールだと(0, 0)から(0, 320))の四隅に対応するワールド座標
下図のように輪郭検出対象の画像を三角形ABC、三角形BCDの2つの三角形に分けて、三角形ABC上にはパスの点Pがあるとする。ここで点Pの座標を A、B、Cの各座標の重みで決定することとし、A、B、Cの重みは T1、T2、T3 の面積に対応させる。
具体的には三角形ABCの面積=T1、T2、T3の面積の合計 であることを利用し、
・T1の面積が大きい場合、Aの座標の割合が大きくなり、B、Cの割合は減る、とする
・T2の面積が大きい場合、Bの座標の割合が大きくなり、A、Cの割合は減る、とする
・T3の面積が大きい場合、Cの座標の割合が大きくなり、A、Bの割合は減る、とする
のように考える。
例えば、三角形ABCの面積=T1の面積 となった場合、Pのワールド座標上の位置は ⑥-1) で取得した左上の座標ということになる。
三角形BCDについても同様に計算する。参考サイト:
実際にこの方法で実装するとそれっぽい結果になる。が、理屈としてこの方法が正確かどうかは、、、わかりません。それっぽく見えているので「それっぽく見せる方法の1つ」ということで本記事を読んでいただければと思います。
let convertPoint: (CGPoint) -> CGPoint = { // パスの各座標について三角形の重心座標系でワールド座標を導出 var point = CGPoint.zero let pl: CGFloat = 1.0 // CGPathの一辺の長さ。VNContourの返す輪郭は(0,0)〜(1,1)の範囲 if $0.y > $0.x { // 四角形の上側の三角形 let t: CGFloat = pl * pl / 2 // 四角形の上側の三角形の面積 let t2 = pl * (pl - $0.y) / 2 // t2の面積 let t3 = pl * $0.x / 2 // t3の面積 let t1 = t - t2 - t3 // t1の面積 let ltRatio = t1 / t // 左上座標の割合 let rtRatio = t3 / t // 右上座標の割合 let lbRatio = t2 / t // 左下座標の割合 // 各頂点の重みに応じてワールド座標を算出 let p = leftTop * ltRatio + rightTop * rtRatio + leftBottom * lbRatio point.x = p.x.cg point.y = p.z.cg * -1 } else { // 四角形の下側の三角形 let t: CGFloat = pl * pl / 2 // 四角形の下側の三角形の面積 let t5 = pl * $0.y / 2 // t5の面積 let t6 = pl * (pl - $0.x) / 2 // t6の面積 let t4 = t - t5 - t6 // t4の面積 let rtRatio = t5 / t // 右上座標の割合 let lbRatio = t6 / t // 左下座標の割合 let rbRatio = t4 / t // 右下座標の割合 // 各頂点の重みに応じてワールド座標を算出 let p = rightTop * rtRatio + leftBottom * lbRatio + rightBottom * rbRatio point.x = p.x.cg point.y = p.z.cg * -1 } // 後でSCNShapeに与える座標となるが、SCNShapeに小さい座標を与えると正しく表示されないのでいったん、拡大しておく。 return point * self.tempGeometryScale }次に、CGPath -> UIBezierPath 変換の説明。
CGPathは単なる点座標の集まりではなく、点と点の間のカーブを表現することもできる。
【ドキュメント:CGPathElementType】
ただ、VNContour が返す CGPath の中にはカーブ情報である QuadCurveToPoint や CurveToPoint は含まれていなかった。これは輪郭検出の要求精度であるVNDetectContoursRequest
のmaximumImageDimension
を下限の 64 に設定しても変わらず。
VNContour
には輪郭のノイズを除去するpolygonApproximationWithEpsilon:error:
というメソッドがありこ、この辺りを設定すると使われるのかもしれないが未検証。カーブを無視した実装は次の通り。let geometryPath = UIBezierPath() let path = Path(normalizedPath) path.forEach { element in switch element { case .move(to: let to): geometryPath.move(to: convertPoint(to)) case .line(to: let to): geometryPath.addLine(to: convertPoint(to)) case .quadCurve(to: let to, control: _): geometryPath.addLine(to: convertPoint(to)) case .curve(to: let to, control1: _, control2: _): geometryPath.addLine(to: convertPoint(to)) case .closeSubpath: geometryPath.close() break } }CGPathから座標を取り出すために、いったん、
let path = Path(normalizedPath)
で SwiftUI で追加されたPath
に変換している。
【ドキュメント:Path】
なぜ、一度変換するかというと、CGPath から座標を取り出すCGPath.apply(info:function:)
がC言語の関数であるためブロックの外との値の受け渡しが面倒なため。
【参考:Equivalent of or alternative to CGPathApply in Swift?】
Pathから1つ1つ要素を取り出しワールド座標系に変換して、UIBezierPathに追加する。⑦⑥の情報からSCNNodeを作成
作成した UIBezierPath から SCNShape を使ってジオメトリ を作成する。
let geometry = SCNShape(path: geometryPath, extrusionDepth: 0.01 * self.tempGeometryScale) geometry.firstMaterial?.diffuse.contents = UIColor.gray let node = SCNNode(geometry: geometry) node.eulerAngles = SCNVector3(x: -Float.pi/2, y: 0, z: 0) // ベジェパスの座標計算時にいったん、拡大していたので縮小する node.scale = SCNVector3(1/self.tempGeometryScale, 1/self.tempGeometryScale, 1/self.tempGeometryScale)SCNShapeの引数にUIBezierPathを与えるだけだとジオメトリ の面数が極端に少なくなり、なぜか棒状に長くなる、というおかしな現象に直面した。どうやら、0.1程度以下の小さい座標の集まりとなっているUIBezierPathを与えると、まともにジオメトリ が作られないらしい。致し方なく、UIBezierPathを作成する際、座標を 10倍にしておき、SCNShapeを生成&SCNNodeに設定後、SCNNode の scale を 1/10 にすることでこの現象を回避した。この動作が仕様なのかバグなのかは不明。
あと、SCNNodeには物理的な振る舞いをさせるため
SCNPhysicsBody
を設定している。let bodyShape = SCNPhysicsShape(geometry: bodyGeometry, options: nil) node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: bodyShape) node.physicsBody?.friction = 1.0 node.physicsBody?.restitution = 0.0 node.physicsBody?.rollingFriction = 1.0 node.physicsBody?.angularDamping = 1.0 node.physicsBody?.linearRestingThreshold = 1.0 node.physicsBody?.angularRestingThreshold = 1.0ここで、SCNPhysicsBody のパラメータに色々と値を設定しているのには理由がある。SCNNodeが大きければこんな設定は不要なのだが、数cm程度の3Dモデルだと、飛び跳ねるし動き続けるしという現象が起こる。ここで設定しているパラメータは、「面の摩擦を大きく(friction)」「弾まないようにする(restitution)」「転がり摩擦を大きく(rollingFriction)」「回転摩擦を大きく(angularDamping)」「ある程度大きな移動をする場合のみ移動(linearRestingThreshold)」「ある程度大きな回転をする場合のみ回転(angularRestingThreshold)」のように、運動を抑止するものである。ここまで設定すると、小さな3Dモデルの運動もだいぶ落ち着くが、不自然さは残る。小さなものに対してどのような設定をすれば良いのかは、ノウハウが必要かもしれないし、そもそも向かないのかもしれない。
参考サイト:
- iOS で SceneKit を試す(Swift 3) その67 - PhysicsBody の振る舞い 1
- iOS 12 SDK Bata 5 の ARKit、SceneKit 変更内容⑧⑦をシーンに追加
作成したSCNNodeをシーンに追加する。
画面中央より少し上の位置のワールド座標を取得し、20cm上から落とす。// 画面中央上の20cm上から落とす let screenCenter = CGPoint(x: self.view.bounds.width/2, y: self.view.bounds.height/2 - 150) guard var position = self.getWorldPosition(from: screenCenter) else { return } position.y += 0.2 node.worldPosition = position self.scnView.scene.rootNode.addChildNode(node)ここまでで処理の流れの説明は終わり。
以下、その他細かな部分について。その他
1) シーンに追加したSCNNodeに影をつける
下の例では床にイカモデルの影が落ちているのがわかる。この方法を説明。
影を落とすにはシーンにライトを追加する必要があるので、まずライトの設定から。// ディレクショナルライト追加 let directionalLightNode = SCNNode() directionalLightNode.light = SCNLight() directionalLightNode.light?.type = .directional directionalLightNode.light?.castsShadow = true // 影が出るライトにする directionalLightNode.light?.shadowMapSize = CGSize(width: 2048, height: 2048) // シャドーマップを大きくしてジャギーが目立たないようにする directionalLightNode.light?.shadowSampleCount = 2 // 影の境界を若干柔らかくする directionalLightNode.light?.shadowColor = UIColor.lightGray.withAlphaComponent(0.8) // 影の色は明るめ directionalLightNode.position = SCNVector3(x: 0, y: 3, z: 0) directionalLightNode.eulerAngles = SCNVector3(x: -Float.pi/3, y: 0, z: -Float.pi/3) scnView.scene.rootNode.addChildNode(directionalLightNode)ポイントは次の場所。
castsShadow = true
で影がでるライトになる- 影の色はデフォルトだと真っ黒で不自然なので、
shadowColor = UIColor.lightGray.withAlphaComponent(0.8)
で 半透明のグレーを設定して影が落ちる部分の床面が見えるようにするライトについては↓のサイトの情報がとてもわかりやすい
iOS で SceneKit を試す(Swift 3) その49 - Scene Editor の Spot Light と Cast Shadow (Shadow Mapping)】つぎに、床面の設定。
let geometry = SCNBox(width: 3.0, height: 3.0, length: self.floorThickness, chamferRadius: 0.0) let material = SCNMaterial() material.lightingModel = .shadowOnly // 平面の色は影だけになるように指定 geometry.materials = [material] let floorNode = SCNNode(geometry: geometry)ポイントは
material.lightingModel = .shadowOnly
の部分。これは床面のマテリアルの色は出さずに、影だけ落ちるようにする、という設定。これを設定すると例えば、material.diffuse.contents = UIColor.red
のように設定しても色は付かない。最後に、配置する3Dモデルの設定。
let geometry = SCNShape(path: geometryPath, extrusionDepth: 0.01 * self.tempGeometryScale) let node = SCNNode(geometry: geometry) (略) node.castsShadow = true // ノードの影をつけるSCNNode の
castsShadow
を true にすることで、そのモデルの影が落ちるようになる。2) 物理判定のすりぬけ対策
床面のジオメトリを ARSCNPlaneGeometry や SCNPlane にすると、配置した3Dモデルが床をすり抜けてしまう現象が発生。3Dモデルが数センチ程度のものだと物理判定が期待した通りにならない。致し方なく、床を箱上(SCNBox)にして回避。
説明は以上です。
次回は、画面からキャプチャした画像を3Dモデルに貼り付けます。全体ソースコード
ViewController.swiftimport ARKit import Vision import CoreImage.CIFilterBuiltins import SwiftUI import UIKit class ViewController: UIViewController, ARSessionDelegate, ARSCNViewDelegate { @IBOutlet weak var scnView: ARSCNView! // 輪郭描画用 private var contourPathLayer: CAShapeLayer? // キャプチャ画像上の輪郭検出範囲 private let detectSize: CGFloat = 320.0 // 3次元化ボタンが押下状態 private var isButtonPressed = false // 床の厚さ(m) private let floorThickness: CGFloat = 1.0 // 床のローカル座標。床の厚さ分、Y座標を下げる private lazy var floorLocalPosition = SCNVector3(0.0, -self.floorThickness/2, 0.0) // SCNShapeの仮の拡大率。SCNShapeに小さいジオメトリ を与えるとジオメトリが崩れるので拡大する private let tempGeometryScale: CGFloat = 10.0 // 検出領域の四隅のシーン内の位置を示すマーカーノード private var cornerMarker1: SCNNode! private var cornerMarker2: SCNNode! private var cornerMarker3: SCNNode! private var cornerMarker4: SCNNode! override func viewDidLoad() { super.viewDidLoad() // シーンの設定 self.setupScene() // AR Session 開始 self.scnView.delegate = self self.scnView.session.delegate = self let configuration = ARWorldTrackingConfiguration() configuration.planeDetection = [.horizontal] self.scnView.session.run(configuration, options: [.removeExistingAnchors, .resetTracking]) } // アンカーが追加された func renderer(_: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { guard anchor is ARPlaneAnchor else { return } // 落ちてくるノードを受け止めるためアンカーに大きめなSCNBoxを設定する。 // ARSCNPlaneGeometry だと衝突判定されなかった。SCNPlane だと小さいモデルがすり抜けてしまう。 // → SCNBoxを利用 let geometry = SCNBox(width: 3.0, height: 3.0, length: self.floorThickness, chamferRadius: 0.0) let material = SCNMaterial() material.lightingModel = .shadowOnly // 平面の色は影だけになるように指定 geometry.materials = [material] let floorNode = SCNNode(geometry: geometry) floorNode.position = self.floorLocalPosition floorNode.castsShadow = false // これがないとplaneNodeがチラつくことがある floorNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2, 1, 0, 0) floorNode.physicsBody = SCNPhysicsBody.static() floorNode.physicsBody?.friction = 1.0 // この辺りのプロパティはモデルの物理運動を抑止するためのもの floorNode.physicsBody?.restitution = 0.0 floorNode.physicsBody?.rollingFriction = 1.0 floorNode.physicsBody?.angularDamping = 1.0 floorNode.physicsBody?.linearRestingThreshold = 1.0 floorNode.physicsBody?.angularRestingThreshold = 1.0 DispatchQueue.main.async { node.addChildNode(floorNode) } } // アンカーが更新された func renderer(_: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) { guard anchor is ARPlaneAnchor else { return } if let childNode = node.childNodes.first { DispatchQueue.main.async { // 床(SCNBox)の位置を再設定 childNode.position = self.floorLocalPosition } } } // ARフレームが更新された func session(_ session: ARSession, didUpdate frame: ARFrame) { // 一番外側の輪郭を取得 guard let contour = getFirstOutsideContour(frame: frame) else { return } // UIKitの座標系のCGPathを取得 guard let path = getCGPathInUIKitSpace(contour: contour) else { return } DispatchQueue.main.async { // 輪郭(2D)を描画 self.drawContourPath(path) // 輪郭(3D)を描画 if self.isButtonPressed { self.isButtonPressed = false self.drawContour3DModel(normalizedPath: contour.normalizedPath) } } } // ジオメトリ化ボタンが押された @IBAction func pressButton(_ sender: Any) { isButtonPressed = true } private func setupScene() { // ディレクショナルライト追加 let directionalLightNode = SCNNode() directionalLightNode.light = SCNLight() directionalLightNode.light?.type = .directional directionalLightNode.light?.castsShadow = true // 影が出るライトにする directionalLightNode.light?.shadowMapSize = CGSize(width: 2048, height: 2048) // シャドーマップを大きくしてジャギーが目立たないようにする directionalLightNode.light?.shadowSampleCount = 2 // 影の境界を若干柔らかくする directionalLightNode.light?.shadowColor = UIColor.lightGray.withAlphaComponent(0.8) // 影の色は明るめ directionalLightNode.position = SCNVector3(x: 0, y: 3, z: 0) directionalLightNode.eulerAngles = SCNVector3(x: -Float.pi/3, y: 0, z: -Float.pi/3) scnView.scene.rootNode.addChildNode(directionalLightNode) // 暗いので環境光を追加 let ambientLightNode = SCNNode() ambientLightNode.light = SCNLight() ambientLightNode.light?.type = .ambient directionalLightNode.position = SCNVector3(x: 0, y: 0, z: 0) scnView.scene.rootNode.addChildNode(ambientLightNode) // 検出領域の四隅のシーン内のマーカーノード self.cornerMarker1 = makeMarkerNode() self.cornerMarker1.isHidden = true self.scnView.scene.rootNode.addChildNode(self.cornerMarker1) self.cornerMarker2 = makeMarkerNode() self.cornerMarker2.isHidden = true self.scnView.scene.rootNode.addChildNode(self.cornerMarker2) self.cornerMarker3 = makeMarkerNode() self.cornerMarker3.isHidden = true self.scnView.scene.rootNode.addChildNode(self.cornerMarker3) self.cornerMarker4 = makeMarkerNode() self.cornerMarker4.isHidden = true self.scnView.scene.rootNode.addChildNode(self.cornerMarker4) } } // MARK: - 輪郭検出関連 extension ViewController { private func getFirstOutsideContour(frame: ARFrame) -> VNContour? { // キャプチャ画像をスクリーンで見える範囲に切り抜く let screenImage = cropScreenImageFromCapturedImage(frame: frame) // 輪郭検出しやすいように画像処理を行う guard let preprocessedImage = preprocessForDetectContour(screenImage: screenImage) else { return nil } // 輪郭検出 let handler = VNImageRequestHandler(ciImage: preprocessedImage) let contourRequest = VNDetectContoursRequest.init() contourRequest.maximumImageDimension = Int(self.detectSize) // 検出画像サイズはクリップした画像と同じにする。デフォルトは512。 contourRequest.detectsDarkOnLight = true // 明るい背景で暗いオブジェクトを検出 try? handler.perform([contourRequest]) // 検出結果取得 guard let observation = contourRequest.results?.first as? VNContoursObservation else { return nil } // トップレベルの輪郭のうち、輪郭の座標数が一番多いパスを見つける let outSideContour = observation.topLevelContours.max(by: { $0.normalizedPoints.count < $1.normalizedPoints.count }) if let contour = outSideContour { return contour } else { return nil } } private func cropScreenImageFromCapturedImage(frame: ARFrame) -> CIImage { let imageBuffer = frame.capturedImage // カメラキャプチャ画像をスクリーンサイズに変換 // 参考 : https://stackoverflow.com/questions/58809070/transforming-arframecapturedimage-to-view-size let imageSize = CGSize(width: CVPixelBufferGetWidth(imageBuffer), height: CVPixelBufferGetHeight(imageBuffer)) let viewPortSize = self.scnView.bounds.size let interfaceOrientation = self.scnView.window!.windowScene!.interfaceOrientation let image = CIImage(cvImageBuffer: imageBuffer) // 1) キャプチャ画像を 0.0〜1.0 の座標に変換 let normalizeTransform = CGAffineTransform(scaleX: 1.0/imageSize.width, y: 1.0/imageSize.height) // 2) 「Flip the Y axis (for some mysterious reason this is only necessary in portrait mode)」とのことでポートレートの場合に座標変換。 // Y軸だけでなくX軸も反転が必要。 var flipTransform = CGAffineTransform.identity if interfaceOrientation.isPortrait { // X軸Y軸共に反転 flipTransform = CGAffineTransform(scaleX: -1, y: -1) // X軸Y軸共にマイナス側に移動してしまうのでプラス側に移動 flipTransform = flipTransform.concatenating(CGAffineTransform(translationX: 1, y: 1)) } // 3) キャプチャ画像上でのスクリーンの向き・位置に移動 // 参考 : https://developer.apple.com/documentation/arkit/arframe/2923543-displaytransform let displayTransform = frame.displayTransform(for: interfaceOrientation, viewportSize: viewPortSize) // 4) 0.0〜1.0 の座標系からスクリーンの座標系に変換 let toViewPortTransform = CGAffineTransform(scaleX: viewPortSize.width, y: viewPortSize.height) // 5) 1〜4までの変換を行い、変換後の画像をスクリーンサイズでクリップ let transformedImage = image.transformed(by: normalizeTransform.concatenating(flipTransform).concatenating(displayTransform).concatenating(toViewPortTransform)).cropped(to: self.scnView.bounds) return transformedImage } private func preprocessForDetectContour(screenImage: CIImage) -> CIImage? { // 画像の暗い部分を広げて細い線を太くする。 // WWDC2020(https://developer.apple.com/videos/play/wwdc2020/10673/) // 04:06あたりで紹介されているCIMorphologyMinimumを利用。 let blurFilter = CIFilter.morphologyMinimum() blurFilter.inputImage = screenImage blurFilter.radius = 5 guard let blurImage = blurFilter.outputImage else { return nil } // ペンの線を強調。RGB各々について閾値より明るい色は 1.0 にする。 let thresholdFilter = CIFilter.colorThreshold() thresholdFilter.inputImage = blurImage thresholdFilter.threshold = 0.1 guard let thresholdImage = thresholdFilter.outputImage else { return nil } // 検出範囲を画面の中心部分に限定する let screenImageSize = screenImage.extent // CIMorphologyMinimumフィルタにより画像サイズと位置が変わってしまうので、オリジナル画像のサイズ・位置を基準にする let croppedImage = thresholdImage.cropped(to: CGRect(x: screenImageSize.width/2 - detectSize/2, y: screenImageSize.height/2 - detectSize/2, width: detectSize, height: detectSize)) return croppedImage } } // MARK: - パス描画(2D) extension ViewController { private func getCGPathInUIKitSpace(contour: VNContour) -> CGPath? { // UIKitで使うため、クリップしたときのサイズに拡大し、上下の座標を反転後、左上が (0,0)になるようにする let path = contour.normalizedPath var transform = CGAffineTransform(scaleX: detectSize, y: -detectSize) transform = transform.concatenating(CGAffineTransform(translationX: 0, y: detectSize)) let transPath = path.copy(using: &transform) return transPath } private func drawContourPath(_ path: CGPath) { // 表示中のパスは消す if let layer = self.contourPathLayer { layer.removeFromSuperlayer() self.contourPathLayer = nil } // 輪郭を描画 let pathLayer = CAShapeLayer() var frame = self.view.bounds frame.origin.x = frame.width/2 - detectSize/2 frame.origin.y = frame.height/2 - detectSize/2 frame.size.width = detectSize frame.size.height = detectSize pathLayer.frame = frame pathLayer.path = path pathLayer.strokeColor = UIColor.blue.cgColor pathLayer.lineWidth = 10 pathLayer.fillColor = UIColor.clear.cgColor self.view.layer.addSublayer(pathLayer) self.contourPathLayer = pathLayer } } // MARK: - パス描画(3D) extension ViewController { private func drawContour3DModel(normalizedPath: CGPath) { // 輪郭(CGPath)をワールド座標のUIBezierPathに変換 guard let geometryPath = convertPath(from: normalizedPath) else { return } // ベジェパスをもとにノードを生成 let node = makeNode(from: geometryPath) // 画面中央上の20cm上から落とす let screenCenter = CGPoint(x: self.view.bounds.width/2, y: self.view.bounds.height/2 - 150) guard var position = self.getWorldPosition(from: screenCenter) else { return } position.y += 0.2 node.worldPosition = position self.scnView.scene.rootNode.addChildNode(node) } // レイキャストでワールド座標を取得(精度は問題なさそう) private func getWorldPosition(from: CGPoint) -> SCNVector3? { guard let query = self.scnView.raycastQuery(from: from, allowing: .existingPlaneGeometry, alignment: .horizontal), let result = self.scnView.session.raycast(query).first else { return nil } let p = result.worldTransform.columns.3 return SCNVector3(p.x, p.y, p.z) } private func makeMarkerNode() -> SCNNode { let sphere = SCNSphere(radius: 0.001) let material = SCNMaterial() material.diffuse.contents = UIColor.red sphere.materials = [material] return SCNNode(geometry: sphere) } private func convertPath(from normalizedPath: CGPath) -> UIBezierPath? { // 検出領域の四隅のワールド座標を取得 let origin = CGPoint(x: self.view.bounds.width/2 - self.detectSize/2, y: self.view.bounds.height/2 - self.detectSize/2) guard let leftTopWorldPosition = self.getWorldPosition(from: origin), let rightTopWorldPosition = self.getWorldPosition(from: CGPoint(x: origin.x + self.detectSize, y: origin.y)), let leftBottomWorldPosition = self.getWorldPosition(from: CGPoint(x: origin.x, y: origin.y + self.detectSize)), let rightBottomWorldPosition = self.getWorldPosition(from: CGPoint(x: origin.x + self.detectSize, y: origin.y + self.detectSize)) else { print("検出領域の四隅のワールド座標が取れない。iPhoneを前後左右に動かしてください。") return nil } // 検出した座標にワールド座標位置確認用の赤い球を配置 self.cornerMarker1.worldPosition = leftTopWorldPosition self.cornerMarker1.isHidden = false self.cornerMarker2.worldPosition = rightTopWorldPosition self.cornerMarker2.isHidden = false self.cornerMarker3.worldPosition = leftBottomWorldPosition self.cornerMarker3.isHidden = false self.cornerMarker4.worldPosition = rightBottomWorldPosition self.cornerMarker4.isHidden = false // 四隅の座標をワールド座標の中心を基準にした座標に変換 let worldCenter = (leftTopWorldPosition + rightTopWorldPosition + leftBottomWorldPosition + rightBottomWorldPosition) / 4 let leftTop = leftTopWorldPosition - worldCenter let rightTop = rightTopWorldPosition - worldCenter let leftBottom = leftBottomWorldPosition - worldCenter let rightBottom = rightBottomWorldPosition - worldCenter // 2次元のCGPathを3次元の座標系に変換 let geometryPath = UIBezierPath() let path = Path(normalizedPath) var elementCount = 0 path.forEach { element in switch element { case .move(to: let to): geometryPath.move(to: convertPathPoint(to, leftTop: leftTop, rightTop: rightTop, leftBottom: leftBottom, rightBottom: rightBottom)) case .line(to: let to): geometryPath.addLine(to: convertPathPoint(to, leftTop: leftTop, rightTop: rightTop, leftBottom: leftBottom, rightBottom: rightBottom)) case .quadCurve(to: let to, control: _): geometryPath.addLine(to: convertPathPoint(to, leftTop: leftTop, rightTop: rightTop, leftBottom: leftBottom, rightBottom: rightBottom)) case .curve(to: let to, control1: _, control2: _): geometryPath.addLine(to: convertPathPoint(to, leftTop: leftTop, rightTop: rightTop, leftBottom: leftBottom, rightBottom: rightBottom)) case .closeSubpath: geometryPath.close() break } elementCount += 1 } print("path element count[\(elementCount)]") return geometryPath } private func convertPathPoint(_ from: CGPoint, leftTop: SCNVector3, rightTop: SCNVector3, leftBottom: SCNVector3, rightBottom: SCNVector3) -> CGPoint { // パスの各座標について三角形の重心座標系でワールド座標を導出 var point = CGPoint.zero let pl: CGFloat = 1.0 // CGPathの一辺の長さ。VNContourの返す輪郭は(0,0)〜(1,1)の範囲 if from.y > from.x { // 四角形の上側の三角形 let t: CGFloat = pl * pl / 2 // 四角形の上側の三角形の面積 let t2 = pl * (pl - from.y) / 2 // t2の面積 let t3 = pl * from.x / 2 // t3の面積 let t1 = t - t2 - t3 // t1の面積 let ltRatio = t1 / t // 左上座標の割合 let rtRatio = t3 / t // 右上座標の割合 let lbRatio = t2 / t // 左下座標の割合 // 各頂点の重みに応じてワールド座標を算出 let p = leftTop * ltRatio + rightTop * rtRatio + leftBottom * lbRatio point.x = p.x.cg point.y = p.z.cg * -1 } else { // 四角形の下側の三角形 let t: CGFloat = pl * pl / 2 // 四角形の下側の三角形の面積 let t5 = pl * from.y / 2 // t5の面積 let t6 = pl * (pl - from.x) / 2 // t6の面積 let t4 = t - t5 - t6 // t4の面積 let rtRatio = t5 / t // 右上座標の割合 let lbRatio = t6 / t // 左下座標の割合 let rbRatio = t4 / t // 右下座標の割合 // 各頂点の重みに応じてワールド座標を算出 let p = rightTop * rtRatio + leftBottom * lbRatio + rightBottom * rbRatio point.x = p.x.cg point.y = p.z.cg * -1 } // 後でSCNShapeに与える座標となるが、SCNShapeに小さい座標を与えると正しく表示されないのでいったん、拡大しておく。 return point * self.tempGeometryScale } private func makeNode(from geometryPath: UIBezierPath) -> SCNNode { let geometry = SCNShape(path: geometryPath, extrusionDepth: 0.01 * self.tempGeometryScale) geometry.firstMaterial?.diffuse.contents = UIColor.gray let node = SCNNode(geometry: geometry) node.eulerAngles = SCNVector3(x: -Float.pi/2, y: 0, z: 0) // ベジェパスの座標計算時にいったん、拡大していたので縮小する node.scale = SCNVector3(1/self.tempGeometryScale, 1/self.tempGeometryScale, 1/self.tempGeometryScale) node.castsShadow = true // ノードの影をつける let bodyMax = geometry.boundingBox.max let bodyMin = geometry.boundingBox.min let bodyGeometry = SCNBox(width: (bodyMax.x - bodyMin.x).cg * 1/self.tempGeometryScale, height: (bodyMax.y - bodyMin.y).cg * 1/self.tempGeometryScale, length: (bodyMax.z - bodyMin.z).cg * 1/self.tempGeometryScale, chamferRadius: 0.0) let bodyShape = SCNPhysicsShape(geometry: bodyGeometry, options: nil) node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: bodyShape) node.physicsBody?.friction = 1.0 node.physicsBody?.restitution = 0.0 node.physicsBody?.rollingFriction = 1.0 node.physicsBody?.angularDamping = 1.0 node.physicsBody?.linearRestingThreshold = 1.0 node.physicsBody?.angularRestingThreshold = 1.0 return node } } extension SCNVector3 { static func + (lhs: SCNVector3, rhs: SCNVector3) -> SCNVector3{ return SCNVector3(lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z) } static func - (lhs: SCNVector3, rhs: SCNVector3) -> SCNVector3{ return SCNVector3(lhs.x - rhs.x, lhs.y - rhs.y, lhs.z - rhs.z) } static func * (lhs: SCNVector3, rhs: CGFloat) -> SCNVector3{ return SCNVector3(lhs.x * Float(rhs), lhs.y * Float(rhs), lhs.z * Float(rhs)) } static func / (lhs: SCNVector3, rhs: Float) -> SCNVector3{ return SCNVector3(lhs.x / rhs, lhs.y / rhs, lhs.z / rhs) } } extension CGPoint { static func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint{ return CGPoint(x: lhs.x * rhs, y: lhs.y * rhs) } } extension Float { var cg: CGFloat { CGFloat(self) } }
- 投稿日:2020-11-23T09:27:15+09:00
CustomView上のButtonから別クラスにアクションを設定する方法
swift
CustomView
を作成し、別のViewContoroller
上に表示させたは良いものの、CustomView
上に設置したButtonのアクションをどうやってそのVCで発動させるかでつまずいたので、その方法をざっくり解説します。結論、
protocol
とdelegate
を使用します。流れ
- 前提確認
- protocol宣言
- 処理を発動するクラスでの記述
- 処理を行うクラスでの記述
- それぞれのコードと画面
- 終わりに
前提確認
MacOS Catalina 10.15.4
Xcode 12.1
Swift version 5
CustomView
(これに接続するVCをCustomViewVC
と命名)で上下矢印のUIButton
を用意し、別のVC(MainVC
と命名)上にあるUILabel
の値を変化させます。変化のさせ方は「上矢印を押せば+1、下矢印を押せば-1」(以下、処理)とします。
protocol
とdelegate
を使用しますが、CustomViewVC
が処理を発動させるクラス、MainVC
が処理を実際に行うクラスになります。
尚、CustomViewVC
の作成は主旨から外れますので他の記事に譲ります。protocol宣言
宣言場所はどのクラスでも大丈夫ですが、
import xxx
とclass xxx
の間に書くようにしましょう。
ここではMainVC
に書くことにします。MainVCimport UIKit @objc protocol CustomViewDelegate : NSObjectProtocol { func plus() func minus() } class MainViewController: UIViewController{処理を発動するクラスでの記述
protocol
型の変数を宣言します。変数名はdelegateTest
とします。
ここでIBOutlet
で接続させるのがミソです。CustomViewVC@IBOutlet var delegateTest: CustomViewDelegate?上下矢印
Button
をそれぞれIBAction
接続し、その中に+1と-1の処理を発動させます。
(発動だけで、ここで処理は行われません)CustomViewVC@IBAction func plus() { delegateTest?.plus() } @IBAction func minus() { delegateTest?.minus() }処理を行うクラスでの記述
まずは、
protocol
を継承させます。MainVCclass MainViewController: UIViewController, CustomViewDelegate {次に、
MainVC
にCustomView
からdelegate
を接続させます。
これを行うことでCustomView
で発動させた処理を、MainVC
にて行うことができます。
その接続方法ですが、CustomView
を表示させているUIViewをクリックし、インスペクタバー の「Show the Connections inspector」(一番右のアイコン)を見ます。
すると、先ほどIBOutletで宣言したdelegate
変数が表示されているので、MainVC
にドラックして接続させます。あとは行う処理を内容を記述すればOKです。
MainVCfunc plus() { number += 1 label.text = "\(number)" } func minus() { number -= 1 label.text = "\(number)" }それぞれコードと画面
CustomViewVCimport UIKit class CustomView: UIView { @IBOutlet var plusBtn: UIButton! @IBOutlet var minusBtn: UIButton! @IBOutlet var delegateTest: CustomViewDelegate? override init(frame: CGRect) { super.init(frame: frame) loadNib() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) loadNib() //fatalErrorがデフォルトで入っていますが消さないとエラーになってしまうので注意してください! } func loadNib() { //CustomViewの部分は各自作成したXibの名前に書き換えてください let view = Bundle.main.loadNibNamed("CustomView", owner: self, options: nil)?.first as! UIView view.frame = self.bounds self.addSubview(view) } @IBAction func plus() { delegateTest?.plus() } @IBAction func minus() { delegateTest?.minus() }CustomViewVCimport UIKit @objc protocol CustomViewDelegate : NSObjectProtocol { func plus() func minus() } class MainViewController: UIViewController, CustomViewDelegate { @IBOutlet weak var testView: CustomView! @IBOutlet weak var label: UILabel! var number = 0 override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } func plus() { number += 1 label.text = "\(number)" } func minus() { number -= 1 label.text = "\(number)" } }終わりに
CustomView
は便利だと思うのですが、私自身今まで使ったことがなく、表示させたは良いものの、使い勝手悪いように感じましたので、ここで使い方の一つを解説させて頂きました。
protocol
の中身がほぼないのは良くないかもしれませんが、あくまでCustomView
の処理を別VCで行うことを主旨とするため、簡潔にさせて頂きました。
初めての投稿ですので、至らない点あるかと思いますが、何かあればご指摘・アドバイス頂けると幸いです。