Files
LCEssentials/Sources/LCEssentials/Message/LCSnackBarView.swift
Daniel Arantes Loverde 0910973e9a documentation
2025-06-23 09:48:57 -03:00

515 lines
18 KiB
Swift

//
// 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?()
}
}
}
}