Blog スタッフブログ
[Swift]UIAlertControllerのサブクラスは何が起きるのか
こんにちは、株式会社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するなどは特に問題なく動作するようで、どうしてもサブクラス化しなければならない場合は最低限の変更に留めるとよいでしょう。良かったですね。