WWDC Quick Look 💓 By SwiftGGTeam
Enhance your iPad and iPhone apps for the Shared Space

Enhance your iPad and iPhone apps for the Shared Space

元の動画を見る

ハイライト

iPad と iPhone アプリはデフォルトで visionOS 上で動作しますが、ホバーエフェクトの追加、カスタムコントロールの調整、カメラ前提の見直しにより、Shared Space でネイティブに感じられるようになります。

主要内容

iPad アプリを何年も磨き上げてきました。カードレイアウト、カスタムプレイヤー、洗練されたボタンスタイル——iPad ではすべて良好に動作します。しかしユーザーは visionOS の Shared Space でアプリを開き、視線と指タップで操作します。既存のタッチロジックは自動的に空間インタラクション体験には変換されません。

visionOS はまったく新しい自然入力を導入します。ユーザーはボタンを見つめ、指でタップして操作します。システムコントロール(Button、Slider など)は自動的にホバーエフェクトを取得し、視線時にハイライトします。しかしインターフェースがカスタムの VStack + onTapGesture の組み合わせやカスタム形状のボタンを使っている場合、これらのインタラクションフィードバックを手動で追加する必要があります。

見落としがちなもう一つの問題はメディア処理です。visionOS には複数のカメラがありますが、ほとんどはアプリに公開されません。前後カメラを直接クエリすると iPhone とは異なる結果になります。アプリは discovery session で利用可能なハードウェアを検出する必要があります。

このセッションはインタラクション、ビジュアル、メディアの適応ポイントを体系的に整理し、「動く」iPad アプリを「正しく感じる」visionOS アプリに変える手助けをします。

インタラクション適応: ホバーエフェクト

visionOS ではホバーエフェクトがインタラクションフィードバックの核心メカニズムです。ユーザーがインタラクティブな要素を見つめると、システムがハイライトしてフォーカス位置を確認できます。

システムコントロールはホバーエフェクトを自動処理します。標準コントロールのみ使用している場合は変更不要です。カスタムコントロールには明示的な追加が必要です。

(02:26) 発表者はカードアプリを示します。各カードは画像、タイトル、時間、メニューボタンを含む VStack です。メニューボタンはシステム Button で自動的にホバーエフェクトがあります。しかしカード全体は .onTapGesture でタップを実装しており、ホバーエフェクトがありません。ユーザーがカードを見つめてもタップ可能かわかりません。

(03:02) 解決策は VStack に .hoverEffect() 修飾子を追加することです。カード全体が視線時にハイライトフィードバックを得ます。

カスタム形状のホバーエフェクト

多くのカスタムビデオプレイヤーはタップ領域を拡大し、小さなボタンを正確にタップする必要を減らします。iPad ではスキップボタンの実際のタップ領域がアイコンよりはるかに大きい場合があります。

(03:49) visionOS ではホバーエフェクトがタップ領域全体を覆い、巨大なハイライトブロックが露出して視覚的に不自然です。

(04:09) 修正方法は .contentShape(.hoverEffect, shape) でホバーエフェクトをより小さな領域に制限しつつ、大きなタップ領域を維持することです。ユーザーにはボタンアイコンに合ったハイライトが見え、タップ範囲はゆったり保たれます。

カスタム ButtonStyle とホバーエフェクト

.buttonStyle でカスタムボタンスタイルを使用すると、ホバーエフェクトは無効になります。手動で再度追加する必要があります。

(05:14) 発表者は虹色ストライプ背景のカスタムボタンを示します。カスタム ButtonStyle 実装では、返されるビューチェーンに .hoverEffect() を追加します。

メディア処理の注意事項

visionOS のカメラ構成は iPhone と異なります。前後カメラが両方利用可能とは限りません。

(09:48) マイクをクエリすると、システムは .front 位置のマイクを返します。カメラをクエリすると2つの結果があります: .back カメラは黒画面(カメラなしアイコン付き)を返す非機能的プレースホルダーカメラで、後方カメラの存在を前提とするアプリとの互換性用です; .front カメラは複合カメラで、spatial persona が設定されていないとフレームを返しません。

(10:42) AVRoutePickerView と Picture in Picture は visionOS では利用できません。カスタムプレイヤーは両コントロールの利用可能性を確認してから表示するか判断する必要があります。

(10:55) デバイスを外すとロックされます。バックグラウンドオーディオを使用するアプリは、ロック後にバックグラウンドモードが付与されず、アプリが完全にサスペンドされることに注意してください。

詳細

VStack カードにホバーエフェクトを追加

struct TappableCard: View {
   var imageName = "BearsInWater"
   var headline = "Bear Fishing"
   var timeAgo = "42 Minutes ago"
   
