酢ろぐ!

カレーが嫌いなスマートフォンアプリプログラマのブログ。

戻るボタンをカスタマイズしているとUINavigationController#pushViewControllerで遷移しなくなる

数年前から開発しているアプリで謎の不具合が発生していました。「ときどきボタンを押してもフリーズしたようになって画面遷移しない」というものでした。

再現性がものすごく低くてほとんど発生しないことからスルーされてきました。つい先日、ほぼ100%再現させる方法が判明したので原因を追求することになりました。

開発環境

  • Xcode 8.3.1
  • iOSアプリ
  • 不具合の発生を確認しているiOSバージョン
    • 10.3
    • 9.3.5

不具合の再現手順について

不具合の再現手順は以下の通りでした。

  • ホーム画面で左から右のスワイプを10回繰り返す
  • 画面遷移ボタンをタップする
  • 画面遷移せずに操作ができなくなる (←おかしい)

UINavigationControllerを実装していると左エッジから右へスワイプする*1と画面が戻るじゃないですか。その動作を同じ画面で何度も繰り返すと画面遷移がおかしくなってしまうというものでした。

現象について

開発中のアプリはよくある画面遷移するアプリです。HomeViewControllerのボタンをタップするとDetailViewController へ遷移するような構成です。

f:id:ch3cooh393:20170425175911p:plain

画面遷移は以下のコードのように書いています。よくある画面遷移です。

guard let nc = self.navigationController else {
    return
}
                
let vc = UIViewController.loadFrom(storyboardName: "Detail") as! DetailViewController
vc.itemCode = code
nc.pushViewController(vc, animated: true)

該当のアプリは戻るボタン(leftBarButtonItem)をカスタマイズしていました。ここが問題だと判明するまでとても時間がかかってしまいました。

f:id:ch3cooh393:20170425175140p:plain

報告が上がって来た時点では「突然ボタンをタップしても反応がなくなってしまう」という指摘でしたからね……

ボタンが反応しなくなっている理由について

ログを貼ってライフサイクル等がどのようになっているかを追跡した結果、UINavigationController#pushViewControllerでは問題なく画面遷移がおこなわれていました。

ただし、遷移先に表示されているのがDetailViewControllerではなくて、謎の透明viewだったために操作を受け付けなくなってしまい反応がなくなる=フリーズしていると判断されてしまったようです。

通常、画面遷移した場合には以下の順番でdelegateが実行されます。

  1. DetailViewController#viewDidLoad
  2. DetailViewController#viewWillAppear
  3. 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:本記事内では左エッジスワイプと呼びます