こんにちは、iOSエンジニアの @motoshima1150 です。 iOSDC Japan 2021の「スタディサプリ」がFull SwiftUIを選択した先に見えてきたもの。のトークセッションで収まりきれなかったTipsを紹介いたします。
はじめに
本記事では、Spotlight機能の実装方法について紹介します。 Spotlight機能とは、アプリのチュートリアルなどで画面はそのままに一部分を切り抜いた半透明Viewを重ねてユーザーの次の行動を促すことができる機能を指します。 要件としては次の要件を想定しています。
- 全画面を覆ったViewで任意の箇所を透過させたい
例として、1と番号が振られたViewを明るく、それ以外の部分を暗く表示させるイメージです。 実装のステップは大きく2つに分けて説明します。
- 切り抜き用のFrameを取得する
- 指定した形にViewを切り抜く
切り抜き用のFrameを取得する
今回の例では、ViewのBackgroundに GeometryReader
を追加し、GeometryProxy
から対象のFrameを取得いたしました。
実現しているコードはこちらです。
// コンテンツを表示するViewです struct ContentView: View { // 指定した形にViewを切り抜く際に、より上位のViewで利用するので @Binding で宣言しています @Binding var spotlightArea: CGRect? var body: some View { ScrollView { // Spotlightの対象View Rectangle() .background(GeometryReader { proxy in DispatchQueue.main.async { let targetFrame = proxy.frame(in: .global) if spotlightArea != targetFrame { spotlightArea = targetFrame } } return Color.clear }) ... // その他のコンテンツが続きます } }
GeometryProxy
から Rectangle()
と同じ形状のFrameを取得できるように GeometryReader
で返す View は EmptyView
ではなく無色透明なColorを返しています。
proxy の frame
メソッドの引数に .global
を指定することで画面座標でのFrameが取得できます。
このFrameは後で使うので spotlightArea
という変数に保存しておきます。
また、Viewの描画サイクル中に変数の書き換えが起こり無限に更新が走ってしまうことを回避するために、DispatchQueueで非同期に変数を更新するようにしています。
GeometryProxy
は絶対座標を取ることができるので、Viewをまたいだ座標取得に便利です。
これで切り抜きたいViewのFrameが取得できました。
指定した形にViewを切り抜く
取得したFrameを使って切り抜く処理(マスク処理)を説明します。
まずはコードの全体像となります、全画面を覆うViewはZStackを使って配置します。
このとき、TabViewやNavigationViewなどよりも前面に来るようにViewの位置に配置します。 今回の例では、RootとなるViewを用意し、その上にTabViewやSpotlightのViewを置くようにしています。
// RootとなるView struct RootView: View { @State var spotlightArea: CGRect? var body: some View { ZStack { TabView { ... // NavigationViewなどのコンテンツが入ります } if let spotlightArea = spotlightArea { // 全画面を覆うView Rectangle() .fill(Color.black.opacity(0.6)) .mask( Rectangle() .frame( width: spotlightArea.width, height: spotlightArea.height, alignment: .center ) .position( x: spotlightArea.midX, y: spotlightArea.midY ) .background(Color.white) .compositingGroup() .luminanceToAlpha() ) } } }
ここから .mask()
内を各行ごとに処理の過程を追いながら説明します。
Rectangle() .frame( width: spotlightArea.width, height: spotlightArea.height, alignment: .center )
切り抜き対象のRectangle
を用意します。
サイズは先ほど取得したFrameを使ってViewを作ります。
.position( x: spotlightArea.midX, y: spotlightArea.midY )
.position(x: y:)
に .midX
, .midY
を指定することでちょうど切り抜き対象のViewと一致するように合わせます。
以上でマスク領域の調整については完了です。
現時点では、切り抜き対象のRectangle
の部分のみ残して切り抜くため、カバーしたい部分と透過したい部分が逆になってしまっています。
今回はViewの色の輝度を使ってマスク領域の調整しています。
.background(Color.white) .compositingGroup() .luminanceToAlpha()
.luminanceToAlpha()
は白が非透過、黒が透過となるmodifierです。
条件に合わせて、全画面に広がるように背景色を白で塗り、Rectangle
はデフォルトの色が黒のためそのままにします。
このままですと背景とRectangle
がそれぞれが.luminanceToAlpha()
処理され全面が透過しないViewとなってしまうため、背景とRectangle
を一つのViewとして扱えるように、.compositingGroup()
modifierを利用して合成します。
これで、切り抜き対象のRectangle
のみを切り抜きの完成です。
まとめ
Spotlightを表現するために以下の内容を説明いたしました。
- 切り抜き用のFrameを取得する
特定のViewの位置を読み取り、その位置・サイズに合わせてViewを切り抜くことを行いました。
Viewの位置については、ScrollViewなどのFrameが高頻度に更新される場合であってもGeometryReader
を使うことで画面上の位置を取得することができました。
今回は切り抜きのみでしたがViewの位置の特定は、吹き出しをつけたり、Viewが画面内に表示されているかの判定などの応用もできると思います。
- 指定した形にViewを切り抜く
切り抜き部分は、.compositingGroup()
modifier と .luminanceToAlpha()
modifierを使うことで簡単に切り抜き領域の反転ができました。
Spotlightは効果的に視線誘導ができる、導入の際には参考にしてみてください。
採用のお知らせ
Quipper ではスタディサプリの開発に関わる iOS エンジニア および シニア iOS エンジニア を積極的に募集しています。
カジュアル面談も行っているので、ぜひお気軽にご連絡ください!