305 lines
12 KiB
Swift
305 lines
12 KiB
Swift
//
|
|
// ToastView.swift
|
|
// ToastView
|
|
//
|
|
// Created by Shadowfacts on 8/14/21.
|
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
class ToastView: UIView {
|
|
|
|
let configuration: ToastConfiguration
|
|
|
|
private(set) var imageView: UIImageView?
|
|
|
|
private var panRecognizer: UIPanGestureRecognizer!
|
|
private var shrinkAnimator: UIViewPropertyAnimator?
|
|
private var recognizedGesture = false
|
|
private var handledLongPress = false
|
|
private var shouldDismissOnScroll = false
|
|
private(set) var shouldDismissAutomatically = true
|
|
|
|
private var offscreenTranslation: CGFloat {
|
|
var translation = bounds.height + configuration.edgeSpacing
|
|
if configuration.edge == .bottom {
|
|
translation += superview?.safeAreaInsets.bottom ?? 0
|
|
} else {
|
|
translation += superview?.safeAreaInsets.top ?? 0
|
|
translation *= -1
|
|
}
|
|
return translation
|
|
}
|
|
|
|
init(configuration: ToastConfiguration) {
|
|
precondition(configuration.edge != .automatic)
|
|
self.configuration = configuration
|
|
|
|
super.init(frame: .zero)
|
|
|
|
setupView()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func setupView() {
|
|
backgroundColor = .tintColor
|
|
layer.shadowColor = UIColor.black.cgColor
|
|
layer.shadowRadius = 5
|
|
layer.shadowOffset = CGSize(width: 0, height: 2.5)
|
|
layer.shadowOpacity = 0.5
|
|
layer.masksToBounds = false
|
|
|
|
addInteraction(UIPointerInteraction(delegate: self))
|
|
|
|
let stack = UIStackView()
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
stack.axis = .horizontal
|
|
stack.spacing = 8
|
|
|
|
if let name = configuration.systemImageName {
|
|
let imageView = UIImageView(image: UIImage(systemName: name))
|
|
imageView.tintColor = .white
|
|
imageView.contentMode = .scaleAspectFit
|
|
stack.addArrangedSubview(imageView)
|
|
self.imageView = imageView
|
|
}
|
|
|
|
let titleLabel = UILabel()
|
|
titleLabel.text = configuration.title
|
|
titleLabel.textColor = .white
|
|
titleLabel.font = configuration.titleFont
|
|
titleLabel.adjustsFontSizeToFitWidth = true
|
|
titleLabel.adjustsFontForContentSizeCategory = true
|
|
|
|
if let subtitle = configuration.subtitle {
|
|
let subtitleLabel = UILabel()
|
|
subtitleLabel.text = subtitle
|
|
subtitleLabel.textColor = .white
|
|
subtitleLabel.numberOfLines = 0
|
|
subtitleLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
|
|
subtitleLabel.adjustsFontForContentSizeCategory = true
|
|
let vStack = UIStackView(arrangedSubviews: [
|
|
titleLabel,
|
|
subtitleLabel
|
|
])
|
|
vStack.axis = .vertical
|
|
vStack.spacing = 4
|
|
stack.addArrangedSubview(vStack)
|
|
} else {
|
|
stack.addArrangedSubview(titleLabel)
|
|
}
|
|
|
|
if let actionTitle = configuration.actionTitle {
|
|
let actionLabel = UILabel()
|
|
actionLabel.text = actionTitle
|
|
actionLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .boldSystemFont(ofSize: 16))
|
|
actionLabel.adjustsFontForContentSizeCategory = true
|
|
actionLabel.textColor = .white
|
|
stack.addArrangedSubview(actionLabel)
|
|
}
|
|
|
|
addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1),
|
|
trailingAnchor.constraint(equalToSystemSpacingAfter: stack.trailingAnchor, multiplier: 1),
|
|
stack.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
|
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
|
|
])
|
|
|
|
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
|
addGestureRecognizer(panRecognizer)
|
|
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressRecognized))
|
|
longPress.delegate = self
|
|
addGestureRecognizer(longPress)
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
layer.cornerRadius = min(32, bounds.height / 2)
|
|
layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: layer.cornerRadius, cornerHeight: layer.cornerRadius, transform: nil)
|
|
}
|
|
|
|
func dismissToast(animated: Bool) {
|
|
guard animated else {
|
|
removeFromSuperview()
|
|
return
|
|
}
|
|
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
|
|
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
|
|
} completion: { (_) in
|
|
self.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
func animateAppearance() {
|
|
self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation)
|
|
let duration = 0.5
|
|
let velocity = 0.5
|
|
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: []) {
|
|
self.transform = .identity
|
|
}
|
|
}
|
|
|
|
func setupDismissOnScroll(connectedTo scrollView: UIScrollView) {
|
|
guard configuration.dismissOnScroll else { return }
|
|
scrollView.panGestureRecognizer.addTarget(self, action: #selector(scrollViewPanGestureRecognized))
|
|
}
|
|
|
|
// MARK: - Interaction
|
|
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesBegan(touches, with: event)
|
|
|
|
recognizedGesture = false
|
|
shouldDismissAutomatically = false
|
|
|
|
shrinkAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) {
|
|
self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
|
}
|
|
shrinkAnimator?.startAnimation(afterDelay: 0.1)
|
|
}
|
|
|
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesEnded(touches, with: event)
|
|
|
|
handledLongPress = false
|
|
|
|
if !recognizedGesture {
|
|
guard let shrinkAnimator = shrinkAnimator else {
|
|
return
|
|
}
|
|
|
|
shrinkAnimator.stopAnimation(true)
|
|
UIView.animate(withDuration: 0.1, delay: 0, options: .curveEaseInOut) {
|
|
self.transform = .identity
|
|
}
|
|
configuration.action?(self)
|
|
}
|
|
|
|
shrinkAnimator = nil
|
|
}
|
|
|
|
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
let translation = recognizer.translation(in: self).y
|
|
|
|
let isDraggingAwayFromDismissalEdge = (configuration.edge == .top && translation > 0) || (configuration.edge == .bottom && translation < 0)
|
|
|
|
switch recognizer.state {
|
|
case .began:
|
|
recognizedGesture = true
|
|
shouldDismissAutomatically = false
|
|
UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
|
|
self.transform = .identity
|
|
}
|
|
break
|
|
|
|
case .changed:
|
|
var distance: CGFloat
|
|
if isDraggingAwayFromDismissalEdge {
|
|
distance = sqrt(abs(translation))
|
|
if configuration.edge == .bottom {
|
|
distance *= -1
|
|
}
|
|
} else {
|
|
distance = translation
|
|
}
|
|
transform = CGAffineTransform(translationX: 0, y: distance)
|
|
|
|
case .ended, .cancelled:
|
|
let velocity = recognizer.velocity(in: self).y
|
|
let distance = isDraggingAwayFromDismissalEdge ? sqrt(abs(translation)) : translation
|
|
|
|
let minDismissalDistance = configuration.edgeSpacing + bounds.height / 2
|
|
let dismissDueToDistance = configuration.edge == .bottom ? distance > minDismissalDistance : -distance > minDismissalDistance
|
|
|
|
let minDismissalVelocity: CGFloat = 250
|
|
let dismissDueToVelocity = configuration.edge == .bottom ? velocity > minDismissalDistance : velocity < -minDismissalVelocity
|
|
if dismissDueToDistance || dismissDueToVelocity {
|
|
|
|
if abs(translation) < abs(offscreenTranslation) {
|
|
let distanceLeft = offscreenTranslation - translation
|
|
let duration = 1 / TimeInterval(max(velocity, minDismissalVelocity) / distanceLeft)
|
|
|
|
UIView.animate(withDuration: duration, delay: 0, options: .allowUserInteraction) {
|
|
self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation)
|
|
} completion: { (_) in
|
|
self.removeFromSuperview()
|
|
}
|
|
} else {
|
|
self.removeFromSuperview()
|
|
}
|
|
|
|
} else {
|
|
let duration = 0.5
|
|
let velocity = distance * duration
|
|
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: .allowUserInteraction) {
|
|
self.transform = .identity
|
|
}
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
@objc private func scrollViewPanGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
switch recognizer.state {
|
|
case .began:
|
|
shouldDismissOnScroll = true
|
|
|
|
case .changed:
|
|
let translation = recognizer.translation(in: recognizer.view).y
|
|
if shouldDismissOnScroll && abs(translation) > 50 {
|
|
dismissToast(animated: true)
|
|
shouldDismissOnScroll = false
|
|
}
|
|
|
|
case .ended, .cancelled:
|
|
shouldDismissOnScroll = false
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
@objc private func longPressRecognized() {
|
|
guard !handledLongPress else {
|
|
return
|
|
}
|
|
configuration.longPressAction?(self)
|
|
handledLongPress = true
|
|
}
|
|
|
|
}
|
|
|
|
extension ToastView: UIGestureRecognizerDelegate {
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
// if another recognizer can recognize simulatenously (e.g., table view cell drag initiation) it should require the toast one to fail
|
|
// otherwise long-pressing on a toast results in the drag beginning instead
|
|
return true
|
|
}
|
|
}
|
|
|
|
extension ToastView: UIPointerInteractionDelegate {
|
|
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
|
|
return defaultRegion
|
|
}
|
|
|
|
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
|
return UIPointerStyle(effect: .highlight(UITargetedPreview(view: self)))
|
|
}
|
|
|
|
func pointerInteraction(_ interaction: UIPointerInteraction, willEnter region: UIPointerRegion, animator: UIPointerInteractionAnimating) {
|
|
shouldDismissAutomatically = false
|
|
}
|
|
|
|
func pointerInteraction(_ interaction: UIPointerInteraction, willExit region: UIPointerRegion, animator: UIPointerInteractionAnimating) {
|
|
shouldDismissAutomatically = true
|
|
}
|
|
}
|