element-ios/Riot/Categories/UIView+Toast.swift

209 lines
9.4 KiB
Swift

//
// Copyright 2021-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
// MARK: - ToastPosition
/// Vertical position for a toast
@objc
enum ToastPosition: Int {
/// Toast will be placed at the top of the screen, with a margin to the safe area insets of the superview. Max height is also limited with safe area insets.
case top
/// Toast will be placed at the middle of the screen vertically. Max height is also limited with safe area insets.
case middle
/// Toast will be placed at the bottom of the screen, with a margin to the safe area insets of the superview. Max height is also limited with safe area insets.
case bottom
}
// MARK: - UIView Extension
extension UIView {
private enum Constants {
static let defaultDuration: TimeInterval = 2.0
static let defaultPosition: ToastPosition = .bottom
}
private static var operationQueue: OperationQueue = {
let queue = OperationQueue.vc_createSerialOperationQueue(name: "ToastQueue")
queue.qualityOfService = .userInteractive
queue.underlyingQueue = .main
return queue
}()
/// Show a toast message with the given properties.
/// - Parameters:
/// - message: Message to be displayed
/// - image: Icon to be displayed. Placed left to the message. Will be tinted.
/// - duration: Duration of the toast messsage
/// - position: Vertical position of the toast message in the view. Toast view spans the receiver view horizontally, taking into account the safe area insets.
/// - additionalMargin: By default, a toast placed according to safe area insets, with a margin.
/// For `top` and `bottom` positions, adds toast an additional margin from the top and bottom respectively.
/// Has no effect for `middle` position.
@objc
func vc_toast(message: String?,
image: UIImage? = nil,
duration: TimeInterval = Constants.defaultDuration,
position: ToastPosition = Constants.defaultPosition,
additionalMargin: CGFloat = 0.0) {
let view = RectangleToastView(withMessage: message, image: image)
vc_toast(view: view, duration: duration, position: position, additionalMargin: additionalMargin)
}
/// Show a toast view with the given properties.
/// - Parameters:
/// - view: View to be displayed as a toast
/// - duration: Duration of the toast messsage
/// - position: Vertical position of the toast message in the view. Toast view spans the receiver view horizontally, taking into account the safe area insets.
/// - additionalMargin: By default, a toast placed according to safe area insets, with a margin.
/// For `top` and `bottom` positions, adds toast an additional margin from the top and bottom respectively.
/// Has no effect for `middle` position.
@objc
func vc_toast(view: UIView,
duration: TimeInterval = Constants.defaultDuration,
position: ToastPosition = Constants.defaultPosition,
additionalMargin: CGFloat = 0.0) {
let operation = ToastOperation(containerView: self,
toastView: view,
duration: duration,
position: position,
additionalMargin: additionalMargin,
completion: nil)
Self.operationQueue.addOperation(operation)
}
}
// MARK: - ToastOperation
/// Async toast UI operation. Will run on the main thread.
///
/// Note: a more recent `Activity` and `ActivityCenter` aim to achieve the same goal of abstracting away the scheduling and display
/// of visual notifications, without using `OperationQueue`.
private class ToastOperation: AsyncOperation {
private enum Constants {
static let margin: UIEdgeInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
static let animationDuration: TimeInterval = 0.15
static let timeBetweenToasts: TimeInterval = 0.5
}
private var containerView: UIView
private var toastView: UIView
private var duration: TimeInterval
private var position: ToastPosition
private var additionalMargin: CGFloat
private var completion: (() -> Void)?
private var timer: Timer?
init(containerView: UIView,
toastView: UIView,
duration: TimeInterval,
position: ToastPosition,
additionalMargin: CGFloat,
completion: (() -> Void)? = nil) {
self.containerView = containerView
self.toastView = toastView
self.duration = duration
self.position = position
self.additionalMargin = additionalMargin
self.completion = completion
}
override func main() {
showToast {
self.invalidateTimer()
let timer = Timer(timeInterval: self.duration,
target: self,
selector: #selector(self.timerFired(_:)),
userInfo: nil,
repeats: false)
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
}
@objc
private func timerFired(_ timer: Timer) {
invalidateTimer()
hideToast()
}
private func showToast(_ completion: @escaping () -> Void) {
toastView.alpha = 0.0
containerView.addSubview(toastView)
toastView.translatesAutoresizingMaskIntoConstraints = false
switch position {
case .top:
NSLayoutConstraint.activate([
toastView.leadingAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.leadingAnchor,
constant: Constants.margin.left),
toastView.topAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.topAnchor,
constant: Constants.margin.top + additionalMargin),
toastView.trailingAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.trailingAnchor,
constant: -Constants.margin.right),
toastView.bottomAnchor.constraint(lessThanOrEqualTo: containerView.safeAreaLayoutGuide.bottomAnchor,
constant: -Constants.margin.bottom)
])
case .middle:
NSLayoutConstraint.activate([
toastView.leadingAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.leadingAnchor,
constant: Constants.margin.left),
toastView.topAnchor.constraint(greaterThanOrEqualTo: containerView.safeAreaLayoutGuide.topAnchor,
constant: Constants.margin.top),
toastView.trailingAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.trailingAnchor,
constant: -Constants.margin.right),
toastView.bottomAnchor.constraint(lessThanOrEqualTo: containerView.safeAreaLayoutGuide.bottomAnchor,
constant: -Constants.margin.bottom),
toastView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
])
case .bottom:
NSLayoutConstraint.activate([
toastView.leadingAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.leadingAnchor,
constant: Constants.margin.left),
toastView.topAnchor.constraint(greaterThanOrEqualTo: containerView.safeAreaLayoutGuide.topAnchor,
constant: Constants.margin.top),
toastView.trailingAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.trailingAnchor,
constant: -Constants.margin.right),
toastView.bottomAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.bottomAnchor,
constant: -Constants.margin.bottom - additionalMargin)
])
}
UIView.animate(withDuration: Constants.animationDuration,
delay: 0.0,
options: [.curveEaseOut, .allowUserInteraction],
animations: {
self.toastView.alpha = 1.0
}, completion: { _ in
completion()
})
}
private func hideToast() {
UIView.animate(withDuration: Constants.animationDuration,
delay: 0.0,
options: [.curveEaseIn, .beginFromCurrentState],
animations: {
self.toastView.alpha = 0.0
}, completion: { _ in
self.toastView.removeFromSuperview()
DispatchQueue.main.asyncAfter(deadline: .now() + Constants.timeBetweenToasts) {
self.finish()
self.completion?()
}
})
}
private func invalidateTimer() {
timer?.invalidate()
timer = nil
}
}