スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

SwiftUI でスポットライト機能を実装する

こんにちは、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がグレーアウトしている

今回は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 エンジニア を積極的に募集しています。

カジュアル面談も行っているので、ぜひお気軽にご連絡ください!