はじめに
久々に投稿します!
NavigationBarの「<」Backボタンにイベントをつけたいと思い、調査をしましたが
見た目を変えずにイベントを付与することができる情報がなかなか見つからなかったので
備忘としてまとめてみます
iOS12以上をサポートしている前提で調査をしたため、SwiftUIは使用していません。
開発環境
以下の環境で開発しました。
Tool | Version |
---|---|
Xcode | 11.2.1 (11B500) |
やりたいこと
今回実現したいことは以下の通りです。
- NavigationBarのBackボタンタップ時にアラートを表示したい
- Backボタンの見た目は変えたくない
- Backボタンで戻る際のアニメーションも変えたくない
1番のみであれば、Backボタンを非表示にしてleftBarButtonItemに自作のBackボタンを追加すれば実現できそうですが、
2番3番の見た目を変えないというのが難しいです。
実現に向けたアプローチ
実現方法を2つ考えました。
- NavigationBarのSubviewsを再帰的に取得して、どうにか「<」の画像を手に入れて自作のBackボタンに使用する
- Backボタンのイベントを取得して、自作のイベントで上書きをする
1番のアプローチでは、画像取得はできましたが結局自分で画像やタイトルの位置を調整する必要が出てきました
2番のアプローチで実現することができました。
具体的には、UIViewControllerにExtensionを実装します。
navigationController?.navigationBarのSubviewsを再帰的に探索してUIControlを取得し、
戻るイベントを削除し、新しいイベントを登録します。
実装
再帰的にSubViewsを取得する
以下を参考にさせていただきました。
Swiftで再帰的なサブビューの取得とUI層のユニットテストへの応用
import UIKit
extension UIView {
/// 再帰的にサブビューを取得する
var recursiveSubviews: [UIView] {
return subviews + subviews.flatMap { $0.recursiveSubviews }
}
/// UIViewの特定サブクラスのビューを取得する
func findViews<T: UIView>(subclassOf: T.Type) -> [T] {
return recursiveSubviews.compactMap { $0 as? T }
}
}
UIControlのタップイベントをクロージャで登録する
ボタン等のイベントの登録addTarget(_:action:for:)
をクロージャでするためのExtensionを作成しました。
import UIKit
typealias TapEvent = () -> Void
extension UIControl {
/// タップイベントをクロージャで登録する
func tap(action: @escaping TapEvent) {
self.eventListener(controlEvents: .touchUpInside, forAction: action)
}
func eventListener(controlEvents control: UIControl.Event, forAction action: @escaping(() -> Void)) {
self.actionHandler(action: action)
self.addTarget(self, action: #selector(triggerActionHandler), for: control)
}
}
private extension UIControl {
func actionHandler(action: (TapEvent)? = nil) {
struct ActionHolder {
static var action :(TapEvent)?
}
if let action = action {
ActionHolder.action = action
} else {
ActionHolder.action?()
}
}
@objc func triggerActionHandler() {
self.actionHandler()
}
}
NavigationBarのBackボタンにイベントを登録する
上記の2つのExtensionを使用して、
NavigationBarのBackボタンにイベントを登録するUIViewControllerのExtensionを作成しました。
import UIKit
extension UIViewController {
/// NavigationBarのBackボタンにイベントを登録する
func addNavigationBackEvent(action: @escaping TapEvent) {
guard let controls = navigationController?.navigationBar.findViews(subclassOf: UIControl.self) else {
return
}
for control in controls {
if control.allTargets.isEmpty {
continue
}
control.removeTarget(nil, action: nil, for: .allEvents)
control.tap(action: action)
break
}
}
}
使い方
対象のViewControllerのviewDidLayoutSubviews()で呼び出します。
viewDidLoadで呼び出すと、Subviewsを取得できず、イベント登録ができません。
final class NavigationNextViewController: UIViewController {
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
self.addNavigationBackEvent { [weak self] in
self?.confirmWhetherToBack()
}
}
/// 前の画面に戻るかどうか確認するアラート表示
private func confirmWhetherToBack() {
let alert = UIAlertController(title: "確認", message: "入力内容が保存されていません。\n編集を終了しますか?", preferredStyle: .alert)
// OKボタン
alert.addAction(
.init(title: "OK", style: .default, handler: { [weak self] _ in
guard let `self` = self else {
return
}
self.navigationController?.popViewController(animated: true)
})
)
// キャンセルボタン
alert.addAction(
.init(title: "Cancel", style: .cancel)
)
present(alert, animated: true)
}
}
実行結果
さいごに
やりたいことは実現できましたが、もう少し簡潔に実装できるのではないかと思っています
もっと良い実装方法等ご存知でしたらご教示ください