418 lines
20 KiB
Swift
418 lines
20 KiB
Swift
/*
|
|
Copyright 2020-2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import Foundation
|
|
import MatrixSDK
|
|
import CommonKit
|
|
|
|
/// SplitViewCoordinatorParameters input parameters
|
|
class SplitViewCoordinatorParameters {
|
|
|
|
let router: RootRouterType
|
|
let userSessionsService: UserSessionsService
|
|
let appNavigator: AppNavigatorProtocol
|
|
|
|
init(router: RootRouterType, userSessionsService: UserSessionsService, appNavigator: AppNavigatorProtocol) {
|
|
self.router = router
|
|
self.userSessionsService = userSessionsService
|
|
self.appNavigator = appNavigator
|
|
}
|
|
}
|
|
|
|
final class SplitViewCoordinator: NSObject, SplitViewCoordinatorType {
|
|
|
|
// MARK: - Constants
|
|
|
|
private enum Constants {
|
|
static let detailModulesCheckDelay: Double = 0.3
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
// MARK: Private
|
|
|
|
private let parameters: SplitViewCoordinatorParameters
|
|
|
|
private let splitViewController: UISplitViewController
|
|
|
|
private weak var masterPresentable: SplitViewMasterPresentable?
|
|
private var detailNavigationController: UINavigationController?
|
|
private var detailNavigationRouter: NavigationRouterType?
|
|
|
|
private var selectedNavigationRouter: NavigationRouterType? {
|
|
return self.masterPresentable?.selectedNavigationRouter
|
|
}
|
|
|
|
private weak var masterCoordinator: SplitViewMasterCoordinatorProtocol?
|
|
|
|
// Indicate if coordinator has been started once
|
|
private var hasStartedOnce: Bool = false
|
|
|
|
// MARK: Public
|
|
|
|
private(set) var detailUserIndicatorPresenter: UserIndicatorTypePresenterProtocol?
|
|
|
|
var childCoordinators: [Coordinator] = []
|
|
|
|
weak var delegate: SplitViewCoordinatorDelegate?
|
|
|
|
// MARK: - Setup
|
|
|
|
init(parameters: SplitViewCoordinatorParameters) {
|
|
self.parameters = parameters
|
|
|
|
let splitViewController = RiotSplitViewController()
|
|
splitViewController.preferredDisplayMode = .allVisible
|
|
self.splitViewController = splitViewController
|
|
}
|
|
|
|
// MARK: - Public methods
|
|
|
|
func start() {
|
|
self.start(with: nil)
|
|
}
|
|
|
|
func start(with spaceId: String?) {
|
|
|
|
if hasStartedOnce == false {
|
|
self.hasStartedOnce = true
|
|
|
|
self.splitViewController.delegate = self
|
|
|
|
// Create primary controller
|
|
let masterCoordinator: SplitViewMasterCoordinatorProtocol = BuildSettings.newAppLayoutEnabled ? self.createAllChatsCoordinator() : self.createTabBarCoordinator()
|
|
masterCoordinator.splitViewMasterPresentableDelegate = self
|
|
masterCoordinator.start(with: spaceId)
|
|
|
|
// Create secondary controller
|
|
let placeholderDetailViewController = self.createPlaceholderDetailsViewController()
|
|
let detailNavigationController = RiotNavigationController(rootViewController: placeholderDetailViewController)
|
|
|
|
// Setup split view controller
|
|
self.splitViewController.viewControllers = [masterCoordinator.toPresentable(), detailNavigationController]
|
|
|
|
// Setup detail user indicator presenter
|
|
let context = SplitViewUserIndicatorPresentationContext(
|
|
splitViewController: splitViewController,
|
|
masterCoordinator: masterCoordinator,
|
|
detailNavigationController: detailNavigationController
|
|
)
|
|
detailUserIndicatorPresenter = UserIndicatorTypePresenter(presentationContext: context)
|
|
|
|
self.add(childCoordinator: masterCoordinator)
|
|
|
|
self.masterCoordinator = masterCoordinator
|
|
self.masterPresentable = masterCoordinator
|
|
self.detailNavigationController = detailNavigationController
|
|
self.detailNavigationRouter = NavigationRouter(navigationController: detailNavigationController)
|
|
|
|
self.parameters.router.setRootModule(self.splitViewController)
|
|
|
|
self.registerNavigationRouterNotifications()
|
|
} else {
|
|
// Pop to home screen when selecting a new space
|
|
self.popToHome(animated: true) {
|
|
// Update tabBarCoordinator selected space
|
|
self.masterCoordinator?.start(with: spaceId)
|
|
}
|
|
}
|
|
}
|
|
|
|
func toPresentable() -> UIViewController {
|
|
return self.splitViewController
|
|
}
|
|
|
|
// TODO: Do not expose publicly this method
|
|
func resetDetails(animated: Bool) {
|
|
// Be sure that the primary is then visible too.
|
|
if splitViewController.displayMode == .primaryHidden {
|
|
splitViewController.preferredDisplayMode = .allVisible
|
|
}
|
|
|
|
self.resetDetailNavigationController(animated: animated)
|
|
|
|
// Release the current selected item (room/contact/group...).
|
|
self.masterCoordinator?.releaseSelectedItems()
|
|
}
|
|
|
|
func popToHome(animated: Bool, completion: (() -> Void)?) {
|
|
self.resetDetails(animated: animated)
|
|
|
|
// Force back to the main screen if this is not the one that is displayed
|
|
self.masterCoordinator?.popToHome(animated: animated, completion: completion)
|
|
}
|
|
|
|
func showErroIndicator(with error: Error) {
|
|
masterCoordinator?.showErroIndicator(with: error)
|
|
}
|
|
|
|
func hideAppStateIndicator() {
|
|
masterCoordinator?.hideAppStateIndicator()
|
|
}
|
|
|
|
func showAppStateIndicator(with text: String, icon: UIImage?) {
|
|
masterCoordinator?.showAppStateIndicator(with: text, icon: icon)
|
|
}
|
|
|
|
// MARK: - Private methods
|
|
|
|
private func createPlaceholderDetailsViewController() -> UIViewController {
|
|
return PlaceholderDetailViewController.instantiate()
|
|
}
|
|
|
|
private func createAllChatsCoordinator() -> AllChatsCoordinator {
|
|
let coordinatorParameters = AllChatsCoordinatorParameters(userSessionsService: self.parameters.userSessionsService, appNavigator: self.parameters.appNavigator)
|
|
|
|
let coordinator = AllChatsCoordinator(parameters: coordinatorParameters)
|
|
coordinator.delegate = self
|
|
return coordinator
|
|
}
|
|
|
|
private func createTabBarCoordinator() -> TabBarCoordinator {
|
|
|
|
let coordinatorParameters = TabBarCoordinatorParameters(userSessionsService: self.parameters.userSessionsService, appNavigator: self.parameters.appNavigator)
|
|
|
|
let tabBarCoordinator = TabBarCoordinator(parameters: coordinatorParameters)
|
|
tabBarCoordinator.delegate = self
|
|
return tabBarCoordinator
|
|
}
|
|
|
|
private func resetDetailNavigationControllerWithPlaceholder(animated: Bool) {
|
|
guard let detailNavigationRouter = self.detailNavigationRouter else {
|
|
return
|
|
}
|
|
|
|
// Check if placeholder is already shown
|
|
if detailNavigationRouter.modules.count == 1 && detailNavigationRouter.modules.last is PlaceholderDetailViewController {
|
|
return
|
|
}
|
|
|
|
// Set placeholder screen as root controller of detail navigation controller
|
|
let placeholderDetailsVC = self.createPlaceholderDetailsViewController()
|
|
detailNavigationRouter.setRootModule(placeholderDetailsVC, hideNavigationBar: false, animated: animated, popCompletion: nil)
|
|
}
|
|
|
|
private func resetDetailNavigationController(animated: Bool) {
|
|
|
|
if self.splitViewController.isCollapsed {
|
|
if let topMostNavigationController = self.selectedNavigationRouter?.modules.last as? UINavigationController, topMostNavigationController == self.detailNavigationController {
|
|
self.selectedNavigationRouter?.popModule(animated: animated)
|
|
}
|
|
} else {
|
|
self.resetDetailNavigationControllerWithPlaceholder(animated: animated)
|
|
}
|
|
}
|
|
|
|
private func isPlaceholderShown(from secondaryViewController: UIViewController) -> Bool {
|
|
|
|
if let detailNavigationController = secondaryViewController as? UINavigationController, let topViewController = detailNavigationController.viewControllers.last {
|
|
return topViewController is PlaceholderDetailViewController
|
|
} else {
|
|
return secondaryViewController is PlaceholderDetailViewController
|
|
}
|
|
}
|
|
|
|
private func releaseRoomDataSourceIfNeeded(for roomCoordinator: RoomCoordinatorProtocol) {
|
|
|
|
guard roomCoordinator.canReleaseRoomDataSource,
|
|
let session = roomCoordinator.mxSession,
|
|
let roomId = roomCoordinator.roomId else {
|
|
return
|
|
}
|
|
|
|
let existingRoomCoordinatorWithSameRoomId = self.detailModules.first { presentable -> Bool in
|
|
if let currentRoomCoordinator = presentable as? RoomCoordinatorProtocol, currentRoomCoordinator.threadId == nil {
|
|
return currentRoomCoordinator.roomId == roomCoordinator.roomId
|
|
}
|
|
return false
|
|
}
|
|
|
|
guard existingRoomCoordinatorWithSameRoomId == nil else {
|
|
MXLog.debug("[SplitViewCoordinator] Do not release RoomDataSource for room id \(roomId), another RoomCoordinator with same room id using it")
|
|
return
|
|
}
|
|
|
|
let dataSourceManager = MXKRoomDataSourceManager.sharedManager(forMatrixSession: session)
|
|
dataSourceManager?.closeRoomDataSource(withRoomId: roomId, forceClose: false)
|
|
}
|
|
|
|
private func registerNavigationRouterNotifications() {
|
|
NotificationCenter.default.addObserver(self, selector: #selector(navigationRouterDidPopViewController(_:)), name: NavigationRouter.didPopModule, object: nil)
|
|
}
|
|
|
|
@objc private func navigationRouterDidPopViewController(_ notification: Notification) {
|
|
|
|
guard let userInfo = notification.userInfo,
|
|
let navigationRouter = userInfo[NavigationRouter.NotificationUserInfoKey.navigationRouter] as? NavigationRouterType,
|
|
let poppedController = userInfo[NavigationRouter.NotificationUserInfoKey.viewController] as? UIViewController else {
|
|
return
|
|
}
|
|
|
|
// In our split view configuration is possible to have nested navigation controller (see https://blog.malcolmhall.com/2017/01/27/default-behaviour-of-uisplitviewcontroller-collapsesecondaryviewcontroller/)).
|
|
// When the split view controller has one column visible with the detail navigation controller nested inside the primary,
|
|
// check to see whether the primary navigation controller is popping the detail navigation controller.
|
|
// In this case the detail navigation controller will be popped but not its content. It means completions will not be called.
|
|
if navigationRouter === self.selectedNavigationRouter,
|
|
let poppedNavigationController = poppedController as? UINavigationController,
|
|
poppedNavigationController == self.detailNavigationController {
|
|
|
|
// Clear the detailNavigationRouter to trigger completions associated to each controllers
|
|
self.detailNavigationRouter?.popAllModules(animated: false)
|
|
}
|
|
|
|
if let poppedModule = userInfo[NavigationRouter.NotificationUserInfoKey.module] as? Presentable {
|
|
|
|
if let roomCoordinator = poppedModule as? RoomCoordinatorProtocol {
|
|
|
|
// If the RoomCoordinator view controller is popped from the detail navigation controller, check if the associated room data source should be released.
|
|
// If there is no other RoomCoordinator using the same data source, release it.
|
|
// A small delay is set to be sure navigation stack manipulation ended before checking the whole stack.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + Constants.detailModulesCheckDelay) {
|
|
self.releaseRoomDataSourceIfNeeded(for: roomCoordinator)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - UISplitViewControllerDelegate
|
|
extension SplitViewCoordinator: UISplitViewControllerDelegate {
|
|
|
|
/// Provide the new secondary view controller for the split view interface.
|
|
/// This method returns the view controller to use as the secondary view controller in the expanded split view interface (when 2 column are visible).
|
|
/// Sample case: large iPhone goes from portrait to landsacpe.
|
|
func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? {
|
|
|
|
// If the primary root controller of the UISplitViewController is a UINavigationController,
|
|
// it's possible to have nested navigation controllers due to private property `_allowNestedNavigationControllers` set to true
|
|
// (https://blog.malcolmhall.com/2017/01/27/default-behaviour-of-uisplitviewcontroller-collapsesecondaryviewcontroller/).
|
|
// So if the top view controller of the primary navigation controller is a navigation controller and it corresponds to the existing `detailNavigationController` instance.
|
|
// Return `detailNavigationController` as is, it will be used as the secondary view of the split view controller.
|
|
if let topMostNavigationController = self.selectedNavigationRouter?.modules.last as? UINavigationController, topMostNavigationController == self.detailNavigationController {
|
|
|
|
return self.detailNavigationController
|
|
}
|
|
|
|
// Else return the default empty details view controller.
|
|
// Be sure that the primary is then visible too.
|
|
if splitViewController.displayMode == .primaryHidden {
|
|
splitViewController.preferredDisplayMode = .allVisible
|
|
}
|
|
|
|
// Restore detail navigation controller with placeholder as root
|
|
self.resetDetailNavigationController(animated: false)
|
|
|
|
// Return up to date detail navigation controller
|
|
// In any cases `detailNavigationController` will be used as secondary view of the split view controller.
|
|
return self.detailNavigationController
|
|
}
|
|
|
|
/// Adjust the primary view controller and incorporate the secondary view controller into the collapsed interface if needed.
|
|
/// Return false to let the split view controller try to incorporate the secondary view controller's content into the collapsed interface,
|
|
/// or true to indicate that you do not want the split view controller to do anything with the secondary view controller.
|
|
/// Sample case: large iPhone goes from landscape to portrait.
|
|
func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
|
|
|
|
// If the secondary view is the placeholder screen do not merge the secondary into the primary.
|
|
// Note: In this case, the secondaryViewController will be automatically discarded.
|
|
if self.isPlaceholderShown(from: secondaryViewController) {
|
|
return true
|
|
}
|
|
|
|
// Return false to let the split view controller try to incorporate the secondary view controller's content into the collapsed interface.
|
|
// If the primary root controller of a UISplitViewController is a UINavigationController,
|
|
// it's possible to have nested navigation controllers due to private property `_allowNestedNavigationControllers` set to true
|
|
// (https://blog.malcolmhall.com/2017/01/27/default-behaviour-of-uisplitviewcontroller-collapsesecondaryviewcontroller/).
|
|
// So in this case returning false here will push the `detailNavigationController` on top of the `primaryNavigationController`.
|
|
// Sample primary view stack:
|
|
// primaryNavigationController[
|
|
// MasterTabBarController,
|
|
// detailNavigationController[RoomViewController, RoomInfoListViewController]]
|
|
// Note that normally pushing a navigation controller on top of a navigation controller don't work.
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - TabBarCoordinatorDelegate
|
|
extension SplitViewCoordinator: SplitViewMasterCoordinatorDelegate {
|
|
func splitViewMasterCoordinatorDidCompleteAuthentication(_ coordinator: SplitViewMasterCoordinatorProtocol) {
|
|
self.delegate?.splitViewCoordinatorDidCompleteAuthentication(self)
|
|
}
|
|
}
|
|
|
|
// MARK: - SplitViewMasterPresentableDelegate
|
|
extension SplitViewCoordinator: SplitViewMasterPresentableDelegate {
|
|
var detailModules: [Presentable] {
|
|
return self.detailNavigationRouter?.modules ?? []
|
|
}
|
|
|
|
func splitViewMasterPresentable(_ presentable: Presentable, wantsToReplaceDetailWith detailPresentable: Presentable, popCompletion: (() -> Void)?) {
|
|
MXLog.debug("[SplitViewCoordinator] splitViewMasterPresentable: \(presentable) wantsToReplaceDetailWith detailPresentable: \(detailPresentable)")
|
|
|
|
guard let detailNavigationController = self.detailNavigationController else {
|
|
MXLog.debug("[SplitViewCoordinator] splitViewMasterPresentable: Failed to display because detailNavigationController is nil")
|
|
return
|
|
}
|
|
|
|
let detailController = detailPresentable.toPresentable()
|
|
|
|
// Reset the detail navigation controller with the given detail controller
|
|
self.detailNavigationRouter?.setRootModule(detailPresentable, popCompletion: popCompletion)
|
|
|
|
// This will call first UISplitViewControllerDelegate method: `splitViewController(_:showDetail:sender:)`, if implemented, to give the opportunity to customise `UISplitViewController.showDetailViewController(:sender:)` behavior.
|
|
// - If the split view controller is collpased (one column visible):
|
|
// The `detailNavigationController` will be pushed on top of the primary navigation controller.
|
|
// In fact if the primary root controller of a UISplitViewController is a UINavigationController,
|
|
// it's possible to have nested navigation controllers due to private property `_allowNestedNavigationControllers` set to true
|
|
// (https://blog.malcolmhall.com/2017/01/27/default-behaviour-of-uisplitviewcontroller-collapsesecondaryviewcontroller/).
|
|
// - Else if the split view controller is not collpased (two column visible)
|
|
// It will set the `detailNavigationController` as the secondary view of the split view controller
|
|
self.splitViewController.showDetailViewController(detailNavigationController, sender: nil)
|
|
|
|
// Set leftBarButtonItem with split view display mode button if there is no leftBarButtonItem defined
|
|
detailController.vc_setupDisplayModeLeftBarButtonItemIfNeeded()
|
|
}
|
|
|
|
func splitViewMasterPresentable(_ presentable: Presentable, wantsToStack detailPresentable: Presentable, popCompletion: (() -> Void)?) {
|
|
|
|
guard let detailNavigationRouter = self.detailNavigationRouter else {
|
|
MXLog.debug("[SplitViewCoordinator] Failed to stack \(detailPresentable) because detailNavigationRouter is nil")
|
|
return
|
|
}
|
|
|
|
detailNavigationRouter.push(detailPresentable, animated: true, popCompletion: popCompletion)
|
|
}
|
|
|
|
func splitViewMasterPresentable(_ presentable: Presentable, wantsToReplaceDetailsWith modules: [NavigationModule]) {
|
|
MXLog.debug("[SplitViewCoordinator] splitViewMasterPresentable: \(presentable) wantsToReplaceDetailsWith modules: \(modules)")
|
|
|
|
self.detailNavigationRouter?.setModules(modules, animated: true)
|
|
}
|
|
|
|
func splitViewMasterPresentable(_ presentable: Presentable, wantsToStack modules: [NavigationModule]) {
|
|
guard let detailNavigationRouter = self.detailNavigationRouter else {
|
|
MXLog.warning("[SplitViewCoordinator] Failed to stack \(modules) because detailNavigationRouter is nil")
|
|
return
|
|
}
|
|
|
|
detailNavigationRouter.push(modules, animated: true)
|
|
}
|
|
|
|
func splitViewMasterPresentable(_ presentable: Presentable, wantsToPopTo module: Presentable) {
|
|
guard let detailNavigationRouter = self.detailNavigationRouter else {
|
|
MXLog.warning("[SplitViewCoordinator] Failed to pop to \(module) because detailNavigationRouter is nil")
|
|
return
|
|
}
|
|
|
|
detailNavigationRouter.popToModule(module, animated: true)
|
|
}
|
|
|
|
func splitViewMasterPresentableWantsToResetDetail(_ presentable: Presentable) {
|
|
self.resetDetails(animated: false)
|
|
}
|
|
}
|