   var body: some View {
      VStack {
         VStack(alignment: .leading) {
            Image(imageName)
               .resizable()
               .clipped()
               .aspectRatio(contentMode: .fill)
               .frame(width: 300, height: 250, alignment: .center)
            Text(headline)
               .padding([.leading])
               .font(.title2)
               .foregroundColor(.black)
         }
         Divider()
         HStack {
            HStack {
               Text(timeAgo)
                  .frame(alignment: .leading)
                  .foregroundColor(.black)
            }
            .padding([.leading])
            Spacer()
            VStack(alignment: .trailing) {
               Button { print("Present menu options") } label: {
                  Image(systemName: "ellipsis")
                     .foregroundColor(.black)
               }
            }
         }
         .padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
      }
      .frame(width: 300, height: 350, alignment: .top)
      .hoverEffect()  // カード全体にホバーフィードバックを付与
      .background(.white)
      .overlay(
         RoundedRectangle(cornerRadius: 10)
            .stroke(Color(.sRGB, red: 150/255, green: 150/255, blue: 150/255, opacity: 0.1), lineWidth: 3.0)
      )
      .cornerRadius(10)
      .onTapGesture {
         print("Present card detail")
      }
   }
}

キーポイント:

  • VStack に .hoverEffect() を付けると、カード全体が視線時にハイライトされます
  • システム Button(メニューボタン)は自動的にホバーエフェクトがあり、追加処理は不要です
  • .onTapGesture は VStack に残し、タップ動作は変わりません

カスタムホバーエフェクト領域

struct ContentView: View {
   var body: some View {
      VStack {
         HStack {
            Button { print("Going back 10 seconds") } label: {
               Image(systemName: "gobackward.10")
                  .padding(.trailing)
                  .contentShape(.hoverEffect, CustomizedRectShape(
                     customRect: CGRect(x: -75, y: -40, width: 100, height: 100)
                  ))
                  .foregroundStyle(.white)
                  .frame(width: 500, height: 834, alignment: .trailing)
            }
            Button { print("Play") } label: {
               Image(systemName: "play.fill")
                  .font(.title)
                  .foregroundStyle(.white)
                  .frame(width: 100, height: 100, alignment: .center)
            }
            .padding()
            Button { print("Going into the future 10 seconds") } label: {
               Image(systemName: "goforward.10")
                  .padding(.leading)
                  .contentShape(.hoverEffect, CustomizedRectShape(
                     customRect: CGRect(x: 0, y: -40, width: 100, height: 100)
                  ))
                  .foregroundStyle(.white)
                  .frame(width: 500, height: 834, alignment: .leading)
            }
         }
         .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
      }
      .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
      .background(.black)
   }
}

struct CustomizedRectShape: Shape {
   var customRect: CGRect
   
   func path(in rect: CGRect) -> Path {
      var path = Path()
      path.move(to: CGPoint(x: customRect.minX, y: customRect.minY))
      path.addLine(to: CGPoint(x: customRect.maxX, y: customRect.minY))
      path.addLine(to: CGPoint(x: customRect.maxX, y: customRect.maxY))
      path.addLine(to: CGPoint(x: customRect.minX, y: customRect.maxY))
      path.addLine(to: CGPoint(x: customRect.minX, y: customRect.minY))
      return path
   }
}

キーポイント:

  • .contentShape(.hoverEffect, shape) でホバーエフェクトをカスタム形状内に制限します
  • ボタンの frame は大きく保ち、ゆったりしたタップ領域を維持します
  • CustomizedRectShape がホバーエフェクトの可視境界を定義します

カスタム ButtonStyle でホバーエフェクトを再有効化

struct ContentView: View {
    var body: some View {
        VStack {
         Button("Howdy y'all") { print("🤠") }
            .buttonStyle(SixColorButton())
        }
        .padding()
    }
}

struct SixColorButton: ButtonStyle {
   func makeBody(configuration: Configuration) -> some View {
      configuration.label
         .padding()
         .font(.title)
         .foregroundStyle(.white)
         .bold()
         .background {
            ZStack {
               Color.black
               HStack(spacing: 0) {
                  Rectangle()
                     .foregroundStyle(Color(red: 125/255, green: 186/255, blue: 66/255))
                     .frame(width: 16)
                  Rectangle()
                     .foregroundStyle(Color(red: 240/255, green: 187/255, blue: 64/255))
                     .frame(width: 16)
                  Rectangle()
                     .foregroundStyle(Color(red: 225/255, green: 137/255, blue: 50/255))
                     .frame(width: 16)
                  Rectangle()
                     .foregroundStyle(Color(red: 200/255, green: 73/255, blue: 65/255))
                     .frame(width: 16)
                  Rectangle()
                     .foregroundStyle(Color(red: 134/255, green: 64/255, blue: 151/255))
                     .frame(width: 16)
                  Rectangle()
                     .foregroundStyle(Color(red: 75/255, green: 154/255, blue: 218/255))
                     .frame(width: 16, height: 500)
               }
               .opacity(0.7)
               .rotationEffect(.degrees(35))
            }
         }
         .cornerRadius(10)
         .hoverEffect()  // カスタムスタイル後に手動追加
   }
}

