🌏

【SwiftUI✖️アラート】アラートをUIKitのように使う実装

2024/02/02に公開

このコードの懸念点

iPadでMultipleWindow有効の場合に、正しくWindowSceneがとれず、アラート表示できない可能性がある。

詳しくは、下記のWindowSceneの取得の話の実装部分
https://zenn.dev/matsuji/articles/0ee306ddfd10dc

追記 ...MultipleWindowへの対処

対応後の実装コード

  • SwiftUIから、UIKitのようにアラート表示が可能。
    • 【AlertWidnow】が、自作アラート。
Button("Show Alert(Cancel)") {
    // アラートを表示
    // 「キャンセルボタン」のみアラート
    AlertWindow.show(
        title: "タイトル",
        message: "アラートのメッセージ",
        cancelButtonTitle: "cancel",
        onTap: {
            print("!!!")
        }
    )
}

Button("Show Alert(Cancel-OK)") {
    // 「キャンセル、OKボタン」のアラート
    AlertWindow.showOKAndCancel(
        title: "タイトル",
        message: "アラートのメッセージ",
        onTapOk: {
            print("OK tapped!")
        },
        onTapCancel: {
            print("Cancel Tapped tapped!")
        }
    )
}

背景

SwiftUIで、「異なるパターンのアラート」を実装しようとすると、現状、工夫が必要。
(ViewModifier作ったり、Alert用のクラス作ったり、モデル側で対応したり...)

SwiftUIだと、アラートが状態(bool) だが、
UIKitだと、「状態ではなく処理( present() )」 なので、View以外でも利用可能で、柔軟性がある。(※...2)

SwiftUIで、実装しつつ、UIKitのように、アラート表示がしたくて、実装。
(SwiftUI標準のアラートは、より進化してから移行したい)

対応した実装のポイント

  • 「UIKitのUIAlertController」を「UIWindow」に乗せて表示(※...1)

実装

import Foundation
import UIKit

final class AlertWindow: UIAlertController {

    // MARK: - static

    // アラート表示(キャンセルのみ)
    static func show(title: String,
                     message: String,
                     cancelButtonTitle: String = "閉じる",
                     onTap: (() -> Void)? = nil
    ) {
        DispatchQueue.main.async {
            let alertController = AlertWindow(title: title,
                                              message: message,
                                              preferredStyle: .alert)
            let cancelAction = UIAlertAction(title: cancelButtonTitle, style: .cancel, handler: { _ in
                onTap?()
            })
            alertController.addAction(cancelAction)
            alertController.presentAsWindow(animated: true, completion: nil)
        }
    }

    // アラート表示(キャンセル・OKボタン)
    static func showOKAndCancel(title: String,
                                message: String,
                                okButtonTitle: String = "OK",
                                cancelButtonTitle: String = "Cancel",
                                onTapOk: (() -> Void)? = nil,
                                onTapCancel: (() -> Void)? = nil
    ) {
        DispatchQueue.main.async {
            let alertController = AlertWindow(title: title,
                                              message: message,
                                              preferredStyle: .alert)
            let cancelAction = UIAlertAction(title: cancelButtonTitle, style: .cancel, handler: { _ in
                onTapCancel?()
            })
            alertController.addAction(cancelAction)
            
            let okAction = UIAlertAction(title: okButtonTitle, style: .default, handler: { _ in
                onTapOk?()
            })
            alertController.addAction(okAction)
            
            alertController.presentAsWindow(animated: true, completion: nil)
        }
    }
    
    
    
    // MARK: - fileprivate
    
    // 表示もとのwindow
    fileprivate var baseWindow: UIWindow?
    
    fileprivate func presentAsWindow(animated: Bool, completion: (() -> Void)?) {
        baseWindow = UIWindow(frame: UIScreen.main.bounds)
        if let currentWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {            
            baseWindow?.windowScene = currentWindowScene
        }
        baseWindow?.rootViewController = UIViewController()
        baseWindow?.backgroundColor = .clear
        DispatchQueue.main.async {
            self.baseWindow?.makeKeyAndVisible()
            self.baseWindow?.rootViewController?.present(self, animated: animated, completion: completion)
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {        
        super.viewDidDisappear(animated)
        // Window自体をnil
        baseWindow?.isHidden = true
        baseWindow = nil
    }

}

使い所

View以外でも使えるので、「ViewModel」でのアラート処理
表示するだけの「エラーアラート」など。

※...1 SwiftUIから「UIWindow」を使ってごにょごにょ、は、SwiftUIで不足する実装において、役立つパターンの1つと思います。(最前面系のUIの利用。ローディング、トーストUI、エラーアラート...etc)
※...2 余談ですが、同じ宣言的UIの「Flutter」は、アラート表示は「処理」です(UIKitと同じような仕組み)

MultipleWindowへの対処

Windowの撮り方が、iPadのMultipleWindowが無効状態の場合の実装です。
そのため、MutlpleWindow対応時には、アラート表示可能性があります。

MultipleWindow有効時でも、アラートを表示させたい場合は、

  • 起動時にWindowを保持(AppDelegate,SceneDelegate等で)して、そのWindowを使うような改良
  • アラート閉じる際の処理で、「window = nil」はせず、「window.hidden = true」

をすることで対応できるかと。
(SwiftUIで、最前面にLoadingUIを表示するような実装では、そのように対応)

Discussion