Copied over loading indicator classes and added a modal one for the login screen.

This commit is contained in:
Stefan Ceriu 2022-02-22 11:24:46 +02:00
parent 7a4be39e7e
commit ad80b91b16
15 changed files with 765 additions and 7 deletions

View File

@ -9,6 +9,16 @@
/* Begin PBXBuildFile section */
182BC42027BE667200A30C33 /* RoomModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC41F27BE667200A30C33 /* RoomModelProtocol.swift */; };
182BC42227BE6C6900A30C33 /* MockRoomModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC42127BE6C6900A30C33 /* MockRoomModel.swift */; };
182BC46F27C4CD6D00A30C33 /* RoundedToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46527C4CD6D00A30C33 /* RoundedToastView.swift */; };
182BC47027C4CD6D00A30C33 /* RectangleToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46627C4CD6D00A30C33 /* RectangleToastView.swift */; };
182BC47127C4CD6D00A30C33 /* ActivityPresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46727C4CD6D00A30C33 /* ActivityPresentable.swift */; };
182BC47227C4CD6D00A30C33 /* ActivityCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46827C4CD6D00A30C33 /* ActivityCenter.swift */; };
182BC47327C4CD6D00A30C33 /* ToastActivityPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46A27C4CD6D00A30C33 /* ToastActivityPresenter.swift */; };
182BC47527C4CD6D00A30C33 /* Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46C27C4CD6D00A30C33 /* Activity.swift */; };
182BC47627C4CD6D00A30C33 /* ActivityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46D27C4CD6D00A30C33 /* ActivityRequest.swift */; };
182BC47727C4CD6D00A30C33 /* ActivityDismissal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC46E27C4CD6D00A30C33 /* ActivityDismissal.swift */; };
182BC47927C4CE2200A30C33 /* LabelledActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC47827C4CE2200A30C33 /* LabelledActivityIndicatorView.swift */; };
182BC47B27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182BC47A27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift */; };
1850253F27B6918D002E6B18 /* ElementXTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850253E27B6918D002E6B18 /* ElementXTests.swift */; };
1850254927B6918D002E6B18 /* ElementXUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850254827B6918D002E6B18 /* ElementXUITests.swift */; };
1850254B27B6918D002E6B18 /* ElementXUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850254A27B6918D002E6B18 /* ElementXUITestsLaunchTests.swift */; };
@ -78,6 +88,16 @@
/* Begin PBXFileReference section */
182BC41F27BE667200A30C33 /* RoomModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomModelProtocol.swift; sourceTree = "<group>"; };
182BC42127BE6C6900A30C33 /* MockRoomModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomModel.swift; sourceTree = "<group>"; };
182BC46527C4CD6D00A30C33 /* RoundedToastView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedToastView.swift; sourceTree = "<group>"; };
182BC46627C4CD6D00A30C33 /* RectangleToastView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RectangleToastView.swift; sourceTree = "<group>"; };
182BC46727C4CD6D00A30C33 /* ActivityPresentable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityPresentable.swift; sourceTree = "<group>"; };
182BC46827C4CD6D00A30C33 /* ActivityCenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityCenter.swift; sourceTree = "<group>"; };
182BC46A27C4CD6D00A30C33 /* ToastActivityPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastActivityPresenter.swift; sourceTree = "<group>"; };
182BC46C27C4CD6D00A30C33 /* Activity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Activity.swift; sourceTree = "<group>"; };
182BC46D27C4CD6D00A30C33 /* ActivityRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityRequest.swift; sourceTree = "<group>"; };
182BC46E27C4CD6D00A30C33 /* ActivityDismissal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityDismissal.swift; sourceTree = "<group>"; };
182BC47827C4CE2200A30C33 /* LabelledActivityIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelledActivityIndicatorView.swift; sourceTree = "<group>"; };
182BC47A27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullscreenLoadingActivityPresenter.swift; sourceTree = "<group>"; };
184230FE27BD080000033771 /* matrix-rust-components-swift */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "matrix-rust-components-swift"; path = "../matrix-rust-components-swift"; sourceTree = "<group>"; };
1850252427B6918C002E6B18 /* ElementX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ElementX.app; sourceTree = BUILT_PRODUCTS_DIR; };
1850253A27B6918D002E6B18 /* ElementXTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ElementXTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@ -157,6 +177,39 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
182BC46327C4CD6D00A30C33 /* Activity */ = {
isa = PBXGroup;
children = (
182BC47827C4CE2200A30C33 /* LabelledActivityIndicatorView.swift */,
182BC46427C4CD6D00A30C33 /* Toasts */,
182BC46727C4CD6D00A30C33 /* ActivityPresentable.swift */,
182BC46827C4CD6D00A30C33 /* ActivityCenter.swift */,
182BC46927C4CD6D00A30C33 /* ActivityPresenters */,
182BC46C27C4CD6D00A30C33 /* Activity.swift */,
182BC46D27C4CD6D00A30C33 /* ActivityRequest.swift */,
182BC46E27C4CD6D00A30C33 /* ActivityDismissal.swift */,
);
path = Activity;
sourceTree = "<group>";
};
182BC46427C4CD6D00A30C33 /* Toasts */ = {
isa = PBXGroup;
children = (
182BC46527C4CD6D00A30C33 /* RoundedToastView.swift */,
182BC46627C4CD6D00A30C33 /* RectangleToastView.swift */,
);
path = Toasts;
sourceTree = "<group>";
};
182BC46927C4CD6D00A30C33 /* ActivityPresenters */ = {
isa = PBXGroup;
children = (
182BC47A27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift */,
182BC46A27C4CD6D00A30C33 /* ToastActivityPresenter.swift */,
);
path = ActivityPresenters;
sourceTree = "<group>";
};
1850251B27B6918C002E6B18 = {
isa = PBXGroup;
children = (
@ -252,6 +305,7 @@
1863A41A27BA76B900B52E4D /* Other */ = {
isa = PBXGroup;
children = (
182BC46327C4CD6D00A30C33 /* Activity */,
1863A43D27BA790000B52E4D /* Coordinator.swift */,
1863A41B27BA76B900B52E4D /* MXLog.swift */,
1863A43627BA789800B52E4D /* SwiftUI */,
@ -577,16 +631,20 @@
buildActionMask = 2147483647;
files = (
1863A44B27BA79FF00B52E4D /* RootRouterType.swift in Sources */,
182BC47327C4CD6D00A30C33 /* ToastActivityPresenter.swift in Sources */,
1863A48C27BAA8A900B52E4D /* HomeScreenViewModel.swift in Sources */,
1863A41727BA716A00B52E4D /* KeychainControllerProtocol.swift in Sources */,
1863A45B27BA7B4700B52E4D /* LoginScreenCoordinator.swift in Sources */,
182BC47127C4CD6D00A30C33 /* ActivityPresentable.swift in Sources */,
1863A41427BA716A00B52E4D /* AuthenticationCoordinator.swift in Sources */,
1863A43B27BA789800B52E4D /* BindableState.swift in Sources */,
182BC47027C4CD6D00A30C33 /* RectangleToastView.swift in Sources */,
1863A42C27BA784300B52E4D /* LoginScreenViewModel.swift in Sources */,
1863A44F27BA79FF00B52E4D /* NavigationModule.swift in Sources */,
1863A43027BA784300B52E4D /* LoginScreenViewModelProtocol.swift in Sources */,
1863A48827BAA8A900B52E4D /* HomeScreenViewModelProtocol.swift in Sources */,
1863A44D27BA79FF00B52E4D /* RootRouter.swift in Sources */,
182BC47627C4CD6D00A30C33 /* ActivityRequest.swift in Sources */,
1863A45F27BAA60300B52E4D /* SplashViewController.swift in Sources */,
1863A48A27BAA8A900B52E4D /* HomeScreen.swift in Sources */,
1863A44927BA79FF00B52E4D /* NavigationRouterStore.swift in Sources */,
@ -595,8 +653,10 @@
1863A45027BA79FF00B52E4D /* NavigationRouterType.swift in Sources */,
182BC42027BE667200A30C33 /* RoomModelProtocol.swift in Sources */,
1863A45627BA7A7800B52E4D /* WeakDictionaryKeyReference.swift in Sources */,
182BC47927C4CE2200A30C33 /* LabelledActivityIndicatorView.swift in Sources */,
1863A49427BAAA6700B52E4D /* RoomModel.swift in Sources */,
1850256F27B6A135002E6B18 /* AppDelegate.swift in Sources */,
182BC47727C4CD6D00A30C33 /* ActivityDismissal.swift in Sources */,
1863A41627BA716A00B52E4D /* KeychainController.swift in Sources */,
1863A41527BA716A00B52E4D /* UserSession.swift in Sources */,
1863A43A27BA789800B52E4D /* StateStoreViewModel.swift in Sources */,
@ -605,11 +665,15 @@
1863A48527BAA8A900B52E4D /* HomeScreenCoordinator.swift in Sources */,
1863A43F27BA790000B52E4D /* Coordinator.swift in Sources */,
1863A41C27BA76B900B52E4D /* MXLog.swift in Sources */,
182BC46F27C4CD6D00A30C33 /* RoundedToastView.swift in Sources */,
182BC47527C4CD6D00A30C33 /* Activity.swift in Sources */,
182BC47B27C4D05200A30C33 /* FullscreenLoadingActivityPresenter.swift in Sources */,
1863A44E27BA79FF00B52E4D /* Presentable.swift in Sources */,
182BC42227BE6C6900A30C33 /* MockRoomModel.swift in Sources */,
1850256C27B6A135002E6B18 /* AppCoordinator.swift in Sources */,
1863A43127BA784300B52E4D /* LoginScreenModels.swift in Sources */,
1863A44A27BA79FF00B52E4D /* NavigationRouter.swift in Sources */,
182BC47227C4CD6D00A30C33 /* ActivityCenter.swift in Sources */,
1863A45927BA7A7800B52E4D /* WeakKeyDictionary.swift in Sources */,
1863A44C27BA79FF00B52E4D /* NavigationRouterStoreProtocol.swift in Sources */,
);

View File

@ -18,6 +18,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
private let keychainController: KeychainControllerProtocol
private let authenticationCoordinator: AuthenticationCoordinator!
private var loadingActivity: Activity?
var childCoordinators: [Coordinator] = []
init() {
@ -47,11 +49,11 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
// MARK: - AuthenticationCoordinatorDelegate
func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator) {
showLoadingIndicator()
}
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didFailWithError error: AuthenticationCoordinatorError) {
hideLoadingIndicator()
}
func authenticationCoordinatorDidSetupUserSession(_ authenticationCoordinator: AuthenticationCoordinator) {
@ -66,6 +68,9 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
// MARK: - Private
private func presentHomeScreen() {
hideLoadingIndicator()
guard let userSession = authenticationCoordinator.userSession else {
fatalError("User session should be already setup at this point")
}
@ -84,7 +89,18 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
navigationRouter.setRootModule(coordinator)
}
private func restart() {
private func showLoadingIndicator() {
let presenter = FullscreenLoadingActivityPresenter(label: "Loading", on: self.mainNavigationController)
let request = ActivityRequest(
presenter: presenter,
dismissal: .manual
)
loadingActivity = ActivityCenter.shared.add(request)
}
private func hideLoadingIndicator() {
loadingActivity = nil
}
}

View File

@ -44,8 +44,6 @@ class AuthenticationCoordinator: Coordinator {
func start() {
delegate?.authenticationCoordinatorDidStartLoading(self)
let availableRestoreTokens = keychainController.restoreTokens()
guard let usernameTokenTuple = availableRestoreTokens.first else {
@ -116,6 +114,9 @@ class AuthenticationCoordinator: Coordinator {
}
private func login(username: String, password: String, completion: @escaping (Result<Void, AuthenticationCoordinatorError>) -> Void) {
delegate?.authenticationCoordinatorDidStartLoading(self)
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else { return }
@ -136,6 +137,9 @@ class AuthenticationCoordinator: Coordinator {
}
private func restorePreviousLogin(_ usernameTokenTuple: (username: String, token: String), completion: @escaping (Result<Void, AuthenticationCoordinatorError>) -> Void) {
delegate?.authenticationCoordinatorDidStartLoading(self)
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else { return }

View File

@ -37,8 +37,8 @@ struct LoginScreen: View {
.padding(.horizontal, 8.0)
.navigationTitle("Login")
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(StackNavigationViewStyle())
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

View File

@ -57,7 +57,6 @@ struct HomeScreen: View {
.listStyle(.plain)
}
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(StackNavigationViewStyle())
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Logout") {
@ -66,6 +65,7 @@ struct HomeScreen: View {
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}

View File

@ -0,0 +1,102 @@
//
// Copyright 2021 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
/// An `Activity` represents the state of a temporary visual indicator, such as activity indicator, 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 `Activity` may be requested by the system at the same time (e.g. global syncing vs local refresh),
/// and the `ActivityCenter` will ensure that only one activity is shown at a given time, putting the other in a pending queue.
///
/// A client that requests an activity can specify a default timeout after which the activity is dismissed, or it has to be manually
/// responsible for dismissing it via `cancel` method, or by deallocating itself.
public class Activity {
public enum State {
case pending
case executing
case completed
}
private let request: ActivityRequest
private let completion: () -> Void
public private(set) var state: State
public init(request: ActivityRequest, 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 activity, triggering any dismissal action / animation
///
/// Note: clients can call this method directly, if they have access to the `Activity`.
/// Once cancelled, `ActivityCenter` will automatically start the next `Activity` 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 Activity {
func store<C>(in collection: inout C) where C: RangeReplaceableCollection, C.Element == Activity {
collection.append(self)
}
}
public extension Collection where Element == Activity {
func cancelAll() {
forEach {
$0.cancel()
}
}
}

View File

@ -0,0 +1,60 @@
//
// Copyright 2021 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 shared activity center with a single FIFO queue which will ensure only one activity is shown at a given time.
///
/// `ActivityCenter` offers a `shared` center that can be used by any clients, but clients are also allowed
/// to create local `ActivityCenter` if the context requres multiple simultaneous activities.
public class ActivityCenter {
private class Weak<T: AnyObject> {
weak var element: T?
init(_ element: T) {
self.element = element
}
}
public static let shared = ActivityCenter()
private var queue = [Weak<Activity>]()
/// Add a new activity to the queue by providing a request.
///
/// The queue will start the activity right away, if there are no currently running activities,
/// otherwise the activity will be put on hold.
public func add(_ request: ActivityRequest) -> Activity {
let activity = Activity(request: request) { [weak self] in
self?.startNextIfIdle()
}
queue.append(Weak(activity))
startNextIfIdle()
return activity
}
private func startNextIfIdle() {
cleanup()
if let activity = queue.first?.element, activity.state == .pending {
activity.start()
}
}
private func cleanup() {
queue.removeAll {
$0.element == nil || $0.element?.state == .completed
}
}
}

View File

@ -0,0 +1,25 @@
//
// Copyright 2021 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
/// Different ways in which an `Activity` can be dismissed
public enum ActivityDismissal {
/// The `Activity` will not manage the dismissal, but will expect the calling client to do so manually
case manual
/// The `Activity` will be automatically dismissed after `TimeInterval`
case timeout(TimeInterval)
}

View File

@ -0,0 +1,25 @@
//
// Copyright 2021 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 an `Activity`, and responsible for the underlying view shown on the screen.
public protocol ActivityPresentable {
/// Called when the `Activity` is started (manually or by the `ActivityCenter`)
func present()
/// Called when the `Activity` is manually cancelled or completed
func dismiss()
}

View File

@ -0,0 +1,67 @@
//
// Copyright 2021 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
/// An `ActivityPresenter` responsible for showing / hiding a full-screen loading view that obscures (and thus disables) all other controls.
/// It is managed by an `Activity`, meaning the `present` and `dismiss` methods will be called when the parent `Activity` starts or completes.
class FullscreenLoadingActivityPresenter: ActivityPresentable {
private let label: String
private weak var viewController: UIViewController?
private weak var view: UIView?
init(label: String, on viewController: UIViewController) {
self.label = label
self.viewController = viewController
}
func present() {
let view = LabelledActivityIndicatorView(text: label)
self.view = view
guard let window = viewController?.view.window else {
return
}
view.translatesAutoresizingMaskIntoConstraints = false
window.addSubview(view)
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: window.topAnchor),
view.bottomAnchor.constraint(equalTo: window.bottomAnchor),
view.leadingAnchor.constraint(equalTo: window.leadingAnchor),
view.trailingAnchor.constraint(equalTo: window.trailingAnchor)
])
view.alpha = 0
CATransaction.commit()
UIView.animate(withDuration: 0.2) {
view.alpha = 1
}
}
func dismiss() {
guard let view = view, view.superview != nil else {
return
}
UIView.animate(withDuration: 0.2, delay: 0, options: .beginFromCurrentState) {
view.alpha = 0
} completion: { _ in
view.removeFromSuperview()
}
}
}

View File

@ -0,0 +1,69 @@
//
// Copyright 2021 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
/// An `ActivityPresenter` responsible for showing / hiding a toast view for activity indicators or success messages.
/// It is managed by an `Activity`, meaning the `present` and `dismiss` methods will be called when the parent `Activity` starts or completes.
class ToastActivityPresenter: ActivityPresentable {
private let viewState: RoundedToastView.ViewState
private weak var navigationController: UINavigationController?
private weak var view: UIView?
init(viewState: RoundedToastView.ViewState, navigationController: UINavigationController) {
self.viewState = viewState
self.navigationController = navigationController
}
func present() {
guard let navigationController = navigationController else {
return
}
let view = RoundedToastView(viewState: viewState)
self.view = view
view.translatesAutoresizingMaskIntoConstraints = false
navigationController.view.addSubview(view)
NSLayoutConstraint.activate([
view.centerXAnchor.constraint(equalTo: navigationController.navigationBar.centerXAnchor),
view.topAnchor.constraint(equalTo: navigationController.navigationBar.bottomAnchor)
])
view.alpha = 0
CATransaction.flush()
view.transform = .init(translationX: 0, y: 5)
UIView.animate(withDuration: 0.2) {
view.alpha = 1
view.transform = .identity
}
}
func dismiss() {
guard let view = view, view.superview != nil else {
return
}
UIView.animate(withDuration: 0.2, delay: 0, options: .beginFromCurrentState) {
view.alpha = 0
view.transform = .init(translationX: 0, y: -5)
} completion: { _ in
view.removeFromSuperview()
}
}
}

View File

@ -0,0 +1,28 @@
//
// Copyright 2021 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 `Activity`, allowing clients to only specify the visual aspects of an activity.
public struct ActivityRequest {
internal let presenter: ActivityPresentable
internal let dismissal: ActivityDismissal
public init(presenter: ActivityPresentable, dismissal: ActivityDismissal) {
self.presenter = presenter
self.dismissal = dismissal
}
}

View File

@ -0,0 +1,92 @@
//
// Copyright 2021 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 stackBackgroundOpacity: CGFloat = 0.9
static let stackSpacing: CGFloat = 15
static let backgroundOpacity: CGFloat = 0.5
}
private let stackBackgroundView: UIView = {
let view = UIView()
view.layer.cornerRadius = Constants.cornerRadius
view.alpha = Constants.stackBackgroundOpacity
view.backgroundColor = .gray.withAlphaComponent(0.75)
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 = {
return UILabel()
}()
init(text: String) {
super.init(frame: .zero)
setup(text: text)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup(text: String) {
setupStackView()
label.text = text
}
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)
])
}
}

View File

@ -0,0 +1,87 @@
//
// Copyright 2021 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 = 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 = 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
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,119 @@
//
// Copyright 2021 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 RoundedToastView: UIView {
private struct 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 shadowOffset = CGSize(width: 0, height: 4)
static let shadowRadius = CGFloat(12)
static let shadowOpacity = Float(0.1)
}
private lazy var activityIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView()
indicator.transform = .init(scaleX: Constants.activityIndicatorScale, y: Constants.activityIndicatorScale)
indicator.startAnimating()
return indicator
}()
private lazy var imagView: 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.spacing = 5
return stack
}()
private let label: UILabel = {
return UILabel()
}()
init(viewState: ViewState) {
super.init(frame: .zero)
setup(viewState: viewState)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup(viewState: ViewState) {
setupLayer()
setupStackView()
stackView.addArrangedSubview(toastView(for: viewState.style))
stackView.addArrangedSubview(label)
label.text = viewState.label
}
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)
])
}
private func setupLayer() {
layer.shadowColor = UIColor.black.cgColor
layer.shadowOffset = Constants.shadowOffset
layer.shadowRadius = Constants.shadowRadius
layer.shadowOpacity = Constants.shadowOpacity
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = layer.frame.height / 2
}
private func toastView(for style: Style) -> UIView {
switch style {
case .loading:
return activityIndicator
case .success:
imagView.image = UIImage(systemName: "checkmark.circle")
return imagView
}
}
}
extension RoundedToastView {
enum Style {
case loading
case success
}
struct ViewState {
let style: Style
let label: String
}
}