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
-54
View File
@@ -1,54 +0,0 @@
/*
Copyright 2019 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 UIKit
/// Protocol describing a [Coordinator](http://khanlou.com/2015/10/coordinators-redux/).
/// Coordinators are the objects which control the navigation flow of the application.
/// It helps to isolate and reuse view controllers and pass dependencies down the navigation hierarchy.
@MainActor
protocol Coordinator: AnyObject {
/// Starts job of the coordinator.
func start()
/// Child coordinators to retain. Prevent them from getting deallocated.
var childCoordinators: [Coordinator] { get set }
/// Stores coordinator to the `childCoordinators` array.
///
/// - Parameter childCoordinator: Child coordinator to store.
func add(childCoordinator: Coordinator)
/// Remove coordinator from the `childCoordinators` array.
///
/// - Parameter childCoordinator: Child coordinator to remove.
func remove(childCoordinator: Coordinator)
/// Stops job of the coordinator. Can be used to clear some resources. Will be automatically called when the coordinator removed.
func stop()
}
// `Coordinator` default implementation
extension Coordinator {
func add(childCoordinator coordinator: Coordinator) {
childCoordinators.append(coordinator)
}
func remove(childCoordinator: Coordinator) {
childCoordinator.stop()
childCoordinators = childCoordinators.filter { $0 !== childCoordinator }
}
}
@@ -1,34 +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 Foundation
/// Structure used to pass modules to routers with pop completion blocks.
struct NavigationModule {
/// Actual presentable of the module
let presentable: Presentable
/// Block to be called when the module is popped
let popCompletion: (() -> Void)?
}
// MARK: - CustomStringConvertible
extension NavigationModule: CustomStringConvertible {
var description: String {
"NavigationModule: \(presentable), pop completion: \(String(describing: popCompletion))"
}
}
@@ -1,397 +0,0 @@
/*
Copyright 2019 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 UIKit
/// `NavigationRouter` is a concrete implementation of NavigationRouterType.
final class NavigationRouter: NSObject, NavigationRouterType {
// MARK: - Properties
// MARK: Private
private var completions: [UIViewController: () -> Void]
private let navigationController: UINavigationController
/// Stores the association between the added Presentable and his view controller.
/// They can be the same if the controller is not added via his Coordinator or it is a simple UIViewController.
private var storedModules = WeakDictionary<UIViewController, AnyObject>()
// MARK: Public
/// Returns the presentables associated to each view controller
var modules: [Presentable] {
viewControllers.map { viewController -> Presentable in
self.module(for: viewController)
}
}
/// Return the view controllers stack
var viewControllers: [UIViewController] {
navigationController.viewControllers
}
// MARK: - Setup
init(navigationController: UINavigationController) {
self.navigationController = navigationController
completions = [:]
super.init()
self.navigationController.delegate = self
// Post local notification on NavigationRouter creation
let userInfo: [String: Any] = [NavigationRouter.NotificationUserInfoKey.navigationRouter: self,
NavigationRouter.NotificationUserInfoKey.navigationController: navigationController]
NotificationCenter.default.post(name: NavigationRouter.didCreate, object: self, userInfo: userInfo)
}
deinit {
// Post local notification on NavigationRouter deinit
let userInfo: [String: Any] = [NavigationRouter.NotificationUserInfoKey.navigationRouter: self,
NavigationRouter.NotificationUserInfoKey.navigationController: navigationController]
NotificationCenter.default.post(name: NavigationRouter.willDestroy, object: self, userInfo: userInfo)
}
// MARK: - Public
func present(_ module: Presentable, animated: Bool = true) {
MXLog.debug("Present \(module)")
navigationController.present(module.toPresentable(), animated: animated, completion: nil)
}
func dismissModule(animated: Bool = true, completion: (() -> Void)? = nil) {
MXLog.debug("Dismiss presented module")
navigationController.dismiss(animated: animated, completion: completion)
}
func setRootModule(_ module: Presentable, hideNavigationBar: Bool = false, animated: Bool = false, popCompletion: (() -> Void)? = nil) {
MXLog.debug("Set root module \(module)")
let controller = module.toPresentable()
// Avoid setting a UINavigationController onto stack
guard controller is UINavigationController == false else {
MXLog.error("Cannot add a UINavigationController to NavigationRouter")
return
}
addModule(module, for: controller)
let controllersToPop = navigationController.viewControllers.reversed()
controllersToPop.forEach {
self.willPopViewController($0)
}
if let popCompletion {
completions[controller] = popCompletion
}
willPushViewController(controller)
navigationController.setViewControllers([controller], animated: animated)
navigationController.isNavigationBarHidden = hideNavigationBar
// Pop old view controllers
controllersToPop.forEach {
self.didPopViewController($0)
}
// Add again controller to module association, in case same module instance is added back
addModule(module, for: controller)
didPushViewController(controller)
}
func setModules(_ modules: [NavigationModule], hideNavigationBar: Bool, animated: Bool) {
MXLog.debug("Set modules \(modules)")
let controllers = modules.map { module -> UIViewController in
let controller = module.presentable.toPresentable()
self.addModule(module.presentable, for: controller)
return controller
}
let controllersToPop = navigationController.viewControllers.reversed()
controllersToPop.forEach {
self.willPopViewController($0)
}
controllers.forEach {
self.willPushViewController($0)
}
// Set new view controllers
navigationController.setViewControllers(controllers, animated: animated)
navigationController.isNavigationBarHidden = hideNavigationBar
// Pop old view controllers
controllersToPop.forEach {
self.didPopViewController($0)
}
// Add again controller to module association, in case same modules instance are added back
modules.forEach { module in
self.addModule(module.presentable, for: module.presentable.toPresentable())
}
controllers.forEach {
self.didPushViewController($0)
}
}
func popToRootModule(animated: Bool) {
MXLog.debug("Pop to root module")
let controllers = navigationController.viewControllers
if controllers.count > 1 {
let controllersToPop = controllers[1..<controllers.count]
controllersToPop.reversed().forEach {
self.willPopViewController($0)
}
}
if let controllers = navigationController.popToRootViewController(animated: animated) {
controllers.reversed().forEach {
self.didPopViewController($0)
}
}
}
func popToModule(_ module: Presentable, animated: Bool) {
MXLog.debug("Pop to module \(module)")
let controller = module.toPresentable()
let controllersBeforePop = navigationController.viewControllers
if let controllerIndex = controllersBeforePop.firstIndex(of: controller) {
let controllersToPop = controllersBeforePop[controllerIndex..<controllersBeforePop.count]
controllersToPop.reversed().forEach {
self.willPopViewController($0)
}
}
if let controllers = navigationController.popToViewController(controller, animated: animated) {
controllers.reversed().forEach {
self.didPopViewController($0)
}
}
}
func push(_ module: Presentable, animated: Bool = true, popCompletion: (() -> Void)? = nil) {
MXLog.debug("Push module \(module)")
let controller = module.toPresentable()
// Avoid pushing UINavigationController onto stack
guard controller is UINavigationController == false else {
MXLog.error("Cannot push a UINavigationController to NavigationRouter")
return
}
addModule(module, for: controller)
if let completion = popCompletion {
completions[controller] = completion
}
willPushViewController(controller)
navigationController.pushViewController(controller, animated: animated)
didPushViewController(controller)
}
func push(_ modules: [NavigationModule], animated: Bool) {
MXLog.debug("Push modules \(modules)")
// Avoid pushing any UINavigationController onto stack
guard modules.first(where: { $0.presentable.toPresentable() is UINavigationController }) == nil else {
MXLog.error("Cannot push a UINavigationController to NavigationRouter")
return
}
for module in modules {
let controller = module.presentable.toPresentable()
addModule(module.presentable, for: controller)
if let completion = module.popCompletion {
completions[controller] = completion
}
willPushViewController(controller)
}
var viewControllers = navigationController.viewControllers
viewControllers.append(contentsOf: modules.map { $0.presentable.toPresentable() })
navigationController.setViewControllers(viewControllers, animated: animated)
for module in modules {
let controller = module.presentable.toPresentable()
didPushViewController(controller)
}
}
func popModule(animated: Bool = true) {
MXLog.debug("Pop module")
if let lastController = navigationController.viewControllers.last {
willPopViewController(lastController)
}
if let controller = navigationController.popViewController(animated: animated) {
didPopViewController(controller)
}
}
func popAllModules(animated: Bool) {
MXLog.debug("Pop all modules")
let controllersToPop = navigationController.viewControllers.reversed()
controllersToPop.forEach {
self.willPopViewController($0)
}
navigationController.setViewControllers([], animated: animated)
controllersToPop.forEach {
self.didPopViewController($0)
}
}
func contains(_ module: Presentable) -> Bool {
let controller = module.toPresentable()
return navigationController.viewControllers.contains(controller)
}
// MARK: Presentable
func toPresentable() -> UIViewController {
navigationController
}
// MARK: - Private
private func module(for viewController: UIViewController) -> Presentable {
guard let module = storedModules[viewController] as? Presentable else {
return viewController
}
return module
}
private func addModule(_ module: Presentable, for viewController: UIViewController) {
storedModules[viewController] = module as AnyObject
}
private func removeModule(for viewController: UIViewController) {
storedModules[viewController] = nil
}
private func runCompletion(for controller: UIViewController) {
guard let completion = completions[controller] else {
return
}
completion()
completions.removeValue(forKey: controller)
}
private func willPushViewController(_ viewController: UIViewController) {
postNotification(withName: NavigationRouter.willPushModule, for: viewController)
}
private func didPushViewController(_ viewController: UIViewController) {
postNotification(withName: NavigationRouter.didPushModule, for: viewController)
}
private func willPopViewController(_ viewController: UIViewController) {
postNotification(withName: NavigationRouter.willPopModule, for: viewController)
}
private func didPopViewController(_ viewController: UIViewController) {
postNotification(withName: NavigationRouter.didPopModule, for: viewController)
// Call completion closure associated to the view controller
// So associated coordinator can be deallocated
runCompletion(for: viewController)
removeModule(for: viewController)
}
private func postNotification(withName name: Notification.Name, for viewController: UIViewController) {
let module = module(for: viewController)
let userInfo: [String: Any] = [
NotificationUserInfoKey.navigationRouter: self,
NotificationUserInfoKey.module: module,
NotificationUserInfoKey.viewController: viewController
]
NotificationCenter.default.post(name: name, object: self, userInfo: userInfo)
}
}
// MARK: - UINavigationControllerDelegate
extension NavigationRouter: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
// Try to post `NavigationRouter.willPopModule` notification here
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
// Ensure the view controller is popping
guard let poppedViewController = navigationController.transitionCoordinator?.viewController(forKey: .from),
!navigationController.viewControllers.contains(poppedViewController) else {
return
}
MXLog.debug("Popped module: \(poppedViewController)")
didPopViewController(poppedViewController)
}
}
// MARK: - NavigationRouter notification constants
extension NavigationRouter {
// MARK: Notification names
public static let willPushModule = Notification.Name("NavigationRouterWillPushModule")
public static let didPushModule = Notification.Name("NavigationRouterDidPushModule")
public static let willPopModule = Notification.Name("NavigationRouterWillPopModule")
public static let didPopModule = Notification.Name("NavigationRouterDidPopModule")
public static let didCreate = Notification.Name("NavigationRouterDidCreate")
public static let willDestroy = Notification.Name("NavigationRouterWillDestroy")
// MARK: Notification keys
public enum NotificationUserInfoKey {
/// The associated view controller (UIViewController).
static let viewController = "viewController"
/// The associated module (Presentable), can the view controller itself or is Coordinator
static let module = "module"
/// The navigation router that send the notification (NavigationRouterType)
static let navigationRouter = "navigationRouter"
/// The navigation controller (UINavigationController) associated to the navigation router
static let navigationController = "navigationController"
}
}
@@ -1,93 +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 UIKit
/// `NavigationRouterStore` enables to get a NavigationRouter from a UINavigationController instance.
class NavigationRouterStore: NavigationRouterStoreProtocol {
// MARK: - Constants
static let shared = NavigationRouterStore()
// MARK: - Properties
// WeakDictionary does not work with protocol
// Find a way to use NavigationRouterType as value
private var navigationRouters = WeakDictionary<UINavigationController, NavigationRouter>()
// MARK: - Setup
/// As we are ensuring that there is only one navigation controller per NavigationRouter, the class here should be used as a singleton.
private init() {
registerNavigationRouterNotifications()
}
// MARK: - Public
func navigationRouter(for navigationController: UINavigationController) -> NavigationRouterType {
if let existingNavigationRouter = findNavigationRouter(for: navigationController) {
return existingNavigationRouter
}
let navigationRouter = NavigationRouter(navigationController: ElementNavigationController())
return navigationRouter
}
// MARK: - Private
private func findNavigationRouter(for navigationController: UINavigationController) -> NavigationRouterType? {
navigationRouters[navigationController]
}
private func removeNavigationRouter(for navigationController: UINavigationController) {
navigationRouters[navigationController] = nil
}
private func registerNavigationRouterNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(navigationRouterDidCreate(_:)), name: NavigationRouter.didCreate, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(navigationRouterWillDestroy(_:)), name: NavigationRouter.willDestroy, object: nil)
}
@objc private func navigationRouterDidCreate(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let navigationRouter = userInfo[NavigationRouter.NotificationUserInfoKey.navigationRouter] as? NavigationRouterType,
let navigationController = userInfo[NavigationRouter.NotificationUserInfoKey.navigationController] as? UINavigationController else {
return
}
if let existingNavigationRouter = findNavigationRouter(for: navigationController) {
fatalError("\(existingNavigationRouter) is already tied to the same navigation controller as \(navigationRouter). We should have only one NavigationRouter per navigation controller")
} else {
// WeakDictionary does not work with protocol
// Find a way to avoid this cast
navigationRouters[navigationController] = navigationRouter as? NavigationRouter
}
}
@objc private func navigationRouterWillDestroy(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let navigationRouter = userInfo[NavigationRouter.NotificationUserInfoKey.navigationRouter] as? NavigationRouterType,
let navigationController = userInfo[NavigationRouter.NotificationUserInfoKey.navigationController] as? UINavigationController else {
return
}
if let existingNavigationRouter = findNavigationRouter(for: navigationController), existingNavigationRouter !== navigationRouter {
fatalError("\(existingNavigationRouter) is already tied to the same navigation controller as \(navigationRouter). We should have only one NavigationRouter per navigation controller")
}
removeNavigationRouter(for: navigationController)
}
}
@@ -1,25 +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 UIKit
/// `NavigationRouterStoreProtocol` describes a structure that enables to get a NavigationRouter from a UINavigationController instance.
@MainActor
protocol NavigationRouterStoreProtocol {
/// Gets the existing navigation router for the supplied controller, creating a new one if it doesn't yet exist.
/// Note: The store only holds a weak reference to the returned router. It is the caller's responsibility to retain it.
func navigationRouter(for navigationController: UINavigationController) -> NavigationRouterType
}
@@ -1,143 +0,0 @@
/*
Copyright 2019 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 UIKit
/// Protocol describing a router that wraps a UINavigationController and add convenient completion handlers. Completions are called when a Presentable is removed.
/// Routers are used to be passed between coordinators. They handles only `physical` navigation.
@MainActor
protocol NavigationRouterType: AnyObject, Presentable {
/// Present modally a view controller on the navigation controller
///
/// - Parameter module: The Presentable to present.
/// - Parameter animated: Specify true to animate the transition.
func present(_ module: Presentable, animated: Bool)
/// Dismiss presented view controller from navigation controller
///
/// - Parameter animated: Specify true to animate the transition.
/// - Parameter completion: Animation completion (not the pop completion).
func dismissModule(animated: Bool, completion: (() -> Void)?)
/// Set root view controller of navigation controller
///
/// - Parameter module: The Presentable to set as root.
/// - Parameter hideNavigationBar: Specify true to hide the UINavigationBar.
/// - Parameter animated: Specify true to animate the transition.
/// - Parameter popCompletion: Completion called when `module` is removed from the navigation stack.
func setRootModule(_ module: Presentable, hideNavigationBar: Bool, animated: Bool, popCompletion: (() -> Void)?)
/// Set view controllers stack of navigation controller
/// - Parameters:
/// - modules: The modules stack to set.
/// - hideNavigationBar: Specify true to hide the UINavigationBar.
/// - animated: Specify true to animate the transition.
func setModules(_ modules: [NavigationModule], hideNavigationBar: Bool, animated: Bool)
/// Pop to root view controller of navigation controller and remove all others
///
/// - Parameter animated: Specify true to animate the transition.
func popToRootModule(animated: Bool)
/// Pops view controllers until the specified view controller is at the top of the navigation stack
///
/// - Parameter module: The Presentable that should to be at the top of the stack.
/// - Parameter animated: Specify true to animate the transition.
func popToModule(_ module: Presentable, animated: Bool)
/// Push a view controller on navigation controller stack
///
/// - Parameter animated: Specify true to animate the transition.
/// - Parameter popCompletion: Completion called when `module` is removed from the navigation stack.
func push(_ module: Presentable, animated: Bool, popCompletion: (() -> Void)?)
/// Push some view controllers on navigation controller stack
///
/// - Parameter modules: Modules to push
/// - Parameter animated: Specify true to animate the transition.
func push(_ modules: [NavigationModule], animated: Bool)
/// Pop last view controller from navigation controller stack
///
/// - Parameter animated: Specify true to animate the transition.
func popModule(animated: Bool)
/// Pops all view controllers
///
/// - Parameter animated: Specify true to animate the transition.
func popAllModules(animated: Bool)
/// Returns the modules that are currently in the navigation stack
var modules: [Presentable] { get }
/// Check if the navigation controller contains the given presentable.
/// - Parameter module: The presentable for which to check the existence.
func contains(_ module: Presentable) -> Bool
}
// `NavigationRouterType` default implementation
extension NavigationRouterType {
func setRootModule(_ module: Presentable) {
setRootModule(module, hideNavigationBar: false, animated: false, popCompletion: nil)
}
func setRootModule(_ module: Presentable, popCompletion: (() -> Void)?) {
setRootModule(module, hideNavigationBar: false, animated: false, popCompletion: popCompletion)
}
func setModules(_ modules: [NavigationModule], animated: Bool) {
setModules(modules, hideNavigationBar: false, animated: animated)
}
func setModules(_ modules: [Presentable], animated: Bool) {
setModules(modules, hideNavigationBar: false, animated: animated)
}
}
// MARK: - Presentable <--> NavigationModule Transitive Methods
extension NavigationRouterType {
func setRootModule(_ module: NavigationModule) {
setRootModule(module.presentable, popCompletion: module.popCompletion)
}
func push(_ module: NavigationModule, animated: Bool) {
push(module.presentable, animated: animated, popCompletion: module.popCompletion)
}
func setModules(_ modules: [Presentable], hideNavigationBar: Bool, animated: Bool) {
setModules(modules.map { $0.toModule() },
hideNavigationBar: hideNavigationBar,
animated: animated)
}
func push(_ modules: [Presentable], animated: Bool) {
push(modules.map { $0.toModule() },
animated: animated)
}
func dismissModule(animated: Bool = true, completion: (() -> Void)? = nil) {
dismissModule(animated: animated, completion: completion)
}
func push(_ module: Presentable, animated: Bool = true, popCompletion: (() -> Void)? = nil) {
push(module, animated: animated, popCompletion: popCompletion)
}
func present(_ module: Presentable, animated: Bool = true) {
present(module, animated: animated)
}
}
@@ -1,37 +0,0 @@
/*
Copyright 2019 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 UIKit
/// Protocol used to pass UIViewControllers to routers
@MainActor
protocol Presentable {
func toPresentable() -> UIViewController
}
extension UIViewController: Presentable {
public func toPresentable() -> UIViewController {
self
}
}
extension Presentable {
/// Returns a new module from the presentable without a pop completion block
/// - Returns: Module
func toModule() -> NavigationModule {
NavigationModule(presentable: self, popCompletion: nil)
}
}
@@ -1,85 +0,0 @@
/*
Copyright 2020 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 UIKit
/// `RootRouter` is a concrete implementation of RootRouterType.
final class RootRouter: RootRouterType {
// MARK: - Constants
// `rootViewController` animation constants
private enum RootViewControllerUpdateAnimation {
static let duration: TimeInterval = 0.3
static let options: UIView.AnimationOptions = .transitionCrossDissolve
}
// MARK: - Properties
private var presentedModule: Presentable?
let window: UIWindow
/// The root view controller currently presented
var rootViewController: UIViewController? {
window.rootViewController
}
// MARK: - Setup
init(window: UIWindow) {
self.window = window
}
// MARK: - Public methods
func setRootModule(_ module: Presentable) {
updateRootViewController(rootViewController: module.toPresentable(), animated: false, completion: nil)
window.makeKeyAndVisible()
}
func dismissRootModule(animated: Bool, completion: (() -> Void)?) {
updateRootViewController(rootViewController: nil, animated: animated, completion: completion)
}
func presentModule(_ module: Presentable, animated: Bool, completion: (() -> Void)?) {
let viewControllerPresenter = rootViewController?.presentedViewController ?? rootViewController
viewControllerPresenter?.present(module.toPresentable(), animated: animated, completion: completion)
presentedModule = module
}
func dismissModule(animated: Bool, completion: (() -> Void)?) {
presentedModule?.toPresentable().dismiss(animated: animated, completion: completion)
}
// MARK: - Private methods
private func updateRootViewController(rootViewController: UIViewController?, animated: Bool, completion: (() -> Void)?) {
if animated {
UIView.transition(with: window, duration: RootViewControllerUpdateAnimation.duration, options: RootViewControllerUpdateAnimation.options, animations: {
let oldState: Bool = UIView.areAnimationsEnabled
UIView.setAnimationsEnabled(false)
self.window.rootViewController = rootViewController
UIView.setAnimationsEnabled(oldState)
}, completion: { _ in
completion?()
})
} else {
window.rootViewController = rootViewController
completion?()
}
}
}
@@ -1,49 +0,0 @@
/*
Copyright 2020 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 UIKit
/// Protocol describing a router that wraps the root navigation of the application.
/// Routers are used to be passed between coordinators. They handles only `physical` navigation.
@MainActor
protocol RootRouterType: AnyObject {
/// Update the root view controller
///
/// - Parameter module: The new root view controller to set
func setRootModule(_ module: Presentable)
/// Dismiss the root view controller
///
/// - Parameters:
/// - animated: Specify true to animate the transition.
/// - completion: The closure executed after the view controller is dismissed.
func dismissRootModule(animated: Bool, completion: (() -> Void)?)
/// Present modally a view controller on the root view controller
///
/// - Parameters:
/// - module: Specify true to animate the transition.
/// - animated: Specify true to animate the transition.
/// - completion: Animation completion.
func presentModule(_ module: Presentable, animated: Bool, completion: (() -> Void)?)
/// Dismiss modally presented view controller from root view controller
///
/// - Parameters:
/// - animated: Specify true to animate the transition.
/// - completion: Animation completion.
func dismissModule(animated: Bool, completion: (() -> Void)?)
}
@@ -17,7 +17,6 @@
import Foundation
import SwiftUI
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public extension Animation {
/// Animation to be used to disable animations.
static let noAnimation: Animation = .linear(duration: 0)
@@ -28,7 +27,6 @@ public extension Animation {
}
}
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
/// Returns the result of recomputing the view's body with the provided
/// animation.
/// - Parameters:
@@ -31,9 +31,14 @@ struct AlertInfo<T: Hashable>: Identifiable {
/// The alert's message (optional).
var message: String?
/// The alert's primary button title and action. Defaults to an Ok button with no action.
var primaryButton: (title: String, action: (() -> Void)?) = (ElementL10n.ok, nil)
var primaryButton = AlertButton(title: ElementL10n.ok, action: nil)
/// The alert's secondary button title and action.
var secondaryButton: (title: String, action: (() -> Void)?)?
var secondaryButton: AlertButton?
}
struct AlertButton {
let title: String
let action: (() -> Void)?
}
extension AlertInfo {
@@ -79,7 +84,7 @@ extension AlertInfo {
}
}
private func alertButton(for buttonParameters: (title: String, action: (() -> Void)?)) -> Alert.Button {
private func alertButton(for buttonParameters: AlertButton) -> Alert.Button {
guard let action = buttonParameters.action else {
return .default(Text(buttonParameters.title))
}
@@ -1,146 +0,0 @@
/*
Copyright 2019 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
import UIKit
/// Used to present activity indicator on a view
final class ActivityIndicatorPresenter: ActivityIndicatorPresenterType {
// MARK: - Constants
private enum Constants {
static let animationDuration: TimeInterval = 0.3
static let backgroundOverlayColor = UIColor.clear
static let backgroundOverlayAlpha: CGFloat = 1.0
}
// MARK: - Properties
private weak var backgroundOverlayView: UIView?
private weak var activityIndicatorView: ActivityIndicatorView?
private weak var presentingView: UIView?
var isPresenting: Bool {
activityIndicatorView != nil
}
// MARK: - Public
func presentActivityIndicator(on view: UIView, animated: Bool, completion: (() -> Void)? = nil) {
if presentingView != nil {
if let completion {
completion()
}
return
}
presentingView = view
view.isUserInteractionEnabled = false
let backgroundOverlayView = createBackgroundOverlayView(with: view.frame)
let activityIndicatorView = ActivityIndicatorView()
// Add activityIndicatorView on backgroundOverlayView centered
backgroundOverlayView.addSubview(activityIndicatorView)
activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
activityIndicatorView.centerXAnchor.constraint(equalTo: backgroundOverlayView.centerXAnchor).isActive = true
activityIndicatorView.centerYAnchor.constraint(equalTo: backgroundOverlayView.centerYAnchor).isActive = true
activityIndicatorView.startAnimating()
backgroundOverlayView.alpha = 0
backgroundOverlayView.isHidden = false
view.vc_addSubViewMatchingParent(backgroundOverlayView)
self.backgroundOverlayView = backgroundOverlayView
self.activityIndicatorView = activityIndicatorView
let animationInstructions = {
backgroundOverlayView.alpha = Constants.backgroundOverlayAlpha
}
if animated {
UIView.animate(withDuration: Constants.animationDuration) {
animationInstructions()
} completion: { _ in
completion?()
}
} else {
animationInstructions()
completion?()
}
}
func removeCurrentActivityIndicator(animated: Bool, completion: (() -> Void)? = nil) {
guard let presentingView,
let backgroundOverlayView = backgroundOverlayView,
let activityIndicatorView = activityIndicatorView else {
return
}
presentingView.isUserInteractionEnabled = true
self.presentingView = nil
let animationInstructions = {
activityIndicatorView.alpha = 0
}
let animationCompletionInstructions = {
activityIndicatorView.stopAnimating()
backgroundOverlayView.isHidden = true
backgroundOverlayView.removeFromSuperview()
}
if animated {
UIView.animate(withDuration: Constants.animationDuration) {
animationInstructions()
} completion: { _ in
animationCompletionInstructions()
}
} else {
animationInstructions()
animationCompletionInstructions()
}
}
// MARK: - Private
private func createBackgroundOverlayView(with frame: CGRect = CGRect.zero) -> UIView {
let backgroundOverlayView = UIView(frame: frame)
backgroundOverlayView.backgroundColor = Constants.backgroundOverlayColor
backgroundOverlayView.alpha = Constants.backgroundOverlayAlpha
return backgroundOverlayView
}
}
private extension UIView {
/// Add a subview matching parent view using autolayout
@objc func vc_addSubViewMatchingParent(_ subView: UIView) {
addSubview(subView)
subView.translatesAutoresizingMaskIntoConstraints = false
let views = ["view": subView]
["H:|[view]|", "V:|[view]|"].forEach { vfl in
let constraints = NSLayoutConstraint.constraints(withVisualFormat: vfl,
options: [],
metrics: nil,
views: views)
constraints.forEach { $0.isActive = true }
}
}
}
@@ -1,34 +0,0 @@
/*
Copyright 2019 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 UIKit
/// Protocol used to present activity indicator on a view
protocol ActivityIndicatorPresenterType {
func presentActivityIndicator(on view: UIView, animated: Bool, completion: (() -> Void)?)
func removeCurrentActivityIndicator(animated: Bool, completion: (() -> Void)?)
}
// `ActivityIndicatorPresenterType` default implementation
extension ActivityIndicatorPresenterType {
func presentActivityIndicator(on view: UIView, animated: Bool) {
presentActivityIndicator(on: view, animated: animated, completion: nil)
}
func removeCurrentActivityIndicator(animated: Bool) {
removeCurrentActivityIndicator(animated: animated, completion: nil)
}
}
@@ -1,111 +0,0 @@
/*
Copyright 2019 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 UIKit
final class ActivityIndicatorView: UIView {
// MARK: - Constants
private enum Constants {
static let cornerRadius: CGFloat = 5.0
static let activityIndicatorMargin = CGSize(width: 30.0, height: 30.0)
}
// MARK: - Properties
// MARK: Outlets
@IBOutlet private var activityIndicatorView: UIActivityIndicatorView!
@IBOutlet private var activityIndicatorBackgroundView: UIView!
// MARK: Public
var color: UIColor? {
get {
activityIndicatorView.color
}
set {
activityIndicatorView.color = newValue
}
}
// MARK: - Setup
private func commonInit() {
activityIndicatorBackgroundView.layer.masksToBounds = true
}
convenience init() {
self.init(frame: CGRect.zero)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
loadNibContent()
commonInit()
}
override init(frame: CGRect) {
super.init(frame: frame)
loadNibContent()
commonInit()
}
// MARK: - Overrides
override var intrinsicContentSize: CGSize {
CGSize(width: activityIndicatorView.intrinsicContentSize.width + Constants.activityIndicatorMargin.width,
height: activityIndicatorView.intrinsicContentSize.height + Constants.activityIndicatorMargin.height)
}
override func layoutSubviews() {
super.layoutSubviews()
activityIndicatorBackgroundView.layer.cornerRadius = Constants.cornerRadius
}
// MARK: - Public
func startAnimating() {
activityIndicatorView.startAnimating()
}
func stopAnimating() {
activityIndicatorView.stopAnimating()
}
}
private extension UIView {
static var nib: UINib {
UINib(nibName: String(describing: self), bundle: Bundle(for: self))
}
func loadNibContent() {
let layoutAttributes: [NSLayoutConstraint.Attribute] = [.top, .leading, .bottom, .trailing]
for case let view as UIView in type(of: self).nib.instantiate(withOwner: self, options: nil) {
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
NSLayoutConstraint.activate(layoutAttributes.map { attribute in
NSLayoutConstraint(
item: view, attribute: attribute,
relatedBy: .equal,
toItem: self, attribute: attribute,
multiplier: 1, constant: 0.0
)
})
}
}
}
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ActivityIndicatorView" customModule="Riot" customModuleProvider="target">
<connections>
<outlet property="activityIndicatorBackgroundView" destination="2qY-Ui-WjQ" id="obw-ml-Ppb"/>
<outlet property="activityIndicatorView" destination="32q-cl-Ru4" id="38r-gn-BJg"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="DuQ-Nt-i8B">
<rect key="frame" x="0.0" y="0.0" width="375" height="204"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2qY-Ui-WjQ">
<rect key="frame" x="0.0" y="0.0" width="375" height="204"/>
<subviews>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" style="white" translatesAutoresizingMaskIntoConstraints="NO" id="32q-cl-Ru4">
<rect key="frame" x="15" y="15" width="345" height="174"/>
</activityIndicatorView>
</subviews>
<color key="backgroundColor" red="0.80000000000000004" green="0.80000000000000004" blue="0.80000000000000004" alpha="0.89787029109589045" colorSpace="calibratedRGB"/>
<constraints>
<constraint firstItem="32q-cl-Ru4" firstAttribute="top" secondItem="2qY-Ui-WjQ" secondAttribute="top" constant="15" id="hLL-Ao-h6a"/>
<constraint firstItem="32q-cl-Ru4" firstAttribute="centerX" secondItem="2qY-Ui-WjQ" secondAttribute="centerX" id="n1z-Rb-ce3"/>
<constraint firstItem="32q-cl-Ru4" firstAttribute="leading" secondItem="2qY-Ui-WjQ" secondAttribute="leading" constant="15" id="tTB-ZQ-Xrm"/>
<constraint firstItem="32q-cl-Ru4" firstAttribute="centerY" secondItem="2qY-Ui-WjQ" secondAttribute="centerY" id="ty6-5W-zen"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Zud-2y-G9d" firstAttribute="bottom" secondItem="2qY-Ui-WjQ" secondAttribute="bottom" id="F2N-Wt-CCH"/>
<constraint firstItem="2qY-Ui-WjQ" firstAttribute="top" secondItem="Zud-2y-G9d" secondAttribute="top" id="V7A-HL-11f"/>
<constraint firstItem="2qY-Ui-WjQ" firstAttribute="leading" secondItem="Zud-2y-G9d" secondAttribute="leading" id="jbB-4Q-tNb"/>
<constraint firstItem="Zud-2y-G9d" firstAttribute="trailing" secondItem="2qY-Ui-WjQ" secondAttribute="trailing" id="psU-2p-PBH"/>
</constraints>
<nil key="simulatedStatusBarMetrics"/>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<viewLayoutGuide key="safeArea" id="Zud-2y-G9d"/>
<point key="canvasLocation" x="55" y="-605"/>
</view>
</objects>
</document>
@@ -1,75 +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 UIKit
/// A presenter responsible for showing / hiding a full-screen loading view that obscures (and thus disables) all other controls.
/// It is managed by a `UserIndicator`, meaning the `present` and `dismiss` methods will be called when the parent `UserIndicator` starts or completes.
class FullscreenLoadingViewPresenter: UserIndicatorViewPresentable {
private let label: String
private let presentationContext: UserIndicatorPresentationContext
private weak var view: UIView?
private var animator: UIViewPropertyAnimator?
init(label: String, presentationContext: UserIndicatorPresentationContext) {
self.label = label
self.presentationContext = presentationContext
}
func present() {
// Find the current top navigation controller
var presentingController: UIViewController? = presentationContext.indicatorPresentingViewController
while presentingController?.navigationController != nil {
presentingController = presentingController?.navigationController
}
guard let presentingController else {
return
}
let view = LabelledActivityIndicatorView(text: label)
self.view = view
view.translatesAutoresizingMaskIntoConstraints = false
presentingController.view.addSubview(view)
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: presentingController.view.topAnchor),
view.bottomAnchor.constraint(equalTo: presentingController.view.bottomAnchor),
view.leadingAnchor.constraint(equalTo: presentingController.view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: presentingController.view.trailingAnchor)
])
view.alpha = 0
animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) {
view.alpha = 1
}
animator?.startAnimation()
}
func dismiss() {
guard let view, view.superview != nil else {
return
}
animator?.stopAnimation(true)
animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeIn) {
view.alpha = 0
}
animator?.addCompletion { _ in
view.removeFromSuperview()
}
animator?.startAnimation()
}
}
@@ -1,90 +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 Foundation
import UIKit
final class LabelledActivityIndicatorView: UIView {
private enum Constants {
static let padding = UIEdgeInsets(top: 20, left: 40, bottom: 15, right: 40)
static let activityIndicatorScale = CGFloat(1.5)
static let cornerRadius: CGFloat = 12.0
static let stackSpacing: CGFloat = 15
static let backgroundOpacity: CGFloat = 0.5
}
private let stackBackgroundView: UIView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
view.layer.cornerRadius = Constants.cornerRadius
view.clipsToBounds = true
return view
}()
private let stackView: UIStackView = {
let stack = UIStackView()
stack.axis = .vertical
stack.distribution = .fill
stack.alignment = .center
stack.spacing = Constants.stackSpacing
return stack
}()
private let activityIndicator: UIActivityIndicatorView = {
let view = UIActivityIndicatorView()
view.transform = .init(scaleX: Constants.activityIndicatorScale, y: Constants.activityIndicatorScale)
view.startAnimating()
return view
}()
private let label = UILabel()
init(text: String) {
super.init(frame: .zero)
setup(text: text)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup(text: String) {
setupStackView()
label.text = text
label.textColor = .element.primaryContent
}
private func setupStackView() {
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: centerYAnchor)
])
stackView.addArrangedSubview(activityIndicator)
stackView.addArrangedSubview(label)
insertSubview(stackBackgroundView, belowSubview: stackView)
stackBackgroundView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackBackgroundView.topAnchor.constraint(equalTo: stackView.topAnchor, constant: -Constants.padding.top),
stackBackgroundView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: Constants.padding.bottom),
stackBackgroundView.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: -Constants.padding.left),
stackBackgroundView.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: Constants.padding.right)
])
}
}
@@ -1,87 +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 Foundation
import UIKit
class RectangleToastView: UIView {
private enum Constants {
static let padding = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
static let cornerRadius: CGFloat = 8.0
}
private lazy var imageView: UIImageView = {
let view = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
return view
}()
private lazy var messageLabel: UILabel = {
let label = UILabel()
label.backgroundColor = .clear
label.numberOfLines = 0
label.textAlignment = .left
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var stackView: UIStackView = {
let result = UIStackView()
result.axis = .horizontal
result.distribution = .fill
result.alignment = .center
result.spacing = 8.0
result.backgroundColor = .clear
addSubview(result)
result.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
result.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.padding.left),
result.topAnchor.constraint(equalTo: topAnchor, constant: Constants.padding.top),
result.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.padding.right),
result.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.padding.bottom)
])
return result
}()
init(withMessage message: String?,
image: UIImage? = nil) {
super.init(frame: .zero)
if let image {
imageView.image = image
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: image.size.width),
imageView.heightAnchor.constraint(equalToConstant: image.size.height)
])
stackView.addArrangedSubview(imageView)
}
messageLabel.text = message
stackView.addArrangedSubview(messageLabel)
stackView.layoutIfNeeded()
layer.cornerRadius = Constants.cornerRadius
layer.masksToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
@@ -1,124 +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 UIKit
class RoundedToastView: UIView {
private struct ShadowStyle {
let offset: CGSize
let radius: CGFloat
let opacity: Float
}
private enum Constants {
static let padding = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
static let activityIndicatorScale = CGFloat(0.75)
static let imageViewSize = CGFloat(15)
static let lightShadow = ShadowStyle(offset: .init(width: 0, height: 4), radius: 12, opacity: 0.1)
static let darkShadow = ShadowStyle(offset: .init(width: 0, height: 4), radius: 4, opacity: 0.2)
}
private lazy var activityIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView()
indicator.transform = .init(scaleX: Constants.activityIndicatorScale, y: Constants.activityIndicatorScale)
indicator.startAnimating()
return indicator
}()
private lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: Constants.imageViewSize),
imageView.heightAnchor.constraint(equalToConstant: Constants.imageViewSize)
])
return imageView
}()
private let stackView: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 5
return stack
}()
private let label = UILabel()
init(viewState: ToastViewState) {
super.init(frame: .zero)
setup(viewState: viewState)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup(viewState: ToastViewState) {
backgroundColor = .clear
clipsToBounds = true
setupBackgroundMaterial()
setupStackView()
stackView.addArrangedSubview(toastView(for: viewState.style))
stackView.addArrangedSubview(label)
label.text = viewState.label
label.textColor = .element.primaryContent
}
private func setupBackgroundMaterial() {
let material = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
addSubview(material)
material.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
material.topAnchor.constraint(equalTo: topAnchor),
material.bottomAnchor.constraint(equalTo: bottomAnchor),
material.leadingAnchor.constraint(equalTo: leadingAnchor),
material.trailingAnchor.constraint(equalTo: trailingAnchor)
])
}
private func setupStackView() {
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.padding.top),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.padding.bottom),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.padding.left),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.padding.right)
])
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = layer.frame.height / 2
}
private func toastView(for style: ToastViewState.Style) -> UIView {
switch style {
case .loading:
return activityIndicator
case .success:
imageView.image = UIImage(systemName: "checkmark")
return imageView
case .error:
imageView.image = UIImage(systemName: "xmark")
return imageView
}
}
}
@@ -1,71 +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 UIKit
/// A presenter responsible for showing / hiding a toast view for loading spinners or success messages.
/// It is managed by an `UserIndicator`, meaning the `present` and `dismiss` methods will be called when the parent `UserIndicator` starts or completes.
class ToastViewPresenter: UserIndicatorViewPresentable {
private let viewState: ToastViewState
private let presentationContext: UserIndicatorPresentationContext
private weak var view: UIView?
private var animator: UIViewPropertyAnimator?
init(viewState: ToastViewState, presentationContext: UserIndicatorPresentationContext) {
self.viewState = viewState
self.presentationContext = presentationContext
}
func present() {
guard let viewController = presentationContext.indicatorPresentingViewController else {
return
}
let view = RoundedToastView(viewState: viewState)
self.view = view
view.translatesAutoresizingMaskIntoConstraints = false
viewController.view.addSubview(view)
NSLayoutConstraint.activate([
view.centerXAnchor.constraint(equalTo: viewController.view.centerXAnchor),
view.topAnchor.constraint(equalTo: viewController.view.safeAreaLayoutGuide.topAnchor)
])
view.alpha = 0
view.transform = .init(translationX: 0, y: 5)
animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeOut) {
view.alpha = 1
view.transform = .identity
}
animator?.startAnimation()
}
func dismiss() {
guard let view, view.superview != nil else {
return
}
animator?.stopAnimation(true)
animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeIn) {
view.alpha = 0
view.transform = .init(translationX: 0, y: -5)
}
animator?.addCompletion { _ in
view.removeFromSuperview()
}
animator?.startAnimation()
}
}
@@ -1,103 +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 Foundation
import UIKit
/// A `UserIndicator` represents the state of a temporary visual indicator, such as loading spinner, success notification or an error message. It does not directly manage the UI, instead it delegates to a `presenter`
/// whenever the UI should be shown or hidden.
///
/// More than one `UserIndicator` may be requested by the system at the same time (e.g. global syncing vs local refresh),
/// and the `UserIndicatorQueue` will ensure that only one indicator is shown at a given time, putting the other in a pending queue.
///
/// A client that requests an indicator can specify a default timeout after which the indicator is dismissed, or it has to be manually
/// responsible for dismissing it via `cancel` method, or by deallocating itself.
public class UserIndicator {
public enum State {
case pending
case executing
case completed
}
private let request: UserIndicatorRequest
private let completion: () -> Void
public private(set) var state: State
public init(request: UserIndicatorRequest, completion: @escaping () -> Void) {
self.request = request
self.completion = completion
state = .pending
}
deinit {
complete()
}
internal func start() {
guard state == .pending else {
return
}
state = .executing
request.presenter.present()
switch request.dismissal {
case .manual:
break
case .timeout(let interval):
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
self?.complete()
}
}
}
/// Cancel the indicator, triggering any dismissal action / animation
///
/// Note: clients can call this method directly, if they have access to the `UserIndicator`. Alternatively
/// deallocating the `UserIndicator` will call `cancel` automatically.
/// Once cancelled, `UserIndicatorQueue` will automatically start the next `UserIndicator` in the queue.
public func cancel() {
complete()
}
private func complete() {
guard state != .completed else {
return
}
if state == .executing {
request.presenter.dismiss()
}
state = .completed
completion()
}
}
public extension UserIndicator {
func store<C>(in collection: inout C) where C: RangeReplaceableCollection, C.Element == UserIndicator {
collection.append(self)
}
}
public extension Collection where Element == UserIndicator {
func cancelAll() {
forEach {
$0.cancel()
}
}
}
@@ -1,42 +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 Foundation
import UIKit
/// The presentation context is used by `UserIndicatorViewPresentable`s to display content
/// on the screen and it serves two primary purposes:
///
/// - abstraction on top of UIKit (passing context instead of view controllers)
/// - immutable context passed at init with variable presenting view controller
/// (e.g. depending on collapsed / uncollapsed iPad presentation that changes
/// at runtime)
public protocol UserIndicatorPresentationContext {
var indicatorPresentingViewController: UIViewController? { get }
}
/// A simple implementation of `UserIndicatorPresentationContext` that uses a weak reference
/// to the passed-in view controller as the presentation context.
public class StaticUserIndicatorPresentationContext: UserIndicatorPresentationContext {
// The presenting view controller will be the parent of the user indicator,
// and the indicator holds a strong reference to the context, so the view controller
// must be decleared `weak` to avoid a retain cycle
public private(set) weak var indicatorPresentingViewController: UIViewController?
public init(viewController: UIViewController) {
indicatorPresentingViewController = viewController
}
}
@@ -1,133 +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 UIKit
/// A set of user interactors commonly used across the app
enum UserIndicatorType {
case loading(label: String, isInteractionBlocking: Bool)
case success(label: String)
case error(label: String)
}
/// A presenter which can handle `UserIndicatorType` by creating the underlying `UserIndicator`
/// and adding it to its `UserIndicatorQueue`
protocol UserIndicatorTypePresenterProtocol {
/// Present a new type of user indicator, such as loading spinner or success message.
///
/// The presenter will internally convert the type into a `UserIndicator` and add it to its internal queue
/// of other indicators.
///
/// If the queue is empty, the indicator will be displayed immediately, otherwise it will be pending
/// until the previously added indicators have completed / been cancelled.
///
/// To remove an indicator, `cancel` or deallocate the returned `UserIndicator`
func present(_ type: UserIndicatorType) -> UserIndicator
/// The queue of user indicators owned by the presenter
///
/// Clients can access the queue to add custom `UserIndicatorRequest`s
/// above and beyond those defined by `UserIndicatorType`
var queue: UserIndicatorQueue { get }
}
class UserIndicatorTypePresenter: UserIndicatorTypePresenterProtocol {
private let presentationContext: UserIndicatorPresentationContext
let queue: UserIndicatorQueue
init(presentationContext: UserIndicatorPresentationContext) {
self.presentationContext = presentationContext
queue = UserIndicatorQueue()
}
convenience init(presentingViewController: UIViewController) {
let context = StaticUserIndicatorPresentationContext(viewController: presentingViewController)
self.init(presentationContext: context)
}
func present(_ type: UserIndicatorType) -> UserIndicator {
let request = userIndicatorRequest(for: type)
return queue.add(request)
}
private func userIndicatorRequest(for type: UserIndicatorType) -> UserIndicatorRequest {
switch type {
case .loading(let label, let isInteractionBlocking):
if isInteractionBlocking {
return fullScreenLoadingRequest(label: label)
} else {
return loadingRequest(label: label)
}
case .success(let label):
return successRequest(label: label)
case .error(let label):
return errorRequest(label: label)
}
}
private func loadingRequest(label: String) -> UserIndicatorRequest {
let presenter = ToastViewPresenter(
viewState: .init(
style: .loading,
label: label
),
presentationContext: presentationContext
)
return UserIndicatorRequest(
presenter: presenter,
dismissal: .manual
)
}
private func fullScreenLoadingRequest(label: String) -> UserIndicatorRequest {
let presenter = FullscreenLoadingViewPresenter(
label: label,
presentationContext: presentationContext
)
return UserIndicatorRequest(
presenter: presenter,
dismissal: .manual
)
}
private func successRequest(label: String) -> UserIndicatorRequest {
let presenter = ToastViewPresenter(
viewState: .init(
style: .success,
label: label
),
presentationContext: presentationContext
)
return UserIndicatorRequest(
presenter: presenter,
dismissal: .timeout(1.5)
)
}
private func errorRequest(label: String) -> UserIndicatorRequest {
let presenter = ToastViewPresenter(
viewState: .init(
style: .error,
label: label
),
presentationContext: presentationContext
)
return UserIndicatorRequest(
presenter: presenter,
dismissal: .timeout(1.5)
)
}
}
@@ -1,63 +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 Foundation
/// A FIFO queue which will ensure only one user indicator is shown at a given time.
///
/// `UserIndicatorQueue` offers a `shared` queue that can be used by any clients app-wide, but clients are also allowed
/// to create local `UserIndicatorQueue` if the context requres multiple simultaneous indicators.
public class UserIndicatorQueue {
private class Weak<T: AnyObject> {
weak var element: T?
init(_ element: T) {
self.element = element
}
}
private var queue: [Weak<UserIndicator>]
public init() {
queue = []
}
/// Add a new indicator to the queue by providing a request.
///
/// The queue will start the indicator right away, if there are no currently running indicators,
/// otherwise the indicator will be put on hold.
public func add(_ request: UserIndicatorRequest) -> UserIndicator {
let indicator = UserIndicator(request: request) { [weak self] in
self?.startNextIfIdle()
}
queue.append(Weak(indicator))
startNextIfIdle()
return indicator
}
private func startNextIfIdle() {
cleanup()
if let indicator = queue.first?.element, indicator.state == .pending {
indicator.start()
}
}
private func cleanup() {
queue.removeAll {
$0.element == nil || $0.element?.state == .completed
}
}
}
@@ -1,28 +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 Foundation
/// A request used to create an underlying `UserIndicator`, allowing clients to only specify the visual aspects of an indicator.
public struct UserIndicatorRequest {
internal let presenter: UserIndicatorViewPresentable
internal let dismissal: UserIndicatorDismissal
public init(presenter: UserIndicatorViewPresentable, dismissal: UserIndicatorDismissal) {
self.presenter = presenter
self.dismissal = dismissal
}
}
@@ -1,63 +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 Foundation
typealias UserIndicatorCancel = () -> Void
/// An abstraction on top of `UserIndicatorTypePresenterProtocol` which manages and stores the individual user indicators.
/// When used to present an indicator the `UserIndicatorStore` will instead returns a simple callback function to the clients
/// letting them cancel the indicators without worrying about memory.
@objc final class UserIndicatorStore: NSObject {
private let presenter: UserIndicatorTypePresenterProtocol
private var indicators: [UserIndicator]
init(presenter: UserIndicatorTypePresenterProtocol) {
self.presenter = presenter
indicators = []
}
/// Present a new type of user indicator, such as loading spinner or success message.
/// To remove an indicator, call the returned `UserIndicatorCancel` function
func present(type: UserIndicatorType) -> UserIndicatorCancel {
let indicator = presenter.present(type)
indicators.append(indicator)
return {
indicator.cancel()
}
}
/// Present a loading indicator.
/// To remove the indicator call the returned `UserIndicatorCancel` function
///
/// Note: This is a convenience function callable by objective-c code
@objc func presentLoading(label: String, isInteractionBlocking: Bool) -> UserIndicatorCancel {
present(
type: .loading(
label: label,
isInteractionBlocking: isInteractionBlocking
)
)
}
/// Present a success message that will be automatically dismissed after a few seconds.
///
/// Note: This is a convenience function callable by objective-c code
@objc func presentSuccess(label: String) {
let indicator = presenter.present(.success(label: label))
indicators.append(indicator)
}
}
@@ -1,25 +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 Foundation
/// A presenter associated with and called by a `UserIndicator`, and responsible for the underlying view shown on the screen.
public protocol UserIndicatorViewPresentable {
/// Called when the `UserIndicator` is started (manually or by the `UserIndicatorQueue`)
func present()
/// Called when the `UserIndicator` is manually cancelled or completed
func dismiss()
}
@@ -14,11 +14,12 @@
// limitations under the License.
//
import UIKit
import Foundation
class ElementNavigationController: UINavigationController {
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
navigationBar.topItem?.backButtonDisplayMode = .minimal
}
struct MockUserNotificationController: UserNotificationControllerProtocol {
func submitNotification(_ notification: UserNotification) { }
func retractNotificationWithId(_ id: String) { }
func retractAllNotifications() { }
}
@@ -14,12 +14,17 @@
// limitations under the License.
//
import Foundation
import SwiftUI
/// Different ways in which a `UserIndicator` can be dismissed
public enum UserIndicatorDismissal {
/// The `UserIndicator` will not manage the dismissal, but will expect the calling client to do so manually
case manual
/// The `UserIndicator` will be automatically dismissed after `TimeInterval`
case timeout(TimeInterval)
enum UserNotificationType {
case toast
case modal
}
struct UserNotification: Equatable, Identifiable {
var id: String = UUID().uuidString
var type = UserNotificationType.toast
var title: String
var iconName: String?
var persistent = false
}
@@ -0,0 +1,80 @@
//
// 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 UserNotificationController: ObservableObject, UserNotificationControllerProtocol {
private let rootCoordinator: CoordinatorProtocol
private var dismisalTimer: Timer?
private var displayTimes = [String: Date]()
var nonPersistentDisplayDuration = 2.5
var minimumDisplayDuration = 0.5
@Published private(set) var activeNotification: UserNotification?
private(set) var notificationQueue = [UserNotification]() {
didSet {
activeNotification = notificationQueue.last
if let activeNotification, !activeNotification.persistent {
dismisalTimer?.invalidate()
dismisalTimer = Timer.scheduledTimer(withTimeInterval: nonPersistentDisplayDuration, repeats: false) { [weak self] _ in
self?.retractNotificationWithId(activeNotification.id)
}
}
}
}
init(rootCoordinator: CoordinatorProtocol) {
self.rootCoordinator = rootCoordinator
}
func toPresentable() -> AnyView {
AnyView(
UserNotificationPresenter(userNotificationController: self, rootView: rootCoordinator.toPresentable())
)
}
func submitNotification(_ notification: UserNotification) {
if let index = notificationQueue.firstIndex(where: { $0.id == notification.id }) {
notificationQueue[index] = notification
} else {
retractNotificationWithId(notification.id)
notificationQueue.append(notification)
}
displayTimes[notification.id] = .now
}
func retractAllNotifications() {
for notification in notificationQueue {
retractNotificationWithId(notification.id)
}
}
func retractNotificationWithId(_ id: String) {
guard let displayTime = displayTimes[id], abs(displayTime.timeIntervalSinceNow) <= minimumDisplayDuration else {
notificationQueue.removeAll { $0.id == id }
return
}
Timer.scheduledTimer(withTimeInterval: minimumDisplayDuration, repeats: false) { [weak self] _ in
self?.notificationQueue.removeAll { $0.id == id }
self?.displayTimes[id] = nil
}
}
}
@@ -16,13 +16,8 @@
import Foundation
struct ToastViewState {
enum Style {
case loading
case success
case error
}
let style: Style
let label: String
protocol UserNotificationControllerProtocol: CoordinatorProtocol {
func submitNotification(_ notification: UserNotification)
func retractNotificationWithId(_ id: String)
func retractAllNotifications()
}
@@ -0,0 +1,70 @@
//
// 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
struct UserNotificationModalView: View {
let notification: UserNotification
var body: some View {
ZStack {
VStack(spacing: 12.0) {
ProgressView()
HStack {
if let iconName = notification.iconName {
Image(systemName: iconName)
}
Text(notification.title)
.font(.element.body)
.foregroundColor(.element.primaryContent)
}
}
.padding()
.frame(minWidth: 150.0)
.background(Color.element.quinaryContent)
.clipShape(RoundedCornerShape(radius: 12.0, corners: .allCorners))
.shadow(color: .black.opacity(0.1), radius: 10.0, y: 4.0)
.transition(.opacity)
}
.id(notification.id)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.1))
.ignoresSafeArea()
}
private var toastTransition: AnyTransition {
AnyTransition
.asymmetric(insertion: .move(edge: .top),
removal: .move(edge: .bottom))
.combined(with: .opacity)
}
}
struct UserNotificationModalView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
private static var body: some View {
VStack {
UserNotificationModalView(notification: UserNotification(type: .modal,
title: "Successfully logged in",
iconName: "checkmark"))
}
}
}
@@ -0,0 +1,44 @@
//
// 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
struct UserNotificationPresenter: View {
@ObservedObject var userNotificationController: UserNotificationController
let rootView: AnyView
var body: some View {
ZStack(alignment: .top) {
rootView
notificationViewFor(notification: userNotificationController.activeNotification)
}
.animation(.elementDefault, value: userNotificationController.activeNotification)
}
@ViewBuilder
private func notificationViewFor(notification: UserNotification?) -> some View {
ZStack { // Need a container to properly animate transitions
if let notification {
switch notification.type {
case .toast:
UserNotificationToastView(notification: notification)
case .modal:
UserNotificationModalView(notification: notification)
}
}
}
}
}
@@ -0,0 +1,63 @@
//
// 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
struct UserNotificationToastView: View {
let notification: UserNotification
var body: some View {
HStack {
if let iconName = notification.iconName {
Image(systemName: iconName)
}
Text(notification.title)
.font(.element.footnote)
.foregroundColor(.element.primaryContent)
}
.id(notification.id)
.padding(.horizontal, 12.0)
.padding(.vertical, 10.0)
.frame(minWidth: 150.0)
.background(Color.element.quaternaryContent)
.clipShape(RoundedCornerShape(radius: 24.0, corners: .allCorners))
.shadow(color: .black.opacity(0.1), radius: 10.0, y: 4.0)
.transition(toastTransition)
}
private var toastTransition: AnyTransition {
AnyTransition
.asymmetric(insertion: .move(edge: .top),
removal: .move(edge: .bottom))
.combined(with: .opacity)
}
}
struct UserNotificationToastView_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
body.preferredColorScheme(.dark)
}
private static var body: some View {
VStack {
UserNotificationToastView(notification: UserNotification(title: "Successfully logged in",
iconName: "checkmark"))
UserNotificationToastView(notification: UserNotification(title: "Toast without icon"))
}
}
}