Instantly share code, notes, and snippets.
Last active
December 6, 2017 17:47
-
Star
(0)
0
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save alexjohnj/df4d969fa0ac6f29fa8a134c91fa30ff to your computer and use it in GitHub Desktop.
A UIView subclass that animates a banner down from the top of the screen (iOS 11+, Supports iPhone X)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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