// // 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 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 = .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 = configuration.titleFont 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) } 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, 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) 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 } } }