NavigationControllerのStackから特定の画面(ViewController)を消してみよう

勉強

「Navigationbarで遷移をくり返した状態から、特定のViewControllerを削除するにはどうしたらいいか?」を解決します。

動作イメージ

行き(PUSH)

帰り(POP)

この問題を解消するには、どうすれば良いか悩みました。

結論:setViewControllers(_:animated:)を使う

NavigationControllerのsetViewControllers(_:animated:)を使用することで、この問題は解消できます。

実装例

func goToNextViewController() {
  let nextViewController = NextViewController()
  var newNavigationViewControllers = navigationController?.viewControllers.filter {
    !($0 is RemoveViewController)
  } ?? []
  newNavigationViewControllers.append(nextViewController)
  
  navigationController?.setViewControllers(newNavigationViewControllers, animated: true)
}

動作例

結論に至るまでの試行錯誤から学ぶ

ここからは、結論に至るまでに辿った手順を踏みながら、うまくいかないケースを挙げます。

NavigationController.viewControllersプロパティに着目

UINavigationControllerにはviewControllers プロパティがあります。

var viewControllers: [UIViewController] { get set }
viewControllers | Apple Developer Documentation
The view controllers currently on the navigation stack.

このプロパティはUIViewControllerの配列型であり、遷移基のViewControllerが順番にスタックされます。

冒頭の動作イメージで挙げた「START」「REMOVE」「END」の画面を例に考えた場合、次のようになります。

イメージ

検証例

func checkStackViewControllers() {
  guard let navigationController else { return }
  let count = navigationController.viewControllers.count
  
  for i in 0..<count {
    let viewController = navigationController.viewControllers[i]
    if i == 0 {
      print("先頭は \(viewController.title!) です。")
    } else {
      let previousViewController = navigationController.viewControllers[i - 1]
      print("\(viewController.title!) は \(previousViewController.title!) の上に積まれています。")
    }
  }
}

動作例

先頭は Start です。
Remove は Start の上に積まれています。
End は Remove の上に積まれています。

配列で管理されているならRemoveできる

viewControllersはsetも可能です。

参照も更新も可能なら、対象のindexが特定できればRemoveが可能と考えました。

イメージ

検証例

removeStackViewControllerをEND画面でnextボタンをタップした際に対応してみました。

func removeStackViewController() {
  guard let navigationController = navigationController else { return }

  if let viewController = navigationController.viewControllers.last,
     let previousViewController = navigationController.viewControllers.dropLast().last {
    print("\(viewController.title!) は \(previousViewController.title!) の上に積まれています。")
  }
        
  if let index = navigationController.viewControllers.firstIndex(where: { $0 is RemoveViewController }) {
    navigationController.viewControllers.remove(at: index)
  }
        
  if let updatedViewController = navigationController.viewControllers.last,
     let updatedPreviousViewController = navigationController.viewControllers.dropLast().last {
       print("\(updatedViewController.title ?? "タイトルなし") は \(updatedPreviousViewController.title ?? "タイトルなし") の上に積まれています。")
  }
  
  let startViewController = StartViewController()
  navigationController.pushViewController(startViewController, animated: true)
}

動作例

End は Remove の上に積まれています。
End は Start の上に積まれています。

Removeの不都合

ここまでは、隠れているRemove画面を削除のみでしたが、さらにEND画面の削除も追加した場合はどうでしょうか?

イメージ

検証例

@objc func removeStackViewController() {
  guard let navigationController = navigationController else { return }

  navigationController.viewControllers.removeAll { viewController in
		viewController is RemoveViewController || viewController is EndViewController
  }

  let startViewController = StartViewController()
  navigationController.pushViewController(startViewController, animated: true)
}

問題に遭遇

表示中の画面を、次の画面への遷移中にRemoveした場合、次の問題が生じました。(正確にはnexボタンのタップ)

  • 即時反映で不自然な切り替えになる
  • Removeは配列の操作は可能だがアニメーションがない

次の動作例のように、ただ画面が削除されて切り替わるため、不自然な挙動になってしまいます。

動作例

遷移中でのRemove処理では、問題が生じてしまいます。

そのため、不自然な挙動が見えないように「END画面が隠れてから処理を実行」を意識する必要があります。

Removeの扱いには工夫が必要

実際に「画面が隠れてから処理の実行」を検討します。

検討するにあたり、画面の遷移とライフサイクルを整理します。

画面が隠れるまでの流れ

  1. 「Nextをタップ」で画面遷移が開始: UINavigationControllerの pushViewController によりEND画面から次(START画面)への遷移が始まる
  2. アニメーション前: START画面がメモリに読み込まれviewDidLoadがコールされる
  3. アニメーション開始: END画面でviewWillDisappearがSTART画面でviewWillAppearがコールされる
  4. アニメーション進行中: pushViewControllerのアニメーションが実施される
  5. アニメーション完了: END画面でviewDidDisappearがSTART画面でviewDidAppearがコールされる✅

「END画面が隠れる」のは、アニメーション完了のタイミングになります。

END画面のviewDidDisappearor START画面のviewDidAppear で、先に述べたremoveStackViewController()を扱うと良さそうです。

検証例

class EndViewController: UIViewController {
  override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
        
    removeStackViewController()
  }
  
  private func removeStackViewController() {
    guard let navigationController = navigationController else { return }
        
    navigationController.viewControllers.removeAll { viewController in
			viewController is RemoveViewController || viewController is EndViewController
    }
  }
}

START画面のviewDidDisappear

class StartViewController: UIViewController {
  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    
    removeStackViewController()
  }

  private func removeStackViewController() {
    guard let navigationController = navigationController else { return }
        
    navigationController.viewControllers.removeAll { viewController in
			viewController is RemoveViewController || viewController is EndViewController
    }
  }
}

動作例

END画面からSTART画面への遷移が綺麗になりました。

しかしRemoveを使った場合、UINavigationControllerが持つ特定のViewControllerを削除するには、考慮するべき点が多くなることもわかりました。

コメント

タイトルとURLをコピーしました