123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606 |
- // This file is based on third party code, see below for the original author
- // and original license.
- // Modifications are (c) by Threema GmbH and licensed under the AGPLv3.
- /*
- The MIT License (MIT)
- Copyright (c) 2017-2018 Dalton Hinterscher
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
- to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
- 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:
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
- ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH
- THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
- import UIKit
- import SnapKit
- import MarqueeLabel
- public protocol NotificationBannerDelegate: class {
- func notificationBannerWillAppear(_ banner: BaseNotificationBanner)
- func notificationBannerDidAppear(_ banner: BaseNotificationBanner)
- func notificationBannerWillDisappear(_ banner: BaseNotificationBanner)
- func notificationBannerDidDisappear(_ banner: BaseNotificationBanner)
- }
- @objcMembers
- open class BaseNotificationBanner: UIView {
- /// Notification that will be posted when a notification banner will appear
- public static let BannerWillAppear: Notification.Name = Notification.Name(rawValue: "NotificationBannerWillAppear")
- /// Notification that will be posted when a notification banner did appear
- public static let BannerDidAppear: Notification.Name = Notification.Name(rawValue: "NotificationBannerDidAppear")
- /// Notification that will be posted when a notification banner will appear
- public static let BannerWillDisappear: Notification.Name = Notification.Name(rawValue: "NotificationBannerWillDisappear")
- /// Notification that will be posted when a notification banner did appear
- public static let BannerDidDisappear: Notification.Name = Notification.Name(rawValue: "NotificationBannerDidDisappear")
- /// Notification banner object key that is included with each Notification
- public static let BannerObjectKey: String = "NotificationBannerObjectKey"
- /// The delegate of the notification banner
- public weak var delegate: NotificationBannerDelegate?
- /// The style of the notification banner
- public let style: BannerStyle
- /// The height of the banner when it is presented
- public var bannerHeight: CGFloat {
- get {
- if let customBannerHeight = customBannerHeight {
- return customBannerHeight
- } else {
- return shouldAdjustForNotchFeaturedIphone() ? 88.0 : 64.0 + heightAdjustment
- }
- } set {
- customBannerHeight = newValue
- }
- }
- /// The topmost label of the notification if a custom view is not desired
- public internal(set) var titleLabel: UILabel?
- /// The time before the notificaiton is automatically dismissed
- public var duration: TimeInterval = 5.0 {
- didSet {
- updateMarqueeLabelsDurations()
- }
- }
- /// If false, the banner will not be dismissed until the developer programatically dismisses it
- public var autoDismiss: Bool = true {
- didSet {
- if !autoDismiss {
- dismissOnTap = false
- dismissOnSwipeUp = false
- }
- }
- }
- /// The transparency of the background of the notification banner
- public var transparency: CGFloat = 1.0 {
- didSet {
- if let customView = customView {
- customView.backgroundColor = customView.backgroundColor?.withAlphaComponent(transparency)
- } else {
- let color = backgroundColor
- self.backgroundColor = color
- }
- }
- }
- /// The type of haptic to generate when a banner is displayed
- public var haptic: BannerHaptic = .heavy
- /// If true, notification will dismissed when tapped
- public var dismissOnTap: Bool = true
- /// If true, notification will dismissed when swiped up
- public var dismissOnSwipeUp: Bool = true
- /// Closure that will be executed if the notification banner is tapped
- public var onTap: (() -> Void)?
- /// Closure that will be executed if the notification banner is swiped up
- public var onSwipeUp: (() -> Void)?
- /// Responsible for positioning and auto managing notification banners
- public var bannerQueue: NotificationBannerQueue = NotificationBannerQueue.default
- /// Banner show and dimiss animation duration
- public var animationDuration: TimeInterval = 0.5
- /// Wether or not the notification banner is currently being displayed
- public var isDisplaying: Bool = false
- /// The view that the notification layout is presented on. The constraints/frame of this should not be changed
- internal var contentView: UIView!
- /// A view that helps the spring animation look nice when the banner appears
- internal var spacerView: UIView!
- // The custom view inside the notification banner
- internal var customView: UIView?
- /// The default offset for spacerView top or bottom
- internal var spacerViewDefaultOffset: CGFloat = 10.0
- /// The maximum number of banners simultaneously visible on screen
- internal var maximumVisibleBanners: Int = 1
- /// The default padding between edges and views
- internal var padding: CGFloat = 15.0
- /// The view controller to display the banner on. This is useful if you are wanting to display a banner underneath a navigation bar
- internal weak var parentViewController: UIViewController?
- /// If this is not nil, then this height will be used instead of the auto calculated height
- internal var customBannerHeight: CGFloat?
-
- ///***** BEGIN THREEMA MODIFICATION: add identifier for the banner *********/
- /// Identifier for the banner
- internal var identifier: String?
- ///***** END THREEMA MODIFICATION: add identifier for the banner *********/
- /// Used by the banner queue to determine wether a notification banner was placed in front of it in the queue
- var isSuspended: Bool = false
- /// The main window of the application which banner views are placed on
- private let appWindow: UIWindow? = {
- if #available(iOS 13.0, *) {
- return UIApplication.shared.connectedScenes
- .first { $0.activationState == .foregroundActive }
- .map { $0 as? UIWindowScene }
- .map { $0?.windows.first } ?? UIApplication.shared.delegate?.window ?? nil
- }
- return UIApplication.shared.delegate?.window ?? nil
- }()
- /// The position the notification banner should slide in from
- private(set) var bannerPosition: BannerPosition!
- /// The notification banner sides edges insets from superview. If presented - spacerView color will be transparent
- internal var bannerEdgeInsets: UIEdgeInsets? = nil {
- didSet {
- if bannerEdgeInsets != nil {
- spacerView.backgroundColor = .clear
- }
- }
- }
- /// Object that stores the start and end frames for the notification banner based on the provided banner position
- internal var bannerPositionFrame: BannerPositionFrame!
- /// The user info that gets passed to each notification
- private var notificationUserInfo: [String: BaseNotificationBanner] {
- return [BaseNotificationBanner.BannerObjectKey: self]
- }
- open override var backgroundColor: UIColor? {
- get {
- return contentView.backgroundColor
- } set {
- guard style != .customView else { return }
- let color = newValue?.withAlphaComponent(transparency)
- contentView.backgroundColor = color
- spacerView.backgroundColor = color
- }
- }
- init(style: BannerStyle, colors: BannerColorsProtocol? = nil) {
- self.style = style
- super.init(frame: .zero)
- spacerView = UIView()
- addSubview(spacerView)
- contentView = UIView()
- addSubview(contentView)
- if let colors = colors {
- backgroundColor = colors.color(for: style)
- } else {
- backgroundColor = BannerColors().color(for: style)
- }
- let swipeUpGesture = UISwipeGestureRecognizer(target: self, action: #selector(onSwipeUpGestureRecognizer))
- swipeUpGesture.direction = .up
- addGestureRecognizer(swipeUpGesture)
- }
- required public init?(coder aDecoder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- deinit {
- NotificationCenter.default.removeObserver(self,
- name: UIDevice.orientationDidChangeNotification,
- object: nil)
- }
- /**
- Creates the proper banner constraints based on the desired banner position
- */
- private func createBannerConstraints(for bannerPosition: BannerPosition) {
- spacerView.snp.remakeConstraints { (make) in
- if bannerPosition == .top {
- make.top.equalToSuperview().offset(-spacerViewDefaultOffset)
- } else {
- make.bottom.equalToSuperview().offset(spacerViewDefaultOffset)
- }
- make.left.equalToSuperview()
- make.right.equalToSuperview()
- updateSpacerViewHeight(make: make)
- }
- contentView.snp.remakeConstraints { (make) in
- if bannerPosition == .top {
- make.top.equalTo(spacerView.snp.bottom)
- make.bottom.equalToSuperview()
- } else {
- make.top.equalToSuperview()
- make.bottom.equalTo(spacerView.snp.top)
- }
- make.left.equalToSuperview()
- make.right.equalToSuperview()
- }
- }
- /**
- Updates the spacer view height. Specifically used for orientation changes.
- */
- private func updateSpacerViewHeight(make: ConstraintMaker? = nil) {
- let finalHeight = spacerViewHeight()
- if let make = make {
- make.height.equalTo(finalHeight)
- } else {
- spacerView.snp.updateConstraints({ (make) in
- make.height.equalTo(finalHeight)
- })
- }
- }
- internal func spacerViewHeight() -> CGFloat {
- return NotificationBannerUtilities.isNotchFeaturedIPhone()
- && UIApplication.shared.statusBarOrientation.isPortrait
- && (parentViewController?.navigationController?.isNavigationBarHidden ?? true) ? 40.0 : 10.0
- }
- private func finishBannerYOffset() -> CGFloat {
- let bannerIndex = (bannerQueue.banners.firstIndex(of: self) ?? bannerQueue.banners.filter { $0.isDisplaying }.count)
-
- return bannerQueue.banners.prefix(bannerIndex).reduce(0) { $0
- + $1.bannerHeight
- - (bannerPosition == .top ? spacerViewHeight() : 0) // notch spacer height for top position only
- + (bannerPosition == .top ? spacerViewDefaultOffset : -spacerViewDefaultOffset) // to reduct additions in createBannerConstraints (it's needed for proper shadow framing)
- + (bannerPosition == .top ? spacerViewDefaultOffset : -spacerViewDefaultOffset) // default space between banners
- // this calculations are made only for banners except first one, for first banner it'll be 0
- }
- }
-
- internal func updateBannerPositionFrames() {
- guard let window = appWindow else { return }
- bannerPositionFrame = BannerPositionFrame(bannerPosition: bannerPosition,
- bannerWidth: window.width,
- bannerHeight: bannerHeight,
- maxY: maximumYPosition(),
- finishYOffset: finishBannerYOffset(),
- edgeInsets: bannerEdgeInsets)
- }
- internal func animateUpdatedBannerPositionFrames() {
- UIView.animate(withDuration: animationDuration,
- delay: 0.0,
- usingSpringWithDamping: 0.7,
- initialSpringVelocity: 1,
- options: [.curveLinear, .allowUserInteraction],animations: {
- self.frame = self.bannerPositionFrame.endFrame
- })
- }
- /**
- Places a NotificationBanner on the queue and shows it if its the first one in the queue
- - parameter queuePosition: The position to show the notification banner. If the position is .front, the
- banner will be displayed immediately
- - parameter bannerPosition: The position the notification banner should slide in from
- - parameter queue: The queue to display the notification banner on. It is up to the developer
- to manage multiple banner queues and prevent any conflicts that may occur.
- - parameter viewController: The view controller to display the notifification banner on. If nil, it will
- be placed on the main app window
- */
- public func show(queuePosition: QueuePosition = .back,
- bannerPosition: BannerPosition = .top,
- queue: NotificationBannerQueue = NotificationBannerQueue.default,
- on viewController: UIViewController? = nil) {
- parentViewController = viewController
- bannerQueue = queue
- show(placeOnQueue: true, queuePosition: queuePosition, bannerPosition: bannerPosition)
- }
- /**
- Places a NotificationBanner on the queue and shows it if its the first one in the queue
- - parameter placeOnQueue: If false, banner will not be placed on the queue and will be showed/resumed immediately
- - parameter queuePosition: The position to show the notification banner. If the position is .front, the
- banner will be displayed immediately
- - parameter bannerPosition: The position the notification banner should slide in from
- */
- func show(placeOnQueue: Bool,
- queuePosition: QueuePosition = .back,
- bannerPosition: BannerPosition = .top) {
- guard !isDisplaying else {
- return
- }
- self.bannerPosition = bannerPosition
- createBannerConstraints(for: bannerPosition)
- updateBannerPositionFrames()
- NotificationCenter.default.removeObserver(self,
- name: UIDevice.orientationDidChangeNotification,
- object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(onOrientationChanged),
- name: UIDevice.orientationDidChangeNotification,
- object: nil)
- if placeOnQueue {
- bannerQueue.addBanner(self,
- bannerPosition: bannerPosition,
- queuePosition: queuePosition)
- } else {
- self.frame = bannerPositionFrame.startFrame
- if let parentViewController = parentViewController {
- parentViewController.view.addSubview(self)
- if statusBarShouldBeShown() {
- appWindow?.windowLevel = UIWindow.Level.normal
- }
- } else {
- appWindow?.addSubview(self)
- if statusBarShouldBeShown() && !(parentViewController == nil && bannerPosition == .top) {
- appWindow?.windowLevel = UIWindow.Level.normal
- } else {
- appWindow?.windowLevel = UIWindow.Level.statusBar + 1
- }
- }
- NotificationCenter.default.post(name: BaseNotificationBanner.BannerWillAppear, object: self, userInfo: notificationUserInfo)
- delegate?.notificationBannerWillAppear(self)
- let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.onTapGestureRecognizer))
- self.addGestureRecognizer(tapGestureRecognizer)
- self.isDisplaying = true
- let bannerIndex = Double(bannerQueue.banners.firstIndex(of: self) ?? 0) + 1
- UIView.animate(withDuration: animationDuration * bannerIndex,
- delay: 0.0,
- usingSpringWithDamping: 0.7,
- initialSpringVelocity: 1,
- options: [.curveLinear, .allowUserInteraction],
- animations: {
- BannerHapticGenerator.generate(self.haptic)
- self.frame = self.bannerPositionFrame.endFrame
- }) { (completed) in
- NotificationCenter.default.post(name: BaseNotificationBanner.BannerDidAppear, object: self, userInfo: self.notificationUserInfo)
- self.delegate?.notificationBannerDidAppear(self)
- /* We don't want to add the selector if another banner was queued in front of it
- before it finished animating or if it is meant to be shown infinitely
- */
- if !self.isSuspended && self.autoDismiss {
- self.perform(#selector(self.dismiss), with: nil, afterDelay: self.duration)
- }
- }
- }
- }
- /**
- 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.
- */
- func suspend() {
- if autoDismiss {
- NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(dismiss), object: nil)
- isSuspended = true
- isDisplaying = false
- }
- }
- /**
- Resumes a notification banner immediately.
- */
- func resume() {
- if autoDismiss {
- self.perform(#selector(dismiss), with: nil, afterDelay: self.duration)
- isSuspended = false
- isDisplaying = true
- }
- }
- /**
- Resets a notification banner's elapsed duration to zero.
- */
- public func resetDuration() {
- if autoDismiss {
- NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(dismiss), object: nil)
- self.perform(#selector(dismiss), with: nil, afterDelay: self.duration)
- }
- }
-
- /**
- The height adjustment needed in order for the banner to look properly displayed.
- */
- internal var heightAdjustment: CGFloat {
- // iOS 13 does not allow covering the status bar on non-notch iPhones
- // The banner needs to be moved further down under the status bar in this case
- guard #available(iOS 13.0, *), !NotificationBannerUtilities.isNotchFeaturedIPhone() else {
- return 0
- }
- return UIApplication.shared.statusBarFrame.height
- }
- /**
- Update banner height, it's necessary after banner labels font update
- */
- internal func updateBannerHeight() {
- onOrientationChanged()
- }
- /**
- Changes the frame of the notification banner when the orientation of the device changes
- */
- @objc private dynamic func onOrientationChanged() {
- guard let window = appWindow else { return }
- updateSpacerViewHeight()
- let edgeInsets = bannerEdgeInsets ?? .zero
- let newY = (bannerPosition == .top) ? (frame.origin.y) : (window.height - bannerHeight + edgeInsets.top - edgeInsets.bottom)
- frame = CGRect(x: frame.origin.x,
- y: newY,
- width: window.width - edgeInsets.left - edgeInsets.right,
- height: bannerHeight)
- bannerPositionFrame = BannerPositionFrame(bannerPosition: bannerPosition,
- bannerWidth: window.width,
- bannerHeight: bannerHeight,
- maxY: maximumYPosition(),
- finishYOffset: finishBannerYOffset(),
- edgeInsets: bannerEdgeInsets)
- }
- /**
- Dismisses the NotificationBanner and shows the next one if there is one to show on the queue
- */
- @objc public func dismiss(forced: Bool = false) {
- guard isDisplaying else {
- return
- }
- NSObject.cancelPreviousPerformRequests(withTarget: self,
- selector: #selector(dismiss),
- object: nil)
- NotificationCenter.default.post(name: BaseNotificationBanner.BannerWillDisappear, object: self, userInfo: notificationUserInfo)
- delegate?.notificationBannerWillDisappear(self)
- isDisplaying = false
- remove()
- UIView.animate(withDuration: forced ? animationDuration / 2 : animationDuration,
- animations: {
- self.frame = self.bannerPositionFrame.startFrame
- }) { (completed) in
- self.removeFromSuperview()
- NotificationCenter.default.post(name: BaseNotificationBanner.BannerDidDisappear, object: self, userInfo: self.notificationUserInfo)
- self.delegate?.notificationBannerDidDisappear(self)
- self.bannerQueue.showNext(callback: { (isEmpty) in
- if isEmpty || self.statusBarShouldBeShown() {
- self.appWindow?.windowLevel = UIWindow.Level.normal
- }
- })
- }
- }
- /**
- Removes the NotificationBanner from the queue if not displaying
- */
- public func remove() {
- guard !isDisplaying else {
- return
- }
- bannerQueue.removeBanner(self)
- }
- /**
- Called when a notification banner is tapped
- */
- @objc private dynamic func onTapGestureRecognizer() {
- if dismissOnTap {
- dismiss()
- }
- onTap?()
- }
- /**
- Called when a notification banner is swiped up
- */
- @objc private dynamic func onSwipeUpGestureRecognizer() {
- if dismissOnSwipeUp {
- dismiss()
- }
- onSwipeUp?()
- }
- /**
- Determines wether or not the status bar should be shown when displaying a banner underneath
- the navigation bar
- */
- private func statusBarShouldBeShown() -> Bool {
- for banner in bannerQueue.banners {
- if (banner.parentViewController == nil && banner.bannerPosition == .top) {
- return false
- }
- }
- return true
- }
- /**
- Calculates the maximum `y` position that a notification banner can slide in from
- */
- private func maximumYPosition() -> CGFloat {
- if let parentViewController = parentViewController {
- return parentViewController.view.frame.height
- } else {
- return appWindow?.height ?? 0
- }
- }
- /**
- Determines wether or not we should adjust the banner for notch featured iPhone
- */
- internal func shouldAdjustForNotchFeaturedIphone() -> Bool {
- return NotificationBannerUtilities.isNotchFeaturedIPhone()
- && UIApplication.shared.statusBarOrientation.isPortrait
- && (self.parentViewController?.navigationController?.isNavigationBarHidden ?? true)
- }
- /**
- Updates the scrolling marquee label duration
- */
- internal func updateMarqueeLabelsDurations() {
- (titleLabel as? MarqueeLabel)?.speed = .duration(CGFloat(duration <= 3 ? 0.5 : duration - 3))
- }
- }
|