既存のコードベースからvisionOS向けの一流体験をCraftでどのように実現したか

新しいプラットフォームへの参入は、開発者の人生において数えるほどしかない特別な出来事です。それは本当に新しい領域に踏み込む、稀で刺激的な瞬間です。素早く動けば、かつての探検家のような気持ちになれます。すべてが新鮮で、確立されたパターンがないからこそ、実験の余地が広がっています。

公開日
5/15/2024
Building Craft
既存のコードベースからvisionOS向けの一流体験をCraftでどのように実現したか

私たちCraftにはすでにこの機会が一度ありました。Mac Catalystという素晴らしい技術を使い始めたときです。これにより、iOSアプリをMacに展開することができました。それ以来、iOS、iPadOS、macOSといった私たちがサポートするすべてのプラットフォームでアプリを構築するために共通のコードベースを使用しています。visionOSの登場により、私たちはこの堅固な基盤の上に構築することができました。

この記事では、CraftをVision Proに移植したプロセスを紹介し、いくつかの興味深い学びをお伝えします。

  • 依存関係を更新する: 使用しているライブラリがvisionOSをサポートするよう更新されているか確認する
  • 非推奨APIを避ける: Appleは数年前に非推奨となっていた古いAPI呼び出しをすべて削除した
  • UIScreenに頼らない: これらの古い制約はもう存在しないホバーエフェクトを使用する:ユーザーの目が向いたときにシステム提供のハイライトをサポートするようカスタムビューを改善する
  • ダークモードとライトモードのないアプリを準備する: アプリはシステム提供のガラスマテリアルに適応する必要があり、それは一つの状態しか持たない
  • UIにスペースを与える: visionOSではUI要素がより大きく、要素間のスペースも広い
  • 3つの入力方法すべてをサポートする: ユーザーはアイコントロール、ダイレクトタッチ、またはトラックパッドを接続したポインターを使用できる
  • 3Dで差別化する: SwiftUIを使ってレイヤーを3次元空間に移動させる
  • アクセシビリティに配慮する: 常にアクセシビリティオプションを有効にしてアプリをテストする

ロードマップ

CraftがvisionOSのApp Storeに適した候補だと判断した後、すぐにそのプラットフォーム向けのアプリ構築の実験を始めました。もちろん、最初は失敗の連続でした。何もすぐには動きませんでした。問題を調査し、コードベース全体を素早く反復して、動作しないコードをすべて削除しました。プラットフォーム上でCraftがどのように見えるか、バージョンを作成するのがどれほど難しいかを素早く確認したかっただけです。動作させるためにいくつかの依存関係を削除し、一部の関数をコメントアウトする必要がありましたが、数時間の作業で済み、幸いなことに重要なコンポーネントは壊れていませんでした。Xcodeが新しいOSに対してCraftを初めてビルドし、シミュレーターで動き始めるのを見ることができました。

image

開始当初の状態。メイクアップ前でも、すでにネイティブで動作している

もちろん、少し荒削りな状態でした。アプリのほとんどは3D空間に埋め込まれたiPadアプリのように見え、一部のインターフェースカラー、ボタンの形状、削除されたコンポーネントに依存するものは壊れていましたが、本物であることに変わりありません。同期やドキュメント編集といった重要な部分はすでに機能していました。これがプロジェクトにゴーサインを出した時点です。

私たちはVision Proバージョンをホビープロジェクトとして扱いました。メインアプリの開発に余裕が生まれたときに取り組みました。リリースに向けていくつかの重要なステップを設定しました:

  1. プロジェクト作業 新しいプラットフォームを追加し、ライブラリを更新し、不要な部分を削除し、新しいOSでプロジェクトをビルドできるようにする
  2. 調整 visionOS向けにプロジェクトを調整する。欠けているクラス、異なる機能、新しい機能に対処する
  3. 美しくする 新しいデザイン言語に合わせてユーザーインターフェースを改善する

これらのステップを一つずつ見ていきましょう。

プロジェクト作業

最初の簡単なテストの後、メインブランチから派生した新しいブランチで最初から始め直しました。最初のステップは、XcodeのサポートされているプラットフォームリストにApple Visionデスティネーションを追加することでした。これにより依存関係に多くの問題が生じたため、最初のタスクはすべてのライブラリを確認して問題を修正することでした。

