Update UserIndicators with newer implementation from Element (modified to work here)

This commit is contained in:
Stefan Ceriu
2022-05-11 16:27:30 +03:00
committed by Stefan Ceriu
parent 32d40eec47
commit 4b728f3d09
25 changed files with 1151 additions and 297 deletions
+9 -22
View File
@@ -19,9 +19,10 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
private let authenticationCoordinator: AuthenticationCoordinator!
private let memberDetailProviderManager: MemberDetailProviderManager
private var loadingActivity: Activity?
private var errorActivity: Activity?
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
private var errorIndicator: UserIndicator?
var childCoordinators: [Coordinator] = []
@@ -35,6 +36,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
memberDetailProviderManager = MemberDetailProviderManager()
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: mainNavigationController)
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
fatalError("Should have a valid bundle identifier at this point")
}
@@ -143,30 +146,14 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
}
private func showLoadingIndicator() {
let presenter = FullscreenLoadingActivityPresenter(label: "Loading",
on: mainNavigationController)
let request = ActivityRequest(
presenter: presenter,
dismissal: .manual
)
loadingActivity = ActivityCenter.shared.add(request)
loadingIndicator = indicatorPresenter.present(.loading(label: "Loading", isInteractionBlocking: true))
}
private func hideLoadingIndicator() {
loadingActivity = nil
loadingIndicator = nil
}
private func showLoginErrorToast() {
let presenter = ToastActivityPresenter(viewState: .init(style: .success, label: "Failed logging in"),
navigationController: mainNavigationController)
let request = ActivityRequest(
presenter: presenter,
dismissal: .timeout(3.0)
)
errorActivity = ActivityCenter.shared.add(request)
errorIndicator = indicatorPresenter.present(.success(label: "Failed logging in"))
}
}
@@ -1,67 +0,0 @@
//
// 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()
}
}
}
@@ -1,70 +0,0 @@
//
// 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,
let window = navigationController.view.window else {
return
}
let view = RoundedToastView(viewState: viewState)
self.view = view
view.translatesAutoresizingMaskIntoConstraints = false
window.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()
}
}
}
@@ -0,0 +1,148 @@
/*
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 {
return self.activityIndicatorView != nil
}
// MARK: - Public
func presentActivityIndicator(on view: UIView, animated: Bool, completion: (() -> Void)? = nil) {
if self.presentingView != nil {
if let completion = completion {
completion()
}
return
}
self.presentingView = view
view.isUserInteractionEnabled = false
let backgroundOverlayView = self.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, animations: {
animationInstructions()
}, completion: { _ in
completion?()
})
} else {
animationInstructions()
completion?()
}
}
func removeCurrentActivityIndicator(animated: Bool, completion: (() -> Void)? = nil) {
guard let presentingView = self.presentingView,
let backgroundOverlayView = self.backgroundOverlayView,
let activityIndicatorView = self.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, animations: {
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) {
self.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 }
}
}
}
@@ -0,0 +1,34 @@
/*
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) {
self.presentActivityIndicator(on: view, animated: animated, completion: nil)
}
func removeCurrentActivityIndicator(animated: Bool) {
self.removeCurrentActivityIndicator(animated: animated, completion: nil)
}
}
@@ -0,0 +1,112 @@
/*
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 weak var activityIndicatorView: UIActivityIndicatorView!
@IBOutlet private weak var activityIndicatorBackgroundView: UIView!
// MARK: Public
var color: UIColor? {
get {
return activityIndicatorView.color
}
set {
activityIndicatorView.color = newValue
}
}
// MARK: - Setup
private func commonInit() {
self.activityIndicatorBackgroundView.layer.masksToBounds = true
}
convenience init() {
self.init(frame: CGRect.zero)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.loadNibContent()
self.commonInit()
}
override init(frame: CGRect) {
super.init(frame: frame)
self.loadNibContent()
self.commonInit()
}
// MARK: - Overrides
override var intrinsicContentSize: CGSize {
return CGSize(width: self.activityIndicatorView.intrinsicContentSize.width + Constants.activityIndicatorMargin.width,
height: self.activityIndicatorView.intrinsicContentSize.height + Constants.activityIndicatorMargin.height)
}
override func layoutSubviews() {
super.layoutSubviews()
self.activityIndicatorBackgroundView.layer.cornerRadius = Constants.cornerRadius
}
// MARK: - Public
func startAnimating() {
self.activityIndicatorView.startAnimating()
}
func stopAnimating() {
self.activityIndicatorView.stopAnimating()
}
}
private extension UIView {
static var nib: UINib {
return 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
self.addSubview(view)
NSLayoutConstraint.activate(layoutAttributes.map { attribute in
NSLayoutConstraint(
item: view, attribute: attribute,
relatedBy: .equal,
toItem: self, attribute: attribute,
multiplier: 1, constant: 0.0
)
})
}
}
}
@@ -0,0 +1,55 @@
<?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>
@@ -0,0 +1,75 @@
//
// 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 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 = 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, 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()
}
}
@@ -14,17 +14,21 @@
// limitations under the License.
//
import Foundation
import UIKit
class RoundedToastView: UIView {
private struct ShadowStyle {
let offset: CGSize
let radius: CGFloat
let opacity: Float
}
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)
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 = {
@@ -34,7 +38,7 @@ class RoundedToastView: UIView {
return indicator
}()
private lazy var imagView: UIImageView = {
private lazy var imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
@@ -57,7 +61,7 @@ class RoundedToastView: UIView {
return UILabel()
}()
init(viewState: ViewState) {
init(viewState: ToastViewState) {
super.init(frame: .zero)
setup(viewState: viewState)
}
@@ -66,11 +70,10 @@ class RoundedToastView: UIView {
fatalError("init(coder:) has not been implemented")
}
private func setup(viewState: ViewState) {
private func setup(viewState: ToastViewState) {
backgroundColor = .gray.withAlphaComponent(0.75)
setupLayer()
setupStackView()
stackView.addArrangedSubview(toastView(for: viewState.style))
stackView.addArrangedSubview(label)
@@ -88,36 +91,18 @@ class RoundedToastView: UIView {
])
}
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 {
private func toastView(for style: ToastViewState.Style) -> UIView {
switch style {
case .loading:
return activityIndicator
case .success:
imagView.image = UIImage(systemName: "checkmark.circle")
return imagView
imageView.image = UIImage(systemName: "checkmark.circle")
return imageView
}
}
}
extension RoundedToastView {
enum Style {
case loading
case success
}
struct ViewState {
let style: Style
let label: String
}
}
@@ -0,0 +1,93 @@
//
// 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 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 {
struct Constants {
static let navigationBarPatting = CGFloat(10)
}
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
if let navigation = viewController.topNavigationController {
navigation.view.addSubview(view)
NSLayoutConstraint.activate([
view.centerXAnchor.constraint(equalTo: navigation.view.centerXAnchor),
view.topAnchor.constraint(equalTo: navigation.navigationBar.safeAreaLayoutGuide.bottomAnchor, constant: -Constants.navigationBarPatting)
])
} else {
viewController.view.addSubview(view)
NSLayoutConstraint.activate([
view.centerXAnchor.constraint(equalTo: viewController.view.centerXAnchor),
view.topAnchor.constraint(equalTo: viewController.view.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, 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()
}
}
private extension UIViewController {
var topNavigationController: UINavigationController? {
var controller: UINavigationController? = self as? UINavigationController ?? navigationController
while controller?.navigationController != nil {
controller = controller?.navigationController
}
return controller
}
}
@@ -0,0 +1,27 @@
//
// 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
struct ToastViewState {
enum Style {
case loading
case success
}
let style: Style
let label: String
}
@@ -17,27 +17,27 @@
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`
/// 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 `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.
/// 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 activity can specify a default timeout after which the activity is dismissed, or it has to be manually
/// 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 Activity {
public class UserIndicator {
public enum State {
case pending
case executing
case completed
}
private let request: ActivityRequest
private let request: UserIndicatorRequest
private let completion: () -> Void
public private(set) var state: State
public init(request: ActivityRequest, completion: @escaping () -> Void) {
public init(request: UserIndicatorRequest, completion: @escaping () -> Void) {
self.request = request
self.completion = completion
@@ -66,10 +66,11 @@ public class Activity {
}
}
/// Cancel the activity, triggering any dismissal action / animation
/// Cancel the indicator, 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.
/// 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()
}
@@ -87,13 +88,13 @@ public class Activity {
}
}
public extension Activity {
func store<C>(in collection: inout C) where C: RangeReplaceableCollection, C.Element == Activity {
public extension UserIndicator {
func store<C>(in collection: inout C) where C: RangeReplaceableCollection, C.Element == UserIndicator {
collection.append(self)
}
}
public extension Collection where Element == Activity {
public extension Collection where Element == UserIndicator {
func cancelAll() {
forEach {
$0.cancel()
@@ -16,10 +16,10 @@
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
/// 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 `Activity` will be automatically dismissed after `TimeInterval`
/// The `UserIndicator` will be automatically dismissed after `TimeInterval`
case timeout(TimeInterval)
}
@@ -0,0 +1,42 @@
//
// 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) {
self.indicatorPresentingViewController = viewController
}
}
@@ -0,0 +1,116 @@
//
// 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 UIKit
/// A set of user interactors commonly used across the app
enum UserIndicatorType {
case loading(label: String, isInteractionBlocking: Bool)
case success(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
self.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)
}
}
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)
)
}
}
@@ -16,11 +16,11 @@
import Foundation
/// A shared activity center with a single FIFO queue which will ensure only one activity is shown at a given time.
/// A FIFO queue which will ensure only one user indicator 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 {
/// `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) {
@@ -28,27 +28,30 @@ public class ActivityCenter {
}
}
public static let shared = ActivityCenter()
private var queue = [Weak<Activity>]()
private var queue: [Weak<UserIndicator>]
/// Add a new activity to the queue by providing a request.
public init() {
queue = []
}
/// Add a new indicator 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
/// 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(activity))
queue.append(Weak(indicator))
startNextIfIdle()
return activity
return indicator
}
private func startNextIfIdle() {
cleanup()
if let activity = queue.first?.element, activity.state == .pending {
activity.start()
if let indicator = queue.first?.element, indicator.state == .pending {
indicator.start()
}
}
@@ -16,12 +16,12 @@
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
/// 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: ActivityPresentable, dismissal: ActivityDismissal) {
public init(presenter: UserIndicatorViewPresentable, dismissal: UserIndicatorDismissal) {
self.presenter = presenter
self.dismissal = dismissal
}
@@ -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 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
self.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)
}
}
@@ -16,10 +16,10 @@
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`)
/// 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 `Activity` is manually cancelled or completed
/// Called when the `UserIndicator` is manually cancelled or completed
func dismiss()
}