数年前から開発しているアプリで謎の不具合が発生していました。「ときどきボタンを押してもフリーズしたようになって画面遷移しない」というものでした。
再現性がものすごく低くてほとんど発生しないことからスルーされてきました。つい先日、ほぼ100%再現させる方法が判明したので原因を追求することになりました。
開発環境
- Xcode 8.3.1
- iOSアプリ
- 不具合の発生を確認しているiOSバージョン
- 10.3
- 9.3.5
不具合の再現手順について
不具合の再現手順は以下の通りでした。
- ホーム画面で左から右のスワイプを10回繰り返す
- 画面遷移ボタンをタップする
- 画面遷移せずに操作ができなくなる (←おかしい)
UINavigationControllerを実装していると左エッジから右へスワイプする*1と画面が戻るじゃないですか。その動作を同じ画面で何度も繰り返すと画面遷移がおかしくなってしまうというものでした。
現象について
開発中のアプリはよくある画面遷移するアプリです。HomeViewControllerのボタンをタップするとDetailViewController へ遷移するような構成です。
画面遷移は以下のコードのように書いています。よくある画面遷移です。
guard let nc = self.navigationController else { return } let vc = UIViewController.loadFrom(storyboardName: "Detail") as! DetailViewController vc.itemCode = code nc.pushViewController(vc, animated: true)
該当のアプリは戻るボタン(leftBarButtonItem)をカスタマイズしていました。ここが問題だと判明するまでとても時間がかかってしまいました。
報告が上がって来た時点では「突然ボタンをタップしても反応がなくなってしまう」という指摘でしたからね……
ボタンが反応しなくなっている理由について
ログを貼ってライフサイクル等がどのようになっているかを追跡した結果、UINavigationController#pushViewController
では問題なく画面遷移がおこなわれていました。
ただし、遷移先に表示されているのがDetailViewControllerではなくて、謎の透明viewだったために操作を受け付けなくなってしまい反応がなくなる=フリーズしていると判断されてしまったようです。
通常、画面遷移した場合には以下の順番でdelegateが実行されます。
- DetailViewController#viewDidLoad
- DetailViewController#viewWillAppear
- DetailViewController#viewDidAppear
異常時には、3番目のDetailViewController#viewDidAppear
が実行されていませんでした。
戻るボタンをカスタマイズしていると画面遷移しなくなる
ログをペタペタ貼った結果なぜ操作ができなくなるのかはわかりましたが、その原因がわかりませんでした。次に少しずつコメントアウトしていって問題の場所を特定していきました。
このアプリでは戻るボタンをカスタマイズしていることは前記の通りです。具体的にはDetailViewController#viewWillAppear
で以下のように書いていました。
//戻るボタンを変更する if let nc = navigationController { if nc.viewControllers.count > 1 { let image = UIImage(named: "back_button_no_image") navigationItem.leftBarButtonItem = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(popAction(_:))) nc.interactivePopGestureRecognizer?.delegate = self as? UIGestureRecognizerDelegate } }
この戻るボタンのカスタマイズ処理をコメントアウトすることで、不具合が発生しなくなるのが確認できました。
もう少し厳密に書いておくと下記の部分をコメントアウトするだけで問題は発生しなくなりました。
nc.interactivePopGestureRecognizer?.delegate = self as? UIGestureRecognizerDelegate
戻るボタンをカスタマイズしたことがある方ならご理解いただけると思うのですが、戻るボタンのカスタマイズでは色々と対応が必要になります。
- 標準の「<」から任意の「戻るボタン画像」に変更する
- 「戻るボタン」がタップされた時に画面をpopする
- 左エッジスワイプでpopする (←今回の問題の要点)
戻るボタンの画像を変更したいだけであれば、navigationItem.leftBarButtonItem
に適当なUIBarButtonItemを突っ込むだけで達成することができます。ただこの方法だけだと「左エッジスワイプ」が無効になってしまいます。iOS 6の頃であればこれだけでよかったんですけれど……
左エッジスワイプを無効にしないために前述したinteractivePopGestureRecognizerを設定していました。
修正方法
修正方法としては2つあります。該当の画面から抜けているにも関わらずにDetailViewControllerをinteractivePopGestureRecognizer
のdelegateに指定したままにしているのがマズイので修正するパターンと、そもそも画面ごとに戻るボタンをカスタマイズしない方法です。
修正案1
viewWillAppearでinteractivePopGestureRecognizerに設定されていたdelegateを保持しておき、viewWillDisappearで元のdelegateに変えることでこの不具合は発生しなくなりました。
var originalRecognizerDelegate: UIGestureRecognizerDelegate? override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) //戻るボタンを変更する if let nc = navigationController { if nc.viewControllers.count > 1 { let image = UIImage(named: "back_button_no_image") navigationItem.leftBarButtonItem = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(popAction(_:))) //元のdelegateを保持しておく!! originalDelegate = nc.interactivePopGestureRecognizer?.delegate nc.interactivePopGestureRecognizer?.delegate = self as? UIGestureRecognizerDelegate } } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.navigationController?.interactivePopGestureRecognizer?.delegate = originalRecognizerDelegate }
修正案2
AppDelegateとかで戻るボタンの矢印を指定する。
let image = UIImage(named: "back_button_no_image")?.withRenderingMode(.alwaysOriginal) UINavigationBar.appearance().backIndicatorImage = image UINavigationBar.appearance().backIndicatorTransitionMaskImage = image
遷移元の画面で
navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) navigationController?.pushViewController(vc, animated: true)
*1:本記事内では左エッジスワイプと呼びます