依存関係の中にはすでにvisionOSをサポートするよう更新されていたものもあり、バージョン番号を上げて更新し、新しいバージョンが以前と同様に機能するかを簡単に確認するだけで済みました。

残念ながら、使用しているライブラリの中には更新されていないものもあり、近い将来に必要な変更をサポートする見込みもありませんでした。それらに依存しすぎていたため削除もできず、CraftのGitHubアカウントでフォークしました。更新は簡単でした。ほとんどの場合、問題は各プラットフォーム(iOS、macOS、tvOS、watchOS)ごとに明示的な動作が定義されており、それ以外のデフォルト実装が提供されていないことでした。つまり、visionOSには実装が全くなかったのです。

visionOSに存在しないAPIコールやオブジェクトにアクセスするライブラリもいくつかありました(例えばUIScreenについては後述します)。これらは簡単に回避できました。

残念ながら、自分たちでは修正できない(例えばクローズドソースのため)が回避できる依存関係もあり、プロジェクトから削除しました。Googleは残念ながらAppleの急速なプラットフォーム開発についていくのが早くないことで知られており、そのため「Googleでサインイン」ボタンは削除しなければなりませんでした。ライブラリを削除しても他のプラットフォームには影響すべきではありません。幸い、Xcodeプロジェクトではそれを設定できます:

image

他方では、対応する部分の呼び出しを無効化するために#if os(visionOS)マクロを追加する必要がありますが、それだけです。

また、ShortcutsサポートなどのApp Extensionのほとんども削除しました(少なくとも今のところ)。これらはいくつかのビルドエラーを引き起こし、まずアプリ本体に集中したかったためです。後のバージョンでは最終的にこれらを追加し直す予定です。

新しい環境への調整

前のフェーズを経て、プロジェクトはまだコンパイルできませんでしたが、少なくともすべてのエラーが自分たちのコードにありました。

簡単な方法はありませんでした:エラーを一つずつ解決していく必要がありました。

本当に一般的だったいくつかのカテゴリと、それらをどのように解決したかをまとめました:

UIScreen

visionOSにはほぼ完全なUIKitがありますが、いくつかの重要な部分が欠けています。Appleが何年も前にiOSで非推奨と宣言していたすべてのコードを削除したため(技術的負債を引き継ぎたくなかったのだと思います)、あるいはここでは意味をなさないためです。UIScreenは後者のグループの一つです:ユーザーが長方形ではなく360度の球体を遊び場として持つことになるため、スクリーンという概念はもはや関係ないのです。

コードを確認した結果、UIScreenはほとんどの場合、ウィンドウとUIのサイズを決めるための制約を取得するために使用しており、スクリーンのスケール(Retinaかどうか)を知るために使用していることがわかりました。これは重要です。なぜなら、コード全体でマニュアルレイアウト(フレームの設定)を使用しているからです。visionOSではデフォルト値を返し、他のプラットフォームでは実際のUIScreenプロパティを返すラッパーを作成しました:

public class CraftScreen: NSObject {
    // MARK: - UIScreen emulation

    /// Acts similarly to `UIScreen.main`, but will be mocked on platforms like visionOS which don't have `UIScreen`
    public static var main: CraftScreen = CraftScreen()

    public var traitCollection: UITraitCollection {
        return Self.traitCollection
    }

    public var bounds: CGRect {
        return Self.bounds
    }

    public var scale: CGFloat {
        return Self.scale
    }

    // MARK: - New style accessors

    public static var traitCollection: UITraitCollection {
        #if os(visionOS)
        return UITraitCollection.current
        #else
        return UIScreen.main.traitCollection
        #endif
    }

    public static var userInterfaceStyle: UIUserInterfaceStyle {
        return Self.traitCollection.userInterfaceStyle
    }

    public static var bounds: CGRect {
        #if os(visionOS)
        return CGRect(x: 0, y: 0, width: 1024, height: 768)
        #else
        return UIScreen.main.bounds
        #endif
    }

