Blog スタッフブログ

iOS Swift システム開発

[Swift]UIAlertControllerのサブクラスは何が起きるのか

Swift

こんにちは、株式会社MIXシステム開発担当のBloomです。

今回はドキュメントで名指しで止められているUIAlertControllerのサブクラス化を行い、実際に何が起こるのか検証をしてみたいと思います。

注意:推奨されていない操作を行うため、結果は環境に強く依存すると思われます。Xcode16.0/Swift6/iOS18.1/iPhone11 Pro環境で執筆しています。

UIAlertControllerのView hierarchy調査

さて、そもそもなぜサブクラス化が禁止されているのでしょうか。ドキュメントには下記の記述があります。

The UIAlertController class is intended to be used as-is and doesn’t support subclassing. The view hierarchy for this class is private and must not be modified.

UIAlertControllerのビュー構造上の問題でなんらかの不具合が起きる予定のようです。実際の構造を確認してみましょう。

    func showAlert() {
        let alert = UIAlertController(title: "title", message: "message", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .cancel))
        self.present(alert, animated: true) {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                self.confirmSubViews(view: alert.view, hierarchy: 0)
            }
        }
    }
    
    func confirmSubViews(view: UIView, hierarchy: Int) {
        print("view\(hierarchy): \(view))")
        let subs = view.subviews
        for sub in subs {
            confirmSubViews(view: sub, indent: hierarchy + 1)
        }
    }

出力ログを整形して掲載します。

view0: <_UIAlertControllerPhoneTVMacView>)
-view1: <UIView>)
--view2: <_UIAlertControllerInterfaceActionGroupView backgroundColor = UIExtendedGrayColorSpace 0 0;>)
---view3: <_UIDimmingKnockoutBackdropView>)
----view4: <UIVisualEffectView clientRequestedContentView effect=<UIBlurEffect> style=UIBlurEffectStyleSystemVibrantBackgroundRegular)
-----view5: <_UIVisualEffectBackdropView>)
-----view5: <_UIVisualEffectContentView>)
---view3: <UIView>)
----view4: <_UIInterfaceActionGroupHeaderScrollView>) // --- 表示部分の親?
-----view5: <UIView>) 
------view6: <UIView>)
------view6: <UILabel text = 't...e' (length = 5); >)
------view6: <UILabel text = 'm...e' (length = 7); >)
------view6: <UIView>)
------view6: <UIView>)
-----view5: <_UIScrollViewScrollIndicator>)
------view6: <UIView>)
-----view5: <_UIScrollViewScrollIndicator>)
------view6: <UIView>)
----view4: <_UIInterfaceActionVibrantSeparatorView>)
-----view5: <UIVisualEffectView clientRequestedContentView effect=<UIVibrancyEffect> style=UIBlurEffectStyleSystemVibrantBackgroundRegular vibrancyStyle=UIVibrancyEffectStyleSeparator)
------view6: <_UIVisualEffectContentView tintColor = UIExtendedGrayColorSpace 1 1; backgroundColor = UIExtendedGrayColorSpace 1 1;>)
----view4: <_UIInterfaceActionRepresentationsSequenceView>) // --- 操作部分の親?
-----view5: <_UIInterfaceActionSeparatableSequenceView>)
------view6: <UIStackView axis=horiz distribution=fill alignment=fill)
-------view7: <_UIInterfaceActionCustomViewRepresentationView action = <_UIAlertControllerActionViewInterfaceAction>  title = OK,  customContentView = <_UIAlertControllerActionView Action = <UIAlertAction Title = "OK" Descriptive = "(null)" Image = 0x0>> action = <_UIAlertControllerActionViewInterfaceAction>  title = OK,  customContentView = <_UIAlertControllerActionView; Action = <UIAlertAction Title = "OK" Descriptive = "(null)" Image = 0x0>>)
--------view8: <_UIAlertControllerActionView: 0x1072b4600; frame = (0 0; 270 44); Action = <UIAlertAction: 0x3000b4120 Title = "OK" Descriptive = "(null)" Image = 0x0>>)
---------view9: <UIView>)
----------view10: <UILabel text = 'OK'; userInteractionEnabled = NO; tintColor = <UIDynamicSystemColor: 0x3027d0100; name = _systemBlueColor2>; backgroundColor = UIExtendedGrayColorSpace 0 0;>)
-----view5: <_UIScrollViewScrollIndicator:>)
------view6: <UIView>>)
-----view5: <_UIScrollViewScrollIndicator>)
------view6: <UIView>)

