Skip to content

Instantly share code, notes, and snippets.

@alexjohnj
Last active December 6, 2017 17:47
Show Gist options
  • Save alexjohnj/df4d969fa0ac6f29fa8a134c91fa30ff to your computer and use it in GitHub Desktop.
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)
// 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