    /// `UITraitCollection` can return different results on main and a background thread. We trust only the value on the main thread, therefore we try to cache it
    private static var _lastScale: CGFloat = 2.0
    public static var scale: CGFloat {
        if Thread.isMainThread {
            #if os(visionOS)
            let scale: CGFloat = UITraitCollection.current.displayScale
            #else
            let scale: CGFloat = UIScreen.main.scale
            #endif
            _lastScale = scale
            return scale
        } else {
            return _lastScale
        }
    }
}

基本的に、すべてのUIScreen.main.traitCollectionの呼び出しをCraftScreen.main.traitCollectionに置き換えました。

プラットフォームサポート

マルチプラットフォーム開発をサポートするために、Craftには非常に便利な拡張機能が多数あります。ほぼすべての一般的な型に、次のように機能する関数セットが追加されています:

extension Int {
  func onMac(_ value: Int) {
    return DeviceUtility.isMac ? value : self
  }
}

これにより、コードにデフォルト値を記述し、次のようにプラットフォーム固有の調整を簡単に追加できます:

let padding: Int = 16.onMac(8)

これはMacで実行されると8、それ以外では16を返します。

このシステムをVision Proを認識するよう拡張し、対応する.onVision関数を追加しました:

public enum PlatformType {
    case unsupported
    case iPadOS
    case iOS
    case macCatalyst
    case visionOS
}

static public var platformType : PlatformType {
    #if targetEnvironment(macCatalyst)
        return .macCatalyst
    #elseif os(visionOS)
        return .visionOS
    #else
    if UIDevice.current.userInterfaceIdiom == .pad {
        return .iPadOS
    } else if UIDevice.current.userInterfaceIdiom == .phone {
        return .iOS
    } else {
        return .unsupported
    }
    #endif
}

static public var isVision : Bool {
    return self.platformType == .visionOS
}

また、新しいプラットフォームを認識し、すべてのプロパティを正しく設定するよう内部アナリティクスツールも拡張しました。

サポートされていないハードウェア機能の除去

visionOSは印刷をサポートしていないため、アプリから対応するすべてのコードを削除する必要がありました。もちろん、PDFにエクスポートして印刷できるデバイスにファイルを送ることはできますが、現在の形ではVision Proで直接印刷することはできません。

同様に、プラットフォームは開発者がカメラを使用する方法を提供していません。皮肉なことに、Vision ProはAppleデバイスの中で最多のカメラ数を持っています🙂。すべてのメニューから写真を撮るオプションを削除し、メディアピッカーとUnsplashのオプションだけを残しました。

細かい修正

visionOSから欠けているその他のクラスとプロパティ:

  • UITextView.inputAssistantItem
  • UIScreenshotServiceDelegate
  • UIImagePickerController.QualityType.typeHigh
  • UIApplication.openURLopenに改名)
  • CLAuthorizationStatus.authorizedAlways & CLLocation.requestAlwaysAuthorization()
  • CoreTelephonyフレームワーク
  • UIViewController.setNeedsStatusBarAppearanceUpdate()
  • UIFeedbackGenerator
  • UIWindow.keyWindow(ずっと前から非推奨)
  • UIViewController.keyboardDismissMode

これらはすべて、小さな、または大きな機能削減で置き換えるか回避することができました。

美しいvisionOSアプリになるまでの道のり

このフェーズを経て、アプリはvisionOS SDKに対してビルドし、Vision Proで安定して動作するようになりました。唯一の問題は、それがvisionOSらしく見えないことでした。

もちろん、visionOS SDKに対してビルドするだけで、iPadアプリと比べて多くの利点がすでに得られます。最も明白なものとして、アプリを自由にリサイズできます。プロダクトエンジニアが基礎作業を行っている間、デザインチームはプラットフォームでアプリが最終的にどのように見えるべきかを想像することに非常に興奮していました。いくつか反復を重ね、visionOSでネイティブに見え、かつ既存のコードベースから実装しやすい親しみやすいコンセプトに到達しました。

ハイライト

