20201123のSwiftに関する記事は13件です。

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を追加することで対応しました

参考リンク

https://stackoverflow.com/questions/59705079/swiftui-canvas-preview-compile-error-undefined-symbols-for-architecture-x86-64

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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をちゃんと勉強します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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を継承していそうなクラスが使われているようでした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

no such module '✖️✖️✖️✖️✖️'というエラーの解決法

僕が経験したno such module '✖️✖️✖️✖️✖️'

僕の場合はno such module 'Firebase'でした。

解決策

このエラーはcocoapodに関係するversionに関してのエラーです。
僕はprofileに

pod 'Firebase','6.27.0'

このように書いていてエラーが出たので、このversion(ここで言う6.27.0)を取り除いて

ターミナルでpod installをして見ると、エラーが解けました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AVPlayerViewControllerのコントロールバーを監視する

概要

画面をタップすると表示・非表示が切り替わるコントロールバーを監視します。
あまりニーズがなさそうな情報ですが、つい最近コントロールバーの表示に合わせて自作UIを表示するという要件が実際にありましたのでメモを兼ねて投稿します。

開発環境

Xcode 12.1
Swift 5

AVPlayerViewControllerのレイヤー構成

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)
        }
    }
CustomAVPlayerViewController
    private 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)
        }
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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")
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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
に記載されている対応をします。

  1. プロジェクトを開く(ワークスペースではないことが重要)
$ open ios/Runner.xcodepro
  1. File > ProjectSettingを開く
    スクリーンショット 2020-11-23 16.56.38.png

  2. Advancedを開く
    スクリーンショット 2020-11-23 16.56.47.png

  3. Legacyからdefaultにする
    おそらくLegacyにチェックが入っているはずなので、スクショのようにXcode DefaultにしてDoneを押下する
    スクリーンショット 2020-11-23 16.56.56.png

これでFlutterコマンドからもビルドが通るようになったはずです。

  1. もし上記の方法でもだめだったら、workspaceも確認してみる

Procject SettingがWorkSpaceSettingになってる以外は同様の項目なので、もしかしたら確認してみても良いかもしれません。

$ open ios/Runner.xcworkspace

感想

Xcodeむずかしい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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
に記載されている対応をします。

  1. プロジェクトを開く(ワークスペースではないことが重要)
$ open ios/Runner.xcodeproject
  1. File > ProjectSettingを開く
    スクリーンショット 2020-11-23 16.56.38.png

  2. Advancedを開く
    スクリーンショット 2020-11-23 16.56.47.png

  3. Legacyからdefaultにする
    おそらくLegacyにチェックが入っているはずなので、スクショのようにXcode DefaultにしてDoneを押下する
    スクリーンショット 2020-11-23 16.56.56.png

これでFlutterコマンドからもビルドが通るようになったはずです。

  1. もし上記の方法でもだめだったら、workspaceも確認してみる

Procject SettingがWorkSpaceSettingになってる以外は同様の項目なので、もしかしたら確認してみても良いかもしれません。

$ open ios/Runner.xcworkspace

感想

Xcodeむずかしい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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を使います。
スクリーンショット 2020-11-23 14.39.55.png

Stack Viewにボタンを上から載せて追加

3つのボタンを並べます。
スクリーンショット 2020-11-23 14.59.25.png

配置を設定

topとcenterの制約を設定します。
右下にあるAdd New Constraintsでtopの制約を追加します。
スクリーンショット 2020-11-23 15.01.57.png
次に、Stack Viewをコントロール押しながらドラックしてViewで離します。
すると画像のような選択肢が出てくるので、Center Horizontallyを選択肢します。
スクリーンショット 2020-11-23 15.03.18.png
これで配置が完了しました。
それぞれのボタンのスペースを8に設定してみました。
スクリーンショット 2020-11-23 15.09.58.png

ボタンを追加する

さて、ここでボタン追加の変更が入ったとします。Stack Viewでなければボタンを追加して、配置なども1からやり直しでしたが。
Stack Viewを使っているので、Stack Viewのなかにボタンをドロップするだけで配置することができます。
もちろん、さっき設定したスペースの8も自動で設定されますので、設定し直す必要がありません。
スクリーンショット 2020-11-23 15.13.35.png
スクリーンショット 2020-11-23 15.16.02.png

最後に

レイアウトを管理することができるのがStack Viewの役割です。
これはかなり便利な機能だなと思いました。
まだこのくらいしか使い方知りませんが、また便利な使い方があれば更新していきます。

参考サイト

https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/AutolayoutPG/AutoLayoutWithoutConstraints.html#//apple_ref/doc/uid/TP40010853-CH8-SW1

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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などデータベースでの保管も楽かと。

