Zenn
🐬

iOSアプリで常に画面上にイルカを表示する

に公開

表題通りです。よくあるTab+Navigationの構造を持つアプリで、何時如何なる時も右下にイルカを表示させるようにしてみます。

ZStackを使う

真っ先に考えられそうなのはZStackですね。

import SwiftUI

struct DolphinOverlay: View {
    var body: some View {
        VStack(alignment: .trailing) {
            Spacer()
                .frame(maxWidth: .infinity)
            Image(.dolphin)
                .resizable()
                .frame(width: 100, height: 100)
                .padding()
        }
        .safeAreaPadding(.bottom, 50)
    }
}

struct ContentView: View {
    var body: some View {
        ZStack {
            MyTabView()
            DolphinOverlay()
        }
    }
}

割といい感じです。ナビゲーションの遷移(navigationDestination)でも上に表示できています。…が、全画面のモーダルの遷移(fullScreenCover)の下になってしまっています。

原因を探る

View Hierarchyで見ると、UITransitionViewが2枚UIWindowのsubviewsにあることがわかります。1枚目が遷移元、2枚目がモーダルで表示するViewですね。

UIKitの世界ではsubviewsの順番が表示の順番になりますので、イルカが1枚目のUITransitionViewに属している限りはモーダルの上に表示することはできません。以下の2点は最低でも満たす必要がありそうです。

  • イルカのViewが遷移元画面のUITransitionViewに属さないこと
  • イルカのViewがモーダル表示されるUITransitionViewよりも常に前面に配置されること

ここからはUIWindowに対してごにょごにょしないと解決は難しそうです。

別のUIWindowを作成して表示

ということでUIKitの力を借りてゴリ押します。イルカ専用のUIWindowを重ねて表示させることで解決します。

イルカのViewを表示するUIWindowを表示する拡張を作成して、

@MainActor
extension UIApplication {
    static var dolphinWindow: UIWindow?
    
    static func showDolphinWindow() {
        guard dolphinWindow == nil,
              let windowScene = shared.connectedScenes.first(where: { $0 is UIWindowScene }) as? UIWindowScene else {
            return
        }
        let newWindow = UIWindow(windowScene: windowScene)
        let vc = UIHostingController(rootView: DolphinOverlay())
        vc.view.backgroundColor = .clear
        newWindow.rootViewController = vc
        // このwindowがタップイベントを奪わないようにする
        newWindow.isUserInteractionEnabled = false
        dolphinWindow = newWindow
        newWindow.makeKeyAndVisible()
    }
}

ContentView表示時にイルカのWindowを表示する。

struct ContentView: View {
    var body: some View {
        MyTabView()
            .task {
                UIApplication.showDolphinWindow()
            }
    }
}

目的達成?です。お疲れ様でした。

余談: 既存のUIWindowにaddSubviewする

はじめは既存のUIWindowを抜き出して、addSubviewすることを考えていました。

@MainActor
extension UIApplication {
    var connectedSceneKeyWindow: UIWindow? {
        guard let windowScene = connectedScenes
            .first(where: { $0 is UIWindowScene }) as? UIWindowScene else {
            return nil
        }
        return windowScene.keyWindow
    }
}

extension View {
    func windowOverlay<V>(@ViewBuilder content: () -> V) -> some View where V : View {
        let tag = 12345
        if let window = UIApplication.shared.connectedSceneKeyWindow,
           !window.subviews.contains(where: { $0.tag == tag }) {
            let vc = UIHostingController(rootView: content())
            vc.view.tag = tag
            vc.view.frame = window.bounds
            vc.view.backgroundColor = .clear
            vc.view.isUserInteractionEnabled = false
            window.addSubview(vc.view)
        }
        return self
    }
}

遷移元のUITransitionViewには属しなくなりますが、結局モーダル表示よって隠れてしまうため断念しました🥺 (swizzlingで常に最前面にするなんて黒魔術は不採用です)

Discussion

ログインするとコメントできます