visionOSは目を追跡し、見ているアイテムを画面上で選択するための優れたメカニズムを提供します。しかし、システムが見ている要素をハイライトすることで理解していることを保証しない場合、確信は突然消えてしまいます。UIButtonのような標準UIエレメントを使用していれば、これは無料で手に入ります。しかし私たちはCraftが自作したコードを信じているので、CraftのUIのほとんどはシンプルなUIViewサブクラスからカスタムメイドされています。

大きな利点がありました:UIViewをそのまま使う代わりに、CraftTappableViewを使用しています。これにより、スタイルオブジェクトの自動継承、多くの設定可能なタップ・クリック動作などの非常に便利な仕組みが提供され、常に使用しています。幸いなことに、画面上のすべてのインタラクティブなボタンとオブジェクトはこのクラスから派生しているため、クラスにこの3行を追加するだけで、いたる場所でナイスなホバーエフェクトを実現できました:

#if os(visionOS)
self.hoverStyle = UIHoverStyle(effect: .highlight)
#endif

ツールチップ

私たちはCatalystアプリケーションであり、iOSはツールチップを長期間サポートしていなかったため、ポインティングデバイスを持つプラットフォームで使用する独自のソリューションを構築する必要がありました。

visionOSではレガシーOSサポートは必要なく、システム提供のUITooltipInteractionを使用し、システムコールでグローバルにコードを置き換えることができました:

システム提供のツールチップとハイライト(まだ未調整のツールバー形状)

public var toolTip: String? {
    didSet {
        #if os(visionOS)
        self.addUITooltipInteractionIfNeeded()
        #endif
    }
}

private func addUITooltipInteractionIfNeeded() {
    if #available(iOS 15.0, macCatalyst 15.0, visionOS 1.0, *) {
        if let existingInteraction = interactions.compactMap({ $0 as? UIToolTipInteraction }).first {
            self.removeInteraction(existingInteraction)
        }

        // Add new tooltip interaction if there's a new tooltip text
        if let toolTip: String = toolTip {
            let newInteraction = UIToolTipInteraction(defaultToolTip: toolTip)
            self.addInteraction(newInteraction)
        }
    }
}

ダークモードとライトモード

visionOSには専用のダークモードとライトモードがありません。没入モードのカラーをダークまたはライトに設定できますが、インターフェースは環境に自動的に適応する美しいガラスマテリアルのままです。

Craftはダークモードとライトモードの両方をサポートし、アプリ内でユーザーがこれをオーバーライドすることもできます。Vision Proではこの機能を再考する必要がありました。私たちの既存のダークインターフェースが目指すものに最も近いと判断し、アプリのグローバルな外観をダークにオーバーライドすることから始めました。

image

これによりナイスなインターフェースになりましたが、ドキュメントは紙の直接の後継として、白くあることも許可されるべきだと感じました。

XMLでUI設定を定義し、プラットフォームに合わせてレンダリングされるモジュール式設定インターフェースがあります。visionOSプラットフォームのサポートを追加し、テーマ設定をダークとライトのバリアントのみに変更しました(自動オプションを削除)。

しかし難しい部分は次のステップでした:overrideUserInterfaceStyleでUIをダークモードに強制しながら、ドキュメントをライトモードで表示するにはどうすればよいか?さらに悪いことに、ドキュメントだけでなく、タブプレビュー、目次のツールチップ、ファイルブラウザ、ホーム画面、そしてUIの一部も更新する必要がありました。ライトなドキュメント背景とダークなボトムツールバーの組み合わせが良く見えないと感じたためです。

解決策は大変な作業を必要としました。すでに述べたCraftTappableViewオブジェクトはpageStyleと呼ばれるものをサポートしています。これは私たちがUITraitCollectionのバージョンと考えることができます:エディターのブロックやボタンなどのオブジェクトがどのように見えるかを決定する色、スタイル、フォントのセットです。pageStyleは現在のテーマに基づいて構築され、サブビューに自動的に継承されます。変更も私たちのビューによって自動的に伝播されます。そのため、pageStyleはダークモード用の色を含んでいました。エディター用にcontentPageStyleも導入し、ユーザーが設定した外観に合わせて変更されたオリジナルのpageStyleのバリアントを含みました。

