forked from shadowfacts/Tusker
Add toast system
This commit is contained in:
parent
ba0d179de5
commit
7f4bf52050
|
@ -141,6 +141,9 @@
|
|||
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; };
|
||||
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; };
|
||||
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; };
|
||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; };
|
||||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; };
|
||||
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; };
|
||||
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */; };
|
||||
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18723C1640A000D0238 /* PinStatusActivity.swift */; };
|
||||
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */; };
|
||||
|
@ -544,6 +547,9 @@
|
|||
D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = "<group>"; };
|
||||
D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = "<group>"; };
|
||||
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = "<group>"; };
|
||||
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
|
||||
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
|
||||
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
|
||||
D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPreviewViewController.swift; sourceTree = "<group>"; };
|
||||
D64BC18723C1640A000D0238 /* PinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinStatusActivity.swift; sourceTree = "<group>"; };
|
||||
D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnpinStatusActivity.swift; sourceTree = "<group>"; };
|
||||
|
@ -1263,6 +1269,16 @@
|
|||
path = Transitions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D64AAE8F26C80DB600FC57FB /* Toast */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D64AAE9026C80DC600FC57FB /* ToastView.swift */,
|
||||
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */,
|
||||
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */,
|
||||
);
|
||||
path = Toast;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D65A37F221472F300087646E /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1483,6 +1499,7 @@
|
|||
D623A53B2635F4E20095BD04 /* Poll */,
|
||||
D641C78B213DD92F004B4513 /* Profile Header */,
|
||||
D641C78A213DD926004B4513 /* Status */,
|
||||
D64AAE8F26C80DB600FC57FB /* Toast */,
|
||||
D6420AEB26BED17500ED8175 /* Timeline Description Cell */,
|
||||
);
|
||||
path = Views;
|
||||
|
@ -2067,6 +2084,7 @@
|
|||
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
|
||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
||||
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
|
||||
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
||||
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
|
||||
|
@ -2148,6 +2166,7 @@
|
|||
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
|
||||
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
|
||||
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
|
||||
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
|
||||
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */,
|
||||
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
|
||||
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
|
||||
|
@ -2162,6 +2181,7 @@
|
|||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */,
|
||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
|
||||
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
|
||||
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
|
||||
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
|
||||
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
|
||||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
//
|
||||
// ToastConfiguration.swift
|
||||
// ToastConfiguration
|
||||
//
|
||||
// Created by Shadowfacts on 8/14/21.
|
||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
struct ToastConfiguration {
|
||||
var systemImageName: String?
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var actionTitle: String?
|
||||
var action: ((ToastView) -> Void)?
|
||||
var edgeSpacing: CGFloat = 8
|
||||
var edge: Edge = .automatic
|
||||
var dismissOnScroll = true
|
||||
|
||||
init(title: String) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
enum Edge: Equatable {
|
||||
case top
|
||||
case bottom
|
||||
/// Determines edge based on the current device. Bottom on iPhone, top on iPad/Mac.
|
||||
case automatic
|
||||
}
|
||||
}
|
|
@ -0,0 +1,223 @@
|
|||
//
|
||||
// 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 var shrinkAnimator: UIViewPropertyAnimator?
|
||||
private var recognizedGesture = false
|
||||
|
||||
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 = .systemBlue
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowRadius = 5
|
||||
layer.shadowOffset = CGSize(width: 0, height: 2.5)
|
||||
layer.shadowOpacity = 0.5
|
||||
layer.masksToBounds = false
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = configuration.title
|
||||
titleLabel.textColor = .white
|
||||
titleLabel.font = .boldSystemFont(ofSize: 14)
|
||||
titleLabel.adjustsFontSizeToFitWidth = true
|
||||
|
||||
if let subtitle = configuration.subtitle {
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.text = subtitle
|
||||
subtitleLabel.textColor = .white
|
||||
subtitleLabel.numberOfLines = 0
|
||||
subtitleLabel.font = .systemFont(ofSize: 14)
|
||||
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 = .boldSystemFont(ofSize: 16)
|
||||
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),
|
||||
])
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
||||
addGestureRecognizer(pan)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
recognizedGesture = 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)
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
//
|
||||
// ToastableViewController.swift
|
||||
// ToastableViewController
|
||||
//
|
||||
// Created by Shadowfacts on 8/14/21.
|
||||
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol ToastableViewController: UIViewController {
|
||||
|
||||
var toastParentView: UIView { get }
|
||||
|
||||
}
|
||||
|
||||
private var currentToastKey = "Tusker_currentToast"
|
||||
|
||||
extension ToastableViewController {
|
||||
|
||||
private(set) var currentToast: ToastView? {
|
||||
get {
|
||||
let holder = objc_getAssociatedObject(self, ¤tToastKey) as? WeakHolder<ToastView>
|
||||
return holder?.object
|
||||
}
|
||||
set {
|
||||
if let newValue = newValue {
|
||||
objc_setAssociatedObject(self, ¤tToastKey, WeakHolder(object: newValue), .OBJC_ASSOCIATION_RETAIN)
|
||||
} else {
|
||||
objc_setAssociatedObject(self, ¤tToastKey, nil, .OBJC_ASSOCIATION_RETAIN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var toastParentView: UIView { view }
|
||||
|
||||
func showToast(configuration config: ToastConfiguration, animated: Bool) {
|
||||
currentToast?.dismissToast(animated: false)
|
||||
|
||||
var config = config
|
||||
config.edge = effectiveEdge(edge: config.edge)
|
||||
|
||||
let toast = ToastView(configuration: config)
|
||||
currentToast = toast
|
||||
|
||||
let parentSafeArea = toastParentView.safeAreaLayoutGuide
|
||||
toast.translatesAutoresizingMaskIntoConstraints = false
|
||||
toastParentView.addSubview(toast)
|
||||
|
||||
let yConstraint: NSLayoutConstraint
|
||||
switch config.edge {
|
||||
case .top:
|
||||
yConstraint = toast.topAnchor.constraint(equalTo: parentSafeArea.topAnchor, constant: config.edgeSpacing)
|
||||
case .bottom:
|
||||
yConstraint = parentSafeArea.bottomAnchor.constraint(equalTo: toast.bottomAnchor, constant: config.edgeSpacing)
|
||||
case .automatic:
|
||||
fatalError("unreachable")
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
toast.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: parentSafeArea.leadingAnchor, multiplier: 1),
|
||||
parentSafeArea.trailingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: toast.trailingAnchor, multiplier: 1),
|
||||
parentSafeArea.centerXAnchor.constraint(equalTo: toast.centerXAnchor),
|
||||
yConstraint,
|
||||
])
|
||||
|
||||
if animated {
|
||||
toast.animateAppearance()
|
||||
}
|
||||
}
|
||||
|
||||
private func effectiveEdge(edge: ToastConfiguration.Edge) -> ToastConfiguration.Edge {
|
||||
guard case .automatic = edge else {
|
||||
return edge
|
||||
}
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
return .bottom
|
||||
} else {
|
||||
return .top
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fileprivate class WeakHolder<T: AnyObject> {
|
||||
weak var object: T?
|
||||
|
||||
init(object: T) {
|
||||
self.object = object
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue