// // Copyright (c) 2024 Loverde Co. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. // MARK: - Framework headers import UIKit // MARK: - Protocols /// A protocol that defines optional methods for `LCSnackBarView` delegate. @objc public protocol LCSnackBarViewDelegate { /// Called when the snackbar starts its exhibition. /// - Parameter didStartExibition: The `LCSnackBarView` instance that started exhibition. @objc optional func snackbar(didStartExibition: LCSnackBarView) /// Called when the snackbar is touched. /// - Parameter snackbar: The `LCSnackBarView` instance that was touched. @objc optional func snackbar(didTouchOn snackbar: LCSnackBarView) /// Called when the snackbar ends its exhibition. /// - Parameter didEndExibition: The `LCSnackBarView` instance that ended exhibition. @objc optional func snackbar(didEndExibition: LCSnackBarView) } // MARK: - Interface Headers // MARK: - Local Defines / ENUMS /// Enumeration defining the visual style of the `LCSnackBarView`. public enum LCSnackBarViewType { /// The default style, typically rectangular. case `default` /// A rounded style for the snackbar. case rounded } /// Enumeration defining the orientation of the `LCSnackBarView`. public enum LCSnackBarOrientation { /// The snackbar appears at the top of the screen. case top /// The snackbar appears at the bottom of the screen. case bottom } /// Enumeration defining the display duration for the `LCSnackBarView`. public enum LCSnackBarTimer: CGFloat { /// The snackbar remains visible indefinitely until dismissed manually. case infinity = 0 /// Minimum display duration (2 seconds). case minimum = 2 /// Medium display duration (5 seconds). case medium = 5 /// Maximum display duration (10 seconds). case maximum = 10 } // MARK: - Class /// `LCSnackBarView` is a simple SnackBar that you can display notifications in-app to improve your app communication. /// /// Usage example: /// ///```swift ///let notification = LCSnackBarView() ///notification /// .configure(text: "Hello World!") /// .present() ///``` ///You can set a delegate to interact with it: /// ///```swift ///let notification = LCSnackBarView(delegate: self) ///notification /// .configure(text: "Hello World!") /// .present() /// ///public func snackbar(didStartExibition: LCSnackBarView){} ///public func snackbar(didTouchOn snackbar: LCSnackBarView){} ///public func snackbar(didEndExibition: LCSnackBarView){} ///``` public final class LCSnackBarView: UIView { // MARK: - Private properties /// The content view that holds the snackbar's elements. private lazy var contentView: UIView = { $0.backgroundColor = .white $0.translatesAutoresizingMaskIntoConstraints = false $0.isOpaque = true return $0 }(UIView()) /// The label that displays the main text of the snackbar. private lazy var descriptionLabel: UILabel = { $0.font = .systemFont(ofSize: 12, weight: .regular) $0.text = nil $0.textColor = .black $0.backgroundColor = UIColor.clear $0.numberOfLines = 0 $0.lineBreakMode = .byWordWrapping $0.textAlignment = .center $0.isOpaque = true $0.translatesAutoresizingMaskIntoConstraints = false return $0 }(UILabel()) private var _style: LCSnackBarViewType private var originPositionX: CGFloat = 0.0 private var originPositionY: CGFloat = 0.0 private var _orientation: LCSnackBarOrientation private var _timer: LCSnackBarTimer = .minimum private var _radius: CGFloat = 4.0 private var spacing: CGFloat = 20.0 private var _width: CGFloat = .zero private var _height: CGFloat = .zero private lazy var systemKeyboardVisible = false private lazy var isOpen = false // MARK: - Internal properties // MARK: - Public properties /// The delegate for the snackbar view. public weak var delegate: LCSnackBarViewDelegate? // MARK: - Initializers /// Initializes a new `LCSnackBarView` instance. /// - Parameters: /// - style: The visual style of the snackbar. Defaults to `.default`. /// - orientation: The orientation (top or bottom) of the snackbar. Defaults to `.top`. /// - delegate: The delegate to receive snackbar events. Defaults to `nil`. public init( style: LCSnackBarViewType = .default, orientation: LCSnackBarOrientation = .top, delegate: LCSnackBarViewDelegate? = nil ) { self._style = style self._orientation = orientation self.delegate = delegate super.init(frame: .zero) setupDefaultLayout() addComponentsAndConstraints() setupGestureRecognizer() setKeyboardObserver() } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Super Class Overrides public override func layoutSubviews() { super.layoutSubviews() } deinit { NotificationCenter.default.removeObserver(self, name: nil, object: nil) } } // MARK: - Extensions public extension LCSnackBarView { // MARK: - Private methods /// Sets up the default layout properties for the snackbar. private func setupDefaultLayout() { backgroundColor = .white contentView.backgroundColor = .white clipsToBounds = true } /// Sets up a tap gesture recognizer for the snackbar. private func setupGestureRecognizer() { let gesture = UITapGestureRecognizer(target: self, action: #selector(onTapGestureAction)) gesture.numberOfTapsRequired = 1 gesture.cancelsTouchesInView = false addGestureRecognizer(gesture) } /// Configures observers for keyboard appearance and disappearance notifications. private func setKeyboardObserver() { // Show NotificationCenter .default .addObserver( self, selector: #selector(self.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil ) // Hidde NotificationCenter .default .addObserver( self, selector: #selector(self.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil ) } /// Handles the `keyboardWillShowNotification` to adjust the snackbar's position. /// - Parameter notification: The `Notification` object containing keyboard information. @objc private func keyboardWillShow(_ notification: Notification?) -> Void { if let info = notification?.userInfo { systemKeyboardVisible = true // let curveUserInfoKey = UIResponder.keyboardAnimationCurveUserInfoKey let durationUserInfoKey = UIResponder.keyboardAnimationDurationUserInfoKey let frameEndUserInfoKey = UIResponder.keyboardFrameEndUserInfoKey // var animationCurve: UIView.AnimationOptions = .curveEaseOut var animationDuration: TimeInterval = 0.25 var height:CGFloat = 0.0 // Getting keyboard animation. if let curve = info[curveUserInfoKey] as? UIView.AnimationOptions { animationCurve = curve } // Getting keyboard animation duration if let duration = info[durationUserInfoKey] as? TimeInterval { animationDuration = duration } // Getting UIKeyboardSize. if let kbFrame = info[frameEndUserInfoKey] as? CGRect { height = kbFrame.size.height } DispatchQueue.main.async { [weak self] in UIView.animate(withDuration: animationDuration, delay: 0, options: animationCurve, animations: { self?.frame.origin.y += height }) } } } /// Handles the `keyboardWillHideNotification`. /// - Parameter notification: The `Notification` object. @objc private func keyboardWillHide(_ notification: Notification?) -> Void { DispatchQueue.main.async { [weak self] in self?.systemKeyboardVisible = false // keyboard is hidded } } /// Updates the snackbar's style properties, such as width and corner radius, based on `_style`. private func updateStyle() { switch _style { case .rounded: _width = UIScreen.main.bounds.width - spacing cornerRadius = _radius originPositionX = 10 default: _width = UIScreen.main.bounds.width cornerRadius = 0 originPositionX = 0 } } /// Positions the snackbar view within the given superview based on its `_orientation`. /// - Parameter view: The `UIView` that will contain the snackbar. private func positioningView(_ view: UIView) { view .addSubview(self, translatesAutoresizingMaskIntoConstraints: true) switch _orientation { case .bottom: var bottomNotch = UIDevice.bottomNotch if _style != .rounded { bottomNotch = (1.25 * bottomNotch) contentView.bottomConstraint?.isActive = false } _height = (descriptionLabel.lineNumbers().cgFloat * descriptionLabel.font.pointSize) + spacing + bottomNotch originPositionY = UIScreen.main.bounds.height default: var topNotch = UIDevice.topNotch if _style != .rounded { topNotch = (1.25 * topNotch) contentView.topConstraint?.isActive = false } _height = (descriptionLabel.lineNumbers().cgFloat * descriptionLabel.font.pointSize) + spacing + topNotch originPositionY = -_height } frame = CGRect(x: originPositionX, y: originPositionY, width: _width, height: _height) } /// Displays the snackbar with an animation. /// - Parameters: /// - controller: The `UIViewController` on which the snackbar will be presented. /// - completion: A closure to be executed once the presentation animation completes. private func showSnackBar(controller: UIViewController, completion: @escaping (() -> Void)) { if isOpen { closeSnackBar(controller: controller) { self.showSnackBar(controller: controller, completion: completion) } return } isHidden = true updateStyle() positioningView(controller.view) let distance = CGFloat(_style == .rounded ? (_orientation == .top ? UIDevice.topNotch : UIDevice.bottomNotch) : 0) layoutIfNeeded() UIView.animate(withDuration: 0.6, delay: 0.6, options: .curveEaseInOut) { [weak self] in self?.layoutIfNeeded() self?.isHidden = false if self?._orientation == .top { self?.frame.origin.y += (self?._height ?? 0) + distance } else { self?.frame.origin.y -= (self?._height ?? 0) + distance } } completion: { finished in self.layoutIfNeeded() completion() self.isOpen = true self.delegate?.snackbar?(didStartExibition: self) guard self._timer != .infinity else { return } LCEssentials.delay(milliseconds: self._timer.rawValue) { self.closeSnackBar(controller: controller, completion: {}) } } } /// Hides the snackbar with an animation. /// - Parameters: /// - controller: The `UIViewController` from which the snackbar is being dismissed. /// - completion: A closure to be executed once the dismissal animation completes. private func closeSnackBar(controller: UIViewController, completion: @escaping (() -> Void)) { let distance = CGFloat(_style == .rounded ? (_orientation == .top ? UIDevice.topNotch : UIDevice.bottomNotch) : 0) layoutIfNeeded() UIView.animate(withDuration: 0.6, delay: 0.6, options: .curveEaseInOut) { [weak self] in self?.layoutIfNeeded() if self?._orientation == .top { self?.frame.origin.y -= (self?._height ?? 0) + distance } else { self?.frame.origin.y += (self?._height ?? 0) + distance } } completion: { finished in self.layoutIfNeeded() self.delegate?.snackbar?(didEndExibition: self) self.isOpen = false self.removeFromSuperview() completion() } } /// Handles tap gestures on the snackbar. /// - Parameter _: The `UITapGestureRecognizer` instance. @objc private func onTapGestureAction(_ : UITapGestureRecognizer) { self.delegate?.snackbar?(didTouchOn: self) if let controller = LCEssentials.getTopViewController(aboveBars: true), _timer == .infinity { self.closeSnackBar(controller: controller, completion: {}) } } /// Adds the subviews to the snackbar and sets up their constraints. private func addComponentsAndConstraints() { // MARK: - Add Subviews contentView.addSubviews([descriptionLabel]) addSubviews([contentView]) // MARK: - Add Constraints contentView .setConstraintsTo(self, .top, 10) .setConstraints(.leading, 10) .setConstraints(.trailing, -10) .setConstraints(.bottom, -10) descriptionLabel .setConstraintsTo(contentView, .top, 0) .setConstraints(.leading, 0) .setConstraints(.trailing, 0) .setConstraints(.bottom, 0) } } public extension LCSnackBarView { // MARK: - Public methods /// Configures the text displayed in the snackbar. /// - Parameter text: The `String` text to display. /// - Returns: The `LCSnackBarView` instance for chaining. @discardableResult func configure(text: String) -> Self { descriptionLabel.text = text return self } /// Configures the color of the text in the snackbar. /// - Parameter textColor: The `UIColor` for the text. /// - Returns: The `LCSnackBarView` instance for chaining. @discardableResult func configure(textColor: UIColor) -> Self { descriptionLabel.textColor = textColor return self } /// Configures the font and text alignment of the snackbar's text. /// - Parameters: /// - textFont: The `UIFont` for the text. /// - alignment: The `NSTextAlignment` for the text. Defaults to `.center`. /// - Returns: The `LCSnackBarView` instance for chaining. @discardableResult func configure(textFont: UIFont, alignment: NSTextAlignment = .center) -> Self { descriptionLabel.font = textFont descriptionLabel.textAlignment = alignment return self } /// Configures the background color of the snackbar. /// - Parameter backgroundColor: The `UIColor` for the background. /// - Returns: The `LCSnackBarView` instance for chaining. @discardableResult func configure(backgroundColor: UIColor) -> Self { self.backgroundColor = backgroundColor contentView.backgroundColor = backgroundColor return self } /// Configures the exhibition duration (timer) of the snackbar. /// - Parameter timer: The `LCSnackBarTimer` value. /// - Returns: The `LCSnackBarView` instance for chaining. @discardableResult func configure(exibition timer: LCSnackBarTimer) -> Self { _timer = timer return self } /// Configures an image icon to be displayed before the text in the snackbar. /// - Parameters: /// - icon: The `UIImageView` to use as the icon. /// - withTintColor: An optional `UIColor` to tint the icon. Defaults to `nil`. /// - Returns: The `LCSnackBarView` instance for chaining. @discardableResult func configure(imageIconBefore icon: UIImageView, withTintColor: UIColor? = nil) -> Self { icon.setHeight(size: 24) icon.setWidth(size: 24) icon.contentMode = .scaleAspectFit descriptionLabel.leadingConstraint?.constant = (icon.widthConstraint?.constant ?? 0) + 24 if let withTintColor { icon.image = icon.image?.withRenderingMode(.alwaysTemplate).tintImage(color: withTintColor) } contentView .addSubviews([icon]) icon .setConstraintsTo(contentView, .leading, 10) .setConstraintsTo(descriptionLabel, .centerY, 0) return self } /// Presents the snackbar on the top-most view controller. /// - Parameter completion: An optional closure to be executed after the presentation. func present(completion: (()->())? = nil) { if isOpen { return } if let controller = LCEssentials.getTopViewController(aboveBars: true) { showSnackBar(controller: controller) { completion?() } } } }