515 lines
18 KiB
Swift
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?()
|
|
}
|
|
}
|
|
}
|
|
}
|