ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
visionOSにおけるカスタムホバーエフェクトの作成
ユーザーが表示中のビューを更新する、カスタムホバーエフェクトの作成方法について説明します。透明度、スケール、クリップのエフェクトを組み合わせて拡張ボタンエフェクトを構築する方法を確認しましょう。使いやすく、アクセシビリティに対するユーザーのニーズを考慮したエフェクトを作成するためのベストプラクティスもご紹介します。
関連する章
- 0:00 - Introduction
- 2:35 - Content effects
- 7:46 - Effect groups
- 9:40 - Delayed effects
- 12:09 - Accessibility
リソース
関連ビデオ
WWDC24
WWDC23
-
ダウンロード
「Create custom hover effects in visionOS」へようこそ 私はSwiftUIチームのエンジニア Christianです このセッションでは SwiftUIのビューをユーザーの視線に 反応させる方法を学びます 使用するのは 新しいCustom Hover Effect APIです
visionOSのインタラクティブなエリアは 視線を向けるとハイライトされます このようなハイライトの適用には ホバーエフェクトを使用します ホバーエフェクトを使うと アプリの応答性が高まるとともに また ユーザーが指をタップした時に どの要素がトリガーされるか フィードバックを提供します
ホバーエフェクトは標準のコントロールに 自動的に追加されます hoverEffectビューモディファイアを使えば カスタムコントロールにも追加できます ハイライトなどの標準エフェクトの 詳細については 「Elevate your windowed app for spatial computing」をご覧ください
標準のハイライトエフェクトは 多くの場合うまく機能しますが カスタムエフェクトを 使った方がいい場合もあります
スライダに 可能な操作を示すノブを表示できます 戻るボタンを展開して 前のページの名前を表示できます タブバーを開いて ラベルを表示できます
Safariのナビゲーションバーを拡張して ブラウザタブを表示できます
visionOS 2の 新しいCustom Hover Effect APIを使えば このようなカスタムホバーエフェクトを 作成できます
カスタムホバーエフェクトは アプリの あらゆる場所のSwiftUIビュー 例えば オーナメントや Reality Viewの アタッチメントに適用できます エフェクト適用のタイミングは ユーザーがビューに視線を向けたり 指を伸ばしたり マウスカーソルを その上に移動させたりした時です
カスタムホバーエフェクトはプライバシーを 尊重するため一から設計しています これらはシステムによって アプリプロセスの外で適用されます 追加のエンタイトルメントや拡張は不要です
RealityKitには ホバーエフェクトを3Dの コンテンツに適用する新しいAPIもあります 詳しくは「What's New in RealityKit」を ご覧ください このAPIをさっそくご紹介しましょう まず コンテンツエフェクトを使って ビューの外観を変える方法を説明します 次に エフェクトグループでエフェクトを 複数まとめて適用する方法を解説します また 遅延エフェクトを使用して エフェクトのタイミングを制御する方法を 説明します
最後に 新しい CustomHoverEffectプロトコルを使用して アクセシビリティ設定を反映する 再利用可能なエフェクトを作成します コンテンツエフェクトから 見ていきましょう
コンテンツエフェクトは ビューの 外観を変える基本的なエフェクトです ビューの不透明度を変更したり ジオメトリを変形したり クリップ図形の適用が可能です これらのエフェクトは ビューの外観を変えるだけです 近くにあるビューの レイアウトには影響しません エフェクトは2つの状態間で遷移します ビューに視線が向いていない時 エフェクトは非アクティブ状態を適用し
ユーザーがビューに視線を向けると エフェクトはアクティブ状態に遷移し ビューが更新されます ジオメトリのエフェクトは この拡大エフェクトと同様に ビューのジオメトリを変更します
クリップのエフェクトは ビューの隠れた部分を表示します 不透明度のエフェクトは コンテンツを フェードイン/フェードアウトさせます ビューから視線がそれると エフェクトは非アクティブの状態に戻ります 複数のエフェクトを組み合わせると 緻密なトランジションを作成できます この拡張エフェクトがその例です 私は 開発中のビデオ再生アプリ用に このエフェクトを作成しています 本セッションの以降のパートでは このエフェクトの作成を 段階ごとに進めていき 完成させます
これはDestination Videoアプリです シミュレータで実行しています 左上隅に プロファイルを 切り替えるボタンを追加しました ボタンに視線を向けるとハイライトされます 拡張エフェクトを完成させるための 最初のステップとして 視線を向けると ボタンが拡大するようにします コードを見てみましょう
カスタムボタンには すでに アイコンと詳細テキストを配置しています また カスタムボタンスタイルで ボタンの外観を制御しています このコードに拡大エフェクトを追加します
ButtonStyle内で hoverEffectモディファイアを使って 標準のハイライトを追加しました
拡大エフェクト用にブロックベースの新しい hoverEffectモディファイアを追加します
ブロック内で scaleEffectなどのモディファイアを使って ビューの外観を変更できます これをアクティブ/非アクティブ状態間の 遷移に対応させます
ブロック呼び出し時に isActiveがtrueなら エフェクトのアクティブ状態を取得します isActiveがfalseなら 非アクティブの状態を取得します エフェクトはシステムにより適用されるので これらの呼び出しは ホバーの 実際の実行時よりも前に行われます
視線を向けた時に ボタンを拡大させたいので アクティブの時は5%の拡大を適用し 非アクティブの時は拡大を適用しません シミュレータでチェックしましょう
ボタンを見た時にハイライトと拡大の 両方のエフェクトが適用されています
次は ボタンの詳細テキストの表示と非表示を クリップエフェクトで切り替えます クリップエフェクトは ビューの表示領域を変更します 非アクティブ時に 付加的なコンテンツを隠すのにも使えます エフェクトがアクティブになると クリップエフェクトによる拡張を利用して 隠れていたコンテンツを表示できます
カスタムボタンスタイルに戻りましょう 既存のclipShapeモディファイアを hoverEffectブロックに移動します エフェクトがアクティブかどうかによって clipShapeを変更できるようになります
クリップ図形のサイズを変更するために sizeモディファイアを図形に追加します さらに hoverEffectブロックに渡される ジオメトリプロキシを使用します これは アクティブ/非アクティブ時の クリップ図形のサイズ計算に使用されます
このエフェクトがアクティブの時 clipShapeはボタンの幅を拡張します これにより ボタン全体が表示されます エフェクトが非アクティブの時は ボタンを円形にして アイコンのみ表示されるようにします これを実現するために clipShapeのwidthとheightを 同じにして クリップ図形を円形にします
最後に新しいanchorパラメータを使用して クリップ図形を ボタンの前縁に揃えることで エフェクトが非アクティブの時に アイコンが表示されるようになります シミュレータで実行してみましょう
成功です ボタンを視線を向けると拡張され 視線をそらすと折りたたまれます エフェクトをもう少し洗練させましょう ボタンを拡張する時に 詳細テキストをフェードインさせます
テキストのみフェードイン/アウトするので 詳細テキストにhoverEffectを追加します さらに 0~1でテキストをフェードさせる 不透明度エフェクトを適用します
ほぼ完成ですが まだ完全ではありません もう一度見てみましょう
ボタンが拡張する時に 詳細テキストがフェードインするはずが 詳細テキストが現れるはずのスペースに 視線を向けるまでフェードインしません なぜでしょうか
ホバーエフェクトがアクティブになるのは アタッチされたビューに視線が向いた時です 私は拡大とクリップ図形のエフェクトを ボタン全体に適用しました そのため ボタン内であれば どこを見てもアクティブになります 一方 不透明度エフェクトは 詳細テキストに追加しました そのため テキストに視線が向かないと アクティブになりません そうではなく 複数のエフェクトを 同時にアクティブする方法が必要です ここで役立つのがエフェクトグループです グループ化されたエフェクトは グループ内のいずれかのエフェクトが アクティブになると 一緒に適用されます 異なるビューのエフェクトを 1つのグループにまとめることもでき いずれかのビューに視線を向けると グループ全体がアクティブになります これにより アプリの特定の領域を使って エフェクトをアクティブにできます
エフェクトをグループ化する方法は 明示的と暗黙的の2種類です まず 明示的な方法で エフェクトをグループ化します
このためのグループとして HoverEffectGroupを作成します 名前空間を使用して グループに一意のIDを渡します
これでグループを識別できるので 明示的に 各エフェクトをグループに追加します まずは不透明度エフェクトです グループをButtonStyleに渡し 残りのエフェクトをグループに追加します すべてのエフェクトが同じグループなので これらは1つのエフェクトとして 同時にアクティブになるはずです
これでうまくいきました すべてのエフェクトが 同時にアクティブになりました ボタンの拡張時にテキストがフェードインし 折りたたみ時にフェードアウトします
エフェクトの明示的なグループ化は グループ化の際の柔軟性が非常に高く エフェクトの制御も容易です そこまで細かい制御が必要でない場合は 暗黙的なグループ化も使用できます
各エフェクトに hoverEffectGroupを渡すのではなく hoverEffectGroupモディファイアを シンプルにビューに追加します これにより このビューとサブビューの すべてのエフェクトが暗黙的にグループに 追加されるので 各エフェクトを グループに追加する必要がなくなります モディファイアに グループを渡さなかった場合も 同様にグループが暗黙的に作成されます 非常に便利ですね プロファイルボタンの完成に 近づいてきました ここまで コンテンツエフェクトと エフェクトグループを使って 必要な外観の変更をすべて適用しました しかし 見た瞬間にボタンが拡張されると 気が散る可能性があります 少し間を置いてから 拡張されるようにしましょう ボタンを拡張するタイミングの制御には 遅延エフェクトを使用できます デフォルトでは ホバーエフェクトは ただちに適用されます これは 操作を促す さりげないエフェクトには効果的です しかし ほとんどのエフェクトでは 少しであっても遅延させる方が適切です アプリ全体を見渡している時にエフェクトが あちこちでアクティブになるのを防げます
人間は静止状態を長くは保てません 遅延により エフェクトを しばらくアクティブにしておけば ユーザーの視線が離れても表示が維持され ちらつきが生じません
最後に 付加的なコンテンツを表示する エフェクトには 長めの遅延が必要です この種のエフェクトは注意をそらすので ユーザーがアプリの特定の要素を 注視している時は 少し間を置いてから アクティブになるようにしましょう 適切な遅延の長さは エフェクトごとに異なるため 必ずApple Vision Proを装着して 実際の感覚を確かめてください
プロファイルボタンはコンテンツを 表示するので 遅延を長めにします
遅延を適用するには エフェクトを animationモディファイアでラップして 遅延アニメーションを渡します
デフォルトアニメーションを使用し エフェクトをアクティブにする際の遅延は 長めにしました 一方 エフェクトを非アクティブにする際の 遅延は短くしました
拡大エフェクトには遅延を適用しません これは即座に反応することが望ましい エフェクトなので 遅延なしにすべきです
テキストはボタンが拡張される時に フェードインするので 不透明度エフェクトにも 同じアニメーションを適用します これで エフェクトが常に同時になります 遅延を適用したので 再度エフェクトを見てみましょう
いいですね ボタンはすぐに反応しています これは拡大とハイライトのエフェクトですが 拡張のタイミングは少し後です
次に進む前に アニメーションの エフェクトについて補足します
指定がない場合 エフェクトはSwiftUIの デフォルトアニメーションを使用します
ホバーエフェクトがサポートする アニメーションはlinear easeOut springなどの一般的なものと カスタムの タイミングカーブを使用するものです
ただし CustomAnimationの型は サポート対象外です アプリのプロセス外では 適用できないためです
拡張するボタンは 私個人的には とても素晴らしいと思いますが 動きに敏感な方は 不快に感じるかもしれません エフェクトを開発する時は 常にアクセシビリティへの配慮が必要です 場合によっては 代替エフェクトの提供も必要でしょう 本セッションの残りの部分では 「視差効果を減らす」設定が有効に なっている場合に クロスフェードエフェクトを使用するように プロファイルボタンを更新します
視線に応じてボタンが 拡張または折りたたむのは同じですが 今回は フェードエフェクトを使用します 詳細テキストのフェードインのために 先ほどフェードエフェクトを記述したので 同じエフェクトの作成は不要です 新しい CustomHoverEffectプロトコルを使用して 両方の場所で使える エフェクトを作成します
エフェクトを再利用するために 記述済みの hoverEffectモディファイアをコピーして CustomHoverEffectに準拠した 新しいFadeEffect型に配置します
このエフェクトのbodyメソッド内では ビューで使用したものと同じ hoverEffectモディファイアに アクセスできるので シンプルに開始し 必要に応じてリファクタリングできます このエフェクトは独自の型なので さらに実用的にしたい場合は 非アクティブとアクティブの 不透明度の値をカスタマイズ可能にします
作成した再利用可能なフェードエフェクトを ボタンビューに戻って試してみましょう ここで必要なのは hoverEffectブロックを削除して 新しいFadeEffect()で 置き換えることだけです
コードが再利用可能になると同時に クリーンになるのがメリットです 拡張エフェクトも CustomHoverEffect型に移動しましょう 先ほどと同様に ビューからhoverEffectブロックを移動し 新しいCustomHoverEffect型に配置します この部分は ExpandEffectという名前にします
ButtonStyleに戻って ブロックベースのhoverEffectを削除し
新しいExpandEffect()で置き換えます 完璧です
これですべてのエフェクトが 再利用可能になったので 「視差効果を減らす」設定時 クロスフェードするように プロファイルボタンを更新します
まず @Environmentプロパティを追加して reduceMotion設定に アクセスできるようにします
reduceMotionがオンの場合は ExpandEffect()が適用されないように 代わりにemptyエフェクトを適用します emptyエフェクトは 何もしないというエフェクトです 様々なエフェクトを動的に切り替えるには 各エフェクトをHoverEffect型でラップして 各自の型を消去する必要があります まだ完成ではありませんが シミュレータで進捗を確認しましょう
背景の図形はまだ不完全ですが ボタンは拡張されなくなりました 詳細テキストは想定通り フェードインしています 次に背景を更新して テキストと同時にクロスフェードさせます これを実現するには 既存の背景を 2種類の背景ビューで置き換えます 1つ目はCapsule()で 幅はボタンと同じです ボタンに視線が向けられた時に 表示されるように reduceMotionがオンの場合は FadeEffectを適用します
2つ目はCircle()で これを表示するのは ボタンに視線が向いていない時です この背景にもFadeEffectを適用しますが カスタムの不透明度の値を追加したので 他方のビューがフェードインする時に フェードアウトします
うまくいきました ボタンを見た時に 背景と詳細テキストが 一緒にフェードインするようになりました これで拡張ボタンは完成です 細部にこだわったエフェクトを いくつかの簡単なエフェクトと 組み合わせて作成しました 適切な遅延を選択し ユーザーのアクセシビリティ設定に 配慮したことで あらゆる人にとって快適な エフェクトになりました みなさんもぜひ 独自のクリエイティブな カスタムホバーエフェクトを作成しましょう まずはシンプルに開始して 段階的に エフェクト構築を進めるとよいでしょう プロファイルボタンのアイコンのように ビューの一部は静的にしてください これらのアンカー要素によって エフェクトの遷移がスムーズに見えます そしてもちろん エフェクトは徹底的にテストしてください シミュレータは 迅速に反復できる点で優れていますが エフェクトの実際の使用感を知るには Apple Vision Proを装着して テストするしかありません
優れたホバーエフェクトの作成に役立つ ヒントの詳細は ヒューマンインターフェイス ガイドラインをご確認ください
-
-
4:06 - Button with Scale Effect
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .clipShape(.capsule) .hoverEffect { effect, isActive, _ in effect.scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
5:37 - Button with Clip and Scale Effects
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
6:50 - Expanding Button with Ungrouped Fade
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle()) } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
8:19 - Expanding Button with Explicit Group
struct ProfileButtonView: View { var action: () -> Void = { } @Namespace var hoverNamespace var hoverGroup: HoverEffectGroup { HoverEffectGroup(hoverNamespace) } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(in: hoverGroup) { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle(hoverGroup: hoverGroup)) } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame(width: 44, height: 44) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } } struct ProfileButtonStyle: ButtonStyle { var hoverGroup: HoverEffectGroup? func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight, in: hoverGroup) .hoverEffect(in: hoverGroup) { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } }
-
9:13 - Expanding Button with Implicit Group
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.opacity(isActive ? 1 : 0) } } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) .scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
10:51 - Expanding Button with Delayed Effect
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? 1 : 0) } } } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
12:50 - Expanding Button with Reusable Effects
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(FadeEffect()) } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background(.thinMaterial) .hoverEffect(.highlight) .hoverEffect(ExpandEffect()) } } struct ExpandEffect: CustomHoverEffect { func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct FadeEffect: CustomHoverEffect { var from: Double = 0 var to: Double = 1 func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? to : from) } } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
14:14 - Final Expanding Button with Accessibility Support
struct ProfileButtonView: View { var action: () -> Void = { } var body: some View { Button(action: action) { HStack(spacing: 2) { ProfileIconView() ProfileDetailView() .hoverEffect(FadeEffect()) } } .buttonStyle(ProfileButtonStyle()) .hoverEffectGroup() } struct ProfileButtonStyle: ButtonStyle { @Environment(\.accessibilityReduceMotion) var reduceMotion func makeBody(configuration: Configuration) -> some View { configuration.label .background { ZStack(alignment: .leading) { Capsule() .fill(.thinMaterial) .hoverEffect(.highlight) .hoverEffect( reduceMotion ? HoverEffect(FadeEffect()) : HoverEffect(.empty)) if reduceMotion { Circle() .fill(.thinMaterial) .hoverEffect(.highlight) .hoverEffect(FadeEffect(from: 1, to: 0)) } } } .hoverEffect( reduceMotion ? HoverEffect(.empty) : HoverEffect(ExpandEffect()) ) } } struct ExpandEffect: CustomHoverEffect { func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, proxy in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.clipShape(.capsule.size( width: isActive ? proxy.size.width : proxy.size.height, height: proxy.size.height, anchor: .leading )) }.scaleEffect(isActive ? 1.05 : 1.0) } } } struct FadeEffect: CustomHoverEffect { var from: Double = 0 var to: Double = 1 func body(content: Content) -> some CustomHoverEffect { content.hoverEffect { effect, isActive, _ in effect.animation(.default.delay(isActive ? 0.8 : 0.2)) { $0.opacity(isActive ? to : from) } } } } struct ProfileIconView: View { var body: some View { Image(systemName: "person.crop.circle") .resizable() .scaledToFit() .frame( width: 44, height: 44 ) .padding(6) } } struct ProfileDetailView: View { var body: some View { VStack(alignment: .leading) { Text("Peter McCullough") .font(.body) .foregroundStyle(.primary) Text("Switch profiles") .font(.footnote) .foregroundStyle(.tertiary) } .padding(.trailing, 24) } } }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。