mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 03:48:27 +00:00
SwiftUI NavigationController and UserNotificationControllers (#309)
* Fixes #286 - Adopted the new SwiftUI NavigationStack based NavigationController throughout the application * Fixes #315 - Implemented new user notification components on top of SwiftUI and the new navigation flows * Add home screen fade animation between skeletons and real rooms * Bump the danger-swift version used on the CI and swiftlint with it * Renamed Splash to Onboarding, Empty to Splash
This commit is contained in:
@@ -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()
|
||||
}
|
||||
+7
-6
@@ -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() { }
|
||||
}
|
||||
+12
-7
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-9
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user