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

6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
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 == UIWindow.Level.normal && 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 public let titleLabel: UILabel = {
  96. let label = UILabel()
  97. label.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.headline)
  98. label.numberOfLines = 0
  99. label.translatesAutoresizingMaskIntoConstraints = false
  100. return label
  101. }()
  102. /// The label that displays the banner's subtitle.
  103. @objc public let detailLabel: UILabel = {
  104. let label = UILabel()
  105. label.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.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 public 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 contentBottomOffsetConstraint: NSLayoutConstraint!
  202. private var minimumHeightConstraint: NSLayoutConstraint!
  203. private func initializeSubviews() {
  204. let views = [
  205. "backgroundView": backgroundView,
  206. "contentView": contentView,
  207. "imageView": imageView,
  208. "labelView": labelView,
  209. "titleLabel": titleLabel,
  210. "detailLabel": detailLabel
  211. ]
  212. translatesAutoresizingMaskIntoConstraints = false
  213. addSubview(backgroundView)
  214. minimumHeightConstraint = backgroundView.constraintWithAttribute(.height, .greaterThanOrEqual, to: minimumHeight)
  215. addConstraint(minimumHeightConstraint) // Arbitrary, but looks nice.
  216. addConstraints(backgroundView.constraintsEqualToSuperview())
  217. backgroundView.backgroundColor = backgroundColor
  218. backgroundView.addSubview(contentView)
  219. labelView.translatesAutoresizingMaskIntoConstraints = false
  220. contentView.addSubview(labelView)
  221. labelView.addSubview(titleLabel)
  222. labelView.addSubview(detailLabel)
  223. backgroundView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("H:|[contentView]|", views: views))
  224. contentTopOffsetConstraint = contentView.constraintWithAttribute(.top, .equal, to: .top, of: backgroundView)
  225. contentBottomOffsetConstraint = contentView.constraintWithAttribute(.bottom, .equal, to: .bottom, of: backgroundView)
  226. backgroundView.addConstraint(contentTopOffsetConstraint)
  227. backgroundView.addConstraint(contentBottomOffsetConstraint)
  228. let leftConstraintText: String
  229. if image == nil {
  230. leftConstraintText = "|"
  231. } else {
  232. contentView.addSubview(imageView)
  233. contentView.addConstraint(imageView.constraintWithAttribute(.leading, .equal, to: contentView, constant: 15.0))
  234. contentView.addConstraint(imageView.constraintWithAttribute(.centerY, .equal, to: contentView))
  235. imageView.addConstraint(imageView.constraintWithAttribute(.width, .equal, to: 25.0))
  236. imageView.addConstraint(imageView.constraintWithAttribute(.height, .equal, to: .width))
  237. leftConstraintText = "[imageView]"
  238. }
  239. let constraintFormat = "H:\(leftConstraintText)-(15)-[labelView]-(8)-|"
  240. contentView.translatesAutoresizingMaskIntoConstraints = false
  241. contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat(constraintFormat, views: views))
  242. contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("V:|-(>=1)-[labelView]-(>=1)-|", views: views))
  243. backgroundView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("H:|[contentView]-(<=1)-[labelView]", options: .alignAllCenterY, views: views))
  244. for view in [titleLabel, detailLabel] {
  245. let constraintFormat = "H:|[label]-(8)-|"
  246. contentView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat(constraintFormat, options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["label": view]))
  247. }
  248. labelView.addConstraints(NSLayoutConstraint.defaultConstraintsWithVisualFormat("V:|-(10)-[titleLabel][detailLabel]-(10)-|", views: views))
  249. }
  250. required public init?(coder aDecoder: NSCoder) {
  251. fatalError("init(coder:) has not been implemented")
  252. }
  253. private var showingConstraint: NSLayoutConstraint?
  254. private var hiddenConstraint: NSLayoutConstraint?
  255. private var commonConstraints = [NSLayoutConstraint]()
  256. override open func didMoveToSuperview() {
  257. super.didMoveToSuperview()
  258. guard let superview = superview, bannerState != .gone else { return }
  259. commonConstraints = self.constraintsWithAttributes([.width], .equal, to: superview)
  260. superview.addConstraints(commonConstraints)
  261. switch self.position {
  262. case .top:
  263. showingConstraint = self.constraintWithAttribute(.top, .equal, to: .top, of: superview)
  264. let yOffset: CGFloat = -7.0 // Offset the bottom constraint to make room for the shadow to animate off screen.
  265. hiddenConstraint = self.constraintWithAttribute(.bottom, .equal, to: .top, of: superview, constant: yOffset)
  266. case .bottom:
  267. showingConstraint = self.constraintWithAttribute(.bottom, .equal, to: .bottom, of: superview)
  268. let yOffset: CGFloat = 7.0 // Offset the bottom constraint to make room for the shadow to animate off screen.
  269. hiddenConstraint = self.constraintWithAttribute(.top, .equal, to: .bottom, of: superview, constant: yOffset)
  270. }
  271. }
  272. open override func layoutSubviews() {
  273. super.layoutSubviews()
  274. adjustHeightOffset()
  275. layoutIfNeeded()
  276. }
  277. private func adjustHeightOffset() {
  278. guard let superview = superview else { return }
  279. if superview === Banner.topWindow() && self.position == .top {
  280. let statusBarSize = UIApplication.shared.statusBarFrame.size
  281. let heightOffset = min(statusBarSize.height, statusBarSize.width) // Arbitrary, but looks nice.
  282. contentTopOffsetConstraint.constant = heightOffset
  283. contentBottomOffsetConstraint.constant = 0
  284. minimumHeightConstraint.constant = statusBarSize.height > 0 ? minimumHeight : 40
  285. } else {
  286. var bottomSpacing: CGFloat = 0
  287. if #available(iOS 11.0, *) {
  288. bottomSpacing = safeAreaInsets.bottom // handle the safe area for iPhone X models
  289. }
  290. contentTopOffsetConstraint.constant = 0
  291. contentBottomOffsetConstraint.constant = -bottomSpacing
  292. minimumHeightConstraint.constant = 0
  293. }
  294. }
  295. /// 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.
  296. /// - 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`.
  297. open func show(_ view: UIView? = nil, duration: TimeInterval? = nil) {
  298. let viewToUse = view ?? Banner.topWindow()
  299. guard let view = viewToUse else {
  300. print("[Banner]: Could not find view. Aborting.")
  301. return
  302. }
  303. view.addSubview(self)
  304. forceUpdates()
  305. let (damping, velocity) = self.springiness.springValues
  306. let oldStatusBarStyle = UIApplication.shared.statusBarStyle
  307. if adjustsStatusBarStyle {
  308. UIApplication.shared.setStatusBarStyle(preferredStatusBarStyle, animated: true)
  309. }
  310. UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: damping, initialSpringVelocity: velocity, options: .allowUserInteraction, animations: {
  311. self.bannerState = .showing
  312. }, completion: { finished in
  313. guard let duration = duration else { return }
  314. DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(1000.0 * duration))) {
  315. self.dismiss(self.adjustsStatusBarStyle ? oldStatusBarStyle : nil)
  316. }
  317. })
  318. }
  319. /// Dismisses the banner.
  320. open func dismiss(_ oldStatusBarStyle: UIStatusBarStyle? = nil) {
  321. let (damping, velocity) = self.springiness.springValues
  322. UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: damping, initialSpringVelocity: velocity, options: .allowUserInteraction, animations: {
  323. self.bannerState = .hidden
  324. if let oldStatusBarStyle = oldStatusBarStyle {
  325. UIApplication.shared.setStatusBarStyle(oldStatusBarStyle, animated: true)
  326. }
  327. }, completion: { finished in
  328. self.bannerState = .gone
  329. self.removeFromSuperview()
  330. self.didDismissBlock?()
  331. })
  332. }
  333. }
  334. extension NSLayoutConstraint {
  335. @objc class func defaultConstraintsWithVisualFormat(_ format: String, options: NSLayoutConstraint.FormatOptions = NSLayoutConstraint.FormatOptions(), metrics: [String: AnyObject]? = nil, views: [String: AnyObject] = [:]) -> [NSLayoutConstraint] {
  336. return NSLayoutConstraint.constraints(withVisualFormat: format, options: options, metrics: metrics, views: views)
  337. }
  338. }
  339. extension UIView {
  340. @objc func constraintsEqualToSuperview(_ edgeInsets: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
  341. self.translatesAutoresizingMaskIntoConstraints = false
  342. var constraints = [NSLayoutConstraint]()
  343. if let superview = self.superview {
  344. constraints.append(self.constraintWithAttribute(.leading, .equal, to: superview, constant: edgeInsets.left))
  345. constraints.append(self.constraintWithAttribute(.trailing, .equal, to: superview, constant: edgeInsets.right))
  346. constraints.append(self.constraintWithAttribute(.top, .equal, to: superview, constant: edgeInsets.top))
  347. constraints.append(self.constraintWithAttribute(.bottom, .equal, to: superview, constant: edgeInsets.bottom))
  348. }
  349. return constraints
  350. }
  351. @objc func constraintWithAttribute(_ attribute: NSLayoutConstraint.Attribute, _ relation: NSLayoutConstraint.Relation, to constant: CGFloat, multiplier: CGFloat = 1.0) -> NSLayoutConstraint {
  352. self.translatesAutoresizingMaskIntoConstraints = false
  353. return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: nil, attribute: .notAnAttribute, multiplier: multiplier, constant: constant)
  354. }
  355. @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 {
  356. self.translatesAutoresizingMaskIntoConstraints = false
  357. return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: item ?? self, attribute: otherAttribute, multiplier: multiplier, constant: constant)
  358. }
  359. @objc func constraintWithAttribute(_ attribute: NSLayoutConstraint.Attribute, _ relation: NSLayoutConstraint.Relation, to item: AnyObject, multiplier: CGFloat = 1.0, constant: CGFloat = 0.0) -> NSLayoutConstraint {
  360. self.translatesAutoresizingMaskIntoConstraints = false
  361. return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: relation, toItem: item, attribute: attribute, multiplier: multiplier, constant: constant)
  362. }
  363. func constraintsWithAttributes(_ attributes: [NSLayoutConstraint.Attribute], _ relation: NSLayoutConstraint.Relation, to item: AnyObject, multiplier: CGFloat = 1.0, constant: CGFloat = 0.0) -> [NSLayoutConstraint] {
  364. return attributes.map { self.constraintWithAttribute($0, relation, to: item, multiplier: multiplier, constant: constant) }
  365. }
  366. }