SwiftUI  へルプ表示に便利なポップビュー




<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117011/370eee18-695a-418a-8aa9-be666054a93f.gif" width="230">

アプリの使い方をもう少し詳しく書いておきたい、でもそんなスペースないからまとめてヘルプに書いておくか！で済ませちゃうことありますよね、でもユーザーはヘルプなんて読んでくれません！このポップアップビューならタップするだけですぐ表示できます。以下のコードをXcodeにコピペしてプレビュー。

```swift
import SwiftUI
import Foundation

// Preview用：Xcodeキャンバスでのプレビュー表示
#Preview{
    PopHelpView(helpClass: HelpClass())
}

/// メインのポップアップヘルプビュー
/// ボタンをタップすると説明テキストが表示される
struct PopHelpView: View {
    // Viewの状態管理を担うHelpClassを保持
    @State var helpClass: HelpClass //⚠️必須

    // 各ボタンの表示フラグ//⚠️ヘルプの数ごとに必要
    @State var showFlag1: Bool = false
    @State var showFlag2: Bool = false
    @State var showFlag3: Bool = false
    @State var showFlag4: Bool = false
    @State var showFlag5: Bool = false

    // 各ボタンに対応する説明テキスト//⚠️ヘルプの数ごとに必要
    let text1 = "どこにでもヘルプテキストを出せるPopViewです。"
    let text2 = "使ってユーザーにやさしいアプリを開発しましょう"
    let text3 = "テキストが多くても大丈夫！ヘルプテキストヘルプテキストヘルプテキストヘルプテキストヘルプテキストヘルプテキストヘルプテキストヘルプテキストヘルプテキストヘルプテキスト"
    let text4 = "右端でも大丈夫！ヘルプテキストヘルプテキストヘルプテキストヘルプテキストヘルプテキストヘルプテキスト"
    let text5 = "左端でも大丈夫！ヘルプテキスト"


    var body: some View {
        ZStack {
            // 背景全体をタップするとヘルプを非表示にする
            Color.clear
                .contentShape(Rectangle())
                .onTapGesture {
                    withAnimation(.linear(duration: 0.1)){
                        helpClass.showHelp = false
                    }
                }

            VStack(spacing: 20) {
                // 各ボタンを配置（位置調整はpaddingで設定）
                helpButton(text: text1, showFlag: $showFlag1)
                    .padding(.bottom, 150)
                    .padding(.trailing, 120)

                HStack {
                    helpButton(text: text2, showFlag: $showFlag2)
                }
                .padding(.leading, 260)

                helpButton(text: text3, showFlag: $showFlag3)
                    .padding(.top, 100)

                HStack {
                    Spacer()
                    helpButton(text: text4, showFlag: $showFlag4)
                        .padding(.trailing, 40)
                }
                .padding(.top, 80)

                HStack {
                    Spacer()
                    helpButton(text: text5, showFlag: $showFlag5)
                        .padding(.trailing, 360)
                }
                .padding(.top, 20)

            }
            .frame(maxHeight: .infinity)

            // 実際のポップアップ表示ビュー
            HelpPopView()//⚠️必須
        }
        // ボタン／ポップアップの座標計算に使う座標空間を定義
        .coordinateSpace(name: "safeArea")//⚠️必須
        // HelpClassを子ビューに渡す
        .environment(helpClass)//⚠️必須
    }

}

/// ボタンの実装を切り出したView
struct helpButton: View {
    // 環境オブジェクトでHelpClassを受け取る
    @EnvironmentObject var helpClass: HelpClass
    // 表示するテキストと表示フラグ
    let text: String
    @Binding var showFlag: Bool

    var body: some View {
        Button {
            // ボタンタップでHelpClassにテキストをセットし
            // ポップアップ表示フラグを立てる
            helpClass.text = text
            withAnimation(.linear(duration: 0.1)) {
                helpClass.showHelp = true
            }
            showFlag = true
        } label: {
            Image(systemName: "questionmark.circle")
                .font(.callout)
                .foregroundColor(.blue)
                .padding()
        }
        .clipShape(Circle())
        .background(
            GeometryReader { geo in
                Color.clear
                // showFlagが変わったらボタンの座標をHelpClassに渡す
                    .onChange(of: showFlag) {
                        helpClass.setProxy(proxy: geo)
                        showFlag = false // フラグをリセット
                    }
            }
        )
    }
}

/// 実際のポップアップ表示ビュー
struct HelpPopView: View {
    @EnvironmentObject var helpClass: HelpClass

    struct XY: Equatable { let x: CGFloat; let y: CGFloat }

    var body: some View {
        // showHelpフラグがtrueのときだけ表示
        if helpClass.showHelp {
            Text(helpClass.text)
                .font(.footnote)
                .padding(11)
            // 幅を画面幅の半分に固定
                .frame(width: UIScreen.main.bounds.width * 0.5)
                .background(
                    RoundedRectangle(cornerRadius: 6)
                        .fill(.ultraThinMaterial)
                )
                .fixedSize()
                .background(
                    GeometryReader { proxy in
                        Color.clear
                        // ポップアップの大きさを取得してHelpClassに渡す
                            .onChange(of: XY(x: helpClass.adjustedX, y: helpClass.adjustedY)){
                                helpClass.size = proxy.size
                            }
                    }
                )
            // HelpClassで計算した調整済み座標に配置
                .position(x: helpClass.adjustedX, y: helpClass.adjustedY)
                .transition(.fade)
        }
    }
}

/// ポップアップの表示制御・座標計算を行うクラス
@MainActor
@Observable
class HelpClass: ObservableObject {
    // ポップアップ表示フラグ
    var showHelp: Bool = false
    // 表示テキスト
    var text: String = ""
    // ボタンのグローバル位置
    var position: CGPoint?
    // ポップアップのサイズ
    var size: CGSize = .zero
    // ポップアップとボタン間のオフセット
    let yOffset: CGFloat = 20

    /// 横方向の表示位置を画面内に収める
    var adjustedX: CGFloat {
        guard let pos = position else { return 0 }
        let popoverWidth = UIScreen.main.bounds.width * 0.5
        let halfWidth = popoverWidth / 2 + 20
        let screenWidth = UIScreen.main.bounds.width
        return min(max(pos.x, halfWidth), screenWidth - halfWidth)
    }

    /// 縦方向はボタン上部 or 下部に表示を切り替え
    var adjustedY: CGFloat {
        guard let pos = position else { return 0 }
        if pos.y > size.height + yOffset {
            // 十分スペースがあれば上側に表示
            return pos.y - size.height / 2 - yOffset
        } else {
            // スペース不足なら下側に表示
            return pos.y + size.height / 2 + yOffset
        }
    }

    /// GeometryProxyからボタン位置を取得して保持
    func setProxy(proxy: GeometryProxy) {
        let frame = proxy.frame(in: .named("safeArea"))
        position = CGPoint(x: frame.midX, y: frame.midY)
    }
}

```