mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
SwiftUI NavigationController and UserNotificationControllers (#309)
* Fixes #286 - Adopted the new SwiftUI NavigationStack based NavigationController throughout the application * Fixes #315 - Implemented new user notification components on top of SwiftUI and the new navigation flows * Add home screen fade animation between skeletons and real rooms * Bump the danger-swift version used on the CI and swiftlint with it * Renamed Splash to Onboarding, Empty to Splash
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
|
||||
import Combine
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
struct ServiceLocator {
|
||||
fileprivate static var serviceLocator: ServiceLocator?
|
||||
@@ -28,19 +28,12 @@ struct ServiceLocator {
|
||||
return serviceLocator
|
||||
}
|
||||
|
||||
let userIndicatorPresenter: UserIndicatorTypePresenter
|
||||
let userNotificationController: UserNotificationControllerProtocol
|
||||
}
|
||||
|
||||
class AppCoordinator: Coordinator {
|
||||
private let window: UIWindow
|
||||
|
||||
class AppCoordinator: CoordinatorProtocol {
|
||||
private let stateMachine: AppCoordinatorStateMachine
|
||||
|
||||
private let mainNavigationController: UINavigationController
|
||||
private let splashViewController: UIViewController
|
||||
|
||||
private let navigationRouter: NavigationRouter
|
||||
|
||||
private let navigationController: NavigationController
|
||||
private let userSessionStore: UserSessionStoreProtocol
|
||||
|
||||
private var userSession: UserSessionProtocol! {
|
||||
@@ -53,34 +46,22 @@ class AppCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
|
||||
private var authenticationCoordinator: AuthenticationCoordinator?
|
||||
|
||||
private let bugReportService: BugReportServiceProtocol
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var statusIndicator: UserIndicator?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
|
||||
init() {
|
||||
navigationController = NavigationController()
|
||||
stateMachine = AppCoordinatorStateMachine()
|
||||
|
||||
bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL)
|
||||
|
||||
splashViewController = SplashViewController()
|
||||
navigationController.setRootCoordinator(SplashScreenCoordinator())
|
||||
|
||||
mainNavigationController = ElementNavigationController(rootViewController: splashViewController)
|
||||
mainNavigationController.navigationBar.prefersLargeTitles = true
|
||||
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = mainNavigationController
|
||||
window.tintColor = .element.accent
|
||||
|
||||
navigationRouter = NavigationRouter(navigationController: mainNavigationController)
|
||||
|
||||
ServiceLocator.serviceLocator = ServiceLocator(userIndicatorPresenter: UserIndicatorTypePresenter(presentingViewController: mainNavigationController))
|
||||
ServiceLocator.serviceLocator = ServiceLocator(userNotificationController: UserNotificationController(rootCoordinator: navigationController))
|
||||
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
|
||||
fatalError("Should have a valid bundle identifier at this point")
|
||||
@@ -95,11 +76,12 @@ class AppCoordinator: Coordinator {
|
||||
|
||||
setupLogging()
|
||||
|
||||
Bundle.elementFallbackLanguage = "en"
|
||||
|
||||
// Benchmark.trackingEnabled = true
|
||||
}
|
||||
|
||||
func start() {
|
||||
window.makeKeyAndVisible()
|
||||
stateMachine.processEvent(userSessionStore.hasSessions ? .startWithExistingSession : .startWithAuthentication)
|
||||
}
|
||||
|
||||
@@ -107,6 +89,10 @@ class AppCoordinator: Coordinator {
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
ServiceLocator.shared.userNotificationController.toPresentable()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupLogging() {
|
||||
@@ -192,12 +178,11 @@ class AppCoordinator: Coordinator {
|
||||
|
||||
private func startAuthentication() {
|
||||
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
|
||||
let coordinator = AuthenticationCoordinator(authenticationService: authenticationService,
|
||||
navigationRouter: navigationRouter)
|
||||
coordinator.delegate = self
|
||||
authenticationCoordinator = AuthenticationCoordinator(authenticationService: authenticationService,
|
||||
navigationController: navigationController)
|
||||
authenticationCoordinator?.delegate = self
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
authenticationCoordinator?.start()
|
||||
}
|
||||
|
||||
private func startAuthenticationSoftLogout() {
|
||||
@@ -206,12 +191,12 @@ class AppCoordinator: Coordinator {
|
||||
if case .success(let name) = await userSession.clientProxy.loadUserDisplayName() {
|
||||
displayName = name
|
||||
}
|
||||
|
||||
|
||||
let credentials = SoftLogoutCredentials(userId: userSession.userID,
|
||||
homeserverName: userSession.homeserver,
|
||||
userDisplayName: displayName,
|
||||
deviceId: userSession.deviceId)
|
||||
|
||||
|
||||
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
|
||||
_ = await authenticationService.configure(for: userSession.homeserver)
|
||||
|
||||
@@ -223,27 +208,22 @@ class AppCoordinator: Coordinator {
|
||||
switch result {
|
||||
case .signedIn(let session):
|
||||
self.userSession = session
|
||||
self.remove(childCoordinator: coordinator)
|
||||
self.stateMachine.processEvent(.succeededSigningIn)
|
||||
case .clearAllData:
|
||||
// clear user data
|
||||
self.userSessionStore.logout(userSession: self.userSession)
|
||||
self.userSession = nil
|
||||
self.remove(childCoordinator: coordinator)
|
||||
self.startAuthentication()
|
||||
}
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
|
||||
navigationRouter.setRootModule(coordinator)
|
||||
|
||||
navigationController.setRootCoordinator(coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUserSession() {
|
||||
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
|
||||
navigationRouter: navigationRouter,
|
||||
navigationController: navigationController,
|
||||
bugReportService: bugReportService)
|
||||
|
||||
userSessionFlowCoordinator.callback = { [weak self] action in
|
||||
@@ -280,12 +260,8 @@ class AppCoordinator: Coordinator {
|
||||
}
|
||||
|
||||
private func presentSplashScreen(isSoftLogout: Bool = false) {
|
||||
if let presentedCoordinator = childCoordinators.first {
|
||||
remove(childCoordinator: presentedCoordinator)
|
||||
}
|
||||
|
||||
mainNavigationController.setViewControllers([splashViewController], animated: false)
|
||||
|
||||
navigationController.setRootCoordinator(SplashScreenCoordinator())
|
||||
|
||||
if isSoftLogout {
|
||||
startAuthenticationSoftLogout()
|
||||
} else {
|
||||
@@ -319,16 +295,21 @@ class AppCoordinator: Coordinator {
|
||||
|
||||
// MARK: Toasts and loading indicators
|
||||
|
||||
static let loadingIndicatorIdentifier = "AppCoordinatorLoading"
|
||||
|
||||
private func showLoadingIndicator() {
|
||||
loadingIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
|
||||
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(id: Self.loadingIndicatorIdentifier,
|
||||
type: .modal,
|
||||
title: ElementL10n.loading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
private func hideLoadingIndicator() {
|
||||
loadingIndicator = nil
|
||||
ServiceLocator.shared.userNotificationController.retractNotificationWithId(Self.loadingIndicatorIdentifier)
|
||||
}
|
||||
|
||||
private func showLoginErrorToast() {
|
||||
statusIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.error(label: "Failed logging in"))
|
||||
ServiceLocator.shared.userNotificationController.submitNotification(UserNotification(title: "Failed logging in"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +318,6 @@ class AppCoordinator: Coordinator {
|
||||
extension AppCoordinator: AuthenticationCoordinatorDelegate {
|
||||
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
|
||||
self.userSession = userSession
|
||||
remove(childCoordinator: authenticationCoordinator)
|
||||
stateMachine.processEvent(.succeededSigningIn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,26 +14,38 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
private lazy var appCoordinator: Coordinator = Tests.isRunningUITests ? UITestsAppCoordinator() : AppCoordinator()
|
||||
|
||||
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
// use `en` as fallback language
|
||||
Bundle.elementFallbackLanguage = "en"
|
||||
|
||||
return true
|
||||
struct Application: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var applicationDelegate
|
||||
private let applicationCoordinator: CoordinatorProtocol
|
||||
|
||||
init() {
|
||||
if Tests.isRunningUITests {
|
||||
applicationCoordinator = UITestsAppCoordinator()
|
||||
} else {
|
||||
applicationCoordinator = AppCoordinator()
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
if Tests.isRunningUnitTests {
|
||||
return true
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if Tests.isRunningUnitTests {
|
||||
EmptyView()
|
||||
} else {
|
||||
applicationCoordinator.toPresentable()
|
||||
.tint(.element.accent)
|
||||
.task {
|
||||
applicationCoordinator.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appCoordinator.start()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
@MainActor
|
||||
protocol CoordinatorProtocol {
|
||||
func start()
|
||||
func stop()
|
||||
func toPresentable() -> AnyView
|
||||
}
|
||||
|
||||
extension CoordinatorProtocol {
|
||||
func start() { }
|
||||
|
||||
func stop() { }
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(Text("View not configured"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user