もっと実用できたら他の演算子も増やして行きます!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UIPickerViewと辞書型で筋トレの内容をlabelに表示させる

せっかくなのでメモ。Swift5 Xcode12.2
UIPickerViewで部位と種目をそれぞれ選択します。
辞書型を使い、選択した部位によって種目のpickerが変わります。

スクリーンショット 2020-11-23 11.05.13.png

スクリーンショット 2020-11-23 11.06.29.png

辞書の宣言

    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
        }
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ARKit+Vision+iOS14 で らくがき のジオメトリ化②【物理判定付き3Dモデル生成】

前回のつづきで検出した輪郭からジオメトリを生成する。
<完成イメージ>
demo.png demo.gif

※作成したジオメトリにテクスチャを貼る手順は次の記事で記載します(これから作成)。

ジオメトリ化の手順

検出した輪郭をジオメトリを表す 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)の変換方法として ARSCNViewraycastQuery(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)

今回作成したサンプルでは取得したワールド座標に小さな赤い球ノードを置いている(シーン内に配置)。輪郭検出の枠とぴったり一致した場所にあることがわかる(=ワールド座標を正確に取得できている)。
redSphere.png

そもそも、こんな便利な仕組みがあるなら、これを使ってパス上の全ての座標をワールド座標に変換することも可能では?と考える。可能かもしれないがこの記事の方法で輪郭検出すると、輪郭上の座標数は数千になることもあるため、計算速度が心配。座標数を減らせば心配ないかもしれないし、そもそも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 の面積に対応させる。
jushin.png

具体的には三角形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 は含まれていなかった。これは輪郭検出の要求精度である VNDetectContoursRequestmaximumImageDimension を下限の 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に影をつける

下の例では床にイカモデルの影が落ちているのがわかる。この方法を説明。
shadow.png
影を落とすにはシーンにライトを追加する必要があるので、まずライトの設定から。

// ディレクショナルライト追加
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.swift
import 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) }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CustomView上のButtonから別クラスにアクションを設定する方法

swift

CustomViewを作成し、別のViewContoroller上に表示させたは良いものの、CustomView 上に設置したButtonのアクションをどうやってそのVCで発動させるかでつまずいたので、その方法をざっくり解説します。

結論、protocoldelegateを使用します。

流れ

  • 前提確認
  • protocol宣言
  • 処理を発動するクラスでの記述
  • 処理を行うクラスでの記述
  • それぞれのコードと画面
  • 終わりに

前提確認

MacOS Catalina 10.15.4
Xcode 12.1
Swift version 5

CustomView(これに接続するVCをCustomViewVCと命名)で上下矢印のUIButtonを用意し、別のVC(MainVCと命名)上にあるUILabelの値を変化させます。変化のさせ方は「上矢印を押せば+1、下矢印を押せば-1」(以下、処理)とします。
protocoldelegateを使用しますが、CustomViewVCが処理を発動させるクラス、MainVCが処理を実際に行うクラスになります。
尚、CustomViewVCの作成は主旨から外れますので他の記事に譲ります。

protocol宣言

宣言場所はどのクラスでも大丈夫ですが、import xxxclass xxxの間に書くようにしましょう。
ここではMainVCに書くことにします。

MainVC
import 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を継承させます。

MainVC
class MainViewController: UIViewController, CustomViewDelegate {

次に、MainVCCustomViewからdelegateを接続させます。
これを行うことでCustomViewで発動させた処理を、MainVCにて行うことができます。
その接続方法ですが、CustomViewを表示させているUIViewをクリックし、インスペクタバー の「Show the Connections inspector」(一番右のアイコン)を見ます。
すると、先ほどIBOutletで宣言したdelegate変数が表示されているので、MainVCにドラックして接続させます。

あとは行う処理を内容を記述すればOKです。

MainVC
    func plus() {
        number += 1
        label.text = "\(number)"
    }

    func minus() {
        number -= 1
        label.text = "\(number)"
    }

それぞれコードと画面

CustomViewVC
import 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()
    }

WechatIMG851.png

CustomViewVC
import 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)"
    }


}

WechatIMG852.png

終わりに

CustomViewは便利だと思うのですが、私自身今まで使ったことがなく、表示させたは良いものの、使い勝手悪いように感じましたので、ここで使い方の一つを解説させて頂きました。
protocolの中身がほぼないのは良くないかもしれませんが、あくまでCustomViewの処理を別VCで行うことを主旨とするため、簡潔にさせて頂きました。
初めての投稿ですので、至らない点あるかと思いますが、何かあればご指摘・アドバイス頂けると幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む