Skip to content

Instantly share code, notes, and snippets.

@douglaszaltron
Created April 16, 2018 17:32
Show Gist options
  • Save douglaszaltron/42464a5a2e9d2ace0cfa0a09be4b67cc to your computer and use it in GitHub Desktop.
Save douglaszaltron/42464a5a2e9d2ace0cfa0a09be4b67cc to your computer and use it in GitHub Desktop.
import Foundation
open class SnackbarView: UIView {
internal let snackRemoval: Notification.Name = Notification.Name(rawValue: "snackbar.removalNotification")
// MARK: Properties
/// The controller for this view
internal var controller: CAPSnackbarPlugin?
/// The amount of margin from the handside, used to layout the `label`, default is `8.0`
open var margin: CGFloat = 8.0 {
didSet {
self.setNeedsLayout()
}
}
/// The width of the total available size that the `view` should take up. , default is `1.0`
@objc open dynamic var viewMaxWidth: CGFloat = 1.0 {
didSet {
self.setNeedsLayout()
}
}
/// Label height max height, max = 80
@objc open dynamic var viewMaxHeight: CGFloat = 48.0 {
didSet {
viewMaxHeight = viewMaxHeight > 48.0 ? 80.0 : viewMaxHeight
self.setNeedsLayout()
}
}
/// Action button min width, min = 64
@objc open dynamic var actionMinWidth: CGFloat = 96.0 {
didSet {
actionMinWidth = actionMinWidth < 64.0 ? 64.0 : actionMinWidth
self.setNeedsLayout()
}
}
/// The default opacity for the view
internal let defaultOpacity: Float = 1.0
// MARK: Overrides
/// Overriden
public override init(frame: CGRect) {
super.init(frame: frame)
initialize()
}
/// Overriden
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initialize()
}
/// Overriden, posts `snackRemoval` notification.
open override func removeFromSuperview() {
super.removeFromSuperview()
let notification = Notification(name: snackRemoval)
NotificationCenter.default.post(notification)
}
// MARK: Private methods
/// Helper initializer which sets some customization for the view and adds the subviews/constraints.
private func initialize() {
isAccessibilityElement = false
autoresizingMask = [.flexibleWidth, .flexibleHeight]
backgroundColor = UIColor(fromHex: "#323232")
layer.opacity = defaultOpacity
layer.cornerRadius = 0
// Add subviews
addSubview(label)
addSubview(button)
//// Add constraints
// Title label to left
NSLayoutConstraint(item: label, attribute: .leading, relatedBy: .equal,
toItem: self, attribute: .leadingMargin, multiplier: 1.0, constant: margin).isActive = true
NSLayoutConstraint(item: label, attribute: .centerY, relatedBy: .equal,
toItem: self, attribute: .centerY, multiplier: 1.0, constant: 0.0).isActive = true
NSLayoutConstraint(item: label, attribute: .trailing, relatedBy: .equal,
toItem: button, attribute: .leading, multiplier: 1.0, constant: -margin).isActive = true
// Button to right
NSLayoutConstraint(item: button, attribute: .width, relatedBy: .lessThanOrEqual,
toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: actionMinWidth).isActive = true
NSLayoutConstraint(item: button, attribute: .trailing, relatedBy: .equal,
toItem: self, attribute: .trailingMargin, multiplier: 1.0, constant: -margin).isActive = true
NSLayoutConstraint(item: button, attribute: .centerY, relatedBy: .equal,
toItem: self, attribute: .centerY, multiplier: 1.0, constant: 0.0).isActive = true
// Register for device rotation notifications
NotificationCenter.default.addObserver(self, selector: #selector(self.didRotate(notification:)), name: .UIDeviceOrientationDidChange, object: nil)
}
/// Called whenever the screen is rotated, this will ask the controller to recalculate the frame for the view.
@objc private func didRotate(notification: Notification) {
DispatchQueue.main.async {
self.frame = self.controller?.frameForView() ?? .zero
}
}
// MARK: Actions
/// Called whenever the button is tapped, will tell the controller to perform the button action
@objc private func buttonTapped(sender: UIButton) {
controller?.viewButtonTapped()
}
// MARK: Subviews
/// The label on the left hand side of the view used to display text.
open lazy var label: UILabel = {
let mLabel = UILabel(frame: .zero)
mLabel.translatesAutoresizingMaskIntoConstraints = false
mLabel.textAlignment = .left
mLabel.textColor = UIColor.white
mLabel.font = UIFont.systemFont(ofSize: 14)
return mLabel
}()
/// The button on the right hand side of the view which allows an action to be performed.
open lazy var button: UIButton = {
let mButton = UIButton(frame: .zero)
mButton.translatesAutoresizingMaskIntoConstraints = false
mButton.setTitleColor(UIColor(fromHex: "#5eb6fc"), for: .normal)
mButton.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14)
mButton.addTarget(self, action: #selector(self.buttonTapped(sender:)), for: .touchUpInside)
return mButton
}()
// MARK: Deinit
deinit {
NotificationCenter.default.removeObserver(self)
}
}
/// Calc sizeLines
extension UILabel {
open var sizeLines: Int {
let mText = self.text! as NSString
let rect = CGSize(width: self.bounds.width, height: CGFloat.greatestFiniteMagnitude)
let labelSize = mText.boundingRect(with: rect, options: .usesLineFragmentOrigin, attributes: [NSAttributedStringKey.font: self.font], context: nil)
return Int(ceil(CGFloat(labelSize.height) / self.font.lineHeight))
}
}
enum SnackbarSetDuration: CGFloat {
case short = 1.5
case long = 3.0
case indefinite = 2147483647
}
struct SnackbarSettings {
var buttonColor:UIColor = .cyan
var buttonText:String = "OK"
var setDuration: SnackbarSetDuration = SnackbarSetDuration.short
}
@objc(CAPToastPlugin)
public class CAPSnackbarPlugin : CAPPlugin {
fileprivate func vc() -> UIViewController{
return self.bridge!.viewController
}
open lazy var view: SnackbarView = {
let mView = SnackbarView(frame: .zero)
mView.controller = self
return mView
}()
/// The completion block for an `Snackbar`, `true` is sent if button was tapped, `false` otherwise.
public typealias SnackbarCompletion = (Bool) -> Void
/// The duration for the animation of both the adding and removal of the `view`.
open var animationDuration: TimeInterval = 0.5
// MARK: Private Members
/// The timer responsible for notifying about when the view needs to be removed.
private var displayTimer: Timer?
/// Whether or not the view was initially animated, this is used when animating out the view.
private var wasAnimated: Bool = false
/// The completion block which is assigned when calling `show(animated:completion:)`
private var completion: SnackbarCompletion?
// MARK: Public Methods
@objc func show(_ call: CAPPluginCall) {
guard let text = call.get("text", String.self) else {
call.error("Text must be provided and must be a string.")
return
}
DispatchQueue.main.async {
self.view.label.text = text
self.view.label.numberOfLines = 0
self.view.button.setTitle("DISMOUNT", for: .normal)
self.vc().view.addSubview(self.view)
self.displayTimer = Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(self.timerDidFinish), userInfo: nil, repeats: false)
self.animateIn()
}
// Register for snack removal notifications
NotificationCenter.default.addObserver(self, selector: #selector(self.snackWasRemoved(notification:)), name: self.view.snackRemoval, object: nil)
}
// MARK: Actions
/// Called whenever the `displayTimer` is done, will animate the view out if allowed
@objc private func timerDidFinish() {
if wasAnimated {
self.animateOut()
} else {
// Call the completion handler, since no animation will be shown
completion?(false)
// Remove view
self.removeSnack()
}
}
/// Called whenever the `views`'s button is tapped, will animate the view out if allowed
internal func viewButtonTapped() {
displayTimer?.invalidate()
displayTimer = nil
if wasAnimated {
self.animateOut(wasButtonTapped: true)
} else {
completion?(true)
self.removeSnack()
}
}
// MARK: Helper Methods
/// Returns the calculated/appropriate frame for the view, takes into account whether there are multiple snacks on the view.
internal func frameForView() -> CGRect {
let width: CGFloat = self.vc().view.bounds.width * self.view.viewMaxWidth
let startX: CGFloat = (self.vc().view.bounds.width - width) / 2.0
let startY: CGFloat
// For iOS 11.0 + we can get the safe area of the view, if allowed, we can inset the snack by this amount in
// addition to the rest of the insets the user has decided they want
let safeAreaInset: CGFloat
if #available(iOS 11.0, *), true {
safeAreaInset = self.vc().view.safeAreaInsets.bottom
} else {
safeAreaInset = 0
}
startY = self.vc().view.bounds.maxY - self.view.viewMaxHeight - safeAreaInset
return CGRect(x: startX, y: startY, width: width, height: self.view.viewMaxHeight)
}
/// Removes the snack view from the super view and invalidates any timers.
private func removeSnack() {
view.removeFromSuperview()
displayTimer?.invalidate()
displayTimer = nil
}
/// Called when another `SnackbarView` was removed from the screen. Refreshes the frame of the current `SnackbarView`.
@objc private func snackWasRemoved(notification: Notification) {
UIView.animate(
withDuration: 0.2,
delay: 0.0,
usingSpringWithDamping: 0.5,
initialSpringVelocity: 0.0,
options: .curveEaseOut,
animations: {
self.view.frame = self.frameForView()
}, completion: nil)
}
// MARK: Animation
/// Animates the view in using a springy/bounce effect
private func animateIn() {
let frame = frameForView()
let inY = frame.origin.y
let outY = frame.origin.y + self.view.viewMaxHeight
view.frame = CGRect(x: frame.origin.x, y: outY, width: frame.width, height: frame.height)
UIView.animate(
withDuration: animationDuration,
delay: 0.1,
usingSpringWithDamping: 1,
initialSpringVelocity: 0.5,
options: .curveEaseOut,
animations: {
self.view.frame = CGRect(x: frame.origin.x, y: inY, width: frame.width, height: frame.height)
},
completion: nil
)
wasAnimated = true
}
/// Animates the view in by moving down towards the edge of the screen and fading it out
private func animateOut(wasButtonTapped: Bool = false) {
let frame = view.frame
let outY = frame.origin.y + self.view.viewMaxHeight
let pos = CGPoint(x: frame.origin.x, y: outY)
UIView.animate(
withDuration: animationDuration,
animations: {
self.view.frame = CGRect(origin: pos, size: frame.size)
},
completion: { _ in
self.completion?(wasButtonTapped)
self.removeSnack()
})
}
deinit {
NotificationCenter.default.removeObserver(self)
displayTimer?.invalidate()
displayTimer = nil
view.controller = nil
view.removeFromSuperview()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment