832 lines
32 KiB
Swift
832 lines
32 KiB
Swift
|
|
//
|
|||
|
|
// Toast.swift
|
|||
|
|
// Toast-Swift
|
|||
|
|
//
|
|||
|
|
// Copyright (c) 2015-2024 Charles Scalesse.
|
|||
|
|
//
|
|||
|
|
// Permission is hereby granted, free of charge, to any person obtaining a
|
|||
|
|
// copy of this software and associated documentation files (the
|
|||
|
|
// "Software"), to deal in the Software without restriction, including
|
|||
|
|
// without limitation the rights to use, copy, modify, merge, publish,
|
|||
|
|
// distribute, sublicense, and/or sell copies of the Software, and to
|
|||
|
|
// permit persons to whom the Software is furnished to do so, subject to
|
|||
|
|
// the following conditions:
|
|||
|
|
//
|
|||
|
|
// The above copyright notice and this permission notice shall be included
|
|||
|
|
// in all copies or substantial portions of the Software.
|
|||
|
|
//
|
|||
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|||
|
|
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|||
|
|
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|||
|
|
// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|||
|
|
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|||
|
|
// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|||
|
|
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||
|
|
|
|||
|
|
import ObjectiveC
|
|||
|
|
import UIKit
|
|||
|
|
import Lottie
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Toast is a Swift extension that adds toast notifications to the `UIView` object class.
|
|||
|
|
It is intended to be simple, lightweight, and easy to use. Most toast notifications
|
|||
|
|
can be triggered with a single line of code.
|
|||
|
|
|
|||
|
|
The `makeToast` methods create a new view and then display it as toast.
|
|||
|
|
|
|||
|
|
The `showToast` methods display any view as toast.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
public extension UIView {
|
|||
|
|
/**
|
|||
|
|
Keys used for associated objects.
|
|||
|
|
*/
|
|||
|
|
private struct ToastKeys {
|
|||
|
|
static var timer = malloc(1)
|
|||
|
|
static var duration = malloc(1)
|
|||
|
|
static var point = malloc(1)
|
|||
|
|
static var completion = malloc(1)
|
|||
|
|
static var activeToasts = malloc(1)
|
|||
|
|
static var activityView = malloc(1)
|
|||
|
|
static var queue = malloc(1)
|
|||
|
|
static var maskView = malloc(1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Swift closures can't be directly associated with objects via the
|
|||
|
|
Objective-C runtime, so the (ugly) solution is to wrap them in a
|
|||
|
|
class that can be used with associated objects.
|
|||
|
|
*/
|
|||
|
|
private class ToastCompletionWrapper {
|
|||
|
|
let completion: ((Bool) -> Void)?
|
|||
|
|
|
|||
|
|
init(_ completion: ((Bool) -> Void)?) {
|
|||
|
|
self.completion = completion
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private enum ToastError: Error {
|
|||
|
|
case missingParameters
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private var activeToasts: NSMutableArray {
|
|||
|
|
if let activeToasts = objc_getAssociatedObject(self, &ToastKeys.activeToasts) as? NSMutableArray {
|
|||
|
|
return activeToasts
|
|||
|
|
} else {
|
|||
|
|
let activeToasts = NSMutableArray()
|
|||
|
|
objc_setAssociatedObject(self, &ToastKeys.activeToasts, activeToasts, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
return activeToasts
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private var queue: NSMutableArray {
|
|||
|
|
if let queue = objc_getAssociatedObject(self, &ToastKeys.queue) as? NSMutableArray {
|
|||
|
|
return queue
|
|||
|
|
} else {
|
|||
|
|
let queue = NSMutableArray()
|
|||
|
|
objc_setAssociatedObject(self, &ToastKeys.queue, queue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
return queue
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Make Toast Methods
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Creates and presents a new toast view.
|
|||
|
|
|
|||
|
|
@param message The message to be displayed
|
|||
|
|
@param duration The toast duration
|
|||
|
|
@param position The toast's position
|
|||
|
|
@param title The title
|
|||
|
|
@param image The image
|
|||
|
|
@param style The style. The shared style will be used when nil
|
|||
|
|
@param completion The completion closure, executed after the toast view disappears.
|
|||
|
|
didTap will be `true` if the toast view was dismissed from a tap.
|
|||
|
|
*/
|
|||
|
|
func makeToast(_ message: String?, duration: TimeInterval = ToastManager.shared.duration, position: ToastPosition = ToastManager.shared.position, title: String? = nil, image: UIImage? = nil, style: ToastStyle = ToastManager.shared.style, completion: ((_ didTap: Bool) -> Void)? = nil) {
|
|||
|
|
do {
|
|||
|
|
let toast = try toastViewForMessage(message, title: title, image: image, style: style)
|
|||
|
|
showToast(toast, duration: duration, position: position, completion: completion)
|
|||
|
|
} catch ToastError.missingParameters {
|
|||
|
|
print("Error: message, title, and image are all nil")
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Creates a new toast view and presents it at a given center point.
|
|||
|
|
|
|||
|
|
@param message The message to be displayed
|
|||
|
|
@param duration The toast duration
|
|||
|
|
@param point The toast's center point
|
|||
|
|
@param title The title
|
|||
|
|
@param image The image
|
|||
|
|
@param style The style. The shared style will be used when nil
|
|||
|
|
@param completion The completion closure, executed after the toast view disappears.
|
|||
|
|
didTap will be `true` if the toast view was dismissed from a tap.
|
|||
|
|
*/
|
|||
|
|
func makeToast(_ message: String?, duration: TimeInterval = ToastManager.shared.duration, point: CGPoint, title: String?, image: UIImage?, style: ToastStyle = ToastManager.shared.style, completion: ((_ didTap: Bool) -> Void)?) {
|
|||
|
|
do {
|
|||
|
|
let toast = try toastViewForMessage(message, title: title, image: image, style: style)
|
|||
|
|
showToast(toast, duration: duration, point: point, completion: completion)
|
|||
|
|
} catch ToastError.missingParameters {
|
|||
|
|
print("Error: message, title, and image cannot all be nil")
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Show Toast Methods
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Displays any view as toast at a provided position and duration. The completion closure
|
|||
|
|
executes when the toast view completes. `didTap` will be `true` if the toast view was
|
|||
|
|
dismissed from a tap.
|
|||
|
|
|
|||
|
|
@param toast The view to be displayed as toast
|
|||
|
|
@param duration The notification duration
|
|||
|
|
@param position The toast's position
|
|||
|
|
@param completion The completion block, executed after the toast view disappears.
|
|||
|
|
didTap will be `true` if the toast view was dismissed from a tap.
|
|||
|
|
*/
|
|||
|
|
func showToast(_ toast: UIView, duration: TimeInterval = ToastManager.shared.duration, position: ToastPosition = ToastManager.shared.position, completion: ((_ didTap: Bool) -> Void)? = nil) {
|
|||
|
|
let point = position.centerPoint(forToast: toast, inSuperview: self)
|
|||
|
|
showToast(toast, duration: duration, point: point, completion: completion)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Displays any view as toast at a provided center point and duration. The completion closure
|
|||
|
|
executes when the toast view completes. `didTap` will be `true` if the toast view was
|
|||
|
|
dismissed from a tap.
|
|||
|
|
|
|||
|
|
@param toast The view to be displayed as toast
|
|||
|
|
@param duration The notification duration
|
|||
|
|
@param point The toast's center point
|
|||
|
|
@param completion The completion block, executed after the toast view disappears.
|
|||
|
|
didTap will be `true` if the toast view was dismissed from a tap.
|
|||
|
|
*/
|
|||
|
|
func showToast(_ toast: UIView, duration: TimeInterval = ToastManager.shared.duration, point: CGPoint, completion: ((_ didTap: Bool) -> Void)? = nil) {
|
|||
|
|
objc_setAssociatedObject(toast, &ToastKeys.completion, ToastCompletionWrapper(completion), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
|
|||
|
|
if ToastManager.shared.isQueueEnabled, activeToasts.count > 0 {
|
|||
|
|
objc_setAssociatedObject(toast, &ToastKeys.duration, NSNumber(value: duration), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
objc_setAssociatedObject(toast, &ToastKeys.point, NSValue(cgPoint: point), .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
|
|||
|
|
queue.add(toast)
|
|||
|
|
} else {
|
|||
|
|
showToast(toast, duration: duration, point: point)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Hide Toast Methods
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Hides the active toast. If there are multiple toasts active in a view, this method
|
|||
|
|
hides the oldest toast (the first of the toasts to have been presented).
|
|||
|
|
|
|||
|
|
@see `hideAllToasts()` to remove all active toasts from a view.
|
|||
|
|
|
|||
|
|
@warning This method has no effect on activity toasts. Use `hideToastActivity` to
|
|||
|
|
hide activity toasts.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
func hideToast() {
|
|||
|
|
guard let activeToast = activeToasts.firstObject as? UIView else { return }
|
|||
|
|
hideToast(activeToast)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Hides an active toast.
|
|||
|
|
|
|||
|
|
@param toast The active toast view to dismiss. Any toast that is currently being displayed
|
|||
|
|
on the screen is considered active.
|
|||
|
|
|
|||
|
|
@warning this does not clear a toast view that is currently waiting in the queue.
|
|||
|
|
*/
|
|||
|
|
func hideToast(_ toast: UIView) {
|
|||
|
|
guard activeToasts.contains(toast) else { return }
|
|||
|
|
hideToast(toast, fromTap: false)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Hides all toast views.
|
|||
|
|
|
|||
|
|
@param includeActivity If `true`, toast activity will also be hidden. Default is `false`.
|
|||
|
|
@param clearQueue If `true`, removes all toast views from the queue. Default is `true`.
|
|||
|
|
*/
|
|||
|
|
func hideAllToasts(includeActivity: Bool = false, clearQueue: Bool = true) {
|
|||
|
|
if clearQueue {
|
|||
|
|
clearToastQueue()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
activeToasts.compactMap { $0 as? UIView }
|
|||
|
|
.forEach { hideToast($0) }
|
|||
|
|
|
|||
|
|
if includeActivity {
|
|||
|
|
hideToastActivity()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Removes all toast views from the queue. This has no effect on toast views that are
|
|||
|
|
active. Use `hideAllToasts(clearQueue:)` to hide the active toasts views and clear
|
|||
|
|
the queue.
|
|||
|
|
*/
|
|||
|
|
func clearToastQueue() {
|
|||
|
|
queue.removeAllObjects()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Activity Methods
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Creates and displays a new toast activity indicator view at a specified position.
|
|||
|
|
|
|||
|
|
@warning Only one toast activity indicator view can be presented per superview. Subsequent
|
|||
|
|
calls to `makeToastActivity(position:)` will be ignored until `hideToastActivity()` is called.
|
|||
|
|
|
|||
|
|
@warning `makeToastActivity(position:)` works independently of the `showToast` methods. Toast
|
|||
|
|
activity views can be presented and dismissed while toast views are being displayed.
|
|||
|
|
`makeToastActivity(position:)` has no effect on the queueing behavior of the `showToast` methods.
|
|||
|
|
|
|||
|
|
@param position The toast's position
|
|||
|
|
@param blockUserInteraction Whether to block user interaction with other elements. Default is false.
|
|||
|
|
*/
|
|||
|
|
func makeToastActivity(_ position: ToastPosition = .center, blockUserInteraction: Bool = false) {
|
|||
|
|
// sanity
|
|||
|
|
guard objc_getAssociatedObject(self, &ToastKeys.activityView) as? UIView == nil else { return }
|
|||
|
|
|
|||
|
|
let toast = createToastActivityView()
|
|||
|
|
let point = position.centerPoint(forToast: toast, inSuperview: self)
|
|||
|
|
makeToastActivity(toast, point: point, blockUserInteraction: blockUserInteraction)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Creates and displays a new toast activity indicator view at a specified position.
|
|||
|
|
|
|||
|
|
@warning Only one toast activity indicator view can be presented per superview. Subsequent
|
|||
|
|
calls to `makeToastActivity(position:)` will be ignored until `hideToastActivity()` is called.
|
|||
|
|
|
|||
|
|
@warning `makeToastActivity(position:)` works independently of the `showToast` methods. Toast
|
|||
|
|
activity views can be presented and dismissed while toast views are being displayed.
|
|||
|
|
`makeToastActivity(position:)` has no effect on the queueing behavior of the `showToast` methods.
|
|||
|
|
|
|||
|
|
@param point The toast's center point
|
|||
|
|
@param blockUserInteraction Whether to block user interaction with other elements. Default is false.
|
|||
|
|
*/
|
|||
|
|
func makeToastActivity(_ point: CGPoint, blockUserInteraction: Bool = false) {
|
|||
|
|
// sanity
|
|||
|
|
guard objc_getAssociatedObject(self, &ToastKeys.activityView) as? UIView == nil else { return }
|
|||
|
|
|
|||
|
|
let toast = createToastActivityView()
|
|||
|
|
makeToastActivity(toast, point: point, blockUserInteraction: blockUserInteraction)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Dismisses the active toast activity indicator view.
|
|||
|
|
*/
|
|||
|
|
func hideToastActivity() {
|
|||
|
|
if let toast = objc_getAssociatedObject(self, &ToastKeys.activityView) as? UIView {
|
|||
|
|
UIView.animate(withDuration: ToastManager.shared.style.fadeDuration, delay: 0.0, options: [.curveEaseIn, .beginFromCurrentState], animations: {
|
|||
|
|
toast.alpha = 0.0
|
|||
|
|
}) { _ in
|
|||
|
|
toast.removeFromSuperview()
|
|||
|
|
objc_setAssociatedObject(self, &ToastKeys.activityView, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 移除遮罩层
|
|||
|
|
if let maskView = objc_getAssociatedObject(self, &ToastKeys.maskView) as? UIView {
|
|||
|
|
UIView.animate(withDuration: ToastManager.shared.style.fadeDuration, delay: 0.0, options: [.curveEaseIn, .beginFromCurrentState], animations: {
|
|||
|
|
maskView.alpha = 0.0
|
|||
|
|
}) { _ in
|
|||
|
|
maskView.removeFromSuperview()
|
|||
|
|
objc_setAssociatedObject(self, &ToastKeys.maskView, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Helper Methods
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Returns `true` if a toast view or toast activity view is actively being displayed.
|
|||
|
|
*/
|
|||
|
|
func isShowingToast() -> Bool {
|
|||
|
|
return activeToasts.count > 0 || objc_getAssociatedObject(self, &ToastKeys.activityView) != nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Private Activity Methods
|
|||
|
|
|
|||
|
|
private func makeToastActivity(_ toast: UIView, point: CGPoint, blockUserInteraction: Bool = false) {
|
|||
|
|
toast.alpha = 0.0
|
|||
|
|
toast.center = point
|
|||
|
|
|
|||
|
|
objc_setAssociatedObject(self, &ToastKeys.activityView, toast, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
|
|||
|
|
// 如果需要阻止用户交互,添加遮罩层
|
|||
|
|
if blockUserInteraction {
|
|||
|
|
let maskView = createMaskView()
|
|||
|
|
objc_setAssociatedObject(self, &ToastKeys.maskView, maskView, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
addSubview(maskView)
|
|||
|
|
maskView.addSubview(toast)
|
|||
|
|
} else {
|
|||
|
|
addSubview(toast)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
UIView.animate(withDuration: ToastManager.shared.style.fadeDuration, delay: 0.0, options: .curveEaseOut, animations: {
|
|||
|
|
toast.alpha = 1.0
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
private func createToastActivityView() -> UIView {
|
|||
|
|
let style = ToastManager.shared.style
|
|||
|
|
|
|||
|
|
let activityView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: style.activitySize.width, height: style.activitySize.height))
|
|||
|
|
activityView.backgroundColor = style.activityBackgroundColor
|
|||
|
|
activityView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin]
|
|||
|
|
activityView.layer.cornerRadius = style.cornerRadius
|
|||
|
|
|
|||
|
|
if style.displayShadow {
|
|||
|
|
activityView.layer.shadowColor = style.shadowColor.cgColor
|
|||
|
|
activityView.layer.shadowOpacity = style.shadowOpacity
|
|||
|
|
activityView.layer.shadowRadius = style.shadowRadius
|
|||
|
|
activityView.layer.shadowOffset = style.shadowOffset
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// let activityIndicatorView = UIActivityIndicatorView(style: .large)
|
|||
|
|
// activityIndicatorView.center = CGPoint(x: activityView.bounds.size.width / 2.0, y: activityView.bounds.size.height / 2.0)
|
|||
|
|
// activityView.addSubview(activityIndicatorView)
|
|||
|
|
// activityIndicatorView.color = style.activityIndicatorColor
|
|||
|
|
// activityIndicatorView.startAnimating()
|
|||
|
|
|
|||
|
|
let animation = LottieAnimation.named("single_ring")
|
|||
|
|
let animationView = LottieAnimationView(animation: animation)
|
|||
|
|
animationView.contentMode = .scaleAspectFit
|
|||
|
|
animationView.loopMode = .loop
|
|||
|
|
animationView.backgroundBehavior = .pauseAndRestore
|
|||
|
|
let size = CGSize(width: 40, height: 40)
|
|||
|
|
animationView.size = size
|
|||
|
|
animationView.backgroundColor = .clear
|
|||
|
|
activityView.addSubview(animationView)
|
|||
|
|
animationView.snp.makeConstraints { make in
|
|||
|
|
make.center.equalToSuperview()
|
|||
|
|
make.size.equalTo(size)
|
|||
|
|
}
|
|||
|
|
animationView.play()
|
|||
|
|
|
|||
|
|
return activityView
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func createMaskView() -> UIView {
|
|||
|
|
let maskView = UIView(frame: bounds)
|
|||
|
|
maskView.backgroundColor = UIColor.clear
|
|||
|
|
maskView.isUserInteractionEnabled = true
|
|||
|
|
maskView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|||
|
|
return maskView
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Private Show/Hide Methods
|
|||
|
|
|
|||
|
|
private func showToast(_ toast: UIView, duration: TimeInterval, point: CGPoint) {
|
|||
|
|
toast.center = point
|
|||
|
|
toast.alpha = 0.0
|
|||
|
|
|
|||
|
|
if ToastManager.shared.isTapToDismissEnabled {
|
|||
|
|
let recognizer = UITapGestureRecognizer(target: self, action: #selector(UIView.handleToastTapped(_:)))
|
|||
|
|
toast.addGestureRecognizer(recognizer)
|
|||
|
|
toast.isUserInteractionEnabled = true
|
|||
|
|
toast.isExclusiveTouch = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
activeToasts.add(toast)
|
|||
|
|
addSubview(toast)
|
|||
|
|
|
|||
|
|
let timer = Timer(timeInterval: duration, target: self, selector: #selector(UIView.toastTimerDidFinish(_:)), userInfo: toast, repeats: false)
|
|||
|
|
objc_setAssociatedObject(toast, &ToastKeys.timer, timer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|||
|
|
|
|||
|
|
UIView.animate(withDuration: ToastManager.shared.style.fadeDuration, delay: 0.0, options: [.curveEaseOut, .allowUserInteraction], animations: {
|
|||
|
|
toast.alpha = 1.0
|
|||
|
|
}) { _ in
|
|||
|
|
guard let timer = objc_getAssociatedObject(toast, &ToastKeys.timer) as? Timer else { return }
|
|||
|
|
RunLoop.main.add(timer, forMode: .common)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
UIAccessibility.post(notification: .screenChanged, argument: toast)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func hideToast(_ toast: UIView, fromTap: Bool) {
|
|||
|
|
if let timer = objc_getAssociatedObject(toast, &ToastKeys.timer) as? Timer {
|
|||
|
|
timer.invalidate()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
UIView.animate(withDuration: ToastManager.shared.style.fadeDuration, delay: 0.0, options: [.curveEaseIn, .beginFromCurrentState], animations: {
|
|||
|
|
toast.alpha = 0.0
|
|||
|
|
}) { _ in
|
|||
|
|
toast.removeFromSuperview()
|
|||
|
|
self.activeToasts.remove(toast)
|
|||
|
|
|
|||
|
|
if let wrapper = objc_getAssociatedObject(toast, &ToastKeys.completion) as? ToastCompletionWrapper, let completion = wrapper.completion {
|
|||
|
|
completion(fromTap)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let nextToast = self.queue.firstObject as? UIView, let duration = objc_getAssociatedObject(nextToast, &ToastKeys.duration) as? NSNumber, let point = objc_getAssociatedObject(nextToast, &ToastKeys.point) as? NSValue {
|
|||
|
|
self.queue.removeObject(at: 0)
|
|||
|
|
self.showToast(nextToast, duration: duration.doubleValue, point: point.cgPointValue)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Events
|
|||
|
|
|
|||
|
|
@objc
|
|||
|
|
private func handleToastTapped(_ recognizer: UITapGestureRecognizer) {
|
|||
|
|
guard let toast = recognizer.view else { return }
|
|||
|
|
hideToast(toast, fromTap: true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@objc
|
|||
|
|
private func toastTimerDidFinish(_ timer: Timer) {
|
|||
|
|
guard let toast = timer.userInfo as? UIView else { return }
|
|||
|
|
hideToast(toast)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Toast Construction
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Creates a new toast view with any combination of message, title, and image.
|
|||
|
|
The look and feel is configured via the style. Unlike the `makeToast` methods,
|
|||
|
|
this method does not present the toast view automatically. One of the `showToast`
|
|||
|
|
methods must be used to present the resulting view.
|
|||
|
|
|
|||
|
|
@warning if message, title, and image are all nil, this method will throw
|
|||
|
|
`ToastError.missingParameters`
|
|||
|
|
|
|||
|
|
@param message The message to be displayed
|
|||
|
|
@param title The title
|
|||
|
|
@param image The image
|
|||
|
|
@param style The style. The shared style will be used when nil
|
|||
|
|
@throws `ToastError.missingParameters` when message, title, and image are all nil
|
|||
|
|
@return The newly created toast view
|
|||
|
|
*/
|
|||
|
|
func toastViewForMessage(_ message: String?, title: String?, image: UIImage?, style: ToastStyle) throws -> UIView {
|
|||
|
|
// sanity
|
|||
|
|
guard message != nil || title != nil || image != nil else {
|
|||
|
|
throw ToastError.missingParameters
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var messageLabel: UILabel?
|
|||
|
|
var titleLabel: UILabel?
|
|||
|
|
var imageView: UIImageView?
|
|||
|
|
|
|||
|
|
let wrapperView = UIView()
|
|||
|
|
wrapperView.backgroundColor = style.backgroundColor
|
|||
|
|
wrapperView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin]
|
|||
|
|
wrapperView.layer.cornerRadius = style.cornerRadius
|
|||
|
|
|
|||
|
|
if style.displayShadow {
|
|||
|
|
wrapperView.layer.shadowColor = style.shadowColor.cgColor
|
|||
|
|
wrapperView.layer.shadowOpacity = style.shadowOpacity
|
|||
|
|
wrapperView.layer.shadowRadius = style.shadowRadius
|
|||
|
|
wrapperView.layer.shadowOffset = style.shadowOffset
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let image = image {
|
|||
|
|
imageView = UIImageView(image: image)
|
|||
|
|
imageView?.contentMode = .scaleAspectFit
|
|||
|
|
imageView?.frame = CGRect(x: style.horizontalPadding, y: style.verticalPadding, width: style.imageSize.width, height: style.imageSize.height)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var imageRect = CGRect.zero
|
|||
|
|
|
|||
|
|
if let imageView = imageView {
|
|||
|
|
imageRect.origin.x = style.horizontalPadding
|
|||
|
|
imageRect.origin.y = style.verticalPadding
|
|||
|
|
imageRect.size.width = imageView.bounds.size.width
|
|||
|
|
imageRect.size.height = imageView.bounds.size.height
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let title = title {
|
|||
|
|
titleLabel = UILabel()
|
|||
|
|
titleLabel?.numberOfLines = style.titleNumberOfLines
|
|||
|
|
titleLabel?.font = style.titleFont
|
|||
|
|
titleLabel?.textAlignment = style.titleAlignment
|
|||
|
|
titleLabel?.lineBreakMode = .byTruncatingTail
|
|||
|
|
titleLabel?.textColor = style.titleColor
|
|||
|
|
titleLabel?.backgroundColor = UIColor.clear
|
|||
|
|
titleLabel?.text = title
|
|||
|
|
|
|||
|
|
let maxTitleSize = CGSize(width: (bounds.size.width * style.maxWidthPercentage) - imageRect.size.width, height: bounds.size.height * style.maxHeightPercentage)
|
|||
|
|
let titleSize = titleLabel?.sizeThatFits(maxTitleSize)
|
|||
|
|
if let titleSize = titleSize {
|
|||
|
|
titleLabel?.frame = CGRect(x: 0.0, y: 0.0, width: titleSize.width, height: titleSize.height)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let message = message {
|
|||
|
|
messageLabel = UILabel()
|
|||
|
|
messageLabel?.text = message
|
|||
|
|
messageLabel?.numberOfLines = style.messageNumberOfLines
|
|||
|
|
messageLabel?.font = style.messageFont
|
|||
|
|
messageLabel?.textAlignment = style.messageAlignment
|
|||
|
|
messageLabel?.lineBreakMode = .byTruncatingTail
|
|||
|
|
messageLabel?.textColor = style.messageColor
|
|||
|
|
messageLabel?.backgroundColor = UIColor.clear
|
|||
|
|
|
|||
|
|
let maxMessageSize = CGSize(width: (bounds.size.width * style.maxWidthPercentage) - imageRect.size.width, height: bounds.size.height * style.maxHeightPercentage)
|
|||
|
|
let messageSize = messageLabel?.sizeThatFits(maxMessageSize)
|
|||
|
|
if let messageSize = messageSize {
|
|||
|
|
let actualWidth = min(messageSize.width, maxMessageSize.width)
|
|||
|
|
let actualHeight = min(messageSize.height, maxMessageSize.height)
|
|||
|
|
messageLabel?.frame = CGRect(x: 0.0, y: 0.0, width: actualWidth, height: actualHeight)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var titleRect = CGRect.zero
|
|||
|
|
|
|||
|
|
if let titleLabel = titleLabel {
|
|||
|
|
titleRect.origin.x = imageRect.origin.x + imageRect.size.width + style.horizontalPadding
|
|||
|
|
titleRect.origin.y = style.verticalPadding
|
|||
|
|
titleRect.size.width = titleLabel.bounds.size.width
|
|||
|
|
titleRect.size.height = titleLabel.bounds.size.height
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var messageRect = CGRect.zero
|
|||
|
|
|
|||
|
|
if let messageLabel = messageLabel {
|
|||
|
|
messageRect.origin.x = imageRect.origin.x + imageRect.size.width + style.horizontalPadding
|
|||
|
|
messageRect.origin.y = titleRect.origin.y + titleRect.size.height + style.verticalPadding
|
|||
|
|
messageRect.size.width = messageLabel.bounds.size.width
|
|||
|
|
messageRect.size.height = messageLabel.bounds.size.height
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let longerWidth = max(titleRect.size.width, messageRect.size.width)
|
|||
|
|
let longerX = max(titleRect.origin.x, messageRect.origin.x)
|
|||
|
|
let wrapperWidth = max(imageRect.size.width + (style.horizontalPadding * 2.0), longerX + longerWidth + style.horizontalPadding)
|
|||
|
|
|
|||
|
|
let textMaxY = messageRect.size.height <= 0.0 && titleRect.size.height > 0.0 ? titleRect.maxY : messageRect.maxY
|
|||
|
|
let wrapperHeight = max(textMaxY + style.verticalPadding, imageRect.size.height + (style.verticalPadding * 2.0))
|
|||
|
|
|
|||
|
|
wrapperView.frame = CGRect(x: 0.0, y: 0.0, width: wrapperWidth, height: wrapperHeight)
|
|||
|
|
|
|||
|
|
if let titleLabel = titleLabel {
|
|||
|
|
titleRect.size.width = longerWidth
|
|||
|
|
titleLabel.frame = titleRect
|
|||
|
|
wrapperView.addSubview(titleLabel)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let messageLabel = messageLabel {
|
|||
|
|
messageRect.size.width = longerWidth
|
|||
|
|
messageLabel.frame = messageRect
|
|||
|
|
wrapperView.addSubview(messageLabel)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let imageView = imageView {
|
|||
|
|
wrapperView.addSubview(imageView)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return wrapperView
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Toast Style
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
`ToastStyle` instances define the look and feel for toast views created via the
|
|||
|
|
`makeToast` methods as well for toast views created directly with
|
|||
|
|
`toastViewForMessage(message:title:image:style:)`.
|
|||
|
|
|
|||
|
|
@warning `ToastStyle` offers relatively simple styling options for the default
|
|||
|
|
toast view. If you require a toast view with more complex UI, it probably makes more
|
|||
|
|
sense to create your own custom UIView subclass and present it with the `showToast`
|
|||
|
|
methods.
|
|||
|
|
*/
|
|||
|
|
public struct ToastStyle {
|
|||
|
|
public init() {}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The background color. Default is `.black` at 80% opacity.
|
|||
|
|
*/
|
|||
|
|
public var backgroundColor: UIColor = UIColor.black.withAlphaComponent(0.8)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The title color. Default is `UIColor.whiteColor()`.
|
|||
|
|
*/
|
|||
|
|
public var titleColor: UIColor = .white
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The message color. Default is `.white`.
|
|||
|
|
*/
|
|||
|
|
public var messageColor: UIColor = .white
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
A percentage value from 0.0 to 1.0, representing the maximum width of the toast
|
|||
|
|
view relative to it's superview. Default is 0.8 (80% of the superview's width).
|
|||
|
|
*/
|
|||
|
|
public var maxWidthPercentage: CGFloat = 0.8 {
|
|||
|
|
didSet {
|
|||
|
|
maxWidthPercentage = max(min(maxWidthPercentage, 1.0), 0.0)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
A percentage value from 0.0 to 1.0, representing the maximum height of the toast
|
|||
|
|
view relative to it's superview. Default is 0.8 (80% of the superview's height).
|
|||
|
|
*/
|
|||
|
|
public var maxHeightPercentage: CGFloat = 0.8 {
|
|||
|
|
didSet {
|
|||
|
|
maxHeightPercentage = max(min(maxHeightPercentage, 1.0), 0.0)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The spacing from the horizontal edge of the toast view to the content. When an image
|
|||
|
|
is present, this is also used as the padding between the image and the text.
|
|||
|
|
Default is 10.0.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
public var horizontalPadding: CGFloat = 10.0
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The spacing from the vertical edge of the toast view to the content. When a title
|
|||
|
|
is present, this is also used as the padding between the title and the message.
|
|||
|
|
Default is 10.0. On iOS11+, this value is added added to the `safeAreaInset.top`
|
|||
|
|
and `safeAreaInsets.bottom`.
|
|||
|
|
*/
|
|||
|
|
public var verticalPadding: CGFloat = 10.0
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The corner radius. Default is 10.0.
|
|||
|
|
*/
|
|||
|
|
public var cornerRadius: CGFloat = 10.0
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The title font. Default is `.boldSystemFont(16.0)`.
|
|||
|
|
*/
|
|||
|
|
public var titleFont: UIFont = .boldSystemFont(ofSize: 16.0)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The message font. Default is `.systemFont(ofSize: 16.0)`.
|
|||
|
|
*/
|
|||
|
|
public var messageFont: UIFont = .systemFont(ofSize: 16.0)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The title text alignment. Default is `NSTextAlignment.Left`.
|
|||
|
|
*/
|
|||
|
|
public var titleAlignment: NSTextAlignment = .left
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The message text alignment. Default is `NSTextAlignment.Left`.
|
|||
|
|
*/
|
|||
|
|
public var messageAlignment: NSTextAlignment = .left
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The maximum number of lines for the title. The default is 0 (no limit).
|
|||
|
|
*/
|
|||
|
|
public var titleNumberOfLines = 0
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The maximum number of lines for the message. The default is 0 (no limit).
|
|||
|
|
*/
|
|||
|
|
public var messageNumberOfLines = 0
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Enable or disable a shadow on the toast view. Default is `false`.
|
|||
|
|
*/
|
|||
|
|
public var displayShadow = false
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The shadow color. Default is `.black`.
|
|||
|
|
*/
|
|||
|
|
public var shadowColor: UIColor = .black
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
A value from 0.0 to 1.0, representing the opacity of the shadow.
|
|||
|
|
Default is 0.8 (80% opacity).
|
|||
|
|
*/
|
|||
|
|
public var shadowOpacity: Float = 0.8 {
|
|||
|
|
didSet {
|
|||
|
|
shadowOpacity = max(min(shadowOpacity, 1.0), 0.0)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The shadow radius. Default is 6.0.
|
|||
|
|
*/
|
|||
|
|
public var shadowRadius: CGFloat = 6.0
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The shadow offset. The default is 4 x 4.
|
|||
|
|
*/
|
|||
|
|
public var shadowOffset = CGSize(width: 4.0, height: 4.0)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The image size. The default is 80 x 80.
|
|||
|
|
*/
|
|||
|
|
public var imageSize = CGSize(width: 80.0, height: 80.0)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The size of the toast activity view when `makeToastActivity(position:)` is called.
|
|||
|
|
Default is 100 x 100.
|
|||
|
|
*/
|
|||
|
|
public var activitySize = CGSize(width: 100.0, height: 100.0)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The fade in/out animation duration. Default is 0.2.
|
|||
|
|
*/
|
|||
|
|
public var fadeDuration: TimeInterval = 0.2
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Activity indicator color. Default is `.white`.
|
|||
|
|
*/
|
|||
|
|
public var activityIndicatorColor: UIColor = .white
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Activity background color. Default is `.black` at 80% opacity.
|
|||
|
|
*/
|
|||
|
|
public var activityBackgroundColor: UIColor = UIColor.black.withAlphaComponent(0.8)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Toast Manager
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
`ToastManager` provides general configuration options for all toast
|
|||
|
|
notifications. Backed by a singleton instance.
|
|||
|
|
*/
|
|||
|
|
public class ToastManager {
|
|||
|
|
/**
|
|||
|
|
The `ToastManager` singleton instance.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
public static let shared = ToastManager()
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The shared style. Used whenever toastViewForMessage(message:title:image:style:) is called
|
|||
|
|
with with a nil style.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
public var style = ToastStyle()
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Enables or disables tap to dismiss on toast views. Default is `true`.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
public var isTapToDismissEnabled = true
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Enables or disables queueing behavior for toast views. When `true`,
|
|||
|
|
toast views will appear one after the other. When `false`, multiple toast
|
|||
|
|
views will appear at the same time (potentially overlapping depending
|
|||
|
|
on their positions). This has no effect on the toast activity view,
|
|||
|
|
which operates independently of normal toast views. Default is `false`.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
public var isQueueEnabled = false
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
The default duration. Used for the `makeToast` and
|
|||
|
|
`showToast` methods that don't require an explicit duration.
|
|||
|
|
Default is 3.0.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
public var duration: TimeInterval = 1.0
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
Sets the default position. Used for the `makeToast` and
|
|||
|
|
`showToast` methods that don't require an explicit position.
|
|||
|
|
Default is `ToastPosition.Bottom`.
|
|||
|
|
|
|||
|
|
*/
|
|||
|
|
public var position: ToastPosition = .center
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - ToastPosition
|
|||
|
|
|
|||
|
|
public enum ToastPosition {
|
|||
|
|
case top
|
|||
|
|
case center
|
|||
|
|
case bottom
|
|||
|
|
|
|||
|
|
fileprivate func centerPoint(forToast toast: UIView, inSuperview superview: UIView) -> CGPoint {
|
|||
|
|
let topPadding: CGFloat = ToastManager.shared.style.verticalPadding + superview.csSafeAreaInsets.top
|
|||
|
|
let bottomPadding: CGFloat = ToastManager.shared.style.verticalPadding + superview.csSafeAreaInsets.bottom
|
|||
|
|
|
|||
|
|
switch self {
|
|||
|
|
case .top:
|
|||
|
|
return CGPoint(x: superview.bounds.size.width / 2.0, y: (toast.frame.size.height / 2.0) + topPadding)
|
|||
|
|
case .center:
|
|||
|
|
return CGPoint(x: superview.bounds.size.width / 2.0, y: superview.bounds.size.height / 2.0)
|
|||
|
|
case .bottom:
|
|||
|
|
return CGPoint(x: superview.bounds.size.width / 2.0, y: (superview.bounds.size.height - (toast.frame.size.height / 2.0)) - bottomPadding)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Private UIView Extensions
|
|||
|
|
|
|||
|
|
private extension UIView {
|
|||
|
|
var csSafeAreaInsets: UIEdgeInsets {
|
|||
|
|
if #available(iOS 11.0, *) {
|
|||
|
|
return self.safeAreaInsets
|
|||
|
|
} else {
|
|||
|
|
return .zero
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|