[SwiftUI] Viewにスポットライトを当てる
概要
スポットライトとは、アプリのチュートリアルなどでユーザーの次の行動を促すためにボタンなど特定の View を目立たせる手法のことを指します。
本記事では、以下のような UI を実装することを目指します。
これを実装するには、大きく以下3つのステップが必要です。
- スポットライトを当てる View の座標と寸法を取得
- 画面全体をぼかす
- View を切り抜く
最終的なコードは GitHub に掲載しています。
座標と寸法を取得
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を使用します。
overlayPreferenceValue
はPreferenceKey
を用いてデータを収集し、それに基づいた View をオーバーレイすることができるメソッドです。
これを使用することで、画面全体をぼかした View で覆う且つ、Preferences で蓄積した座標データを参照することができます。
- .overlay {
+ .overlayPreferenceValue(SpotlightBoundsKey.self) { values in // valuesが蓄積した座標・寸法の情報を持っている
スポットライトをあてる
最後にスポットライトを当てる処理を見ていきます。
座標・寸法を取り出す
スポットライトを当てたい View のCGRect
を取得します。これはGeometryProxy
のsubscriptを使うことで、Anchor
のValue
パラメータを取り出すことができます。
.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 の一部を切り抜くため、maskとblendModeを使ってリバースマスクを実現します。
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
Discussion