NCSplitViewController.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. //
  2. // SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
  3. // SPDX-License-Identifier: GPL-3.0-or-later
  4. //
  5. @objcMembers class NCSplitViewController: UISplitViewController, UISplitViewControllerDelegate, UINavigationControllerDelegate, UIGestureRecognizerDelegate {
  6. var placeholderViewController = UIViewController()
  7. override func viewDidLoad() {
  8. super.viewDidLoad()
  9. self.delegate = self
  10. self.preferredDisplayMode = .oneBesideSecondary
  11. // As we always show the columns on iPads, we don't need gesture support
  12. self.presentsWithGesture = false
  13. for viewController in self.viewControllers {
  14. if let navController = viewController as? UINavigationController {
  15. navController.delegate = self
  16. }
  17. }
  18. placeholderViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "placeholderChatViewController")
  19. }
  20. func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
  21. if NCUtils.isiOSAppOnMac() {
  22. // When the app is running on MacOS there's a gap between the titleBar and the navigationBar.
  23. // We can remove that gap when setting a negative additionalSafeAreaInsets.top
  24. navigationController.additionalSafeAreaInsets.top = -navigationController.navigationBar.frame.maxY
  25. }
  26. if !isCollapsed {
  27. return
  28. }
  29. if let navController = self.viewController(for: .secondary) as? UINavigationController,
  30. viewController is RoomsTableViewController {
  31. // MovingFromParentViewController is always false in case of a rootViewController,
  32. // because of this, the chat will never be left in NCChatViewController
  33. // (see viewDidDisappear). So we have to leave the chat here, if collapsed
  34. if let chatViewController = getActiveChatViewController() {
  35. chatViewController.leaveChat()
  36. }
  37. // Make sure the chatViewController gets properly deallocated
  38. setViewController(placeholderViewController, for: .secondary)
  39. navController.setViewControllers([placeholderViewController], animated: false)
  40. // Instead of always allowing a gesture to be recognized, we need more control here.
  41. // See gestureRecognizerShouldBegin.
  42. navigationController.interactivePopGestureRecognizer?.delegate = self
  43. }
  44. }
  45. func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
  46. // We only want to recognize a "back gesture" through the interactivePopGestureRecognizer
  47. // when a chatViewController is shown. Otherwise (on the RoomsTableViewController)
  48. // recognizing a gesture might result in an unfinished transition and a broken UI
  49. if self.hasActiveChatViewController() {
  50. return true
  51. }
  52. return false
  53. }
  54. override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
  55. // Don't use internalExecuteAfterTransition here as that might interfere with presenting the CallViewController from CallKit/Background
  56. if !viewControllerToPresent.isBeingPresented {
  57. super.present(viewControllerToPresent, animated: flag, completion: completion)
  58. }
  59. }
  60. override func showDetailViewController(_ vc: UIViewController, sender: Any?) {
  61. self.internalExecuteAfterTransition {
  62. if !self.isCollapsed {
  63. // When another room is selected while there's still an active chatViewController
  64. // we need to make sure the active one is removed (applies to expanded mode only)
  65. if let navController = self.viewController(for: .secondary) as? UINavigationController {
  66. navController.popToRootViewController(animated: false)
  67. }
  68. }
  69. super.showDetailViewController(vc, sender: sender)
  70. if self.isCollapsed {
  71. // Make sure we don't have accidentally a placeholderView in our navigation
  72. // while in collapsed mode
  73. if let navController = self.viewController(for: .secondary) as? UINavigationController,
  74. vc is ChatViewController {
  75. // Only set the viewController if there's actually an active one shown by showDetailViewController
  76. // Otherwise UI might break or crash (view not loaded correctly)
  77. // This might happen if a chatViewController is shown by a push notification
  78. if self.hasActiveChatViewController() {
  79. // First set the placeholderViewController, to make sure it is only referencing one navController
  80. navController.setViewControllers([self.placeholderViewController], animated: false)
  81. navController.setViewControllers([vc], animated: false)
  82. }
  83. }
  84. }
  85. }
  86. }
  87. override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
  88. super.viewWillTransition(to: size, with: coordinator)
  89. coordinator.animate(alongsideTransition: nil) { _ in
  90. guard self.isCollapsed else { return }
  91. if let navController = self.viewController(for: .secondary) as? UINavigationController,
  92. let chatViewController = self.getActiveChatViewController() {
  93. // Make sure the navigationController has the correct reference to the chatViewController.
  94. // After a transition (eg. portrait to landscape) the navigationController still references the
  95. // the placeholderViewController in the navigationBar. When navigating back the app crashes in iOS 17,
  96. // because the navigationBar is referenced twice.
  97. navController.setViewControllers([self.placeholderViewController, chatViewController], animated: false)
  98. navController.setViewControllers([chatViewController], animated: false)
  99. }
  100. }
  101. }
  102. func internalExecuteAfterTransition(action: @escaping () -> Void) {
  103. if self.transitionCoordinator == nil {
  104. // No ongoing animations -> execute action directly
  105. action()
  106. } else {
  107. // Wait until the splitViewController finished all it's animations.
  108. // Otherwise this can lead to different UI glitches, for example a chatViewController might
  109. // end up in the wrong column. This mainly happens when being in a
  110. // conversation and tapping a push notification of another conversation.
  111. self.transitionCoordinator?.animate(alongsideTransition: nil, completion: { _ in
  112. DispatchQueue.main.async {
  113. action()
  114. }
  115. })
  116. }
  117. }
  118. func hasActiveChatViewController() -> Bool {
  119. return getActiveChatViewController() != nil
  120. }
  121. func getActiveChatViewController() -> ChatViewController? {
  122. return getActiveViewController()
  123. }
  124. func getActiveViewController<T: UIViewController>() -> T? {
  125. // In case we have a collapsed view, we need to retrieve the viewController this way
  126. if let navController = self.viewController(for: .secondary) as? UINavigationController {
  127. for secondaryViewController in navController.viewControllers {
  128. if let activeViewController = secondaryViewController as? T {
  129. return activeViewController
  130. }
  131. }
  132. }
  133. if let navController = self.viewController(for: .primary) as? UINavigationController {
  134. for primaryViewController in navController.viewControllers {
  135. if let activeViewController = primaryViewController as? T {
  136. return activeViewController
  137. }
  138. }
  139. }
  140. return nil
  141. }
  142. func splitViewController(_ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column) -> UISplitViewController.Column {
  143. // When we rotate the device and the splitViewController gets collapsed
  144. // we need to determine if we're still in a chat or not.
  145. // In case we are, we want to stay in the chat view, else we want to show the roomList
  146. if hasActiveChatViewController() {
  147. return .secondary
  148. }
  149. return .primary
  150. }
  151. func splitViewControllerDidExpand(_ svc: UISplitViewController) {
  152. if let navController = self.viewController(for: .secondary) as? UINavigationController {
  153. if hasActiveChatViewController() {
  154. // When we expand (show the second columns) and there's a active chatViewController
  155. // make sure we can drop back to the placeholderView
  156. navController.setViewControllers([placeholderViewController, getActiveChatViewController()!], animated: false)
  157. } else {
  158. navController.setViewControllers([placeholderViewController], animated: false)
  159. }
  160. }
  161. }
  162. func splitViewControllerDidCollapse(_ svc: UISplitViewController) {
  163. if hasActiveChatViewController() {
  164. // If we collapse (only show one column) and there's a active chatViewController
  165. // make sure only have the chatViewController in the stack
  166. if let navController = self.viewController(for: .secondary) as? UINavigationController {
  167. navController.setViewControllers([getActiveChatViewController()!], animated: false)
  168. }
  169. }
  170. }
  171. func popSecondaryColumnToRootViewController() {
  172. self.internalExecuteAfterTransition {
  173. if let navController = self.viewController(for: .secondary) as? UINavigationController {
  174. if let chatViewController = self.getActiveChatViewController() {
  175. chatViewController.leaveChat()
  176. }
  177. // No animation -> animated would interfere with room highlighting
  178. navController.popToRootViewController(animated: false)
  179. // We also need to make sure, that the popToRootViewController animation is finished before setting the placeholderVC
  180. self.internalExecuteAfterTransition {
  181. // Make sure the chatViewController gets properly deallocated
  182. self.setViewController(self.placeholderViewController, for: .secondary)
  183. navController.setViewControllers([self.placeholderViewController], animated: false)
  184. }
  185. }
  186. }
  187. }
  188. }