BaseNotificationBanner.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. // This file is based on third party code, see below for the original author
  2. // and original license.
  3. // Modifications are (c) by Threema GmbH and licensed under the AGPLv3.
  4. /*
  5. The MIT License (MIT)
  6. Copyright (c) 2017-2018 Dalton Hinterscher
  7. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
  8. to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
  9. and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
  10. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
  11. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  12. MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
  13. ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH
  14. THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  15. */
  16. import UIKit
  17. import SnapKit
  18. import MarqueeLabel
  19. public protocol NotificationBannerDelegate: class {
  20. func notificationBannerWillAppear(_ banner: BaseNotificationBanner)
  21. func notificationBannerDidAppear(_ banner: BaseNotificationBanner)
  22. func notificationBannerWillDisappear(_ banner: BaseNotificationBanner)
  23. func notificationBannerDidDisappear(_ banner: BaseNotificationBanner)
  24. }
  25. @objcMembers
  26. open class BaseNotificationBanner: UIView {
  27. /// Notification that will be posted when a notification banner will appear
  28. public static let BannerWillAppear: Notification.Name = Notification.Name(rawValue: "NotificationBannerWillAppear")
  29. /// Notification that will be posted when a notification banner did appear
  30. public static let BannerDidAppear: Notification.Name = Notification.Name(rawValue: "NotificationBannerDidAppear")
  31. /// Notification that will be posted when a notification banner will appear
  32. public static let BannerWillDisappear: Notification.Name = Notification.Name(rawValue: "NotificationBannerWillDisappear")
  33. /// Notification that will be posted when a notification banner did appear
  34. public static let BannerDidDisappear: Notification.Name = Notification.Name(rawValue: "NotificationBannerDidDisappear")
  35. /// Notification banner object key that is included with each Notification
  36. public static let BannerObjectKey: String = "NotificationBannerObjectKey"
  37. /// The delegate of the notification banner
  38. public weak var delegate: NotificationBannerDelegate?
  39. /// The style of the notification banner
  40. public let style: BannerStyle
  41. /// The height of the banner when it is presented
  42. public var bannerHeight: CGFloat {
  43. get {
  44. if let customBannerHeight = customBannerHeight {
  45. return customBannerHeight
  46. } else {
  47. return shouldAdjustForNotchFeaturedIphone() ? 88.0 : 64.0 + heightAdjustment
  48. }
  49. } set {
  50. customBannerHeight = newValue
  51. }
  52. }
  53. /// The topmost label of the notification if a custom view is not desired
  54. public internal(set) var titleLabel: UILabel?
  55. /// The time before the notificaiton is automatically dismissed
  56. public var duration: TimeInterval = 5.0 {
  57. didSet {
  58. updateMarqueeLabelsDurations()
  59. }
  60. }
  61. /// If false, the banner will not be dismissed until the developer programatically dismisses it
  62. public var autoDismiss: Bool = true {
  63. didSet {
  64. if !autoDismiss {
  65. dismissOnTap = false
  66. dismissOnSwipeUp = false
  67. }
  68. }
  69. }
  70. /// The transparency of the background of the notification banner
  71. public var transparency: CGFloat = 1.0 {
  72. didSet {
  73. if let customView = customView {
  74. customView.backgroundColor = customView.backgroundColor?.withAlphaComponent(transparency)
  75. } else {
  76. let color = backgroundColor
  77. self.backgroundColor = color
  78. }
  79. }
  80. }
  81. /// The type of haptic to generate when a banner is displayed
  82. public var haptic: BannerHaptic = .heavy
  83. /// If true, notification will dismissed when tapped
  84. public var dismissOnTap: Bool = true
  85. /// If true, notification will dismissed when swiped up
  86. public var dismissOnSwipeUp: Bool = true
  87. /// Closure that will be executed if the notification banner is tapped
  88. public var onTap: (() -> Void)?
  89. /// Closure that will be executed if the notification banner is swiped up
  90. public var onSwipeUp: (() -> Void)?
  91. /// Responsible for positioning and auto managing notification banners
  92. public var bannerQueue: NotificationBannerQueue = NotificationBannerQueue.default
  93. /// Banner show and dimiss animation duration
  94. public var animationDuration: TimeInterval = 0.5
  95. /// Wether or not the notification banner is currently being displayed
  96. public var isDisplaying: Bool = false
  97. /// The view that the notification layout is presented on. The constraints/frame of this should not be changed
  98. internal var contentView: UIView!
  99. /// A view that helps the spring animation look nice when the banner appears
  100. internal var spacerView: UIView!
  101. // The custom view inside the notification banner
  102. internal var customView: UIView?
  103. /// The default offset for spacerView top or bottom
  104. internal var spacerViewDefaultOffset: CGFloat = 10.0
  105. /// The maximum number of banners simultaneously visible on screen
  106. internal var maximumVisibleBanners: Int = 1
  107. /// The default padding between edges and views
  108. internal var padding: CGFloat = 15.0
  109. /// The view controller to display the banner on. This is useful if you are wanting to display a banner underneath a navigation bar
  110. internal weak var parentViewController: UIViewController?
  111. /// If this is not nil, then this height will be used instead of the auto calculated height
  112. internal var customBannerHeight: CGFloat?
  113. ///***** BEGIN THREEMA MODIFICATION: add identifier for the banner *********/
  114. /// Identifier for the banner
  115. internal var identifier: String?
  116. ///***** END THREEMA MODIFICATION: add identifier for the banner *********/
  117. /// Used by the banner queue to determine wether a notification banner was placed in front of it in the queue
  118. var isSuspended: Bool = false
  119. /// The main window of the application which banner views are placed on
  120. private let appWindow: UIWindow? = {
  121. if #available(iOS 13.0, *) {
  122. return UIApplication.shared.connectedScenes
  123. .first { $0.activationState == .foregroundActive }
  124. .map { $0 as? UIWindowScene }
  125. .map { $0?.windows.first } ?? UIApplication.shared.delegate?.window ?? nil
  126. }
  127. return UIApplication.shared.delegate?.window ?? nil
  128. }()
  129. /// The position the notification banner should slide in from
  130. private(set) var bannerPosition: BannerPosition!
  131. /// The notification banner sides edges insets from superview. If presented - spacerView color will be transparent
  132. internal var bannerEdgeInsets: UIEdgeInsets? = nil {
  133. didSet {
  134. if bannerEdgeInsets != nil {
  135. spacerView.backgroundColor = .clear
  136. }
  137. }
  138. }
  139. /// Object that stores the start and end frames for the notification banner based on the provided banner position
  140. internal var bannerPositionFrame: BannerPositionFrame!
  141. /// The user info that gets passed to each notification
  142. private var notificationUserInfo: [String: BaseNotificationBanner] {
  143. return [BaseNotificationBanner.BannerObjectKey: self]
  144. }
  145. open override var backgroundColor: UIColor? {
  146. get {
  147. return contentView.backgroundColor
  148. } set {
  149. guard style != .customView else { return }
  150. let color = newValue?.withAlphaComponent(transparency)
  151. contentView.backgroundColor = color
  152. spacerView.backgroundColor = color
  153. }
  154. }
  155. init(style: BannerStyle, colors: BannerColorsProtocol? = nil) {
  156. self.style = style
  157. super.init(frame: .zero)
  158. spacerView = UIView()
  159. addSubview(spacerView)
  160. contentView = UIView()
  161. addSubview(contentView)
  162. if let colors = colors {
  163. backgroundColor = colors.color(for: style)
  164. } else {
  165. backgroundColor = BannerColors().color(for: style)
  166. }
  167. let swipeUpGesture = UISwipeGestureRecognizer(target: self, action: #selector(onSwipeUpGestureRecognizer))
  168. swipeUpGesture.direction = .up
  169. addGestureRecognizer(swipeUpGesture)
  170. }
  171. required public init?(coder aDecoder: NSCoder) {
  172. fatalError("init(coder:) has not been implemented")
  173. }
  174. deinit {
  175. NotificationCenter.default.removeObserver(self,
  176. name: UIDevice.orientationDidChangeNotification,
  177. object: nil)
  178. }
  179. /**
  180. Creates the proper banner constraints based on the desired banner position
  181. */
  182. private func createBannerConstraints(for bannerPosition: BannerPosition) {
  183. spacerView.snp.remakeConstraints { (make) in
  184. if bannerPosition == .top {
  185. make.top.equalToSuperview().offset(-spacerViewDefaultOffset)
  186. } else {
  187. make.bottom.equalToSuperview().offset(spacerViewDefaultOffset)
  188. }
  189. make.left.equalToSuperview()
  190. make.right.equalToSuperview()
  191. updateSpacerViewHeight(make: make)
  192. }
  193. contentView.snp.remakeConstraints { (make) in
  194. if bannerPosition == .top {
  195. make.top.equalTo(spacerView.snp.bottom)
  196. make.bottom.equalToSuperview()
  197. } else {
  198. make.top.equalToSuperview()
  199. make.bottom.equalTo(spacerView.snp.top)
  200. }
  201. make.left.equalToSuperview()
  202. make.right.equalToSuperview()
  203. }
  204. }
  205. /**
  206. Updates the spacer view height. Specifically used for orientation changes.
  207. */
  208. private func updateSpacerViewHeight(make: ConstraintMaker? = nil) {
  209. let finalHeight = spacerViewHeight()
  210. if let make = make {
  211. make.height.equalTo(finalHeight)
  212. } else {
  213. spacerView.snp.updateConstraints({ (make) in
  214. make.height.equalTo(finalHeight)
  215. })
  216. }
  217. }
  218. internal func spacerViewHeight() -> CGFloat {
  219. return NotificationBannerUtilities.isNotchFeaturedIPhone()
  220. && UIApplication.shared.statusBarOrientation.isPortrait
  221. && (parentViewController?.navigationController?.isNavigationBarHidden ?? true) ? 40.0 : 10.0
  222. }
  223. private func finishBannerYOffset() -> CGFloat {
  224. let bannerIndex = (bannerQueue.banners.firstIndex(of: self) ?? bannerQueue.banners.filter { $0.isDisplaying }.count)
  225. return bannerQueue.banners.prefix(bannerIndex).reduce(0) { $0
  226. + $1.bannerHeight
  227. - (bannerPosition == .top ? spacerViewHeight() : 0) // notch spacer height for top position only
  228. + (bannerPosition == .top ? spacerViewDefaultOffset : -spacerViewDefaultOffset) // to reduct additions in createBannerConstraints (it's needed for proper shadow framing)
  229. + (bannerPosition == .top ? spacerViewDefaultOffset : -spacerViewDefaultOffset) // default space between banners
  230. // this calculations are made only for banners except first one, for first banner it'll be 0
  231. }
  232. }
  233. internal func updateBannerPositionFrames() {
  234. guard let window = appWindow else { return }
  235. bannerPositionFrame = BannerPositionFrame(bannerPosition: bannerPosition,
  236. bannerWidth: window.width,
  237. bannerHeight: bannerHeight,
  238. maxY: maximumYPosition(),
  239. finishYOffset: finishBannerYOffset(),
  240. edgeInsets: bannerEdgeInsets)
  241. }
  242. internal func animateUpdatedBannerPositionFrames() {
  243. UIView.animate(withDuration: animationDuration,
  244. delay: 0.0,
  245. usingSpringWithDamping: 0.7,
  246. initialSpringVelocity: 1,
  247. options: [.curveLinear, .allowUserInteraction],animations: {
  248. self.frame = self.bannerPositionFrame.endFrame
  249. })
  250. }
  251. /**
  252. Places a NotificationBanner on the queue and shows it if its the first one in the queue
  253. - parameter queuePosition: The position to show the notification banner. If the position is .front, the
  254. banner will be displayed immediately
  255. - parameter bannerPosition: The position the notification banner should slide in from
  256. - parameter queue: The queue to display the notification banner on. It is up to the developer
  257. to manage multiple banner queues and prevent any conflicts that may occur.
  258. - parameter viewController: The view controller to display the notifification banner on. If nil, it will
  259. be placed on the main app window
  260. */
  261. public func show(queuePosition: QueuePosition = .back,
  262. bannerPosition: BannerPosition = .top,
  263. queue: NotificationBannerQueue = NotificationBannerQueue.default,
  264. on viewController: UIViewController? = nil) {
  265. parentViewController = viewController
  266. bannerQueue = queue
  267. show(placeOnQueue: true, queuePosition: queuePosition, bannerPosition: bannerPosition)
  268. }
  269. /**
  270. Places a NotificationBanner on the queue and shows it if its the first one in the queue
  271. - parameter placeOnQueue: If false, banner will not be placed on the queue and will be showed/resumed immediately
  272. - parameter queuePosition: The position to show the notification banner. If the position is .front, the
  273. banner will be displayed immediately
  274. - parameter bannerPosition: The position the notification banner should slide in from
  275. */
  276. func show(placeOnQueue: Bool,
  277. queuePosition: QueuePosition = .back,
  278. bannerPosition: BannerPosition = .top) {
  279. guard !isDisplaying else {
  280. return
  281. }
  282. self.bannerPosition = bannerPosition
  283. createBannerConstraints(for: bannerPosition)
  284. updateBannerPositionFrames()
  285. NotificationCenter.default.removeObserver(self,
  286. name: UIDevice.orientationDidChangeNotification,
  287. object: nil)
  288. NotificationCenter.default.addObserver(self,
  289. selector: #selector(onOrientationChanged),
  290. name: UIDevice.orientationDidChangeNotification,
  291. object: nil)
  292. if placeOnQueue {
  293. bannerQueue.addBanner(self,
  294. bannerPosition: bannerPosition,
  295. queuePosition: queuePosition)
  296. } else {
  297. self.frame = bannerPositionFrame.startFrame
  298. if let parentViewController = parentViewController {
  299. parentViewController.view.addSubview(self)
  300. if statusBarShouldBeShown() {
  301. appWindow?.windowLevel = UIWindow.Level.normal
  302. }
  303. } else {
  304. appWindow?.addSubview(self)
  305. if statusBarShouldBeShown() && !(parentViewController == nil && bannerPosition == .top) {
  306. appWindow?.windowLevel = UIWindow.Level.normal
  307. } else {
  308. appWindow?.windowLevel = UIWindow.Level.statusBar + 1
  309. }
  310. }
  311. NotificationCenter.default.post(name: BaseNotificationBanner.BannerWillAppear, object: self, userInfo: notificationUserInfo)
  312. delegate?.notificationBannerWillAppear(self)
  313. let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.onTapGestureRecognizer))
  314. self.addGestureRecognizer(tapGestureRecognizer)
  315. self.isDisplaying = true
  316. let bannerIndex = Double(bannerQueue.banners.firstIndex(of: self) ?? 0) + 1
  317. UIView.animate(withDuration: animationDuration * bannerIndex,
  318. delay: 0.0,
  319. usingSpringWithDamping: 0.7,
  320. initialSpringVelocity: 1,
  321. options: [.curveLinear, .allowUserInteraction],
  322. animations: {
  323. BannerHapticGenerator.generate(self.haptic)
  324. self.frame = self.bannerPositionFrame.endFrame
  325. }) { (completed) in
  326. NotificationCenter.default.post(name: BaseNotificationBanner.BannerDidAppear, object: self, userInfo: self.notificationUserInfo)
  327. self.delegate?.notificationBannerDidAppear(self)
  328. /* We don't want to add the selector if another banner was queued in front of it
  329. before it finished animating or if it is meant to be shown infinitely
  330. */
  331. if !self.isSuspended && self.autoDismiss {
  332. self.perform(#selector(self.dismiss), with: nil, afterDelay: self.duration)
  333. }
  334. }
  335. }
  336. }
  337. /**
  338. Suspends a notification banner so it will not be dismissed. This happens because a new notification banner was placed in front of it on the queue.
  339. */
  340. func suspend() {
  341. if autoDismiss {
  342. NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(dismiss), object: nil)
  343. isSuspended = true
  344. isDisplaying = false
  345. }
  346. }
  347. /**
  348. Resumes a notification banner immediately.
  349. */
  350. func resume() {
  351. if autoDismiss {
  352. self.perform(#selector(dismiss), with: nil, afterDelay: self.duration)
  353. isSuspended = false
  354. isDisplaying = true
  355. }
  356. }
  357. /**
  358. Resets a notification banner's elapsed duration to zero.
  359. */
  360. public func resetDuration() {
  361. if autoDismiss {
  362. NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(dismiss), object: nil)
  363. self.perform(#selector(dismiss), with: nil, afterDelay: self.duration)
  364. }
  365. }
  366. /**
  367. The height adjustment needed in order for the banner to look properly displayed.
  368. */
  369. internal var heightAdjustment: CGFloat {
  370. // iOS 13 does not allow covering the status bar on non-notch iPhones
  371. // The banner needs to be moved further down under the status bar in this case
  372. guard #available(iOS 13.0, *), !NotificationBannerUtilities.isNotchFeaturedIPhone() else {
  373. return 0
  374. }
  375. return UIApplication.shared.statusBarFrame.height
  376. }
  377. /**
  378. Update banner height, it's necessary after banner labels font update
  379. */
  380. internal func updateBannerHeight() {
  381. onOrientationChanged()
  382. }
  383. /**
  384. Changes the frame of the notification banner when the orientation of the device changes
  385. */
  386. @objc private dynamic func onOrientationChanged() {
  387. guard let window = appWindow else { return }
  388. updateSpacerViewHeight()
  389. let edgeInsets = bannerEdgeInsets ?? .zero
  390. let newY = (bannerPosition == .top) ? (frame.origin.y) : (window.height - bannerHeight + edgeInsets.top - edgeInsets.bottom)
  391. frame = CGRect(x: frame.origin.x,
  392. y: newY,
  393. width: window.width - edgeInsets.left - edgeInsets.right,
  394. height: bannerHeight)
  395. bannerPositionFrame = BannerPositionFrame(bannerPosition: bannerPosition,
  396. bannerWidth: window.width,
  397. bannerHeight: bannerHeight,
  398. maxY: maximumYPosition(),
  399. finishYOffset: finishBannerYOffset(),
  400. edgeInsets: bannerEdgeInsets)
  401. }
  402. /**
  403. Dismisses the NotificationBanner and shows the next one if there is one to show on the queue
  404. */
  405. @objc public func dismiss(forced: Bool = false) {
  406. guard isDisplaying else {
  407. return
  408. }
  409. NSObject.cancelPreviousPerformRequests(withTarget: self,
  410. selector: #selector(dismiss),
  411. object: nil)
  412. NotificationCenter.default.post(name: BaseNotificationBanner.BannerWillDisappear, object: self, userInfo: notificationUserInfo)
  413. delegate?.notificationBannerWillDisappear(self)
  414. isDisplaying = false
  415. remove()
  416. UIView.animate(withDuration: forced ? animationDuration / 2 : animationDuration,
  417. animations: {
  418. self.frame = self.bannerPositionFrame.startFrame
  419. }) { (completed) in
  420. self.removeFromSuperview()
  421. NotificationCenter.default.post(name: BaseNotificationBanner.BannerDidDisappear, object: self, userInfo: self.notificationUserInfo)
  422. self.delegate?.notificationBannerDidDisappear(self)
  423. self.bannerQueue.showNext(callback: { (isEmpty) in
  424. if isEmpty || self.statusBarShouldBeShown() {
  425. self.appWindow?.windowLevel = UIWindow.Level.normal
  426. }
  427. })
  428. }
  429. }
  430. /**
  431. Removes the NotificationBanner from the queue if not displaying
  432. */
  433. public func remove() {
  434. guard !isDisplaying else {
  435. return
  436. }
  437. bannerQueue.removeBanner(self)
  438. }
  439. /**
  440. Called when a notification banner is tapped
  441. */
  442. @objc private dynamic func onTapGestureRecognizer() {
  443. if dismissOnTap {
  444. dismiss()
  445. }
  446. onTap?()
  447. }
  448. /**
  449. Called when a notification banner is swiped up
  450. */
  451. @objc private dynamic func onSwipeUpGestureRecognizer() {
  452. if dismissOnSwipeUp {
  453. dismiss()
  454. }
  455. onSwipeUp?()
  456. }
  457. /**
  458. Determines wether or not the status bar should be shown when displaying a banner underneath
  459. the navigation bar
  460. */
  461. private func statusBarShouldBeShown() -> Bool {
  462. for banner in bannerQueue.banners {
  463. if (banner.parentViewController == nil && banner.bannerPosition == .top) {
  464. return false
  465. }
  466. }
  467. return true
  468. }
  469. /**
  470. Calculates the maximum `y` position that a notification banner can slide in from
  471. */
  472. private func maximumYPosition() -> CGFloat {
  473. if let parentViewController = parentViewController {
  474. return parentViewController.view.frame.height
  475. } else {
  476. return appWindow?.height ?? 0
  477. }
  478. }
  479. /**
  480. Determines wether or not we should adjust the banner for notch featured iPhone
  481. */
  482. internal func shouldAdjustForNotchFeaturedIphone() -> Bool {
  483. return NotificationBannerUtilities.isNotchFeaturedIPhone()
  484. && UIApplication.shared.statusBarOrientation.isPortrait
  485. && (self.parentViewController?.navigationController?.isNavigationBarHidden ?? true)
  486. }
  487. /**
  488. Updates the scrolling marquee label duration
  489. */
  490. internal func updateMarqueeLabelsDurations() {
  491. (titleLabel as? MarqueeLabel)?.speed = .duration(CGFloat(duration <= 3 ? 0.5 : duration - 3))
  492. }
  493. }