// // 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() configuration.onDismiss?(nil) return } let animator = UIViewPropertyAnimator(duration: 0.25, curve: .easeInOut) animator.addAnimations { self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation) } animator.addCompletion { _ in self.removeFromSuperview() } configuration.onDismiss?(animator) animator.startAnimation() } func animateAppearance() { self.transform = CGAffineTransform(translationX: 0, y: offscreenTranslation) let duration = 0.5 let velocity = CGVector(dx: 0, dy: 0.5) let animator = UIViewPropertyAnimator(duration: duration, timingParameters: UISpringTimingParameters(dampingRatio: 0.65, initialVelocity: velocity)) animator.addAnimations { self.transform = .identity } configuration.onAppear?(animator) animator.startAnimation() } func setupDismissOnScroll(connectedTo scrollView: UIScrollView) { guard configuration.dismissOnScroll else { return } scrollView.panGestureRecognizer.addTarget(self, action: #selector(scrollViewPanGestureRecognized)) } // MARK: - Interaction override func touchesBegan(_ touches: Set, 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, 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 } private var panGestureDismissAnimator: UIViewPropertyAnimator? @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 } panGestureDismissAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) configuration.onDismiss?(panGestureDismissAnimator!) case .changed: var distance: CGFloat var alongsideAnimationProgress: CGFloat if isDraggingAwayFromDismissalEdge { distance = sqrt(abs(translation)) if configuration.edge == .bottom { distance *= -1 } alongsideAnimationProgress = 0 } else { distance = translation alongsideAnimationProgress = abs(translation) / (configuration.edgeSpacing + bounds.height) } transform = CGAffineTransform(translationX: 0, y: distance) panGestureDismissAnimator!.fractionComplete = alongsideAnimationProgress 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) { panGestureDismissAnimator!.addAnimations { self.transform = CGAffineTransform(translationX: 0, y: self.offscreenTranslation) } panGestureDismissAnimator!.addCompletion { _ in self.removeFromSuperview() } } else { self.removeFromSuperview() } panGestureDismissAnimator!.startAnimation() } 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 } panGestureDismissAnimator!.isReversed = true panGestureDismissAnimator!.startAnimation() } 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 } }