_UIInterfaceActionVibrantSeparatorViewで分かれて上の_UIInterfaceActionGroupHeaderScrollViewがメッセージ表示部、下の_UIInterfaceActionRepresentationsSequenceViewがUIAlertActionの表示部のように見えます。この辺りを重点的に操作してみましょう。

実験

_UIInterfaceActionGroupHeaderScrollView, _UIInterfaceActionRepresentationsSequenceViewはともに非公開APIであり、runtime headerを参照することでクラスの定義を確認できます。まずはこれらのクラスのインスタンスへの参照を保持しておきましょう。

import UIKit

class MyAlertController: UIAlertController {
    // _UIAlertControllerShadowedScrollViewを経由してUIScrollViewを継承している
    var uiInterfaceActionGroupHeaderScrollView: UIScrollView!
    var uiInterfaceActionVibrantSeparatorView: UIView!
    var uiInterfaceActionRepresentationsSequenceView: UIScrollView!
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        self.getProperties()
    }
    
    func getProperties() {
        let subviews = recursiveSubviews(view: self.view)
        
        uiInterfaceActionGroupHeaderScrollView = subviews.first(where: { view in
            return String(describing: type(of: view)).contains("UIInterfaceActionGroupHeaderScrollView") == true && view is UIScrollView
        }) as? UIScrollView
        uiInterfaceActionVibrantSeparatorView = subviews.first(where: { view in
            return String(describing: type(of: view)).contains("UIInterfaceActionVibrantSeparatorView") == true
        })
        uiInterfaceActionRepresentationsSequenceView = subviews.first(where: { view in
            return String(describing: type(of: view)).contains("UIInterfaceActionRepresentationsSequenceView") == true && view is UIScrollView
        }) as? UIScrollView
        
        print("\(String(describing: uiInterfaceActionGroupHeaderScrollView))")
        print("\(String(describing: uiInterfaceActionVibrantSeparatorView))")
        print("\(String(describing: uiInterfaceActionRepresentationsSequenceView))")
        
    }
    
    func recursiveSubviews(view: UIView) -> [UIView] {
        return view.subviews + view.subviews.flatMap { self.recursiveSubviews(view: $0) }
    }
}

これで参照を保持できました。このまま色々操作してみます。

// 呼び出し元
    func showAlert() {
        let alert = MyAlertController(title: "title", message: "message", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Default", style: .default))
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        alert.addAction(UIAlertAction(title: "Destructive", style: .destructive))
        self.present(alert, animated: true)
    }
        

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        self.getProperties()
        let contentView = uiInterfaceActionGroupHeaderScrollView.subviews.first // contentView
        let uiInterfaceActionSeparatableSequenceView = uiInterfaceActionRepresentationsSequenceView.subviews.first(where: { view in
            return String(describing: type(of: view)).contains("UIInterfaceActionSeparatableSequenceView") == true
        })
        let stackView = uiInterfaceActionSeparatableSequenceView?.subviews.first as? UIStackView
        
        // --- 1
        contentView?.backgroundColor = .red
        uiInterfaceActionVibrantSeparatorView.backgroundColor = .black
        stackView?.arrangedSubviews.forEach({ $0.backgroundColor = .blue })
        
        // --- 2
        stackView?.axis = .horizontal
        stackView?.alignment = .leading
        stackView?.arrangedSubviews.forEach({ $0.backgroundColor = .systemMint })
        self.view.layoutIfNeeded()
        
        // --- 3
        let imageView = UIImageView(image: UIImage(named: "image.png"))
        imageView.center = contentView?.center ?? .zero
        contentView?.addSubview(imageView)
        
        let dummy = UILabel()
        dummy.text = "Dummy"
        stackView?.addArrangedSubview(dummy)
        self.view.layoutIfNeeded()
    }

