diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 066b26e4..ec3194c4 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = ""; }; D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = ""; }; + D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; + D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = ""; }; + D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = ""; }; D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPreviewViewController.swift; sourceTree = ""; }; D64BC18723C1640A000D0238 /* PinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinStatusActivity.swift; sourceTree = ""; }; D64BC18923C16487000D0238 /* UnpinStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnpinStatusActivity.swift; sourceTree = ""; }; @@ -1263,6 +1269,16 @@ path = Transitions; sourceTree = ""; }; + D64AAE8F26C80DB600FC57FB /* Toast */ = { + isa = PBXGroup; + children = ( + D64AAE9026C80DC600FC57FB /* ToastView.swift */, + D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */, + D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */, + ); + path = Toast; + sourceTree = ""; + }; 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 */, diff --git a/Tusker/Views/Toast/ToastConfiguration.swift b/Tusker/Views/Toast/ToastConfiguration.swift new file mode 100644 index 00000000..524c05d4 --- /dev/null +++ b/Tusker/Views/Toast/ToastConfiguration.swift @@ -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 + } +} diff --git a/Tusker/Views/Toast/ToastView.swift b/Tusker/Views/Toast/ToastView.swift new file mode 100644 index 00000000..dd71096e --- /dev/null +++ b/Tusker/Views/Toast/ToastView.swift @@ -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, 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, 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 + } + } + +} diff --git a/Tusker/Views/Toast/ToastableViewController.swift b/Tusker/Views/Toast/ToastableViewController.swift new file mode 100644 index 00000000..d5a71759 --- /dev/null +++ b/Tusker/Views/Toast/ToastableViewController.swift @@ -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 + 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 { + weak var object: T? + + init(object: T) { + self.object = object + } +}