Instantly share code, notes, and snippets.
alexjohnj/BannerView.swift
Last active Dec 6, 2017
A UIView subclass that animates a banner down from the top of the screen (iOS 11+, Supports iPhone X)
// swiftlint:disable file_length | |
// | |
// Created by Alex Jackson on 05/12/2017. | |
// | |
//Copyright 2017 Alex Jackson | |
// | |
//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 | |
/** | |
# BannerView | |
A view that slides down from the top of a container view to display a message. `BannerView` uses its parent view's safe | |
area layout guides to position itself relative to status bars and navigation bars. `BannerView`s contain a gesture | |
recogniser so a user can swipe up to dismiss the banner. Banners can be configured to dismiss themselves automatically | |
after a timeout (transient) or require user to dismiss them with a swipe (sticky) using the `BannerView.Mode` enum. | |
## Usage | |
The content of a `BannerView` is configured using the `BannerView.Message` struct. This defines a property for the | |
banner's title and optional properties for the banner's subtitle and image. | |
You create a `BannerView` using `init(message:style:)` passing in instances of `BannerView.Message` and `BannerStyle` | |
(see the section on styling). Once initialised, you can configure the banner's display mode using the `mode` property. | |
To present the banner, call `present(in:)` on the banner, passing in the view controller that will contain the banner. | |
How the banner is presented depends on what type of view controller you pass in. See the documentation for | |
`present(in:)` to see how the banner decides. In general, it should do what you expect---appearing over the view | |
controller's contents but clearing the status bar and any navigation bars. | |
To dismiss the banner call `dismiss()` on the banner. This will remove the `BannerView` from its containing view. | |
## Styling | |
You configure a banner using a type that conforms to the `BannerStyle` protocol. This protocol defines a series of | |
properties for the banner's text color, font sizes, background color and an optional default image. | |
### Banner Image | |
If no image is specified in either the `BannerView.Message` or `BannerStyle` associated with a banner, no image view is | |
created or displayed in the banner. If the banner's style object provides an image, it'll be used unless the message | |
provides an image of its own. | |
### Default Style | |
The default style (`BannerView.DefaultStyle`) uses the preferred system headline font for the title and subheadline | |
font for the subtitle. The text is colored white and the background is red. There is no default image. | |
## Notes | |
- While a banner is presented, any changes to the contents of the banner or its style will be ignored. | |
## Known Issues | |
- Banners don't update their vertical offset when a view is rotated so they can become obscured by the status bar or | |
navigation bar. | |
- Banners work with navigation bars using large titles but do not resize correctly with the navigation bar. | |
- The status bar's text colour doesn't change with a banner's style. | |
- You have a choice of one animation. | |
- There's no sanctioned way to respond to taps. | |
- There are no callbacks for when the banner's presentation and dismissal animations finish. | |
## Changes | |
### 2017-12-06 | |
- Rewrite so that `present(in:)` method takes a view controller instead of a view. | |
- Add logic that traverses a tree of view controllers to present the banner properly in nested split view controllers, | |
tab view, controllers or navigation controllers. | |
- Support for presenting directly in a navigation controller. | |
- Increased the default transient timeout to 2.5 seconds. | |
- Add a license. | |
- Add some known issues. | |
*/ | |
@available(iOS 11.0, *) | |
open class BannerView: UIView { | |
// MARK: Public Properties | |
/// The message the banner should display. | |
private(set) open var message: Message | |
/// A `BannerStyle` conforming type that provides styling information for the banner's background and text. | |
private(set) open var style: BannerStyle | |
/// Distance between the banner's contents and the superview's top safe area layout guide and the banner's | |
/// contents and the banner's bottom margin layout guide. | |
open var verticalPadding: CGFloat = 8.0 | |
/// True if the banner is being displayed | |
private(set) public var isPresenting = false | |
/// The mode the banner is displayed using. See `BannerView.Mode` for the possible values. Defaults to `transient` | |
/// with a timeout of 1.0 seconds. | |
/// The mode the banner should be displayed using. Defaults to `transient` with a timeout of 1 second. | |
/// See `BannerView.Mode` for alternatives. | |
open var mode: Mode = .transient(timeout: 2.5) | |
/// Time duration to use for the banner's presentation and dismissal animations. Defaults to 0.5 seconds. | |
open var animationDuration: TimeInterval = 0.5 | |
// MARK: Private Properties | |
/// The preferred image for the image view in the following order: | |
/// | |
/// 1. The message's image. | |
/// 2. The banner style's image. | |
/// 3. nil | |
private var _preferredImage: UIImage? { | |
if message.image != nil { | |
return message.image | |
} else if style.defaultImage != nil { | |
return style.defaultImage | |
} else { | |
return nil | |
} | |
} | |
private lazy var _titleLabel: UILabel = { | |
let label = UILabel() | |
label.translatesAutoresizingMaskIntoConstraints = false | |
label.text = message.title | |
label.font = style.titleFont | |
label.textColor = style.textColor | |
label.numberOfLines = 0 | |
return label | |
}() | |
private lazy var _subtitleLabel: UILabel = { | |
let label = UILabel() | |
label.translatesAutoresizingMaskIntoConstraints = false | |
label.text = message.subtitle | |
label.font = style.subtitleFont | |
label.textColor = style.textColor | |
label.numberOfLines = 0 | |
return label | |
}() | |
private lazy var _imageView: UIImageView = { | |
let imageView = UIImageView(image: _preferredImage) | |
imageView.translatesAutoresizingMaskIntoConstraints = false | |
return imageView | |
}() | |
/// The height of the navigation bar when presenting inside a navigation controller. Otherwise this remains 0. Note, | |
/// if you present inside a view that's inside a navigation controller, this'll remain 0 as the navigation bar's | |
/// height is included in `_topSafeAreaInset` by the system. | |
private var _navigationBarHeight: CGFloat = 0.0 | |
/// The top safe area inset needed to clear the status bar and the navigation bar. | |
private var _topSafeAreaInset: CGFloat = 0.0 | |
/// The initial top offset which is enough to push the banner out of the safe area and then off the screen. | |
private var _initialTopOffset: CGFloat { | |
return -(frame.size.height + _topSafeAreaInset) | |
} | |
/// The final top offset which is enough to put the banner inside the safe area. | |
private var _finalTopOffset: CGFloat { | |
return -_topSafeAreaInset | |
} | |
/// The top constraint between the banner and the superview | |
private var _topConstraint: NSLayoutConstraint? | |
private var _timeoutTimer: Timer? | |
// MARK: Initializers | |
public init(message: Message, style: BannerStyle = BannerView.defaultStyle) { | |
self.message = message | |
self.style = style | |
super.init(frame: .zero) | |
self.preservesSuperviewLayoutMargins = true | |
self.backgroundColor = style.backgroundColor | |
self._setupSwipeGestureRecognizer() | |
} | |
public required init?(coder aDecoder: NSCoder) { | |
fatalError("Not available") | |
} | |
// MARK: Public Methods | |
/** | |
Present the banner as a subview of `container`. Does nothing if the banner is being presented. How the banner is | |
presented in the container depends on the type of `container`. If `container` is a UISplitViewController or | |
UITabBarController, the banner traverses the sub-viewcontrollers until it encounters a UINavigationController or | |
generic UIViewController subclass. | |
When `container` is a UINavigationController, the banner is presented below the navigation bar. When `container` is | |
a UIViewController, the banner is presented over the view. If `container` is embedded in a navigation controller, | |
the view's safe area layout guides should ensure the banner clears the navigation bar. | |
- Note: Do not present a banner inside a view controller whose root view is a UIScrollView subclass. Embed the view | |
controller in a navigation controller and pass the navigation controller in to `present(in:)`. | |
*/ | |
public func present(in container: UIViewController) { | |
guard !isPresenting else { return } | |
add(to: container) | |
_layoutBanner(in: superview!) | |
superview?.layoutIfNeeded() | |
isPresenting = true | |
UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: 1.0, | |
initialSpringVelocity: 0.1, options: .curveEaseInOut, | |
animations: { | |
self._topConstraint?.constant = self._finalTopOffset | |
self.superview?.layoutIfNeeded() | |
}, | |
completion: { _ in | |
if case .transient(let timeout) = self.mode { | |
self._timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false, | |
block: { _ in self.dismiss() }) | |
} | |
}) | |
} | |
/// Dismiss the banner and remove it from its superview. Does nothing if the banner isn't being presented. | |
@objc public func dismiss() { | |
guard isPresenting, | |
let container = self.superview else { return } | |
self._timeoutTimer?.invalidate() | |
self._timeoutTimer = nil | |
container.layoutIfNeeded() | |
UIView.animate(withDuration: animationDuration, delay: 0.0, usingSpringWithDamping: 1.0, | |
initialSpringVelocity: 0.1, options: .curveEaseOut, | |
animations: { | |
self._topConstraint?.constant = self._initialTopOffset | |
container.layoutIfNeeded() | |
}, | |
completion: { _ in | |
self.isPresenting = false | |
self.removeFromSuperview() | |
}) | |
} | |
// MARK: Private Functions | |
/** | |
Add the banner as a subview of the container's view and gather the information needed to calculate the banner's | |
starting and ending top constraints. How this function behaves depends on the type of view controller passed in: | |
1. UISplitViewController -> Call `add(to:)` passing in the last element of `viewControllers` | |
2. UITabBarController -> Call `add(to:)` passing in the selected view controller. | |
3. UINavigationController -> Get the navigation bar's height, navigation controller's safe area inset and insert | |
the banner below the navigation bar. | |
4. UIViewController -> Get the view's safe area layout guide and insert the banner as a subview of the view | |
controller's view. | |
*/ | |
private func add(to container: UIViewController) { | |
if let container = container as? UISplitViewController, | |
let nextVC = container.viewControllers.last { | |
add(to: nextVC) | |
} else if let container = container as? UITabBarController, | |
let nextVC = container.selectedViewController { | |
add(to: nextVC) | |
} else if let container = container as? UINavigationController { | |
container.view.layoutIfNeeded() | |
_navigationBarHeight = container.navigationBar.frame.height | |
_topSafeAreaInset = container.view.safeAreaInsets.top | |
container.view.insertSubview(self, belowSubview: container.navigationBar) | |
} else { | |
_navigationBarHeight = 0.0 | |
_topSafeAreaInset = container.view.safeAreaInsets.top | |
container.view.addSubview(self) | |
} | |
} | |
private func _layoutBanner(in superview: UIView) { | |
self.translatesAutoresizingMaskIntoConstraints = false | |
_layoutBannerContents() | |
// Layout the banner to give it a height | |
layoutIfNeeded() | |
self.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: 0).isActive = true | |
self.trailingAnchor.constraint(equalTo: superview.trailingAnchor, constant: 0).isActive = true | |
_topConstraint = self.topAnchor.constraint(equalTo: superview.safeAreaLayoutGuide.topAnchor, | |
constant: _initialTopOffset) | |
_topConstraint?.isActive = true | |
} | |
/// Lays out the banner's contents | |
private func _layoutBannerContents() { | |
let stackView = _setupBannerStackView() | |
stackView.translatesAutoresizingMaskIntoConstraints = false | |
self.translatesAutoresizingMaskIntoConstraints = false | |
addSubview(stackView) | |
stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor, constant: 0).isActive = true | |
stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor, constant: 0).isActive = true | |
// For this next constraint we have to include the height of the navigation bar if we're presenting inside a | |
// UINavigationController but not if we're presenting inside a view controller that's inside a navigation | |
// controller because the banner's safeAreaLayoutGuide will include the navigation bar's height. Since | |
// _navigationBarHeight will appear to be zero in the latter case, we can safely add it here without any | |
// consequence. | |
stackView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, | |
constant: (verticalPadding + _navigationBarHeight)).isActive = true | |
stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor, | |
constant: -verticalPadding).isActive = true | |
} | |
/// Build the main stack view for the banner | |
private func _setupBannerStackView() -> UIStackView { | |
var arrangedViews: [UIView] = [] | |
if _preferredImage != nil { arrangedViews.append(_imageView) } | |
let titleStack = _setupBannerTitleStackView() | |
arrangedViews.append(titleStack) | |
let stack = UIStackView(arrangedSubviews: arrangedViews) | |
stack.axis = .horizontal | |
stack.spacing = 8.0 | |
stack.alignment = .center | |
if _preferredImage != nil { | |
// Image must maintain 1:1 ratio but can not be too big nor too small. Ideally, it should have the same | |
// height as the text. | |
_imageView.heightAnchor.constraint(equalTo: _imageView.widthAnchor).isActive = true | |
let imageMinWidthConstraint = _imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 44.0) | |
let imageBannerWidthConstraint = _imageView.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor, | |
multiplier: 1 / 5) | |
let imageTextHeightConstraint = _imageView.heightAnchor.constraint(equalTo: titleStack.heightAnchor, | |
multiplier: 1.0) | |
imageTextHeightConstraint.priority = .init(998) | |
imageBannerWidthConstraint.isActive = true | |
imageTextHeightConstraint.isActive = true | |
imageMinWidthConstraint.isActive = true | |
// Allow image view to shrink easily | |
_imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) | |
_imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) | |
for textLabel in titleStack.arrangedSubviews { | |
// Text labels mustn't be compressed | |
textLabel.setContentCompressionResistancePriority(.required, for: .horizontal) | |
textLabel.setContentCompressionResistancePriority(.required, for: .vertical) | |
// Allow text labels to expand horizontally to fill the stack view | |
textLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) | |
// labels should hug content vertically | |
textLabel.setContentHuggingPriority(.required, for: .vertical) | |
} | |
} | |
return stack | |
} | |
/// Builds the sub-stack view that contains the banner's title and optional subtitle | |
private func _setupBannerTitleStackView() -> UIStackView { | |
var arrangedViews: [UIView] = [_titleLabel] | |
if message.subtitle != nil { arrangedViews.append(_subtitleLabel) } | |
let stack = UIStackView(arrangedSubviews: arrangedViews) | |
stack.axis = .vertical | |
stack.distribution = .fillProportionally | |
return stack | |
} | |
private func _setupSwipeGestureRecognizer() { | |
let gesture = UISwipeGestureRecognizer(target: self, action: #selector(dismiss)) | |
gesture.direction = .up | |
addGestureRecognizer(gesture) | |
} | |
} | |
// MARK: - Subtypes | |
extension BannerView { | |
/// Enum defining the different dismissal modes | |
public enum Mode { | |
/// Automatically dismiss the banner after a timeout of `timeout` seconds. | |
case transient(timeout: TimeInterval) | |
/// The banner must be manually dismissed. | |
case sticky | |
} | |
public struct Message { | |
var title: String | |
var subtitle: String? | |
var image: UIImage? | |
} | |
public struct DefaultStyle: BannerStyle { | |
/// The preferred system headline font. | |
public var titleFont: UIFont { return UIFont.preferredFont(forTextStyle: .headline) } | |
/// The preferred system subheadline font. | |
public var subtitleFont: UIFont { return UIFont.preferredFont(forTextStyle: .subheadline) } | |
/// White | |
public var textColor: UIColor { return .white } | |
/// Red | |
public var backgroundColor: UIColor { return .red } | |
/// No image | |
public var defaultImage: UIImage? | |
} | |
public static var defaultStyle: BannerStyle { return DefaultStyle() } | |
} | |
public protocol BannerStyle { | |
/// Font used for the title of the banner. | |
var titleFont: UIFont { get } | |
/// Font used for the subtitle of the banner. | |
var subtitleFont: UIFont { get } | |
/// Color used for both the title and subtitle of the banner. | |
var textColor: UIColor { get } | |
/// The background color of the banner. | |
var backgroundColor: UIColor { get } | |
/// An image to be displayed on the left of the banner or `nil` for no image. Will be overridden if the banner's | |
/// message's image is non-nil. | |
var defaultImage: UIImage? { get } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment