mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Added room timeline screen with live events listening and back pagination. Rearranged project files.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+67
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// 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,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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// 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.alignment = .center
|
||||
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) {
|
||||
|
||||
backgroundColor = .gray.withAlphaComponent(0.75)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
// `Coordinator` default implementation
|
||||
extension Coordinator {
|
||||
|
||||
func add(childCoordinator coordinator: Coordinator) {
|
||||
childCoordinators.append(coordinator)
|
||||
}
|
||||
|
||||
func remove(childCoordinator: Coordinator) {
|
||||
self.childCoordinators = self.childCoordinators.filter { $0 !== childCoordinator }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//
|
||||
// Copyright 2021 The Matrix.org Foundation C.I.C
|
||||
//
|
||||
// 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 SwiftyBeaver
|
||||
|
||||
/// Various MXLog configuration options. Used in conjunction with `MXLog.configure()`
|
||||
@objc public class MXLogConfiguration: NSObject {
|
||||
|
||||
/// the desired log level. `.verbose` by default.
|
||||
@objc public var logLevel: MXLogLevel = MXLogLevel.verbose
|
||||
|
||||
/// whether logs should be written directly to files. `false` by default.
|
||||
@objc public var redirectLogsToFiles: Bool = false
|
||||
|
||||
/// the maximum total space to use for log files in bytes. `100MB` by default.
|
||||
@objc public var logFilesSizeLimit: UInt = 100 * 1024 * 1024 // 100MB
|
||||
|
||||
/// the maximum number of log files to use before rolling. `50` by default.
|
||||
@objc public var maxLogFilesCount: UInt = 50
|
||||
|
||||
/// the subname for log files. Files will be named as 'console-[subLogName].log'. `nil` by default
|
||||
@objc public var subLogName: String?
|
||||
}
|
||||
|
||||
/// MXLog logging levels. Use .none to disable logging entirely.
|
||||
@objc public enum MXLogLevel: UInt {
|
||||
case none
|
||||
case verbose
|
||||
case debug
|
||||
case info
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
|
||||
private var logger: SwiftyBeaver.Type = {
|
||||
let logger = SwiftyBeaver.self
|
||||
MXLog.configureLogger(logger, withConfiguration: MXLogConfiguration())
|
||||
return logger
|
||||
}()
|
||||
|
||||
/**
|
||||
Logging utility that provies multiple logging levels as well as file output and rolling.
|
||||
Its purpose is to provide a common entry for customizing logging and should be used throughout the code.
|
||||
Please see `MXLog.h` for Objective-C options.
|
||||
*/
|
||||
@objc public class MXLog: NSObject {
|
||||
|
||||
/// Method used to customize MXLog's behavior.
|
||||
/// Called automatically when first accessing the logger with the default values.
|
||||
/// Please see `MXLogConfiguration` for all available options.
|
||||
/// - Parameters:
|
||||
/// - configuration: the `MXLogConfiguration` instance to use
|
||||
@objc static public func configure(_ configuration: MXLogConfiguration) {
|
||||
configureLogger(logger, withConfiguration: configuration)
|
||||
}
|
||||
|
||||
public static func verbose(_ message: @autoclosure () -> Any,
|
||||
_ file: String = #file,
|
||||
_ function: String = #function,
|
||||
line: Int = #line,
|
||||
context: Any? = nil) {
|
||||
logger.verbose(message(), file, function, line: line, context: context)
|
||||
}
|
||||
|
||||
@available(swift, obsoleted: 5.4)
|
||||
@objc public static func logVerbose(_ message: String, file: String, function: String, line: Int) {
|
||||
logger.verbose(message, file, function, line: line)
|
||||
}
|
||||
|
||||
public static func debug(_ message: @autoclosure () -> Any,
|
||||
_ file: String = #file,
|
||||
_ function: String = #function,
|
||||
line: Int = #line,
|
||||
context: Any? = nil) {
|
||||
logger.debug(message(), file, function, line: line, context: context)
|
||||
}
|
||||
|
||||
@available(swift, obsoleted: 5.4)
|
||||
@objc public static func logDebug(_ message: String, file: String, function: String, line: Int) {
|
||||
logger.debug(message, file, function, line: line)
|
||||
}
|
||||
|
||||
public static func info(_ message: @autoclosure () -> Any,
|
||||
_ file: String = #file,
|
||||
_ function: String = #function,
|
||||
line: Int = #line,
|
||||
context: Any? = nil) {
|
||||
logger.info(message(), file, function, line: line, context: context)
|
||||
}
|
||||
|
||||
@available(swift, obsoleted: 5.4)
|
||||
@objc public static func logInfo(_ message: String, file: String, function: String, line: Int) {
|
||||
logger.info(message, file, function, line: line)
|
||||
}
|
||||
|
||||
public static func warning(_ message: @autoclosure () -> Any, _
|
||||
file: String = #file,
|
||||
_ function: String = #function,
|
||||
line: Int = #line,
|
||||
context: Any? = nil) {
|
||||
logger.warning(message(), file, function, line: line, context: context)
|
||||
}
|
||||
|
||||
@available(swift, obsoleted: 5.4)
|
||||
@objc public static func logWarning(_ message: String, file: String, function: String, line: Int) {
|
||||
logger.warning(message, file, function, line: line)
|
||||
}
|
||||
|
||||
public static func error(_ message: @autoclosure () -> Any,
|
||||
_ file: String = #file,
|
||||
_ function: String = #function,
|
||||
line: Int = #line,
|
||||
context: Any? = nil) {
|
||||
logger.error(message(), file, function, line: line, context: context)
|
||||
}
|
||||
|
||||
@available(swift, obsoleted: 5.4)
|
||||
@objc public static func logError(_ message: String, file: String, function: String, line: Int) {
|
||||
logger.error(message, file, function, line: line)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
fileprivate static func configureLogger(_ logger: SwiftyBeaver.Type, withConfiguration configuration: MXLogConfiguration) {
|
||||
// if let subLogName = configuration.subLogName {
|
||||
// MXLogger.setSubLogName(subLogName)
|
||||
// }
|
||||
//
|
||||
// MXLogger.redirectNSLog(toFiles: configuration.redirectLogsToFiles,
|
||||
// numberOfFiles: configuration.maxLogFilesCount,
|
||||
// sizeLimit: configuration.logFilesSizeLimit)
|
||||
//
|
||||
guard configuration.logLevel != .none else {
|
||||
return
|
||||
}
|
||||
|
||||
let consoleDestination = ConsoleDestination()
|
||||
consoleDestination.useNSLog = true
|
||||
consoleDestination.asynchronously = false
|
||||
consoleDestination.format = "$C $N.$F():$l $M"
|
||||
consoleDestination.levelColor.verbose = ""
|
||||
consoleDestination.levelColor.debug = ""
|
||||
consoleDestination.levelColor.info = ""
|
||||
consoleDestination.levelColor.warning = "⚠️"
|
||||
consoleDestination.levelColor.error = "🚨"
|
||||
|
||||
switch configuration.logLevel {
|
||||
case .verbose:
|
||||
consoleDestination.minLevel = .verbose
|
||||
case .debug:
|
||||
consoleDestination.minLevel = .debug
|
||||
case .info:
|
||||
consoleDestination.minLevel = .info
|
||||
case .warning:
|
||||
consoleDestination.minLevel = .warning
|
||||
case .error:
|
||||
consoleDestination.minLevel = .error
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
logger.removeAllDestinations()
|
||||
logger.addDestination(consoleDestination)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// 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 {
|
||||
return "NavigationModule: \(presentable), pop completion: \(String(describing: popCompletion))"
|
||||
}
|
||||
|
||||
}
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
/*
|
||||
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] {
|
||||
return self.viewControllers.map { (viewController) -> Presentable in
|
||||
return self.module(for: viewController)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the view controllers stack
|
||||
var viewControllers: [UIViewController] {
|
||||
return navigationController.viewControllers
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(navigationController: UINavigationController) {
|
||||
self.navigationController = navigationController
|
||||
self.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("[NavigationRouter] Present \(module)")
|
||||
navigationController.present(module.toPresentable(), animated: animated, completion: nil)
|
||||
}
|
||||
|
||||
func dismissModule(animated: Bool = true, completion: (() -> Void)? = nil) {
|
||||
MXLog.debug("[NavigationRouter] Dismiss presented module")
|
||||
navigationController.dismiss(animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func setRootModule(_ module: Presentable, hideNavigationBar: Bool = false, animated: Bool = false, popCompletion: (() -> Void)? = nil) {
|
||||
MXLog.debug("[NavigationRouter] 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
|
||||
}
|
||||
|
||||
self.addModule(module, for: controller)
|
||||
|
||||
let controllersToPop = self.navigationController.viewControllers.reversed()
|
||||
|
||||
controllersToPop.forEach {
|
||||
self.willPopViewController($0)
|
||||
}
|
||||
|
||||
if let popCompletion = popCompletion {
|
||||
completions[controller] = popCompletion
|
||||
}
|
||||
|
||||
self.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
|
||||
self.addModule(module, for: controller)
|
||||
|
||||
self.didPushViewController(controller)
|
||||
}
|
||||
|
||||
func setModules(_ modules: [NavigationModule], hideNavigationBar: Bool, animated: Bool) {
|
||||
|
||||
MXLog.debug("[NavigationRouter] 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 = self.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("[NavigationRouter] Pop to root module")
|
||||
|
||||
let controllers = self.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("[NavigationRouter] Pop to module \(module)")
|
||||
|
||||
let controller = module.toPresentable()
|
||||
let controllersBeforePop = self.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("[NavigationRouter] 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
|
||||
}
|
||||
|
||||
self.addModule(module, for: controller)
|
||||
|
||||
if let completion = popCompletion {
|
||||
completions[controller] = completion
|
||||
}
|
||||
|
||||
self.willPushViewController(controller)
|
||||
|
||||
navigationController.pushViewController(controller, animated: animated)
|
||||
|
||||
self.didPushViewController(controller)
|
||||
}
|
||||
|
||||
func push(_ modules: [NavigationModule], animated: Bool) {
|
||||
MXLog.debug("[NavigationRouter] 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()
|
||||
self.addModule(module.presentable, for: controller)
|
||||
|
||||
if let completion = module.popCompletion {
|
||||
completions[controller] = completion
|
||||
}
|
||||
|
||||
self.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()
|
||||
self.didPushViewController(controller)
|
||||
}
|
||||
}
|
||||
|
||||
func popModule(animated: Bool = true) {
|
||||
MXLog.debug("[NavigationRouter] Pop module")
|
||||
|
||||
if let lastController = navigationController.viewControllers.last {
|
||||
self.willPopViewController(lastController)
|
||||
}
|
||||
|
||||
if let controller = navigationController.popViewController(animated: animated) {
|
||||
self.didPopViewController(controller)
|
||||
}
|
||||
}
|
||||
|
||||
func popAllModules(animated: Bool) {
|
||||
MXLog.debug("[NavigationRouter] Pop all modules")
|
||||
|
||||
let controllersToPop = self.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 self.navigationController.viewControllers.contains(controller)
|
||||
}
|
||||
|
||||
// MARK: Presentable
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
return navigationController
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func module(for viewController: UIViewController) -> Presentable {
|
||||
|
||||
guard let module = self.storedModules[viewController] as? Presentable else {
|
||||
return viewController
|
||||
}
|
||||
return module
|
||||
}
|
||||
|
||||
private func addModule(_ module: Presentable, for viewController: UIViewController) {
|
||||
self.storedModules[viewController] = module as AnyObject
|
||||
}
|
||||
|
||||
private func removeModule(for viewController: UIViewController) {
|
||||
self.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) {
|
||||
self.postNotification(withName: NavigationRouter.willPushModule, for: viewController)
|
||||
}
|
||||
|
||||
private func didPushViewController(_ viewController: UIViewController) {
|
||||
self.postNotification(withName: NavigationRouter.didPushModule, for: viewController)
|
||||
}
|
||||
|
||||
private func willPopViewController(_ viewController: UIViewController) {
|
||||
self.postNotification(withName: NavigationRouter.willPopModule, for: viewController)
|
||||
}
|
||||
|
||||
private func didPopViewController(_ viewController: UIViewController) {
|
||||
self.postNotification(withName: NavigationRouter.didPopModule, for: viewController)
|
||||
|
||||
// Call completion closure associated to the view controller
|
||||
// So associated coordinator can be deallocated
|
||||
runCompletion(for: viewController)
|
||||
|
||||
self.removeModule(for: viewController)
|
||||
}
|
||||
|
||||
private func postNotification(withName name: Notification.Name, for viewController: UIViewController) {
|
||||
|
||||
let module = self.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("[NavigationRouter] Popped module: \(poppedViewController)")
|
||||
|
||||
self.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 struct 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// `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() {
|
||||
self.registerNavigationRouterNotifications()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func navigationRouter(for navigationController: UINavigationController) -> NavigationRouterType {
|
||||
|
||||
if let existingNavigationRouter = self.findNavigationRouter(for: navigationController) {
|
||||
return existingNavigationRouter
|
||||
}
|
||||
|
||||
let navigationRouter = NavigationRouter(navigationController: UINavigationController())
|
||||
return navigationRouter
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func findNavigationRouter(for navigationController: UINavigationController) -> NavigationRouterType? {
|
||||
return self.navigationRouters[navigationController]
|
||||
}
|
||||
|
||||
private func removeNavigationRouter(for navigationController: UINavigationController) {
|
||||
self.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 = self.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
|
||||
self.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 = self.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")
|
||||
}
|
||||
|
||||
self.removeNavigationRouter(for: navigationController)
|
||||
}
|
||||
}
|
||||
@@ -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 UIKit
|
||||
|
||||
/// `NavigationRouterStoreProtocol` describes a structure that enables to get a NavigationRouter from a UINavigationController instance.
|
||||
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
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
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
|
||||
protocol Presentable {
|
||||
func toPresentable() -> UIViewController
|
||||
}
|
||||
|
||||
extension UIViewController: Presentable {
|
||||
public func toPresentable() -> UIViewController {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
extension Presentable {
|
||||
|
||||
/// Returns a new module from the presentable without a pop completion block
|
||||
/// - Returns: Module
|
||||
func toModule() -> NavigationModule {
|
||||
return NavigationModule(presentable: self, popCompletion: nil)
|
||||
}
|
||||
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
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? {
|
||||
return self.window.rootViewController
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(window: UIWindow) {
|
||||
self.window = window
|
||||
}
|
||||
|
||||
// MARK: - Public methods
|
||||
|
||||
func setRootModule(_ module: Presentable) {
|
||||
self.updateRootViewController(rootViewController: module.toPresentable(), animated: false, completion: nil)
|
||||
self.window.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
func dismissRootModule(animated: Bool, completion: (() -> Void)?) {
|
||||
self.updateRootViewController(rootViewController: nil, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func presentModule(_ module: Presentable, animated: Bool, completion: (() -> Void)?) {
|
||||
let viewControllerPresenter = self.rootViewController?.presentedViewController ?? self.rootViewController
|
||||
|
||||
viewControllerPresenter?.present(module.toPresentable(), animated: animated, completion: completion)
|
||||
self.presentedModule = module
|
||||
}
|
||||
|
||||
func dismissModule(animated: Bool, completion: (() -> Void)?) {
|
||||
self.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: { (_: Bool) -> Void in
|
||||
completion?()
|
||||
})
|
||||
} else {
|
||||
self.window.rootViewController = rootViewController
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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.
|
||||
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)?)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// Represents a specific portion of the ViewState that can be bound to with SwiftUI's [2-way binding](https://developer.apple.com/documentation/swiftui/binding).
|
||||
protocol BindableState {
|
||||
/// The associated type of the Bindable State. Defaults to Void.
|
||||
associatedtype BindStateType = Void
|
||||
var bindings: BindStateType { get set }
|
||||
}
|
||||
|
||||
extension BindableState where BindStateType == Void {
|
||||
/// We provide a default implementation for the Void type so that we can have `ViewState` that
|
||||
/// just doesn't include/take advantage of the bindings.
|
||||
var bindings: Void {
|
||||
get {
|
||||
|
||||
}
|
||||
set {
|
||||
fatalError("Can't bind to the default Void binding.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// 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 Combine
|
||||
|
||||
/// A constrained and concise interface for interacting with the ViewModel.
|
||||
///
|
||||
/// This class is closely bound to`StateStoreViewModel`. It provides the exact interface the view should need to interact
|
||||
/// ViewModel (as modelled on our previous template architecture with the addition of two-way binding):
|
||||
/// - The ability read/observe view state
|
||||
/// - The ability to send view events
|
||||
/// - The ability to bind state to a specific portion of the view state safely.
|
||||
/// This class was brought about a little bit by necessity. The most idiomatic way of interacting with SwiftUI is via `@Published`
|
||||
/// properties which which are property wrappers and therefore can't be defined within protocols.
|
||||
/// A similar approach is taken in libraries like [CombineFeedback](https://github.com/sergdort/CombineFeedback).
|
||||
/// It provides a nice layer of consistency and also safety. As we are not passing the `ViewModel` to the view directly, shortcuts/hacks
|
||||
/// can't be made into the `ViewModel`.
|
||||
@available(iOS 14, *)
|
||||
@dynamicMemberLookup
|
||||
class ViewModelContext<ViewState: BindableState, ViewAction>: ObservableObject {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
fileprivate let viewActions: PassthroughSubject<ViewAction, Never>
|
||||
|
||||
// MARK: Public
|
||||
|
||||
/// Get-able/Observable `Published` property for the `ViewState`
|
||||
@Published fileprivate(set) var viewState: ViewState
|
||||
|
||||
/// Set-able/Bindable access to the bindable state.
|
||||
subscript<T>(dynamicMember keyPath: WritableKeyPath<ViewState.BindStateType, T>) -> T {
|
||||
get { viewState.bindings[keyPath: keyPath] }
|
||||
set { viewState.bindings[keyPath: keyPath] = newValue }
|
||||
}
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
init(initialViewState: ViewState) {
|
||||
self.viewActions = PassthroughSubject()
|
||||
self.viewState = initialViewState
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
/// Send a `ViewAction` to the `ViewModel` for processing.
|
||||
/// - Parameter viewAction: The `ViewAction` to send to the `ViewModel`.
|
||||
func send(viewAction: ViewAction) {
|
||||
viewActions.send(viewAction)
|
||||
}
|
||||
}
|
||||
|
||||
/// A common ViewModel implementation for handling of `State`, `StateAction`s and `ViewAction`s
|
||||
///
|
||||
/// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to)
|
||||
/// a specific portion of state that can be safely bound to.
|
||||
/// If we decide to add more features to our state management (like doing state processing off the main thread)
|
||||
/// we can do it in this centralised place.
|
||||
@available(iOS 14, *)
|
||||
class StateStoreViewModel<State: BindableState, StateAction, ViewAction> {
|
||||
|
||||
typealias Context = ViewModelContext<State, ViewAction>
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Public
|
||||
|
||||
/// For storing subscription references.
|
||||
///
|
||||
/// Left as public for `ViewModel` implementations convenience.
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Constrained interface for passing to Views.
|
||||
var context: Context
|
||||
|
||||
var state: State {
|
||||
get { context.viewState }
|
||||
set { context.viewState = newValue }
|
||||
}
|
||||
|
||||
// MARK: Setup
|
||||
|
||||
init(initialViewState: State) {
|
||||
self.context = Context(initialViewState: initialViewState)
|
||||
self.context.viewActions.sink { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
self.process(viewAction: action)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Send state actions to modify the state within the reducer.
|
||||
/// - Parameter action: The state action to send to the reducer.
|
||||
@available(*, deprecated, message: "Mutate state directly instead")
|
||||
func dispatch(action: StateAction) {
|
||||
Self.reducer(state: &context.viewState, action: action)
|
||||
}
|
||||
|
||||
/// Send state actions from a publisher to modify the state within the reducer.
|
||||
/// - Parameter actionPublisher: The publisher that produces actions to be sent to the reducer
|
||||
@available(*, deprecated, message: "Mutate state directly instead")
|
||||
func dispatch(actionPublisher: AnyPublisher<StateAction, Never>) {
|
||||
actionPublisher.sink { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
Self.reducer(state: &self.context.viewState, action: action)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Override to handle mutations to the `State`
|
||||
///
|
||||
/// A redux style reducer, all modifications to state happen here.
|
||||
/// - Parameters:
|
||||
/// - state: The `inout` state to be modified,
|
||||
/// - action: The action that defines which state modification should take place.
|
||||
class func reducer(state: inout State, action: StateAction) {
|
||||
// Default implementation, -no-op
|
||||
}
|
||||
|
||||
/// Override to handles incoming `ViewAction`s from the `ViewModel`.
|
||||
/// - Parameter viewAction: The `ViewAction` to be processed in `ViewModel` implementation.
|
||||
func process(viewAction: ViewAction) {
|
||||
// Default implementation, -no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// WeakDictionary.swift
|
||||
// WeakDictionary
|
||||
//
|
||||
// Created by Nicholas Cross on 19/10/2016.
|
||||
// Copyright © 2016 Nicholas Cross. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct WeakDictionary<Key: Hashable, Value: AnyObject> {
|
||||
|
||||
private var storage: [Key: WeakDictionaryReference<Value>]
|
||||
|
||||
public init() {
|
||||
self.init(storage: [Key: WeakDictionaryReference<Value>]())
|
||||
}
|
||||
|
||||
public init(dictionary: [Key: Value]) {
|
||||
var newStorage = [Key: WeakDictionaryReference<Value>]()
|
||||
dictionary.forEach({ key, value in newStorage[key] = WeakDictionaryReference<Value>(value: value) })
|
||||
self.init(storage: newStorage)
|
||||
}
|
||||
|
||||
private init(storage: [Key: WeakDictionaryReference<Value>]) {
|
||||
self.storage = storage
|
||||
}
|
||||
|
||||
public mutating func reap() {
|
||||
storage = weakDictionary().storage
|
||||
}
|
||||
|
||||
public func weakDictionary() -> WeakDictionary<Key, Value> {
|
||||
return self[startIndex ..< endIndex]
|
||||
}
|
||||
|
||||
public func dictionary() -> [Key: Value] {
|
||||
var newStorage = [Key: Value]()
|
||||
|
||||
storage.forEach { key, value in
|
||||
if let retainedValue = value.value {
|
||||
newStorage[key] = retainedValue
|
||||
}
|
||||
}
|
||||
|
||||
return newStorage
|
||||
}
|
||||
}
|
||||
|
||||
extension WeakDictionary: Collection {
|
||||
|
||||
public typealias Index = DictionaryIndex<Key, WeakDictionaryReference<Value>>
|
||||
|
||||
public var startIndex: Index {
|
||||
return storage.startIndex
|
||||
}
|
||||
|
||||
public var endIndex: Index {
|
||||
return storage.endIndex
|
||||
}
|
||||
|
||||
public func index(after index: Index) -> Index {
|
||||
return storage.index(after: index)
|
||||
}
|
||||
|
||||
public subscript(position: Index) -> (Key, WeakDictionaryReference<Value>) {
|
||||
return storage[position]
|
||||
}
|
||||
|
||||
public subscript(key: Key) -> Value? {
|
||||
get {
|
||||
guard let valueRef = storage[key] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return valueRef.value
|
||||
}
|
||||
|
||||
set {
|
||||
guard let value = newValue else {
|
||||
storage[key] = nil
|
||||
return
|
||||
}
|
||||
|
||||
storage[key] = WeakDictionaryReference<Value>(value: value)
|
||||
}
|
||||
}
|
||||
|
||||
public subscript(bounds: Range<Index>) -> WeakDictionary<Key, Value> {
|
||||
let subStorage = storage[bounds.lowerBound ..< bounds.upperBound]
|
||||
var newStorage = [Key: WeakDictionaryReference<Value>]()
|
||||
|
||||
subStorage.filter { _, value in return value.value != nil }
|
||||
.forEach { key, value in newStorage[key] = value }
|
||||
|
||||
return WeakDictionary<Key, Value>(storage: newStorage)
|
||||
}
|
||||
}
|
||||
|
||||
extension Dictionary where Value: AnyObject {
|
||||
public func weakDictionary() -> WeakDictionary<Key, Value> {
|
||||
return WeakDictionary<Key, Value>(dictionary: self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// WeakDictionaryKeyReference.swift
|
||||
// WeakDictionary-iOS
|
||||
//
|
||||
// Created by Nicholas Cross on 2/1/19.
|
||||
// Copyright © 2019 Nicholas Cross. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct WeakDictionaryKey<Key: AnyObject & Hashable, Value: AnyObject> : Hashable {
|
||||
|
||||
private weak var baseKey: Key?
|
||||
private let hash: Int
|
||||
private var retainedValue: Value?
|
||||
private let nilKeyHash = UUID().hashValue
|
||||
|
||||
public init(key: Key, value: Value? = nil) {
|
||||
baseKey = key
|
||||
retainedValue = value
|
||||
hash = key.hashValue
|
||||
}
|
||||
|
||||
public static func == (lhs: WeakDictionaryKey, rhs: WeakDictionaryKey) -> Bool {
|
||||
return (lhs.baseKey != nil && rhs.baseKey != nil && lhs.baseKey == rhs.baseKey)
|
||||
|| lhs.hashValue == rhs.hashValue
|
||||
}
|
||||
|
||||
public var hashValue: Int {
|
||||
return baseKey != nil ? hash : nilKeyHash
|
||||
}
|
||||
|
||||
public var key: Key? {
|
||||
return baseKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// WeakDictionaryReference.swift
|
||||
// WeakDictionary-iOS
|
||||
//
|
||||
// Created by Nicholas Cross on 2/1/19.
|
||||
// Copyright © 2019 Nicholas Cross. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct WeakDictionaryReference<Value: AnyObject> {
|
||||
private weak var referencedValue: Value?
|
||||
|
||||
init(value: Value) {
|
||||
referencedValue = value
|
||||
}
|
||||
|
||||
public var value: Value? {
|
||||
return referencedValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// WeakKeyDictionary.swift
|
||||
// WeakDictionary-iOS
|
||||
//
|
||||
// Created by Nicholas Cross on 2/1/19.
|
||||
// Copyright © 2019 Nicholas Cross. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct WeakKeyDictionary<Key: AnyObject & Hashable, Value: AnyObject> {
|
||||
|
||||
private var storage: WeakDictionary<WeakDictionaryKey<Key, Value>, Value>
|
||||
private let valuesRetainedByKey: Bool
|
||||
|
||||
public init(valuesRetainedByKey: Bool = false) {
|
||||
self.init(
|
||||
storage: WeakDictionary<WeakDictionaryKey<Key, Value>, Value>(),
|
||||
valuesRetainedByKey: valuesRetainedByKey
|
||||
)
|
||||
}
|
||||
|
||||
public init(dictionary: [Key: Value], valuesRetainedByKey: Bool = false) {
|
||||
var newStorage = WeakDictionary<WeakDictionaryKey<Key, Value>, Value>()
|
||||
|
||||
dictionary.forEach { key, value in
|
||||
var keyRef: WeakDictionaryKey<Key, Value>!
|
||||
|
||||
if valuesRetainedByKey {
|
||||
keyRef = WeakDictionaryKey<Key, Value>(key: key, value: value)
|
||||
} else {
|
||||
keyRef = WeakDictionaryKey<Key, Value>(key: key)
|
||||
}
|
||||
|
||||
newStorage[keyRef] = value
|
||||
}
|
||||
|
||||
self.init(storage: newStorage, valuesRetainedByKey: valuesRetainedByKey)
|
||||
}
|
||||
|
||||
private init(storage: WeakDictionary<WeakDictionaryKey<Key, Value>, Value>, valuesRetainedByKey: Bool = false) {
|
||||
self.storage = storage
|
||||
self.valuesRetainedByKey = valuesRetainedByKey
|
||||
}
|
||||
|
||||
public mutating func reap() {
|
||||
storage = weakKeyDictionary().storage
|
||||
}
|
||||
|
||||
public func weakDictionary() -> WeakDictionary<Key, Value> {
|
||||
return dictionary().weakDictionary()
|
||||
}
|
||||
|
||||
public func weakKeyDictionary() -> WeakKeyDictionary<Key, Value> {
|
||||
return self[startIndex ..< endIndex]
|
||||
}
|
||||
|
||||
public func dictionary() -> [Key: Value] {
|
||||
var newStorage = [Key: Value]()
|
||||
|
||||
storage.forEach { key, value in
|
||||
if let retainedKey = key.key, let retainedValue = value.value {
|
||||
newStorage[retainedKey] = retainedValue
|
||||
}
|
||||
}
|
||||
|
||||
return newStorage
|
||||
}
|
||||
}
|
||||
|
||||
extension WeakKeyDictionary: Collection {
|
||||
|
||||
public typealias Index = DictionaryIndex<WeakDictionaryKey<Key, Value>, WeakDictionaryReference<Value>>
|
||||
|
||||
public var startIndex: Index {
|
||||
return storage.startIndex
|
||||
}
|
||||
|
||||
public var endIndex: Index {
|
||||
return storage.endIndex
|
||||
}
|
||||
|
||||
public func index(after index: Index) -> Index {
|
||||
return storage.index(after: index)
|
||||
}
|
||||
|
||||
public subscript(position: Index) -> (WeakDictionaryKey<Key, Value>, WeakDictionaryReference<Value>) {
|
||||
return storage[position]
|
||||
}
|
||||
|
||||
public subscript(key: Key) -> Value? {
|
||||
get {
|
||||
return storage[WeakDictionaryKey<Key, Value>(key: key)]
|
||||
}
|
||||
|
||||
set {
|
||||
let retainedValue = valuesRetainedByKey ? newValue : nil
|
||||
let weakKey = WeakDictionaryKey<Key, Value>(key: key, value: retainedValue)
|
||||
storage[weakKey] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public subscript(bounds: Range<Index>) -> WeakKeyDictionary<Key, Value> {
|
||||
let subStorage = storage[bounds.lowerBound ..< bounds.upperBound]
|
||||
var newStorage = WeakDictionary<WeakDictionaryKey<Key, Value>, Value>()
|
||||
|
||||
subStorage.filter { key, value in return key.key != nil && value.value != nil }
|
||||
.forEach { key, value in newStorage[key] = value.value }
|
||||
|
||||
return WeakKeyDictionary<Key, Value>(storage: newStorage)
|
||||
}
|
||||
}
|
||||
|
||||
extension WeakDictionary where Key: AnyObject {
|
||||
public func weakKeyDictionary(valuesRetainedByKey: Bool = false) -> WeakKeyDictionary<Key, Value> {
|
||||
return WeakKeyDictionary<Key, Value>(dictionary: dictionary(), valuesRetainedByKey: valuesRetainedByKey)
|
||||
}
|
||||
}
|
||||
|
||||
extension Dictionary where Key: AnyObject, Value: AnyObject {
|
||||
public func weakKeyDictionary(valuesRetainedByKey: Bool = false) -> WeakKeyDictionary<Key, Value> {
|
||||
return WeakKeyDictionary<Key, Value>(dictionary: self, valuesRetainedByKey: valuesRetainedByKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user