キーポイント:

  • カスタム ButtonStyle はシステムデフォルトのホバーエフェクトを無効にします
  • makeBody が返すビューチェーンの末尾に .hoverEffect() を追加してフィードバックを復元します

カスタム形状ボタンのホバーエフェクト

struct ContentView: View {
    var body: some View {
      VStack {
         Button { print("🐝") } label: {
            HoneyComb()
               .fill(.yellow)
               .frame(width: 300, height: 300)
               .contentShape(.hoverEffect, HoneyComb())
            }
         }
         .frame(width: 400, height: 400, alignment: .center)
         .background(.black)
         .padding()
      }
    }
}

struct HoneyComb: Shape {
   func path(in rect: CGRect) -> Path {
      var path = Path()
      path.move(to: CGPoint(x: rect.minX + (rect.width * 0.25), y: rect.minY))
      path.addLine(to: CGPoint(x: rect.maxX - (rect.maxX * 0.25), y: rect.minY))
      path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
      path.addLine(to: CGPoint(x: rect.maxX - (rect.maxX * 0.25), y: rect.maxY))
      path.addLine(to: CGPoint(x: rect.minX + (rect.width * 0.25), y: rect.maxY))
      path.addLine(to: CGPoint(x: rect.minX, y: rect.midY))
      path.addLine(to: CGPoint(x: rect.minX + (rect.width * 0.25), y: rect.minY))
      return path
   }
}

キーポイント:

  • .contentShape(.hoverEffect, HoneyComb()) でホバーエフェクトが六角形の境界に沿います
  • この修飾子がないと、ホバーエフェクトは frame 矩形全体を覆います
  • カスタム Shape の path(in:) メソッドがホバーエフェクトのクリッピング境界を定義します

重要ポイント

1. すべてのカスタムインタラクティブ要素にホバーエフェクトを追加

アプリが onTapGestureDragGesture、または他のジェスチャ修飾子を使うカスタムビューを使用している場合、.hoverEffect() が追加されているか確認してください。視覚フィードバックのない空間インタラクションはユーザーを混乱させます。入口: カスタムビューのルートコンテナに .hoverEffect() を追加します。

2. メディア関連機能のハードウェア検出を最適化

アプリが写真撮影、動画録画、スキャン、音声録音を行う場合、ハードコードされたカメラ/マイククエリを AVCaptureDevice.DiscoverySession に置き換えてください。visionOS のカメラ構成は iPhone とまったく異なり、前後カメラが利用可能と仮定すると機能が異常になります。入口: discovery session で利用可能なデバイスを列挙し、利用可能なデバイスがない場合の代替手段(ドキュメントピッカー、iCloud)を提供します。

3. ゲームにコントローラサポートを追加

visionOS の視線+タップインタラクションはゆっくりした操作に適しますが、ゲームには高速で並行した入力が必要なことが多いです。Info.plist に GCSupportsControllerUserInteraction を追加し、Game Controller capability を有効にしてください。App Store 商品ページにコントローラサポートバッジが表示され、プレイヤーがゲームを発見しやすくなります。入口: GameController フレームワーク + Info.plist 設定。

4. バックグラウンドオーディオの前提を確認

visionOS デバイスは外すとロックされ、バックグラウンドオーディオモードは機能しなくなります。バックグラウンド再生に依存するアプリ(音楽プレイヤー、ポッドキャストアプリ)ではユーザー体験を再検討してください。入口: バックグラウンド移行時に再生状態を保存し、フォアグラウンド復帰時に復元します。

関連セッション

  • Build great games for spatial computing — visionOS ゲーム開発の入力方式(コントローラ、ジェスチャ、視線+タップ)について学ぶ
  • Explore rendering for spatial computing — RealityKit のレンダリング、ライティング、シャドウを深く学び、3D コンテンツを実環境に溶け込ませる
  • Meet RealityKit Trace — Instruments の RealityKit Trace テンプレートで空間コンピューティングアプリのパフォーマンスを分析・最適化する

コメント

GitHub Issues · utterances