Fixes #317 - Adopt a split layout for iPad and Mac apps

Rename navigation components: SplitScreenCoordinator -> NavigationSplitCoordinator, StackScreenCoordinator -> NavigationStackCoordinator and SingleScreenCoordinator -> NavigationRootCoordinator
[0c161039] Tweak navigation logging
[826c19cf] Move the navigation dismissal callbacks to the NavigationModule, add SingleScreenCoordinator tests
[b8830d9c] Add tests
[252ad119] Merge the StackScreenCoordinator and SplitScreenCoordinators into a single file and stop publicly exposing their internal workings. Add more documentation.
[37671699] Cleanup navigation logging
[51406184] Use the parent SplitScreenCoordinator to present embedded StackScreenCoordinator sheets
[b94b04c9] Retract the room "syncing" indicator when dismissing a room
[1467b0ac] Correctly move to the no room selected state when popping in compact layouts
[10bf2ad8] Allow nilling root coordinators, replace present/dismiss sheet with setSheetCoordinator(?)
[33716784] Add single screen coordinator fade transition animation
[3cbe65e7] Prevent the timeline table view from being reused between different rooms
[9c94c50b] Move files around
[c10b6bc5] Adapt the user session state machine to the split layout
[7115a319] Fix unit and UI tests
[1ece59e8] Fix login flows
[6884dc3b] Use modules everywhere the underlying object is a NavigationModule
[ab08d44c] Rename navigation components to: SingleScreenCoordinator, SplitScreenCoordinator and StackScreenCoordinator
[ada2be57] Add SplitNavigationController