var contentPageStyle : BlockModelPageStyle? {
    var retVal : BlockModelPageStyle? =  BlockPageStyleAPI.sharedInstance.styleForDescriptor(self.mainBlockModel?.pageStyleToUse) ?? BlockPageStyleAPI.sharedInstance.defaultStyle
    if retVal?.scaleFactor != self.scaleFactor {
        retVal = retVal?.duplicate(withScaleFactor: self.scaleFactor)
    }

    if DeviceUtility.isVision {
        let forceUserInterfaceStyle: UIUserInterfaceStyle = {
            switch OnDeviceStorage.sharedInstance.appearanceUserInterfaceStyle {
                case 2: return .dark
                default: return .light
            }
        }()
        if retVal?.forceUserInterfaceStyle != forceUserInterfaceStyle {
            retVal = retVal?.duplicate(withForceUserInterfaceStyle: forceUserInterfaceStyle)
        }
    }
    return retVal
}

あとは、設定に反応させたいビューに通常のpageStyleの代わりにこのcontentPageStyleを渡し、環境設定が変更されたときに発行するNSNotificationのリスナーを追加するだけでした。

UIをすべて確認して、ライトバリアントを表示すべきと感じた箇所でこのpageStyleを上書きする必要がありました。これによって多くの課題が生じました。ページ読み込みのたびに何百回も呼び出される関数からディスクベースの値にアクセスするのは良いアイデアではなく、また、これらのクラスがSwiftモジュールから常に直接アクセスできるわけでもありませんでした。

ガラスマテリアル

Craftは既にウィンドウの背景色に多くの作業が施されています。ドキュメントの背景画像またはスペースプロファイル画像の拡張バージョンを使用したUIEffectView、色、グラデーションの組み合わせを使用して、ウィンドウに少しスパイスを加えています。

visionOSではOSが提供するピュアなガラスマテリアルのみを使用することにしました。そのために、他のプラットフォームでこの効果を実現するために配置したすべてのビューをオフにし、基本的に透明な背景を使用して、残りはOSに任せることにしました。

パディングとボタンの形状

近づいてきましたが、アプリはプラットフォーム上の他のアプリと比べてまだ密度が高く見えました。トップツールバーから始めて、デザインのサイズに合わせるために.onVisionコンディションを使用してすべてのパディングを調整しました。

image

調整されたツールバー。サイドバーにはまだ元のサイズが残っている点に注目。

レイヤーに.cornerRadiusを追加することで、visionOSはほとんどの場合、正しいホバーフォームを自動的に選択しましたが、クリック可能なオブジェクトの形状を決定するサブビューを使用していた一部のケースでは、手動でホバーシェイプを調整する必要がありました:

if #available(iOS 17.0, *) {
    self.closeButton.hoverStyle = .init(shape: .rect(cornerRadius: 8))
}

タブバーの高さ、ウィンドウエッジからのパディング、ボタン間のパディングなどを増加しました。カスタムツールバーの実装を使用しており、これらの数値を定数として抽出した優れた拡張可能なコードが既にあったため、他のプラットフォームでの方法と同様に、それらにonVisionモディファイアを追加するだけでよかったです:

var preferredWidth : CGFloat { return 40.onMac(36).onVision(44) }

色にも同じテクニックを使用しました:

self._bgView.backgroundColor = UIColor.clear.onVision(UIColor.white.withAlphaComponent(0.06))

入力方法

Vision Proには3つの異なる入力方法があります:

  • ピンチジェスチャーによるアイコントロール。 ものを見て、指でピンチして「クリック」します。システム提供のホバーエフェクトがありますが、アプリはホバーイベントを受け取りません。
  • ダイレクトフィンガーコントロール。 アプリを自分に近づけて、まるで本物のように指でボタンを押します。この方法はスーパークールなホバーエフェクトも提供します

ホバージェスチャーで少し遊んでみる

  • トラックパッドによる間接コントロール。 接続されたMacのトラックパッドやペアリングしたBluetooth トラックパッドアクセサリを使用して、iPadやMacのようにインターフェースをコントロールするカーソルを使えます。これはiPadやMacとまったく同じように機能します。

