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.

408 lines
19 KiB

6 years ago
  1. //
  2. // Banner.swift
  3. //
  4. // Created by Harlan Haskins on 7/27/15.
  5. // Copyright (c) 2015 Bryx. All rights reserved.
  6. //
  7. import UIKit
  8. private enum BannerState {
  9. case showing, hidden, gone
  10. }
  11. /// Wheter the banner should appear at the top or the bottom of the screen.
  12. ///
  13. /// - Top: The banner will appear at the top.
  14. /// - Bottom: The banner will appear at the bottom.
  15. public enum BannerPosition {
  16. case top, bottom
  17. }
  18. /// A level of 'springiness' for Banners.
  19. ///
  20. /// - None: The banner will slide in and not bounce.
  21. /// - Slight: The banner will bounce a little.
  22. /// - Heavy: The banner will bounce a lot.
  23. public enum BannerSpringiness {
  24. case none, slight, heavy
  25. fileprivate var springValues: (damping: CGFloat, velocity: CGFloat) {
  26. switch self {
  27. case .none: return (damping: 1.0, velocity: 1.0)
  28. case .slight: return (damping: 0.7, velocity: 1.5)
  29. case .heavy: return (damping: 0.6, velocity: 2.0)
  30. }
  31. }
  32. }
  33. /// Banner is a dropdown notification view that presents above the main view controller, but below the status bar.
  34. open class Banner: UIView {
  35. @objc class func topWindow() -> UIWindow? {
  36. for window in UIApplication.shared.windows.reversed() {
  37. if window.windowLevel == UIWindowLevelNormal && window.isKeyWindow && window.frame != CGRect.zero { return window }
  38. }
  39. return nil
  40. }
  41. private let contentView = UIView()
  42. private let labelView = UIView()
  43. private let backgroundView = UIView()
  44. /// How long the slide down animation should last.
  45. @objc open var animationDuration: TimeInterval = 0.4
  46. /// The preferred style of the status bar during display of the banner. Defaults to `.LightContent`.
  47. ///
  48. /// If the banner's `adjustsStatusBarStyle` is false, this property does nothing.
  49. @objc open var preferredStatusBarStyle = UIStatusBarStyle.lightContent
  50. /// Whether or not this banner should adjust the status bar style during its presentation. Defaults to `false`.
  51. @objc open var adjustsStatusBarStyle = false
  52. /// Wheter the banner should appear at the top or the bottom of the screen. Defaults to `.Top`.
  53. open var position = BannerPosition.top
  54. /// How 'springy' the banner should display. Defaults to `.Slight`
  55. open var springiness = BannerSpringiness.slight
  56. /// The color of the text as well as the image tint color if `shouldTintImage` is `true`.
  57. @objc open var textColor = UIColor.white {
  58. didSet {
  59. resetTintColor()
  60. }
  61. }
  62. /// The height of the banner. Default is 80.
  63. @objc open var minimumHeight: CGFloat = 80
  64. /// Whether or not the banner should show a shadow when presented.
  65. @objc open var hasShadows = true {
  66. didSet {
  67. resetShadows()
  68. }
  69. }
  70. /// The color of the background view. Defaults to `nil`.
  71. override open var backgroundColor: UIColor? {
  72. get { return backgroundView.backgroundColor }
  73. set { backgroundView.backgroundColor = newValue }
  74. }
  75. /// The opacity of the background view. Defaults to 0.95.
  76. override open var alpha: CGFloat {
  77. get { return backgroundView.alpha }
  78. set { backgroundView.alpha = newValue }
  79. }
  80. /// A block to call when the uer taps on the banner.
  81. @objc open var didTapBlock: (() -> ())?
  82. /// A block to call after the banner has finished dismissing and is off screen.
  83. @objc open var didDismissBlock: (() -> ())?
  84. /// Whether or not the banner should dismiss itself when the user taps. Defaults to `true`.
  85. @objc open var dismissesOnTap = true
  86. /// Whether or not the banner should dismiss itself when the user swipes up. Defaults to `true`.
  87. @objc open var dismissesOnSwipe = true
  88. /// Whether or not the banner should tint the associated image to the provided `textColor`. Defaults to `true`.
  89. @objc open var shouldTintImage = true {
  90. didSet {
  91. resetTintColor()
  92. }
  93. }
  94. /// The label that displays the banner's title.
  95. @objc open let titleLabel: UILabel = {
  96. let label = UILabel()
  97. label.font = UIFont.preferredFont(forTextStyle: UIFontTextStyle.headline)
  98. label.numberOfLines = 0
  99. label.translatesAutoresizingMaskIntoConstraints = false
  100. return label
  101. }()
  102. /// The label that displays the banner's subtitle.
  103. @objc open let detailLabel: UILabel = {
  104. let label = UILabel()
  105. label.font = UIFont.preferredFont(forTextStyle: UIFontTextStyle.subheadline)
  106. label.numberOfLines = 0
  107. label.translatesAutoresizingMaskIntoConstraints = false
  108. return label
  109. }()
  110. /// The image on the left of the banner.
  111. @objc let image: UIImage?
  112. /// The image view that displays the `image`.
  113. @objc open let imageView: UIImageView = {
  114. let imageView = UIImageView()
  115. imageView.translatesAutoresizingMaskIntoConstraints = false
  116. imageView.contentMode = .scaleAspectFit
  117. return imageView
  118. }()
  119. private var bannerState = BannerState.hidden {
  120. didSet {
  121. if bannerState != oldValue {
  122. forceUpdates()
  123. }
  124. }
  125. }
  126. /// A Banner with the provided `title`, `subtitle`, and optional `image`, ready to be presented with `show()`.
  127. ///
  128. /// - parameter title: The title of the banner. Optional. Defaults to nil.
  129. /// - parameter subtitle: The subtitle of the banner. Optional. Defaults to nil.
  130. /// - parameter image: The image on the left of the banner. Optional. Defaults to nil.
  131. /// - parameter backgroundColor: The color of the banner's background view. Defaults to `UIColor.blackColor()`.
  132. /// - parameter didTapBlock: An action to be called when the user taps on the banner. Optional. Defaults to `nil`.
  133. @objc public required init(title: String? = nil, subtitle: String? = nil, image: UIImage? = nil, backgroundColor: UIColor = UIColor.black, didTapBlock: (() -> ())? = nil) {
  134. self.didTapBlock = didTapBlock
  135. self.image = image
  136. super.init(frame: CGRect.zero)
  137. resetShadows()
  138. addGestureRecognizers()
  139. initializeSubviews()
  140. resetTintColor()
  141. imageView.image = image
  142. titleLabel.text = title
  143. detailLabel.text = subtitle
  144. backgroundView.backgroundColor = backgroundColor
  145. backgroundView.alpha = 0.95
  146. }
  147. private func forceUpdates() {
  148. guard let superview = superview, let showingConstraint = showingConstraint, let hiddenConstraint = hiddenConstraint else { return }
  149. switch bannerState {
  150. case .hidden:
  151. superview.removeConstraint(showingConstraint)
  152. superview.addConstraint(hiddenConstraint)
  153. case .showing:
  154. superview.removeConstraint(hiddenConstraint)
  155. superview.addConstraint(showingConstraint)
  156. case .gone:
  157. superview.removeConstraint(hiddenConstraint)
  158. superview.removeConstraint(showingConstraint)
  159. superview.removeConstraints(commonConstraints)
  160. }
  161. setNeedsLayout()
  162. setNeedsUpdateConstraints()
  163. // Managing different -layoutIfNeeded behaviours among iOS versions (for more, read the UIKit iOS 10 release notes)
  164. if #available(iOS 10.0, *) {
  165. superview.layoutIfNeeded()
  166. } else {
  167. layoutIfNeeded()
  168. }
  169. updateConstraintsIfNeeded()
  170. }
  171. @objc internal func didTap(_ recognizer: UITapGestureRecognizer) {
  172. if dismissesOnTap {
  173. dismiss()
  174. }
  175. didTapBlock?()
  176. }
  177. @objc internal func didSwipe(_ recognizer: UISwipeGestureRecognizer) {
  178. if dismissesOnSwipe {
  179. dismiss()
  180. }
  181. }
  182. private func addGestureRecognizers() {
  183. addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(Banner.didTap(_:))))
  184. let swipe = UISwipeGestureRecognizer(target: self, action: #selector(Banner.didSwipe(_:)))
  185. swipe.direction = .up
  186. addGestureRecognizer(swipe)
  187. }
  188. private func resetTintColor() {
  189. titleLabel.textColor = textColor
  190. detailLabel.textColor = textColor
  191. imageView.image = shouldTintImage ? image?.withRenderingMode(.alwaysTemplate) : image
  192. imageView.tintColor = shouldTintImage ? textColor : nil
  193. }
  194. private func resetShadows() {
  195. layer.shadowColor = UIColor.black.cgColor
  196. layer.shadowOpacity = self.hasShadows ? 0.5 : 0.0
  197. layer.shadowOffset = CGSize(width: 0, height: 0)
  198. layer.shadowRadius = 4
  199. }
  200. private var contentTopOffsetConstraint: NSLayoutConstraint!
  201. private var minimumHeightConstraint: NSLayoutConstraint!
  202. private func initializeSubviews() {
  203. let views = [
  204. "backgroundView": backgroundView,
  205. "contentView": contentView,
  206. "imageView": imageView,
  207. "labelView": labelView,
  208. "titleLabel": titleLabel,
  209. "detailLabel": detailLabel
  210. ]
  211. translatesAutoresizingMaskIntoConstraints = false
  212. addSubview(backgroundView)
  213. minimumHeightConstraint = backgroundView.constraintWithAttribute(.height, .greaterThanOrEqual, to: minimumHeight)
  214. addConstraint(minimumHeightConstraint) // Arbitrary, but looks nice.
  215. addConstraints(backgroundView.constraintsEqualToSuperview())
  216. backgroundView.backgroundColor = backgroundColor
  217. backgroundView.addSubview(contentView)
  218. labelView.translatesAutoresizingMaskIntoConstraints = false
  219. contentView.addSubview(labelView)
  220. labelView.addSubview(titleLabel)
  221. labelView.addSubview(detailLabel)
  222. backgroundView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("H:|[contentView]|", views: views))
  223. backgroundView.addConstraint(contentView.constraintWithAttribute(.bottom, .equal, to: .bottom, of: backgroundView))
  224. contentTopOffsetConstraint = contentView.constraintWithAttribute(.top, .equal, to: .top, of: backgroundView)
  225. backgroundView.addConstraint(contentTopOffsetConstraint)
  226. let leftConstraintText: String
  227. if image == nil {
  228. leftConstraintText = "|"
  229. } else {
  230. contentView.addSubview(imageView)
  231. contentView.addConstraint(imageView.constraintWithAttribute(.leading, .equal, to: contentView, constant: 15.0))
  232. contentView.addConstraint(imageView.constraintWithAttribute(.centerY, .equal, to: contentView))
  233. imageView.addConstraint(imageView.constraintWithAttribute(.width, .equal, to: 25.0))
  234. imageView.addConstraint(imageView.constraintWithAttribute(.height, .equal, to: .width))
  235. leftConstraintText = "[imageView]"
  236. }
  237. let constraintFormat = "H:\(leftConstraintText)-(15)-[labelView]-(8)-|"
  238. contentView.translatesAutoresizingMaskIntoConstraints = false
  239. contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat(constraintFormat, views: views))
  240. contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("V:|-(>=1)-[labelView]-(>=1)-|", views: views))
  241. backgroundView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("H:|[contentView]-(<=1)-[labelView]", options: .alignAllCenterY, views: views))
  242. for view in [titleLabel, detailLabel] {
  243. let constraintFormat = "H:|[label]-(8)-|"
  244. contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat(constraintFormat, options: NSLayoutFormatOptions(), metrics: nil, views: ["label": view]))
  245. }
  246. labelView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("V:|-(10)-[titleLabel][detailLabel]-(10)-|", views: views))
  247. }
  248. required public init?(coder aDecoder: NSCoder) {
  249. fatalError("init(coder:) has not been implemented")
  250. }
  251. private var showingConstraint: NSLayoutConstraint?
  252. private var hiddenConstraint: NSLayoutConstraint?
  253. private var commonConstraints = [NSLayoutConstraint]()
  254. override open func didMoveToSuperview() {
  255. super.didMoveToSuperview()
  256. guard let superview = superview, bannerState != .gone else { return }
  257. commonConstraints = self.constraintsWithAttributes([.width], .equal, to: superview)
  258. superview.addConstraints(commonConstraints)
  259. switch self.position {
  260. case .top:
  261. showingConstraint = self.constraintWithAttribute(.top, .equal, to: .top, of: superview)
  262. let yOffset: CGFloat = -7.0 // Offset the bottom constraint to make room for the shadow to animate off screen.
  263. hiddenConstraint = self.constraintWithAttribute(.bottom, .equal, to: .top, of: superview, constant: yOffset)
  264. case .bottom:
  265. showingConstraint = self.constraintWithAttribute(.bottom, .equal, to: .bottom, of: superview)
  266. let yOffset: CGFloat = 7.0 // Offset the bottom constraint to make room for the shadow to animate off screen.
  267. hiddenConstraint = self.constraintWithAttribute(.top, .equal, to: .bottom, of: superview, constant: yOffset)
  268. }
  269. }
  270. open override func layoutSubviews() {
  271. super.layoutSubviews()
  272. adjustHeightOffset()
  273. layoutIfNeeded()
  274. }
  275. private func adjustHeightOffset() {
  276. guard let superview = superview else { return }
  277. if superview === Banner.topWindow() && self.position == .top {
  278. let statusBarSize = UIApplication.shared.statusBarFrame.size
  279. let heightOffset = min(statusBarSize.height, statusBarSize.width) // Arbitrary, but looks nice.
  280. contentTopOffsetConstraint.constant = heightOffset
  281. minimumHeightConstraint.constant = statusBarSize.height > 0 ? minimumHeight : 40
  282. } else {
  283. contentTopOffsetConstraint.constant = 0
  284. minimumHeightConstraint.constant = 0
  285. }
  286. }
  287. /// 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.
  288. /// - 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`.
  289. open func show(_ view: UIView? = nil, duration: TimeInterval? = nil) {
  290. let viewToUse = view ?? Banner.topWindow()
  291. guard let view = viewToUse else {
  292. print("[Banner]: Could not find view. Aborting.")
  293. return
  294. }
  295. view.addSubview(self)
  296. forceUpdates()
  297. let (damping, velocity) = self.springiness.springValues
  298. let oldStatusBarStyle = UIApplication.shared.statusBarStyle
  299. if adjustsStatusBarStyle {
  300. UIApplication.shared.setStatusBarStyle(preferredStatusBarStyle, animated: true)
  301. }
  302. UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: damping, initialSpringVelocity: velocity, options: .allowUserInteraction, animations: {
  303. self.bannerState = .showing
  304. }, completion: { finished in
  305. guard let duration = duration else { return }
  306. DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(1000.0 * duration))) {
  307. self.dismiss(self.adjustsStatusBarStyle ? oldStatusBarStyle : nil)
  308. }
  309. })
  310. }
  311. /// Dismisses the banner.
  312. open func dismiss(_ oldStatusBarStyle: UIStatusBarStyle? = nil) {
  313. let (damping, velocity) = self.springiness.springValues
  314. UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: damping, initialSpringVelocity: velocity, options: .allowUserInteraction, animations: {
  315. self.bannerState = .hidden
  316. if let oldStatusBarStyle = oldStatusBarStyle {
  317. UIApplication.shared.setStatusBarStyle(oldStatusBarStyle, animated: true)
  318. }
  319. }, completion: { finished in
  320. self.bannerState = .gone
  321. self.removeFromSuperview()
  322. self.didDismissBlock?()
  323. })
  324. }
  325. }
  326. extension NSLayoutConstraint {
  327. @objc class func defaultConstraintsWithVisualFormat(_ format: String, options: NSLayoutFormatOptions = NSLayoutFormatOptions(), metrics: [String: AnyObject]? = nil, views: [String: AnyObject] = [:]) -> [NSLayoutConstraint] {
  328. return NSLayoutConstraint.constraints(withVisualFormat: format, options: options, metrics: metrics, views: views)
  329. }
  330. }
  331. extension UIView {
  332. @objc func constraintsEqualToSuperview(_ edgeInsets: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
  333. self.translatesAutoresizingMaskIntoConstraints = false
  334. var constraints = [NSLayoutConstraint]()
  335. if let superview = self.superview {
  336. constraints.append(self.constraintWithAttribute(.leading, .equal, to: superview, constant: edgeInsets.left))
  337. constraints.append(self.constraintWithAttribute(.trailing, .equal, to: superview, constant: edgeInsets.right))
  338. constraints.append(self.constraintWithAttribute(.top, .equal, to: superview, constant: edgeInsets.top))
  339. constraints.append(self.constraintWithAttribute(.bottom, .equal, to: superview, constant: edgeInsets.bottom))
  340. }
  341. return constraints
  342. }
  343. @objc func constraintWithAttribute(_ attribute: NSLayoutAttribute, _ relation: NSLayoutRelation, to constant: CGFloat, multiplier: CGFloat = 1.0) -> NSLayoutConstraint {
  344. self.translatesAutoresizingMaskIntoConstraints = false
  345. return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: nil, attribute: .notAnAttribute, multiplier: multiplier, constant: constant)
  346. }
  347. @objc func constraintWithAttribute(_ attribute: NSLayoutAttribute, _ relation: NSLayoutRelation, to otherAttribute: NSLayoutAttribute, of item: AnyObject? = nil, multiplier: CGFloat = 1.0, constant: CGFloat = 0.0) -> NSLayoutConstraint {
  348. self.translatesAutoresizingMaskIntoConstraints = false
  349. return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: item ?? self, attribute: otherAttribute, multiplier: multiplier, constant: constant)
  350. }
  351. @objc func constraintWithAttribute(_ attribute: NSLayoutAttribute, _ relation: NSLayoutRelation, to item: AnyObject, multiplier: CGFloat = 1.0, constant: CGFloat = 0.0) -> NSLayoutConstraint {
  352. self.translatesAutoresizingMaskIntoConstraints = false
  353. return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: item, attribute: attribute, multiplier: multiplier, constant: constant)
  354. }
  355. func constraintsWithAttributes(_ attributes: [NSLayoutAttribute], _ relation: NSLayoutRelation, to item: AnyObject, multiplier: CGFloat = 1.0, constant: CGFloat = 0.0) -> [NSLayoutConstraint] {
  356. return attributes.map { self.constraintWithAttribute($0, relation, to: item, multiplier: multiplier, constant: constant) }
  357. }
  358. }