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:
Stefan Ceriu
2022-11-16 15:37:34 +02:00
committed by GitHub
parent af85c770da
commit 2fd0491a18
108 changed files with 1627 additions and 3720 deletions
@@ -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)
}
}
+28 -16
View File
@@ -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)
}
}