1の実行結果

2の実行結果

3の実行結果

プロパティの変更のみで済むような表示カスタマイズはこれだけでほとんど実現できそうです。ここでは掲載していませんが実行結果3の要領でWKWebViewなども問題なく表示できるようです。このままどこまでやったら壊れるか調べてみましょう。

    override func viewDidAppear(_ animated: Bool) {
        /* 略 */
        let button = UIButton(type: .custom)
        button.addTarget(self, action: #selector(buttonTouchUpInside(_:)), for: .touchUpInside)
        button.setTitleColor(.blue, for: .normal)
        button.backgroundColor = .gray
        button.frame = CGRect(x: 0, y: 0, width: 120, height: 60)
        button.center = contentView?.center ?? .zero
        contentView?.addSubview(button)
    }
    
    @objc func buttonTouchUpInside(_ sender: Any) {
        let alert = MyAlertController(title: "multiple alert", message: "message", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        self.present(alert, animated: true)
    }

実行結果

UIButtonをcontentViewのほうへ配置し入れ子でアラートを表示させてみました。上手く入れ子で表示されるため、見た目はともかく安定した動作といえそうです。まだ無理をさせてみましょう。

    @objc func buttonTouchUpInside(_ sender: Any) {
        let labels = recursiveSubviews(view: uiInterfaceActionGroupHeaderScrollView).filter({ $0 is UILabel })
        labels.forEach { $0.removeFromSuperview() }
        self.message = "new message"
    }

このコードを実行するとmessageを代入したタイミングでクラッシュします。どこかのクラスでmessageを担当するUILabelへの弱参照があり、解放されたインスタンスへのアクセスが行われたためと思われますが、別途これらのUILabelへの強参照を保持していてもクラッシュします。また、messageへの代入をせず、ただremoveFromSuperviewを実行させた後にキャンセルボタンなどを押下するのみであればその辺りの処理で参照されていないためかクラッシュは起きないようです。

    @objc func buttonTouchUpInside(_ sender: Any) {
        let uiInterfaceActionSeparatableSequenceView = uiInterfaceActionRepresentationsSequenceView.subviews.first(where: { view in
            return String(describing: type(of: view)).contains("UIInterfaceActionSeparatableSequenceView") == true
        })
        let stackView = uiInterfaceActionSeparatableSequenceView?.subviews.first as? UIStackView
        let labels = recursiveSubviews(view: uiInterfaceActionGroupHeaderScrollView).filter({ $0 is UILabel })
        labels.forEach { view in
            view.removeFromSuperview()
            stackView?.addArrangedSubview(view)
        }
        self.title = "new title"
    }

実行結果

このような形でビュー構造を大幅に変えてしまっても表示崩れが起きるもののクラッシュは発生しないようです。参照以外になんらかのクラッシュ要因が存在するようです。

おわりに

いかがでしたでしょうか。ドキュメントでのサブクラス化がサポートされていないとの記載の通り、内部のビューを参照しプロパティを操作するのはビュー構造が環境によって統一されている保証がない以上避けたほうが良いでしょう。

一方でUIAlertControllerをサブクラス化しsupportedInterfaceOrientationsなどのプロパティをoverrideするなどは特に問題なく動作するようで、どうしてもサブクラス化しなければならない場合は最低限の変更に留めるとよいでしょう。良かったですね。

関連リンク

UIAlertController – Apple Developer Documentation