タブの実装においてアイコントロールをより使いやすくするために、いくつかの小さな変更を加えました。通常、ユーザーがタブにホバーしたときに閉じるボタンを表示し、非アクティブなタブを閉じることができます。アイコントロールではvisionOSがホバーイベントを提供しないため、閉じるボタンの可視性を設定できず、ボタンが表示されませんでした。しかし、タブの左側を見てピンチしても当然閉じることはできました。これは良くなかったため、ポインターを検出しないvisionOSプラットフォームでは閉じるボタンを完全に廃止しました。

3Dパネル

開発中に行った最も興味深いことの一つは、すべてのパネルを3D空間に移動させることです。

Craftにはパネルグループと呼ぶナイスなクラスがあります。このクラスはウィンドウの上にコンテンツを表示するために使用しており、モーダルビューのプレゼンテーション、ポップオーバー、コンテキストメニューの一部を置き換えています。OSの実装のいずれかに見えるほど柔軟でありながら、UIKitのバリアントよりも柔軟にサイズと配置ができます。また、コンテンツを置き換えるときのナイスなトランジションもあります:自動的に新しいサイズにアニメーションします。

このクラスはアプリ全体で使用しているため、少し3Dエフェクトを適用することは理にかなっていました。

しかし、どのように?

Appleはvisionが提供するほぼすべてのものをUIKitからアクセスできるようにする素晴らしい仕事をしましたが、Zオフセットはその一つではありませんでした。しかし、SwiftUIから実現することができました。私たちはまだCraftでSwiftUIを使用していなかったため、これも新しい領域でした。

アイデアは次のとおりです:

  1. SwiftUIビューをUIKitに埋め込む
  2. SwiftUIビューで.offset(z: 25)を調整して、3D空間でユーザーに近づける
  3. このSwiftUIビューにUIKitビューを埋め込む

テストビューを作成したところ、うまくいきました!あとは、このすべての大変な作業を行うUIKitビューを作成し、パネルグループのコンテンツをこのビューのサブビューとして追加し、3番目のポイントのUIKitビューに実際に追加されることを確認するだけでよかったです。

#if os(visionOS)

// +-----------------------------------------------+
// | CraftPanelRaisedContainerView                 |
// |  +------------------------------------------+ |
// |  | UIHostingController                      | |
// |  |  +-------------------------------------+ | |
// |  |  | RaisedView (SwiftUI)                | | |
// |  |  |  +-------------------------------+  | | |
// |  |  |  | UIKitEmbedderView             |  | | |
// |  |  |  | (SwiftUI)                     |  | | |
// |  |  |  |  +-------------------------+  |  | | |
// |  |  |  |  | RaisedViewUIKitContents |  |  | | |
// |  |  |  |  +-------------------------+  |  | | |
// |  |  |  +-------------------------------+  | | |
// |  |  +-------------------------------------+ | |
// |  +------------------------------------------+ |
// +-----------------------------------------------+

/// Public interface. DO NOT USE `addSubview`! USE `addRisedSubview` instead!
public class CraftPanelRaisedContainerView: UIView, RaisedViewProtocol {
    let embeddedHostingViewController: UIHostingController<RaisedView> = UIHostingController(rootView: RaisedView(level: 0))

    init() {
        super.init(frame: .zero)
        self.embeddedHostingViewController.sizingOptions = [.intrinsicContentSize, .preferredContentSize]
        self.addSubview(self.embeddedHostingViewController.view)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public override func layoutSubviews() {
        super.layoutSubviews()
        self.updateLevel()
        self.embeddedHostingViewController.view.frame = self.bounds
    }

    func updateLevel() {
        guard
            let window: UIWindow = self.window,
            self.embeddedHostingViewController.rootView.level != CraftPanelRaisedContainerViewRegistry.shared.level(for: window)
        else { return }

        let newRaisedView: RaisedView = RaisedView(level: CraftPanelRaisedContainerViewRegistry.shared.level(for: window))
        self.embeddedHostingViewController.rootView.contents.subviews.forEach { v in
            v.removeFromSuperview()
            newRaisedView.contents.addSubview(v)
        }
        self.embeddedHostingViewController.rootView = newRaisedView
    }

    public func addRaisedSubview(_ view: UIView) {
        self.embeddedHostingViewController.rootView.contents.addSubview(view)
    }
}

/// SwiftUI view which is responsible for the Z axis transformation
struct RaisedView: View {
    let contents: RaisedViewUIKitContents = RaisedViewUIKitContents()
    var level: Int
    var contentCornerRadius: CGFloat = 20

