Created
April 16, 2018 17:32
-
-
Save douglaszaltron/42464a5a2e9d2ace0cfa0a09be4b67cc to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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