🍣

[SwiftUI] Viewにスポットライトを当てる

2024/04/10に公開

概要

スポットライトとは、アプリのチュートリアルなどでユーザーの次の行動を促すためにボタンなど特定の View を目立たせる手法のことを指します。
本記事では、以下のような UI を実装することを目指します。

これを実装するには、大きく以下3つのステップが必要です。

  1. スポットライトを当てる View の座標と寸法を取得
  2. 画面全体をぼかす
  3. View を切り抜く

最終的なコードは GitHub に掲載しています。
https://github.com/ueshun109/SwiftUIStylebook/blob/main/SwiftUIStylebook/UIComponents/SpotlightView.swift

座標と寸法を取得

SwiftUI には、子ビュー(Subviews)から親ビュー(Container)に値を渡すために、Preferencesという仕組みが用意されています。
これを使って、スポットライトを当てたい View の座標を親ビューに送信するための仕組みを実装していきます。

struct SpotlightBoundsKey: PreferenceKey {
  typealias ID = Int

  static var defaultValue: [ID: Anchor<CGRect>] = [:]

  static func reduce(
    value: inout [ID: Anchor<CGRect>],
    nextValue: () -> [ID: Anchor<CGRect>]
  ) {
    value.merge(nextValue()) { $1 }
  }
}

reduceメソッドでは、inoutキーワードがついたvalueプロパティを更新していきます。
今回は、SpotlightBoundsKeyを指定した View の座標を蓄積していきたい且つ ID が重複した場合新しい値を優先したいため、merge メソッドを使用しています。

次にスポットライトを当てたい View の座標・寸法を、PreferenceKey に蓄積するための仕組みを用意します。具体的には、anchorPreferenceを指定して View の座標・寸法をSpotlightBoundsKeyの値に蓄積します。

extension View {
  func spotlightAnchor(at id: SpotlightBoundsKey.ID) -> some View {
    self.anchorPreference(key: SpotlightBoundsKey.self, value: .bounds) { [id: $0] }
  }
}

画面全体をぼかす

画面全体をぼかすためには、以下のようにぼかした色の View をオーバーレイするだけです。

struct SpotlightModifier: ViewModifier {
  func body(content: Content) -> some View {
    content
      .overlay {
        Rectangle()
          .fill(.ultraThinMaterial)
          .environment(\.colorScheme, .dark)
          .ignoresSafeArea()
      }
  }
}

しかしこれでは、スポットライトを当てるために必要な、座標・寸法を参照することはできません。
そこでoverlayPreferenceValueを使用します。
overlayPreferenceValuePreferenceKeyを用いてデータを収集し、それに基づいた View をオーバーレイすることができるメソッドです。
これを使用することで、画面全体をぼかした View で覆う且つ、Preferences で蓄積した座標データを参照することができます。

- .overlay {
+ .overlayPreferenceValue(SpotlightBoundsKey.self) { values in // valuesが蓄積した座標・寸法の情報を持っている

スポットライトをあてる

最後にスポットライトを当てる処理を見ていきます。

座標・寸法を取り出す

スポットライトを当てたい View のCGRectを取得します。これはGeometryProxysubscriptを使うことで、AnchorValueパラメータを取り出すことができます。

.overlayPreferenceValue(SpotlightBoundsKey.self) {
- Rectangle()
-   .fill(.ultraThinMaterial)
-   .environment(\.colorScheme, .dark)
-   .ignoresSafeArea()
+  GeometryReader { proxy in
+    let preference = values.first(where: { $0.key == spotlightingID })
+     if let preference {
+       let rect = proxy[preference.value]
+       Rectangle()
+         .fill(.ultraThinMaterial)
+         .environment(\.colorScheme, .dark)
+     }
+   }
+   .ignoresSafeArea()
}

切り抜く

ぼかした View の一部を切り抜くことで、スポットライトが当たっているように見せていきます。
指定した形で View の一部を切り抜くため、maskblendModeを使ってリバースマスクを実現します。

extension View {
+  func reverseMask<Content: View>(alignment: Alignment, = .center, _ content: () -> Content) -> some View {
+    self.mask {
+      Rectangle()
+        .overlay(alignment: alignment) {
+          content()
+            .blendMode(.destinationOut)
+        }
+    }
+  }
}

blendModeは重なり合った View を結合し、色や輝度などを調整様々な視覚効果を使用した View を生成します。
destinationOutを指定することで、ソースレイヤーの色を使用してデスティネーションレイヤーから色を消去しています。

あとは以下のようにreverseMaskを指定すれば、スポットライトが当たっているような視覚効果が得られます。

Rectangle()
  .fill(.ultraThinMaterial)
  .environment(\.colorScheme, .dark)
+ .reverseMask(alignment: .topLeading) {
+   RoundedRectangle(cornerRadius: 8)
+     .frame(width: rect.width, height: rect.height)
+     .offset(x: rect.minX, y: rect.minY)
+ }

Preview code
#Preview {
  HStack(spacing: 24) {
    Spacer()
    Image(systemName: "lightbulb.fill")
      .resizable()
      .scaledToFit()
      .frame(width: 100, height: 100)
      .padding()
      .spotlightAnchor(at: 1)

    Image(systemName: "lightbulb.max.fill")
      .resizable()
      .scaledToFit()
      .frame(width: 100, height: 100)
      .padding()
    Spacer()
  }
  .frame(maxWidth: .infinity, maxHeight: .infinity)
  .spotlight(enable: .constant(true), spotlightingID: .constant(1))
}

その他

ここまでの内容でスポットライトが当たっているように見せることはできたのですが、これだけではプロダクトとしてリリースするのは難しいと思います。そこで以下の処理も追加で実装していきます。

  • タップすると別の View にスポットライトがあたる
  • スポットライトが切り替わる際、アニメーションをつける
struct SpotlightModifier: ViewModifier {
+  @Binding var enable: Bool
+  @Binding var spotlightingID: SpotlightBoundsKey.ID

  func body(content: Content) -> some View {
    content
      .overlayPreferenceValue(SpotlightBoundsKey.self) { values in
        GeometryReader { proxy in
          let preference = values.first(where: { $0.key == spotlightingID })
          if let preference {
            let rect = proxy[preference.value]
            Rectangle()
              .fill(.ultraThinMaterial)
              .environment(\.colorScheme, .dark)
+             .opacity(enable ? 1 : 0)  // 無効の場合はぼかさないようにする
              .reverseMask(alignment: .topLeading) {
                RoundedRectangle(cornerRadius: 8)
                  .frame(width: rect.width, height: rect.height)
                  .offset(x: rect.minX, y: rect.minY)
              }
+             .onTapGesture {
+               if spotlightingID <= values.count {
+                 spotlightingID += 1
+               } else {
+                 enable = false
+               }
+             }
          }
        }
        .ignoresSafeArea()
+       .animation(.easeInOut, value: enable)
+       .animation(.easeInOut, value: spotlightingID)
      }
  }
}

まとめ

スポットライトを当てるための実装のやり方は色々あると思いますが、本稿では主に以下3つの機能を用いて実装してみました。

  • PreferenceKey
  • mask
  • blendMode

参考 URL

GitHubで編集を提案

Discussion