    var body: some View {
        UIKitEmbedderView(embeddedView: contents)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .glassBackgroundEffect(in: RoundedRectangle(cornerSize: CGSize(width: contentCornerRadius, height: contentCornerRadius)))
            .offset(z: CGFloat(level * 25)) // This will move the view to the 3D space
    }
}

/// This view will contain the actual subviews for the whole hierarchy
class RaisedViewUIKitContents: UIView { }

/// SwiftUI view which embeds the `CraftPanelRaisedContainerViewController`
struct UIKitEmbedderView: UIViewControllerRepresentable {
    typealias UIViewControllerType = CraftPanelRaisedContainerViewController
    let embeddedView: UIView

    func makeUIViewController(context: Context) -> CraftPanelRaisedContainerViewController {
        return CraftPanelRaisedContainerViewController()
    }

    func updateUIViewController(_ uiViewController: CraftPanelRaisedContainerViewController, context: Context) {
        uiViewController.addContainedView(embeddedView)
    }
}

/// `UIKitEmbedderView` will embed this controller and add it's `embeddedView` to a subview of this
class CraftPanelRaisedContainerViewController: UIViewController {
    var containedView: UIView?

    func addContainedView(_ view: UIView) {
        self.containedView = view
        self.view.addSubview(view)
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        self.containedView?.frame = self.view.bounds
    }
}

#else

public class CraftPanelRaisedContainerView: UIView, RaisedViewProtocol {
    public func addRaisedSubview(_ view: UIView) {
        self.addSubview(view)
    }
}

#endif

これが実際の見た目です:

image

3D空間に浮かぶパネル

面白いサイドストーリーを追加させてください:システムのコンテキストメニューが私たちの50ptオフセットよりもウィンドウに近いことに気づきました。では、ここでZ軸の距離をどのように測定しますか?簡単です!昔のように指を使って空間で測ればいいんです!😉 距離はだいたい半分に見えたので、値を半分にして合わせました。

ここで注意すべき点があります:ウィンドウに追加されたタップリスナーは、ウィンドウレベルより上に表示されるため、浮き上がったビューでは発火しません。注意してください!

多くの時間を費やした問題の一つは、visionOSでUIColorがどのように異なる動作をするかを理解し修正することでした。私たちのUIコードの一部は、ダーク背景を持つボタンのラベル色を設定するためにUIColor.systemBackgroundを使用していました(ライトモードでは明るく表示されていた)。Vision Proでは残念ながら、この定数が多くの場合に透明色を返すため、これらのラベルが消えていました。

もう一つの似たような問題は、UIColor.labelが背景色に自動的に適応することです。そして、それは配置した場所ごとに正しい色で表示されることで行われます。少なくともこれが理論ですが、残念ながら私たちにとっては、白い背景に白色で表示されることもありました。さらに悪いことに、デバッガーでその値を表示すると、常に元のダーク色が表示されます。

アクセシビリティ

すべてのAppleプラットフォームと同様に、visionOSも充実したアクセシビリティオプションのリストを提供しています。コントラストを高めた状態、ボタンシェイプを有効にした状態、その他すべてのオプションでアプリを確認することが非常に重要です。コントラストを高めたモードで一部の設定ラベルが欠けている箇所を見つけました。

image

まとめ

振り返ってみると、アプリをvisionOSに移植するには大きな努力が必要でしたが、予想と比べると非常に簡単で、比較的わかりやすいものでした。ほとんどすべてのものがすぐに機能し、OSは美しいデフォルトを提供しており、プラットフォームに合わせるためにより小規模な調整を行うだけでよかったです。私たちはこのターゲットをメインコードベースに維持し、通常のプラットフォームと同様にCraftの新しいバージョンをリリースできると考えています。

Apple Vision ProバージョンのCraftを無料で試すことができます:Craft Vision Pro

ブログをもっと読む