You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

416 lines
20 KiB

//
// Banner.swift
//
// Created by Harlan Haskins on 7/27/15.
// Copyright (c) 2015 Bryx. All rights reserved.
//
import UIKit
private enum BannerState {
case showing, hidden, gone
}
/// Wheter the banner should appear at the top or the bottom of the screen.
///
/// - Top: The banner will appear at the top.
/// - Bottom: The banner will appear at the bottom.
public enum BannerPosition {
case top, bottom
}
/// A level of 'springiness' for Banners.
///
/// - None: The banner will slide in and not bounce.
/// - Slight: The banner will bounce a little.
/// - Heavy: The banner will bounce a lot.
public enum BannerSpringiness {
case none, slight, heavy
fileprivate var springValues: (damping: CGFloat, velocity: CGFloat) {
switch self {
case .none: return (damping: 1.0, velocity: 1.0)
case .slight: return (damping: 0.7, velocity: 1.5)
case .heavy: return (damping: 0.6, velocity: 2.0)
}
}
}
/// Banner is a dropdown notification view that presents above the main view controller, but below the status bar.
open class Banner: UIView {
@objc class func topWindow() -> UIWindow? {
for window in UIApplication.shared.windows.reversed() {
if window.windowLevel == UIWindow.Level.normal && window.isKeyWindow && window.frame != CGRect.zero { return window }
}
return nil
}
private let contentView = UIView()
private let labelView = UIView()
private let backgroundView = UIView()
/// How long the slide down animation should last.
@objc open var animationDuration: TimeInterval = 0.4
/// The preferred style of the status bar during display of the banner. Defaults to `.LightContent`.
///
/// If the banner's `adjustsStatusBarStyle` is false, this property does nothing.
@objc open var preferredStatusBarStyle = UIStatusBarStyle.lightContent
/// Whether or not this banner should adjust the status bar style during its presentation. Defaults to `false`.
@objc open var adjustsStatusBarStyle = false
/// Wheter the banner should appear at the top or the bottom of the screen. Defaults to `.Top`.
open var position = BannerPosition.top
/// How 'springy' the banner should display. Defaults to `.Slight`
open var springiness = BannerSpringiness.slight
/// The color of the text as well as the image tint color if `shouldTintImage` is `true`.
@objc open var textColor = UIColor.white {
didSet {
resetTintColor()
}
}
/// The height of the banner. Default is 80.
@objc open var minimumHeight: CGFloat = 80
/// Whether or not the banner should show a shadow when presented.
@objc open var hasShadows = true {
didSet {
resetShadows()
}
}
/// The color of the background view. Defaults to `nil`.
override open var backgroundColor: UIColor? {
get { return backgroundView.backgroundColor }
set { backgroundView.backgroundColor = newValue }
}
/// The opacity of the background view. Defaults to 0.95.
override open var alpha: CGFloat {
get { return backgroundView.alpha }
set { backgroundView.alpha = newValue }
}
/// A block to call when the uer taps on the banner.
@objc open var didTapBlock: (() -> ())?
/// A block to call after the banner has finished dismissing and is off screen.
@objc open var didDismissBlock: (() -> ())?
/// Whether or not the banner should dismiss itself when the user taps. Defaults to `true`.
@objc open var dismissesOnTap = true
/// Whether or not the banner should dismiss itself when the user swipes up. Defaults to `true`.
@objc open var dismissesOnSwipe = true
/// Whether or not the banner should tint the associated image to the provided `textColor`. Defaults to `true`.
@objc open var shouldTintImage = true {
didSet {
resetTintColor()
}
}
/// The label that displays the banner's title.
@objc public let titleLabel: UILabel = {
let label = UILabel()
label.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.headline)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
/// The label that displays the banner's subtitle.
@objc public let detailLabel: UILabel = {
let label = UILabel()
label.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.subheadline)
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
/// The image on the left of the banner.
@objc let image: UIImage?
/// The image view that displays the `image`.
@objc public let imageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
return imageView
}()
private var bannerState = BannerState.hidden {
didSet {
if bannerState != oldValue {
forceUpdates()
}
}
}
/// A Banner with the provided `title`, `subtitle`, and optional `image`, ready to be presented with `show()`.
///
/// - parameter title: The title of the banner. Optional. Defaults to nil.
/// - parameter subtitle: The subtitle of the banner. Optional. Defaults to nil.
/// - parameter image: The image on the left of the banner. Optional. Defaults to nil.
/// - parameter backgroundColor: The color of the banner's background view. Defaults to `UIColor.blackColor()`.
/// - parameter didTapBlock: An action to be called when the user taps on the banner. Optional. Defaults to `nil`.
@objc public required init(title: String? = nil, subtitle: String? = nil, image: UIImage? = nil, backgroundColor: UIColor = UIColor.black, didTapBlock: (() -> ())? = nil) {
self.didTapBlock = didTapBlock
self.image = image
super.init(frame: CGRect.zero)
resetShadows()
addGestureRecognizers()
initializeSubviews()
resetTintColor()
imageView.image = image
titleLabel.text = title
detailLabel.text = subtitle
backgroundView.backgroundColor = backgroundColor
backgroundView.alpha = 0.95
}
private func forceUpdates() {
guard let superview = superview, let showingConstraint = showingConstraint, let hiddenConstraint = hiddenConstraint else { return }
switch bannerState {
case .hidden:
superview.removeConstraint(showingConstraint)
superview.addConstraint(hiddenConstraint)
case .showing:
superview.removeConstraint(hiddenConstraint)
superview.addConstraint(showingConstraint)
case .gone:
superview.removeConstraint(hiddenConstraint)
superview.removeConstraint(showingConstraint)
superview.removeConstraints(commonConstraints)
}
setNeedsLayout()
setNeedsUpdateConstraints()
// Managing different -layoutIfNeeded behaviours among iOS versions (for more, read the UIKit iOS 10 release notes)
if #available(iOS 10.0, *) {
superview.layoutIfNeeded()
} else {
layoutIfNeeded()
}
updateConstraintsIfNeeded()
}
@objc internal func didTap(_ recognizer: UITapGestureRecognizer) {
if dismissesOnTap {
dismiss()
}
didTapBlock?()
}
@objc internal func didSwipe(_ recognizer: UISwipeGestureRecognizer) {
if dismissesOnSwipe {
dismiss()
}
}
private func addGestureRecognizers() {
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(Banner.didTap(_:))))
let swipe = UISwipeGestureRecognizer(target: self, action: #selector(Banner.didSwipe(_:)))
swipe.direction = .up
addGestureRecognizer(swipe)
}
private func resetTintColor() {
titleLabel.textColor = textColor
detailLabel.textColor = textColor
imageView.image = shouldTintImage ? image?.withRenderingMode(.alwaysTemplate) : image
imageView.tintColor = shouldTintImage ? textColor : nil
}
private func resetShadows() {
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = self.hasShadows ? 0.5 : 0.0
layer.shadowOffset = CGSize(width: 0, height: 0)
layer.shadowRadius = 4
}
private var contentTopOffsetConstraint: NSLayoutConstraint!
private var contentBottomOffsetConstraint: NSLayoutConstraint!
private var minimumHeightConstraint: NSLayoutConstraint!
private func initializeSubviews() {
let views = [
"backgroundView": backgroundView,
"contentView": contentView,
"imageView": imageView,
"labelView": labelView,
"titleLabel": titleLabel,
"detailLabel": detailLabel
]
translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundView)
minimumHeightConstraint = backgroundView.constraintWithAttribute(.height, .greaterThanOrEqual, to: minimumHeight)
addConstraint(minimumHeightConstraint) // Arbitrary, but looks nice.
addConstraints(backgroundView.constraintsEqualToSuperview())
backgroundView.backgroundColor = backgroundColor
backgroundView.addSubview(contentView)
labelView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(labelView)
labelView.addSubview(titleLabel)
labelView.addSubview(detailLabel)
backgroundView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("H:|[contentView]|", views: views))
contentTopOffsetConstraint = contentView.constraintWithAttribute(.top, .equal, to: .top, of: backgroundView)
contentBottomOffsetConstraint = contentView.constraintWithAttribute(.bottom, .equal, to: .bottom, of: backgroundView)
backgroundView.addConstraint(contentTopOffsetConstraint)
backgroundView.addConstraint(contentBottomOffsetConstraint)
let leftConstraintText: String
if image == nil {
leftConstraintText = "|"
} else {
contentView.addSubview(imageView)
contentView.addConstraint(imageView.constraintWithAttribute(.leading, .equal, to: contentView, constant: 15.0))
contentView.addConstraint(imageView.constraintWithAttribute(.centerY, .equal, to: contentView))
imageView.addConstraint(imageView.constraintWithAttribute(.width, .equal, to: 25.0))
imageView.addConstraint(imageView.constraintWithAttribute(.height, .equal, to: .width))
leftConstraintText = "[imageView]"
}
let constraintFormat = "H:\(leftConstraintText)-(15)-[labelView]-(8)-|"
contentView.translatesAutoresizingMaskIntoConstraints = false
contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat(constraintFormat, views: views))
contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("V:|-(>=1)-[labelView]-(>=1)-|", views: views))
backgroundView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("H:|[contentView]-(<=1)-[labelView]", options: .alignAllCenterY, views: views))
for view in [titleLabel, detailLabel] {
let constraintFormat = "H:|[label]-(8)-|"
contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat(constraintFormat, options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["label": view]))
}
labelView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("V:|-(10)-[titleLabel][detailLabel]-(10)-|", views: views))
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var showingConstraint: NSLayoutConstraint?
private var hiddenConstraint: NSLayoutConstraint?
private var commonConstraints = [NSLayoutConstraint]()
override open func didMoveToSuperview() {
super.didMoveToSuperview()
guard let superview = superview, bannerState != .gone else { return }
commonConstraints = self.constraintsWithAttributes([.width], .equal, to: superview)
superview.addConstraints(commonConstraints)
switch self.position {
case .top:
showingConstraint = self.constraintWithAttribute(.top, .equal, to: .top, of: superview)
let yOffset: CGFloat = -7.0 // Offset the bottom constraint to make room for the shadow to animate off screen.
hiddenConstraint = self.constraintWithAttribute(.bottom, .equal, to: .top, of: superview, constant: yOffset)
case .bottom:
showingConstraint = self.constraintWithAttribute(.bottom, .equal, to: .bottom, of: superview)
let yOffset: CGFloat = 7.0 // Offset the bottom constraint to make room for the shadow to animate off screen.
hiddenConstraint = self.constraintWithAttribute(.top, .equal, to: .bottom, of: superview, constant: yOffset)
}
}
open override func layoutSubviews() {
super.layoutSubviews()
adjustHeightOffset()
layoutIfNeeded()
}
private func adjustHeightOffset() {
guard let superview = superview else { return }
if superview === Banner.topWindow() && self.position == .top {
let statusBarSize = UIApplication.shared.statusBarFrame.size
let heightOffset = min(statusBarSize.height, statusBarSize.width) // Arbitrary, but looks nice.
contentTopOffsetConstraint.constant = heightOffset
contentBottomOffsetConstraint.constant = 0
minimumHeightConstraint.constant = statusBarSize.height > 0 ? minimumHeight : 40
} else {
var bottomSpacing: CGFloat = 0
if #available(iOS 11.0, *) {
bottomSpacing = safeAreaInsets.bottom // handle the safe area for iPhone X models
}
contentTopOffsetConstraint.constant = 0
contentBottomOffsetConstraint.constant = -bottomSpacing
minimumHeightConstraint.constant = 0
}
}
/// Shows the banner. If a view is specified, the banner will be displayed at the top of that view, otherwise at top of the top window. If a `duration` is specified, the banner dismisses itself automatically after that duration elapses.
/// - parameter view: A view the banner will be shown in. Optional. Defaults to 'nil', which in turn means it will be shown in the top window. duration A time interval, after which the banner will dismiss itself. Optional. Defaults to `nil`.
open func show(_ view: UIView? = nil, duration: TimeInterval? = nil) {
let viewToUse = view ?? Banner.topWindow()
guard let view = viewToUse else {
print("[Banner]: Could not find view. Aborting.")
return
}
view.addSubview(self)
forceUpdates()
let (damping, velocity) = self.springiness.springValues
let oldStatusBarStyle = UIApplication.shared.statusBarStyle
if adjustsStatusBarStyle {
UIApplication.shared.setStatusBarStyle(preferredStatusBarStyle, animated: true)
}
UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: damping, initialSpringVelocity: velocity, options: .allowUserInteraction, animations: {
self.bannerState = .showing
}, completion: { finished in
guard let duration = duration else { return }
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(1000.0 * duration))) {
self.dismiss(self.adjustsStatusBarStyle ? oldStatusBarStyle : nil)
}
})
}
/// Dismisses the banner.
open func dismiss(_ oldStatusBarStyle: UIStatusBarStyle? = nil) {
let (damping, velocity) = self.springiness.springValues
UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: damping, initialSpringVelocity: velocity, options: .allowUserInteraction, animations: {
self.bannerState = .hidden
if let oldStatusBarStyle = oldStatusBarStyle {
UIApplication.shared.setStatusBarStyle(oldStatusBarStyle, animated: true)
}
}, completion: { finished in
self.bannerState = .gone
self.removeFromSuperview()
self.didDismissBlock?()
})
}
}
extension NSLayoutConstraint {
@objc class func defaultConstraintsWithVisualFormat(_ format: String, options: NSLayoutConstraint.FormatOptions = NSLayoutConstraint.FormatOptions(), metrics: [String: AnyObject]? = nil, views: [String: AnyObject] = [:]) -> [NSLayoutConstraint] {
return NSLayoutConstraint.constraints(withVisualFormat: format, options: options, metrics: metrics, views: views)
}
}
extension UIView {
@objc func constraintsEqualToSuperview(_ edgeInsets: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
self.translatesAutoresizingMaskIntoConstraints = false
var constraints = [NSLayoutConstraint]()
if let superview = self.superview {
constraints.append(self.constraintWithAttribute(.leading, .equal, to: superview, constant: edgeInsets.left))
constraints.append(self.constraintWithAttribute(.trailing, .equal, to: superview, constant: edgeInsets.right))
constraints.append(self.constraintWithAttribute(.top, .equal, to: superview, constant: edgeInsets.top))
constraints.append(self.constraintWithAttribute(.bottom, .equal, to: superview, constant: edgeInsets.bottom))
}
return constraints
}
@objc func constraintWithAttribute(_ attribute: NSLayoutConstraint.Attribute, _ relation: NSLayoutConstraint.Relation, to constant: CGFloat, multiplier: CGFloat = 1.0) -> NSLayoutConstraint {
self.translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: nil, attribute: .notAnAttribute, multiplier: multiplier, constant: constant)
}
@objc func constraintWithAttribute(_ attribute: NSLayoutConstraint.Attribute, _ relation: NSLayoutConstraint.Relation, to otherAttribute: NSLayoutConstraint.Attribute, of item: AnyObject? = nil, multiplier: CGFloat = 1.0, constant: CGFloat = 0.0) -> NSLayoutConstraint {
self.translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: item ?? self, attribute: otherAttribute, multiplier: multiplier, constant: constant)
}
@objc func constraintWithAttribute(_ attribute: NSLayoutConstraint.Attribute, _ relation: NSLayoutConstraint.Relation, to item: AnyObject, multiplier: CGFloat = 1.0, constant: CGFloat = 0.0) -> NSLayoutConstraint {
self.translatesAutoresizingMaskIntoConstraints = false
return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: item, attribute: attribute, multiplier: multiplier, constant: constant)
}
func constraintsWithAttributes(_ attributes: [NSLayoutConstraint.Attribute], _ relation: NSLayoutConstraint.Relation, to item: AnyObject, multiplier: CGFloat = 1.0, constant: CGFloat = 0.0) -> [NSLayoutConstraint] {
return attributes.map { self.constraintWithAttribute($0, relation, to: item, multiplier: multiplier, constant: constant) }
}
}