* Remove the navigationRootCoordinator from the UserSessionFlowCoordinator
This commit is contained in:
Stefan Ceriu
2022-12-12 12:31:27 +02:00
committed by GitHub
parent 5ef53e9220
commit cd40da6c3f
22 changed files with 1407 additions and 541 deletions
@@ -20,7 +20,7 @@ import SwiftUI
class AppCoordinator: AppCoordinatorProtocol {
private let stateMachine: AppCoordinatorStateMachine
private let navigationController: NavigationController
private let navigationRootCoordinator: NavigationRootCoordinator
private let userSessionStore: UserSessionStoreProtocol
/// Common background task to resume long-running tasks in the background.
/// When this task expiring, we'll try to suspend the state machine by `suspend` event.
@@ -51,14 +51,14 @@ class AppCoordinator: AppCoordinatorProtocol {
private(set) var notificationManager: NotificationManagerProtocol?
init() {
navigationController = NavigationController()
navigationRootCoordinator = NavigationRootCoordinator()
stateMachine = AppCoordinatorStateMachine()
bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL)
navigationController.setRootCoordinator(SplashScreenCoordinator())
navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
ServiceLocator.shared.register(userNotificationController: UserNotificationController(rootCoordinator: navigationController))
ServiceLocator.shared.register(userNotificationController: UserNotificationController(rootCoordinator: navigationRootCoordinator))
backgroundTaskService = UIKitBackgroundTaskService {
UIApplication.shared
@@ -168,12 +168,15 @@ class AppCoordinator: AppCoordinatorProtocol {
}
private func startAuthentication() {
let authenticationNavigationStackCoordinator = NavigationStackCoordinator()
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
authenticationCoordinator = AuthenticationCoordinator(authenticationService: authenticationService,
navigationController: navigationController)
navigationStackCoordinator: authenticationNavigationStackCoordinator)
authenticationCoordinator?.delegate = self
authenticationCoordinator?.start()
navigationRootCoordinator.setRootCoordinator(authenticationNavigationStackCoordinator)
}
private func startAuthenticationSoftLogout() {
@@ -208,13 +211,14 @@ class AppCoordinator: AppCoordinatorProtocol {
}
}
navigationController.setRootCoordinator(coordinator)
navigationRootCoordinator.setRootCoordinator(coordinator)
}
}
private func setupUserSession() {
let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SplashScreenCoordinator())
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
navigationController: navigationController,
navigationSplitCoordinator: navigationSplitCoordinator,
bugReportService: bugReportService)
userSessionFlowCoordinator.callback = { [weak self] action in
@@ -227,6 +231,8 @@ class AppCoordinator: AppCoordinatorProtocol {
userSessionFlowCoordinator.start()
self.userSessionFlowCoordinator = userSessionFlowCoordinator
navigationRootCoordinator.setRootCoordinator(navigationSplitCoordinator)
}
private func tearDownUserSession(isSoftLogout: Bool = false) {
@@ -253,7 +259,7 @@ class AppCoordinator: AppCoordinatorProtocol {
}
private func presentSplashScreen(isSoftLogout: Bool = false) {
navigationController.setRootCoordinator(SplashScreenCoordinator())
navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
if isSoftLogout {
startAuthenticationSoftLogout()
@@ -0,0 +1,526 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
/// Class responsible for displaying 2 coordinators side by side and collapsing them
/// into a single navigation stack on compact layouts
class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomStringConvertible {
fileprivate let placeholderModule: NavigationModule
private var cancellables = Set<AnyCancellable>()
@Published fileprivate var sidebarModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove sidebar", oldValue)
oldValue.coordinator.stop()
oldValue.dismissalCallback?()
}
if let sidebarModule {
logPresentationChange("Set sidebar", sidebarModule)
sidebarModule.coordinator.start()
}
updateCompactLayoutComponents()
}
}
/// The currently displayed sidebar coordinator
var sidebarCoordinator: (any CoordinatorProtocol)? {
sidebarModule?.coordinator
}
@Published fileprivate var detailModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove detail", oldValue)
oldValue.coordinator.stop()
oldValue.dismissalCallback?()
}
if let detailModule {
logPresentationChange("Set detail", detailModule)
detailModule.coordinator.start()
}
updateCompactLayoutComponents()
}
}
/// The currently displayed detail coordinator
var detailCoordinator: (any CoordinatorProtocol)? {
detailModule?.coordinator
}
@Published fileprivate var sheetModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove sheet", oldValue)
oldValue.coordinator.stop()
oldValue.dismissalCallback?()
}
if let sheetModule {
logPresentationChange("Set sheet", sheetModule)
sheetModule.coordinator.start()
}
updateCompactLayoutComponents()
}
}
/// The currently displayed sheet coordinator
var sheetCoordinator: (any CoordinatorProtocol)? {
sheetModule?.coordinator
}
@Published fileprivate var compactLayoutRootModule: NavigationModule?
var compactLayoutRootCoordinator: (any CoordinatorProtocol)? {
compactLayoutRootModule?.coordinator
}
/// This is set as internal so that we can manipulate the compact layout stack from the unit tests.
/// Shouldn't be used otherwise.
@Published internal var compactLayoutStackModules: [NavigationModule] = []
var compactLayoutStackCoordinators: [any CoordinatorProtocol] {
compactLayoutStackModules.map(\.coordinator)
}
/// Default NavigationSplitCoordinator initialiser
/// - Parameter placeholderCoordinator: coordinator to use if no siderbar or detail is set
init(placeholderCoordinator: CoordinatorProtocol) {
placeholderModule = NavigationModule(placeholderCoordinator)
}
/// Set the coordinator to be used on the split's left pannel
/// - Parameters:
/// - coordinator: the sidebar coordinator
/// - dismissalCallback: called when this particular sidebar coordinator has removed/replaced
func setSidebarCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
sidebarModule = nil
return
}
sidebarModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
/// Set the coordinator to be used on the split's right pannel
/// - Parameters:
/// - coordinator: the detail coordinator
/// - dismissalCallback: called when this particular detail coordinator has removed/replaced
func setDetailCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
detailModule = nil
return
}
detailModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
/// Present a sheet on top of the split view
/// - Parameters:
/// - coordinator: the coordinator to display
/// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise
func setSheetCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
sheetModule = nil
return
}
sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
// MARK: - CoordinatorProtocol
func toPresentable() -> AnyView {
AnyView(NavigationSplitCoordinatorView(navigationSplitCoordinator: self))
}
// MARK: - CustomStringConvertible
var description: String {
switch (sidebarModule?.coordinator, detailModule?.coordinator) {
case (.some(let sidebarCoordinator), .some(let detailCoordinator)):
return "NavigationSplitCoordinator(\(sidebarCoordinator) | \(detailCoordinator))"
case (.some(let sidebarCoordinator), .none):
return "NavigationSplitCoordinator(\(sidebarCoordinator) | Empty)"
case (.none, .some(let detailCoordinator)):
return "NavigationSplitCoordinator(Empty | \(detailCoordinator))"
case (.none, .none):
return "NavigationSplitCoordinator(Empty | Empty)"
}
}
// MARK: - Private
private func logPresentationChange(_ change: String, _ module: NavigationModule) {
MXLog.info("\(self) \(change): \(module.coordinator)")
}
/// We need to update the compact layout whenever anything changes within the split coordinator or
/// the navigation coordinators embedded into it
private func updateCompactLayoutComponents() {
// First remove all observers
cancellables.removeAll()
// Start building the new compact layout navigation stack
var stackModules: [NavigationModule] = []
// If the sidebar is a stackCoordinator then use it's root as the compact layout root
// and push its children to the compact layout stack
if let sidebarNavigationStackCoordinator = sidebarModule?.coordinator as? NavigationStackCoordinator {
// Observe changes on embedded stackCoordinators and reflect them in the compact layout components
observe(navigationStackCoordinator: sidebarNavigationStackCoordinator)
if let sidebarRootModule = sidebarNavigationStackCoordinator.rootModule {
compactLayoutRootModule = sidebarRootModule
}
stackModules.append(contentsOf: sidebarNavigationStackCoordinator.stackModules)
} else if let sidebarModule { // Otherwise just use it as a root directly
compactLayoutRootModule = sidebarModule
}
// If the detail is a stackCoordinator then push its root and children to the compact layout stack
if let detailNavigationStackCoordinator = detailModule?.coordinator as? NavigationStackCoordinator {
// Observe changes on embedded stackCoordinators and reflect them in the compact layout components
observe(navigationStackCoordinator: detailNavigationStackCoordinator)
if let detailRootCoordinator = detailNavigationStackCoordinator.rootModule {
stackModules.append(detailRootCoordinator)
}
stackModules.append(contentsOf: detailNavigationStackCoordinator.stackModules)
} else if let detailModule { // Otherwise just push it entirely
stackModules.append(detailModule)
}
compactLayoutStackModules = stackModules
// Observe and process compact layout changes
observeCompactLayoutStackChanges()
}
/// Changes to the navigation stack while in a compact layout should be
/// reflected back onto the embedded components e.g. stackCoordinator pops
private func observeCompactLayoutStackChanges() {
$compactLayoutStackModules.sink { [weak self] stackModules in
guard let self, self.compactLayoutStackModules != stackModules else { return }
let diffs = stackModules.difference(from: self.compactLayoutStackModules)
diffs.forEach { change in
switch change {
case .insert:
break
case .remove(_, let module, _):
self.processCompactLayoutStackModuleRemoval(module)
}
}
}
.store(in: &cancellables)
}
/// Manually process changes to the compact layout navigation stack and update embedded components
/// We need to either: pop from the detail, nil the detail or pop from the sidebar
private func processCompactLayoutStackModuleRemoval(_ module: NavigationModule) {
if let sidebarNavigationStackCoordinator = sidebarModule?.coordinator as? NavigationStackCoordinator {
if sidebarNavigationStackCoordinator.stackModules.contains(module) {
sidebarNavigationStackCoordinator.stackModules.removeAll { $0 == module }
}
}
if module == detailModule {
detailModule = nil
}
if let detailNavigationStackCoordinator = detailModule?.coordinator as? NavigationStackCoordinator {
if detailNavigationStackCoordinator.stackModules.contains(module) {
detailNavigationStackCoordinator.stackModules.removeAll { $0 == module }
} else if module == detailNavigationStackCoordinator.rootModule {
detailModule = nil
}
}
}
/// Any change to a NavigationStackCoordinator's internal state should be observed and reflected in the
/// compact layout components
private func observe(navigationStackCoordinator: NavigationStackCoordinator) {
navigationStackCoordinator.$rootModule.sink { [weak self] rootModule in
guard navigationStackCoordinator.rootModule != rootModule else { return }
DispatchQueue.main.async { self?.updateCompactLayoutComponents() }
}
.store(in: &cancellables)
navigationStackCoordinator.$stackModules.sink { [weak self] stackModules in
guard navigationStackCoordinator.stackModules != stackModules else { return }
DispatchQueue.main.async { self?.updateCompactLayoutComponents() }
}
.store(in: &cancellables)
navigationStackCoordinator.$sheetModule.sink { [weak self] sheetModule in
guard navigationStackCoordinator.sheetModule != sheetModule else { return }
DispatchQueue.main.async { self?.updateCompactLayoutComponents() }
}
.store(in: &cancellables)
}
}
private struct NavigationSplitCoordinatorView: View {
@State private var columnVisibility = NavigationSplitViewVisibility.all
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ObservedObject var navigationSplitCoordinator: NavigationSplitCoordinator
var body: some View {
Group {
if horizontalSizeClass == .compact {
navigationStack
} else {
navigationSplitView
}
}
// This needs to be handled on the top level otherwise sheets
// will be automatically dismissed on hierarchy changes.
// Embedded NavigationStackCoordinators will present their sheets
// through the NavigationSplitCoordinator as well.
.sheet(item: $navigationSplitCoordinator.sheetModule) { module in
module.coordinator.toPresentable()
}
}
/// The NavigationStack that will be used in compact layouts
var navigationStack: some View {
NavigationStack(path: $navigationSplitCoordinator.compactLayoutStackModules) {
navigationSplitCoordinator.compactLayoutRootModule?.coordinator.toPresentable()
.navigationDestination(for: NavigationModule.self) { module in
module.coordinator.toPresentable()
}
}
}
/// The NavigationSplitView that will be used in non-compact layouts
var navigationSplitView: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
if let sidebarModule = navigationSplitCoordinator.sidebarModule {
sidebarModule.coordinator.toPresentable()
} else {
navigationSplitCoordinator.placeholderModule.coordinator.toPresentable()
}
} detail: {
if let detailModule = navigationSplitCoordinator.detailModule {
detailModule.coordinator.toPresentable()
} else {
navigationSplitCoordinator.placeholderModule.coordinator.toPresentable()
}
}
.navigationSplitViewStyle(.balanced)
.navigationDestination(for: NavigationModule.self) { module in
module.coordinator.toPresentable()
}
}
}
// MARK: - NavigationStackCoordinator
/// Class responsible for displaying a normal "NavigationController" style hierarchy
class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomStringConvertible {
private(set) weak var navigationSplitCoordinator: NavigationSplitCoordinator?
@Published fileprivate var rootModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove root", oldValue)
oldValue.coordinator.stop()
oldValue.dismissalCallback?()
}
if let rootModule {
logPresentationChange("Set root", rootModule)
rootModule.coordinator.start()
}
}
}
// The stack's current root coordinator
var rootCoordinator: (any CoordinatorProtocol)? {
rootModule?.coordinator
}
@Published fileprivate var sheetModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove sheet", oldValue)
oldValue.coordinator.stop()
oldValue.dismissalCallback?()
}
if let sheetModule {
logPresentationChange("Set sheet", sheetModule)
sheetModule.coordinator.start()
}
}
}
// The currently presented sheet coordinator
// Sheets will be presented through the NavigationSplitCoordinator if provided
var sheetCoordinator: (any CoordinatorProtocol)? {
if let navigationSplitCoordinator {
return navigationSplitCoordinator.sheetCoordinator
}
return sheetModule?.coordinator
}
@Published fileprivate var stackModules = [NavigationModule]() {
didSet {
let diffs = stackModules.difference(from: oldValue)
diffs.forEach { change in
switch change {
case .insert(_, let module, _):
logPresentationChange("Push", module)
module.coordinator.start()
case .remove(_, let module, _):
logPresentationChange("Pop", module)
module.coordinator.stop()
module.dismissalCallback?()
}
}
}
}
// The current navigation stack. Excludes the rootCoordinator
var stackCoordinators: [any CoordinatorProtocol] {
stackModules.map(\.coordinator)
}
/// If this NavigationStackCoordinator will be embedded into a NavigationSplitCoordinator pass it here
/// so that sheet presentations are done through it. Otherwise sheets will not be presented properly
/// and dismissed automatically in compact layouts
/// - Parameter navigationSplitCoordinator: The expected parent NavigationSplitCoordinator
init(navigationSplitCoordinator: NavigationSplitCoordinator? = nil) {
self.navigationSplitCoordinator = navigationSplitCoordinator
}
/// Set the coordinator to be used on the stack's root
/// - Parameters:
/// - coordinator: the root coordinator
/// - dismissalCallback: called when this root coordinator has removed/replaced
func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
rootModule = nil
return
}
popToRoot(animated: false)
rootModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
/// Pushes a new coordinator on the navigation stack
/// - Parameters:
/// - coordinator: the coordinator to be displayed
/// - dismissalCallback: called when the coordinator has been popped, programatically or otherwise
func push(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) {
stackModules.append(NavigationModule(coordinator, dismissalCallback: dismissalCallback))
}
/// Pop all the coordinators from the stack, returning to the root coordinator
/// - Parameter animated: whether to animate the transition or not. Default is true
func popToRoot(animated: Bool = true) {
guard !stackModules.isEmpty else {
return
}
if !animated {
// Disabling animations doesn't work through normal Transactions
// https://stackoverflow.com/questions/72832243
UIView.setAnimationsEnabled(false)
}
stackModules.removeAll()
if !animated {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
UIView.setAnimationsEnabled(true)
}
}
}
/// Removes the last coordinator from the navigation stack
func pop() {
stackModules.removeLast()
}
/// Present a sheet on top of the stack. If this NavigationStackCoordinator is embedded within a NavigationSplitCoordinator
/// then the presentation will be proxied to the split
/// - Parameters:
/// - coordinator: the coordinator to display
/// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise
func setSheetCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
if let navigationSplitCoordinator {
navigationSplitCoordinator.setSheetCoordinator(coordinator, dismissalCallback: dismissalCallback)
return
}
guard let coordinator else {
sheetModule = nil
return
}
sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
// MARK: - CoordinatorProtocol
func toPresentable() -> AnyView {
AnyView(NavigationStackCoordinatorView(navigationStackCoordinator: self))
}
// MARK: - CustomStringConvertible
var description: String {
if let rootCoordinator = rootModule?.coordinator {
return "NavigationStackCoordinator(\(rootCoordinator))"
} else {
return "NavigationStackCoordinator(Empty)"
}
}
// MARK: - Private
private func logPresentationChange(_ change: String, _ module: NavigationModule) {
MXLog.info("\(self) \(change): \(module.coordinator)")
}
}
private struct NavigationStackCoordinatorView: View {
@ObservedObject var navigationStackCoordinator: NavigationStackCoordinator
var body: some View {
NavigationStack(path: $navigationStackCoordinator.stackModules) {
navigationStackCoordinator.rootModule?.coordinator.toPresentable()
.navigationDestination(for: NavigationModule.self) { module in
module.coordinator.toPresentable()
}
}
.sheet(item: $navigationStackCoordinator.sheetModule) { module in
module.coordinator.toPresentable()
}
}
}
@@ -0,0 +1,38 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// A CoordinatorProtocol wrapper and type erasing component that allows
/// dynamically presenting arbitrary screens
struct NavigationModule: Identifiable, Hashable {
let id = UUID()
let coordinator: any CoordinatorProtocol
let dismissalCallback: (() -> Void)?
init(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) {
self.coordinator = coordinator
self.dismissalCallback = dismissalCallback
}
static func == (lhs: NavigationModule, rhs: NavigationModule) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
@@ -0,0 +1,82 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
class NavigationRootCoordinator: ObservableObject, CoordinatorProtocol, CustomStringConvertible {
@Published fileprivate var rootModule: NavigationModule? {
didSet {
if let oldValue {
oldValue.coordinator.stop()
oldValue.dismissalCallback?()
}
if let rootModule {
logPresentationChange("Set root", rootModule)
rootModule.coordinator.start()
}
}
}
/// The currently displayed coordinator
var rootCoordinator: (any CoordinatorProtocol)? {
rootModule?.coordinator
}
/// Sets or replaces the presented coordinator
/// - Parameter coordinator: the coordinator to display
func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
rootModule = nil
return
}
rootModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
// MARK: - CoordinatorProtocol
func toPresentable() -> AnyView {
AnyView(NavigationRootCoordinatorView(rootCoordinator: self))
}
// MARK: - CustomStringConvertible
var description: String {
if let rootCoordinator = rootModule?.coordinator {
return "SingleScreenCoordinator(\(rootCoordinator)"
} else {
return "SingleScreenCoordinator(Empty)"
}
}
// MARK: - Private
private func logPresentationChange(_ change: String, _ module: NavigationModule) {
MXLog.info("\(self) \(change): \(module.coordinator)")
}
}
private struct NavigationRootCoordinatorView: View {
@ObservedObject var rootCoordinator: NavigationRootCoordinator
var body: some View {
ZStack {
rootCoordinator.rootModule?.coordinator.toPresentable()
}
.animation(.elementDefault, value: rootCoordinator.rootModule)
}
}
@@ -1,186 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
class NavigationController: ObservableObject, CoordinatorProtocol {
private var dismissalCallbacks = [UUID: () -> Void]()
@Published fileprivate var internalRootCoordinator: AnyCoordinator? {
didSet {
if let oldValue {
oldValue.coordinator.stop()
}
if let internalRootCoordinator {
logPresentationChange("Set root", internalRootCoordinator)
internalRootCoordinator.coordinator.start()
}
}
}
@Published fileprivate var internalSheetCoordinator: AnyCoordinator? {
didSet {
if let oldValue {
logPresentationChange("Dismiss", oldValue)
oldValue.coordinator.stop()
dismissalCallbacks[oldValue.id]?()
dismissalCallbacks.removeValue(forKey: oldValue.id)
}
if let internalSheetCoordinator {
logPresentationChange("Present", internalSheetCoordinator)
internalSheetCoordinator.coordinator.start()
}
}
}
@Published fileprivate var internalNavigationStack = [AnyCoordinator]() {
didSet {
let diffs = internalNavigationStack.difference(from: oldValue)
diffs.forEach { change in
switch change {
case .insert(_, let anyCoordinator, _):
logPresentationChange("Push", anyCoordinator)
anyCoordinator.coordinator.start()
case .remove(_, let anyCoordinator, _):
logPresentationChange("Pop", anyCoordinator)
anyCoordinator.coordinator.stop()
dismissalCallbacks[anyCoordinator.id]?()
dismissalCallbacks.removeValue(forKey: anyCoordinator.id)
}
}
}
}
var rootCoordinator: CoordinatorProtocol? {
internalRootCoordinator?.coordinator
}
var coordinators: [CoordinatorProtocol] {
internalNavigationStack.map(\.coordinator)
}
var sheetCoordinator: CoordinatorProtocol? {
internalSheetCoordinator?.coordinator
}
func setRootCoordinator(_ coordinator: any CoordinatorProtocol) {
popToRoot(animated: false)
internalRootCoordinator = AnyCoordinator(coordinator)
}
func push(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) {
let anyCoordinator = AnyCoordinator(coordinator)
if let dismissalCallback {
dismissalCallbacks[anyCoordinator.id] = dismissalCallback
}
internalNavigationStack.append(anyCoordinator)
}
func popToRoot(animated: Bool = true) {
dismissSheet()
guard !internalNavigationStack.isEmpty else {
return
}
if !animated {
// Disabling animations doesn't work through normal Transactions
// https://stackoverflow.com/questions/72832243
UIView.setAnimationsEnabled(false)
}
internalNavigationStack.removeAll()
if !animated {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
UIView.setAnimationsEnabled(true)
}
}
}
func pop() {
dismissSheet()
internalNavigationStack.removeLast()
}
func presentSheet(_ coordinator: any CoordinatorProtocol, dismissalCallback: (() -> Void)? = nil) {
let anyCoordinator = AnyCoordinator(coordinator)
if let dismissalCallback {
dismissalCallbacks[anyCoordinator.id] = dismissalCallback
}
internalSheetCoordinator = anyCoordinator
}
func dismissSheet() {
internalSheetCoordinator = nil
}
// MARK: - CoordinatorProtocol
func toPresentable() -> AnyView {
AnyView(NavigationControllerView(navigationController: self))
}
// MARK: - Private
private func logPresentationChange(_ change: String, _ anyCoordinator: AnyCoordinator) {
if let navigationCoordinator = anyCoordinator.coordinator as? NavigationController, let rootCoordinator = navigationCoordinator.rootCoordinator {
MXLog.info("\(change): NavigationController(\(anyCoordinator.id)) - \(rootCoordinator)")
} else {
MXLog.info("\(change): \(anyCoordinator.coordinator)(\(anyCoordinator.id))")
}
}
}
private struct NavigationControllerView: View {
@ObservedObject var navigationController: NavigationController
var body: some View {
NavigationStack(path: $navigationController.internalNavigationStack) {
navigationController.internalRootCoordinator?.coordinator.toPresentable()
.navigationDestination(for: AnyCoordinator.self) { anyCoordinator in
anyCoordinator.coordinator.toPresentable()
}
}
.sheet(item: $navigationController.internalSheetCoordinator) { anyCoordinator in
anyCoordinator.coordinator.toPresentable()
}
}
}
private struct AnyCoordinator: Identifiable, Hashable {
let id = UUID()
let coordinator: any CoordinatorProtocol
init(_ coordinator: any CoordinatorProtocol) {
self.coordinator = coordinator
}
static func == (lhs: AnyCoordinator, rhs: AnyCoordinator) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
@@ -16,7 +16,7 @@
import SwiftUI
class UserNotificationController: ObservableObject, UserNotificationControllerProtocol {
class UserNotificationController: ObservableObject, UserNotificationControllerProtocol, CustomStringConvertible {
private let rootCoordinator: CoordinatorProtocol
private var dismisalTimer: Timer?
@@ -77,4 +77,10 @@ class UserNotificationController: ObservableObject, UserNotificationControllerPr
self?.displayTimes[id] = nil
}
}
// MARK: - CustomStringConvertible
var description: String {
"UserNotificationController(\(String(describing: rootCoordinator)))"
}
}
@@ -24,14 +24,14 @@ protocol AuthenticationCoordinatorDelegate: AnyObject {
class AuthenticationCoordinator: CoordinatorProtocol {
private let authenticationService: AuthenticationServiceProxyProtocol
private let navigationController: NavigationController
private let navigationStackCoordinator: NavigationStackCoordinator
weak var delegate: AuthenticationCoordinatorDelegate?
init(authenticationService: AuthenticationServiceProxyProtocol,
navigationController: NavigationController) {
navigationStackCoordinator: NavigationStackCoordinator) {
self.authenticationService = authenticationService
self.navigationController = navigationController
self.navigationStackCoordinator = navigationStackCoordinator
}
func start() {
@@ -55,7 +55,7 @@ class AuthenticationCoordinator: CoordinatorProtocol {
}
}
navigationController.setRootCoordinator(coordinator)
navigationStackCoordinator.setRootCoordinator(coordinator)
}
private func startAuthentication() async {
@@ -88,12 +88,12 @@ class AuthenticationCoordinator: CoordinatorProtocol {
}
}
navigationController.push(coordinator)
navigationStackCoordinator.push(coordinator)
}
private func showLoginScreen() {
let parameters = LoginCoordinatorParameters(authenticationService: authenticationService,
navigationController: navigationController)
navigationStackCoordinator: navigationStackCoordinator)
let coordinator = LoginCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
@@ -105,7 +105,7 @@ class AuthenticationCoordinator: CoordinatorProtocol {
}
}
navigationController.push(coordinator)
navigationStackCoordinator.push(coordinator)
}
private func showAnalyticsPrompt(with userSession: UserSessionProtocol) {
@@ -117,7 +117,7 @@ class AuthenticationCoordinator: CoordinatorProtocol {
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
}
navigationController.setRootCoordinator(coordinator)
navigationStackCoordinator.setRootCoordinator(coordinator)
}
static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"
@@ -21,7 +21,7 @@ struct LoginCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProxyProtocol
/// The navigation router used to present the server selection screen.
let navigationController: NavigationController
let navigationStackCoordinator: NavigationStackCoordinator
}
enum LoginCoordinatorAction {
@@ -43,7 +43,7 @@ final class LoginCoordinator: CoordinatorProtocol {
}
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
private var navigationController: NavigationController { parameters.navigationController }
private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator }
var callback: (@MainActor (LoginCoordinatorAction) -> Void)?
@@ -195,9 +195,9 @@ final class LoginCoordinator: CoordinatorProtocol {
/// Presents the server selection screen as a modal.
private func presentServerSelectionScreen() {
let serverSelectionNavigationController = NavigationController()
let serverSelectionNavigationStackCoordinator = NavigationStackCoordinator()
let userNotificationController = UserNotificationController(rootCoordinator: serverSelectionNavigationController)
let userNotificationController = UserNotificationController(rootCoordinator: serverSelectionNavigationStackCoordinator)
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
userNotificationController: userNotificationController,
@@ -209,9 +209,9 @@ final class LoginCoordinator: CoordinatorProtocol {
self.serverSelectionCoordinator(coordinator, didCompleteWith: action)
}
serverSelectionNavigationController.setRootCoordinator(coordinator)
serverSelectionNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationController.presentSheet(userNotificationController)
navigationStackCoordinator.setSheetCoordinator(userNotificationController)
}
/// Handles the result from the server selection modal, dismissing it after updating the view.
@@ -221,7 +221,7 @@ final class LoginCoordinator: CoordinatorProtocol {
updateViewModel()
}
navigationController.dismissSheet()
navigationStackCoordinator.setSheetCoordinator(nil)
}
/// Shows the forgot password screen.
@@ -21,7 +21,7 @@ struct HomeScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let attributedStringBuilder: AttributedStringBuilderProtocol
let bugReportService: BugReportServiceProtocol
let navigationController: NavigationController
let navigationStackCoordinator: NavigationStackCoordinator
}
enum HomeScreenCoordinatorAction {
@@ -94,6 +94,6 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
}
private func presentInviteFriends() {
parameters.navigationController.presentSheet(InviteFriendsCoordinator(userId: parameters.userSession.userID))
parameters.navigationStackCoordinator.setSheetCoordinator(InviteFriendsCoordinator(userId: parameters.userSession.userID))
}
}
@@ -17,7 +17,7 @@
import SwiftUI
struct RoomScreenCoordinatorParameters {
let navigationController: NavigationController
let navigationStackCoordinator: NavigationStackCoordinator
let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let roomName: String?
@@ -29,12 +29,12 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
private var parameters: RoomScreenCoordinatorParameters?
private var viewModel: RoomScreenViewModelProtocol?
private var navigationController: NavigationController {
private var navigationStackCoordinator: NavigationStackCoordinator {
guard let parameters else {
fatalError()
}
return parameters.navigationController
return parameters.navigationStackCoordinator
}
init(parameters: RoomScreenCoordinatorParameters) {
@@ -86,19 +86,19 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
if params.isModallyPresented {
coordinator.callback = { [weak self] _ in
self?.navigationController.dismissSheet()
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
let controller = NavigationController()
let controller = NavigationStackCoordinator()
controller.setRootCoordinator(coordinator)
navigationController.presentSheet(controller)
navigationStackCoordinator.setSheetCoordinator(controller)
} else {
coordinator.callback = { [weak self] _ in
self?.navigationController.pop()
self?.navigationStackCoordinator.pop()
}
navigationController.push(coordinator)
navigationStackCoordinator.push(coordinator)
}
}
@@ -106,10 +106,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
let params = FilePreviewCoordinatorParameters(fileURL: fileURL, title: title)
let coordinator = FilePreviewCoordinator(parameters: params)
coordinator.callback = { [weak self] _ in
self?.navigationController.pop()
self?.navigationStackCoordinator.pop()
}
navigationController.push(coordinator)
navigationStackCoordinator.push(coordinator)
}
private func displayEmojiPickerScreen(for itemId: String) {
@@ -123,13 +123,14 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
coordinator.callback = { [weak self] action in
switch action {
case let .emojiSelected(emoji: emoji, itemId: itemId):
self?.navigationController.dismissSheet()
self?.navigationStackCoordinator.setSheetCoordinator(nil)
MXLog.debug("Save \(emoji) for \(itemId)")
Task {
await timelineController.sendReaction(emoji, for: itemId)
}
}
}
navigationController.presentSheet(coordinator)
navigationStackCoordinator.setSheetCoordinator(coordinator)
}
}
@@ -31,6 +31,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Setup
deinit {
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Constants.backPaginationIndicatorID)
}
init(timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactoryProtocol,
mediaProvider: MediaProviderProtocol,
@@ -33,6 +33,7 @@ struct RoomScreen: View {
var timeline: some View {
TimelineTableView()
.id(context.viewState.roomId)
.environmentObject(context)
.timelineStyle(settings.timelineStyle)
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
@@ -17,7 +17,7 @@
import SwiftUI
struct SettingsCoordinatorParameters {
let navigationController: NavigationController
let navigationStackCoordinator: NavigationStackCoordinator
let userNotificationController: UserNotificationControllerProtocol
let userSession: UserSessionProtocol
let bugReportService: BugReportServiceProtocol
@@ -88,10 +88,10 @@ final class SettingsCoordinator: CoordinatorProtocol {
break
}
self?.parameters.navigationController.pop()
self?.parameters.navigationStackCoordinator.pop()
}
parameters.navigationController.push(coordinator)
parameters.navigationStackCoordinator.push(coordinator)
}
private func showSuccess(label: String) {
@@ -24,18 +24,26 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
private let stateMachine: UserSessionFlowCoordinatorStateMachine
private let userSession: UserSessionProtocol
private let navigationController: NavigationController
private let navigationSplitCoordinator: NavigationSplitCoordinator
private let bugReportService: BugReportServiceProtocol
private let emojiProvider: EmojiProviderProtocol = EmojiProvider()
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
private let detailNavigationStackCoordinator: NavigationStackCoordinator
var callback: ((UserSessionFlowCoordinatorAction) -> Void)?
init(userSession: UserSessionProtocol, navigationController: NavigationController, bugReportService: BugReportServiceProtocol) {
init(userSession: UserSessionProtocol, navigationSplitCoordinator: NavigationSplitCoordinator, bugReportService: BugReportServiceProtocol) {
stateMachine = UserSessionFlowCoordinatorStateMachine()
self.userSession = userSession
self.navigationController = navigationController
self.navigationSplitCoordinator = navigationSplitCoordinator
self.bugReportService = bugReportService
sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator)
setupStateMachine()
}
@@ -50,7 +58,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
}
func tryDisplayingRoomScreen(roomId: String) {
stateMachine.processEvent(.showRoomScreen(roomId: roomId))
stateMachine.processEvent(.selectRoom(roomId: roomId))
}
// MARK: - Private
@@ -61,27 +69,32 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
guard let self else { return }
switch (context.fromState, context.event, context.toState) {
case (.initial, .start, .homeScreen):
case (.initial, .start, .roomList):
self.presentHomeScreen()
case(.homeScreen, .showRoomScreen, .roomScreen(let roomId)):
self.presentRoomWithIdentifier(roomId)
case(.roomScreen, .dismissedRoomScreen, .homeScreen):
break
case(.roomList(let currentRoomId), .selectRoom, .roomList(let selectedRoomId)):
guard let selectedRoomId,
selectedRoomId != currentRoomId else {
return
}
case (.homeScreen, .showSessionVerificationScreen, .sessionVerificationScreen):
self.presentRoomWithIdentifier(selectedRoomId)
case(.roomList, .deselectRoom, .roomList):
break
case (.roomList, .showSessionVerificationScreen, .sessionVerificationScreen):
self.presentSessionVerification()
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .homeScreen):
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .roomList):
break
case (.homeScreen, .showSettingsScreen, .settingsScreen):
case (.roomList, .showSettingsScreen, .settingsScreen):
self.presentSettingsScreen()
case (.settingsScreen, .dismissedSettingsScreen, .homeScreen):
case (.settingsScreen, .dismissedSettingsScreen, .roomList):
break
case (.homeScreen, .feedbackScreen, .feedbackScreen):
case (.roomList, .feedbackScreen, .feedbackScreen):
self.presentFeedbackScreen()
case (.feedbackScreen, .dismissedFeedbackScreen, .homeScreen):
case (.feedbackScreen, .dismissedFeedbackScreen, .roomList):
break
default:
@@ -100,7 +113,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(),
bugReportService: bugReportService,
navigationController: navigationController)
navigationStackCoordinator: detailNavigationStackCoordinator)
let coordinator = HomeScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
@@ -108,7 +121,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
switch action {
case .presentRoomScreen(let roomIdentifier):
self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier))
self.stateMachine.processEvent(.selectRoom(roomId: roomIdentifier))
case .presentSettingsScreen:
self.stateMachine.processEvent(.showSettingsScreen)
case .presentFeedbackScreen:
@@ -119,8 +132,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
self.callback?(.signOut)
}
}
navigationController.setRootCoordinator(coordinator)
sidebarNavigationStackCoordinator.setRootCoordinator(coordinator)
}
// MARK: Rooms
@@ -145,7 +158,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
mediaProvider: userSession.mediaProvider,
roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController,
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
roomName: roomProxy.displayName ?? roomProxy.name,
@@ -153,9 +166,17 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
emojiProvider: emojiProvider)
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationController.push(coordinator) { [weak self] in
detailNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator) { [weak self, roomIdentifier] in
guard let self else { return }
self.stateMachine.processEvent(.dismissedRoomScreen)
// Move the state machine to no room selected if the room currently being dimissed
// is the same as the one selected in the state machine.
// This generally happens when popping the room screen while in a compact layout
if case let .roomList(selectedRoomId) = self.stateMachine.state, selectedRoomId == roomIdentifier {
self.stateMachine.processEvent(.deselectRoom)
self.detailNavigationStackCoordinator.setRootCoordinator(nil)
}
}
}
}
@@ -163,11 +184,11 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
// MARK: Settings
private func presentSettingsScreen() {
let settingsNavigationController = NavigationController()
let settingsNavigationStackCoordinator = NavigationStackCoordinator()
let userNotificationController = UserNotificationController(rootCoordinator: settingsNavigationController)
let userNotificationController = UserNotificationController(rootCoordinator: settingsNavigationStackCoordinator)
let parameters = SettingsCoordinatorParameters(navigationController: settingsNavigationController,
let parameters = SettingsCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator,
userNotificationController: userNotificationController,
userSession: userSession,
bugReportService: bugReportService)
@@ -176,16 +197,16 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .dismiss:
self.navigationController.dismissSheet()
self.navigationSplitCoordinator.setSheetCoordinator(nil)
case .logout:
self.navigationController.dismissSheet()
self.navigationSplitCoordinator.setSheetCoordinator(nil)
self.callback?(.signOut)
}
}
settingsNavigationController.setRootCoordinator(settingsCoordinator)
settingsNavigationStackCoordinator.setRootCoordinator(settingsCoordinator)
navigationController.presentSheet(userNotificationController) { [weak self] in
navigationSplitCoordinator.setSheetCoordinator(userNotificationController) { [weak self] in
self?.stateMachine.processEvent(.dismissedSettingsScreen)
}
}
@@ -202,10 +223,10 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
let coordinator = SessionVerificationCoordinator(parameters: parameters)
coordinator.callback = { [weak self] in
self?.navigationController.dismissSheet()
self?.navigationSplitCoordinator.setSheetCoordinator(nil)
}
navigationController.presentSheet(coordinator) { [weak self] in
navigationSplitCoordinator.setSheetCoordinator(coordinator) { [weak self] in
self?.stateMachine.processEvent(.dismissedSessionVerificationScreen)
}
}
@@ -213,9 +234,9 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
// MARK: Bug reporting
private func presentFeedbackScreen(for image: UIImage? = nil) {
let feedbackNavigationController = NavigationController()
let feedbackNavigationStackCoordinator = NavigationStackCoordinator()
let userNotificationController = UserNotificationController(rootCoordinator: feedbackNavigationController)
let userNotificationController = UserNotificationController(rootCoordinator: feedbackNavigationStackCoordinator)
let parameters = BugReportCoordinatorParameters(bugReportService: bugReportService,
userNotificationController: userNotificationController,
@@ -223,12 +244,12 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
isModallyPresented: true)
let coordinator = BugReportCoordinator(parameters: parameters)
coordinator.completion = { [weak self] _ in
self?.navigationController.dismissSheet()
self?.navigationSplitCoordinator.setSheetCoordinator(nil)
}
feedbackNavigationController.setRootCoordinator(coordinator)
feedbackNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationController.presentSheet(userNotificationController) { [weak self] in
navigationSplitCoordinator.setSheetCoordinator(userNotificationController) { [weak self] in
self?.stateMachine.processEvent(.dismissedFeedbackScreen)
}
}
@@ -24,20 +24,16 @@ class UserSessionFlowCoordinatorStateMachine {
case initial
/// Showing the home screen
case homeScreen
/// Showing a particular room's timeline
/// - Parameter roomId: that room's identifier
case roomScreen(roomId: String)
case roomList(selectedRoomId: String?)
/// Showing the session verification flows
case sessionVerificationScreen(selectedRoomId: String?)
/// Showing the session verification flows
case sessionVerificationScreen
/// Showing the session verification flows
case feedbackScreen
case feedbackScreen(selectedRoomId: String?)
/// Showing the settings screen
case settingsScreen
case settingsScreen(selectedRoomId: String?)
}
/// Events that can be triggered on the AppCoordinator state machine
@@ -47,9 +43,9 @@ class UserSessionFlowCoordinatorStateMachine {
/// Request presentation for a particular room
/// - Parameter roomId:the room identifier
case showRoomScreen(roomId: String)
case selectRoom(roomId: String)
/// The room screen has been dismissed
case dismissedRoomScreen
case deselectRoom
/// Request presentation of the settings screen
case showSettingsScreen
@@ -69,35 +65,39 @@ class UserSessionFlowCoordinatorStateMachine {
private let stateMachine: StateMachine<State, Event>
var state: UserSessionFlowCoordinatorStateMachine.State {
stateMachine.state
}
init() {
stateMachine = StateMachine(state: .initial)
configure()
}
private func configure() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .homeScreen])
stateMachine.addRoutes(event: .start, transitions: [.initial => .roomList(selectedRoomId: nil)])
stateMachine.addRouteMapping { event, fromState, _ in
switch (event, fromState) {
case (.showRoomScreen(let roomId), .homeScreen):
return .roomScreen(roomId: roomId)
case (.dismissedRoomScreen, .roomScreen):
return .homeScreen
case (.selectRoom(let roomId), .roomList):
return .roomList(selectedRoomId: roomId)
case (.deselectRoom, .roomList):
return .roomList(selectedRoomId: nil)
case (.showSettingsScreen, .homeScreen):
return .settingsScreen
case (.dismissedSettingsScreen, .settingsScreen):
return .homeScreen
case (.showSettingsScreen, .roomList(let selectedRoomId)):
return .settingsScreen(selectedRoomId: selectedRoomId)
case (.dismissedSettingsScreen, .settingsScreen(let selectedRoomId)):
return .roomList(selectedRoomId: selectedRoomId)
case (.feedbackScreen, .homeScreen):
return .feedbackScreen
case (.dismissedFeedbackScreen, .feedbackScreen):
return .homeScreen
case (.feedbackScreen, .roomList(let selectedRoomId)):
return .feedbackScreen(selectedRoomId: selectedRoomId)
case (.dismissedFeedbackScreen, .feedbackScreen(let selectedRoomId)):
return .roomList(selectedRoomId: selectedRoomId)
case (.showSessionVerificationScreen, .homeScreen):
return .sessionVerificationScreen
case (.dismissedSessionVerificationScreen, .sessionVerificationScreen):
return .homeScreen
case (.showSessionVerificationScreen, .roomList(let selectedRoomId)):
return .sessionVerificationScreen(selectedRoomId: selectedRoomId)
case (.dismissedSessionVerificationScreen, .sessionVerificationScreen(let selectedRoomId)):
return .roomList(selectedRoomId: selectedRoomId)
default:
return nil
@@ -132,8 +132,8 @@ class UserSessionFlowCoordinatorStateMachine {
/// Flag indicating the machine is displaying room screen with given room identifier
func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool {
switch stateMachine.state {
case .roomScreen(let displayedRoomId):
return roomId == displayedRoomId
case .roomList(let selectedRoomId):
return roomId == selectedRoomId
default:
return false
}
@@ -19,11 +19,11 @@ import UIKit
class UITestsAppCoordinator: AppCoordinatorProtocol {
private var currentRootCoordinator: CoordinatorProtocol?
private let navigationController: NavigationController
private let navigationStackCoordinator: NavigationStackCoordinator
let notificationManager: NotificationManagerProtocol? = nil
init() {
navigationController = NavigationController()
navigationStackCoordinator = NavigationStackCoordinator()
ServiceLocator.shared.register(userNotificationController: MockUserNotificationController())
}
@@ -39,32 +39,32 @@ class UITestsAppCoordinator: AppCoordinatorProtocol {
// For example when replacing the root in the authentication flows
self.currentRootCoordinator = screen.coordinator
self.navigationController.setRootCoordinator(screen.coordinator)
self.navigationStackCoordinator.setRootCoordinator(screen.coordinator)
}
navigationController.setRootCoordinator(rootCoordinator)
navigationStackCoordinator.setRootCoordinator(rootCoordinator)
Bundle.elementFallbackLanguage = "en"
}
func toPresentable() -> AnyView {
navigationController.toPresentable()
navigationStackCoordinator.toPresentable()
}
private func mockScreens() -> [MockScreen] {
UITestScreenIdentifier.allCases.map { MockScreen(id: $0, navigationController: navigationController) }
UITestScreenIdentifier.allCases.map { MockScreen(id: $0, navigationStackCoordinator: navigationStackCoordinator) }
}
}
@MainActor
class MockScreen: Identifiable {
let id: UITestScreenIdentifier
let navigationController: NavigationController
let navigationStackCoordinator: NavigationStackCoordinator
lazy var coordinator: CoordinatorProtocol = {
switch id {
case .login:
return LoginCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(),
navigationController: navigationController))
navigationStackCoordinator: navigationStackCoordinator))
case .serverSelection:
return ServerSelectionCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(),
userNotificationController: MockUserNotificationController(),
@@ -78,7 +78,7 @@ class MockScreen: Identifiable {
mediaProvider: MockMediaProvider())))
case .authenticationFlow:
return AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(),
navigationController: navigationController)
navigationStackCoordinator: navigationStackCoordinator)
case .softLogout:
let credentials = SoftLogoutCredentials(userId: "@mock:matrix.org",
homeserverName: "matrix.org",
@@ -97,9 +97,9 @@ class MockScreen: Identifiable {
return HomeScreenCoordinator(parameters: .init(userSession: session,
attributedStringBuilder: AttributedStringBuilder(),
bugReportService: MockBugReportService(),
navigationController: navigationController))
navigationStackCoordinator: navigationStackCoordinator))
case .settings:
return SettingsCoordinator(parameters: .init(navigationController: navigationController,
return SettingsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
userNotificationController: MockUserNotificationController(),
userSession: MockUserSession(clientProxy: MockClientProxy(userIdentifier: "@mock:client.com"),
mediaProvider: MockMediaProvider()),
@@ -117,7 +117,7 @@ class MockScreen: Identifiable {
case .onboarding:
return OnboardingCoordinator()
case .roomPlainNoAvatar:
let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController,
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomName: "Some room name",
@@ -125,7 +125,7 @@ class MockScreen: Identifiable {
emojiProvider: EmojiProvider())
return RoomScreenCoordinator(parameters: parameters)
case .roomEncryptedWithAvatar:
let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController,
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomName: "Some room name",
@@ -138,8 +138,8 @@ class MockScreen: Identifiable {
}
}()
init(id: UITestScreenIdentifier, navigationController: NavigationController) {
init(id: UITestScreenIdentifier, navigationStackCoordinator: NavigationStackCoordinator) {
self.id = id
self.navigationController = navigationController
self.navigationStackCoordinator = navigationStackCoordinator
}
}