source/UberRides/RideRequestButton.swift (273 lines of code) (raw):
//
// RideRequestButton.swift
// UberRides
//
// Copyright © 2015 Uber Technologies, Inc. All rights reserved.
//
// 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 CoreLocation
import UberCore
/**
* Protocol to listen to request button events, such as loading button content
*/
@objc(UBSDKRideRequestButtonDelegate) public protocol RideRequestButtonDelegate {
/**
The button finished loading ride information successfully.
- parameter button: the RideRequestButton
*/
@objc func rideRequestButtonDidLoadRideInformation(_ button: RideRequestButton)
/**
The button encountered an error when refreshing its metadata content.
- parameter button: the RideRequestButton
- parameter error: the error that it encountered
*/
@objc func rideRequestButton(_ button: RideRequestButton, didReceiveError error: UberError)
}
/// RequestButton implements a button on the touch screen to request a ride.
@objc(UBSDKRideRequestButton) public class RideRequestButton: UberButton {
/// Delegate is informed of events that occur with request button.
@objc public var delegate: RideRequestButtonDelegate?
/// The RideParameters object this button will use to make a request
@objc public var rideParameters: RideParameters
/// The RideRequesting object the button will use to make a request
@objc public var requestBehavior: RideRequesting
/// The RidesClient used for retrieving metadata for the button.
@objc public var client: RidesClient?
static let sourceString = "button"
var metadata: ButtonMetadata = ButtonMetadata()
var uberMetadataLabel: UILabel = UILabel()
private let opticalCorrection: CGFloat = 1.0
/**
Initializer to use in storyboard. Must call setRidesClient for request button to show metadata.
requestBehavior defaults to DeeplinkRequestingBehavior
rideParameters defaults to RideParameters with pickup location set to current location
*/
required public init?(coder aDecoder: NSCoder) {
requestBehavior = DeeplinkRequestingBehavior()
rideParameters = RideParametersBuilder().build()
super.init(coder: aDecoder)
}
/**
The Request button initializer.
- parameter client: The RidesClient to use for getting button metadata
- parameter rideParameters: The RideParameters for this button. These parameters are used to request a ride when the button is tapped.
- parameter requestingBehavior: The RideRequesting object to use for requesting a ride.
- returns: An initialized RideRequestButton
*/
@objc public init(client: RidesClient, rideParameters: RideParameters, requestingBehavior: RideRequesting) {
requestBehavior = requestingBehavior
self.rideParameters = rideParameters
super.init(frame: CGRect.zero)
self.client = client
}
/**
The Request button initializer.
Uses a default RidesClient
- parameter rideParameters: The RideParameters for this button. These parameters are used to request a ride when the button is tapped.
- parameter requestingBehavior: The RideRequesting object to use for requesting a ride.
- returns: An initialized RideRequestButton
*/
@objc public convenience init(rideParameters: RideParameters, requestingBehavior: RideRequesting) {
self.init(client: RidesClient(), rideParameters: rideParameters, requestingBehavior: requestingBehavior)
}
/**
The RideRequestButton initializer.
Uses DeeplinkRequestingBehavior by default
Defaults to using the current location for pickup
- parameter client: The RidesClient to use for getting button metadata
- returns: An initialized RideRequestButton
*/
@objc public convenience init(client: RidesClient) {
self.init(client: client, rideParameters: RideParametersBuilder().build(), requestingBehavior: DeeplinkRequestingBehavior())
}
/**
The RideRequestButton initializer. Creates a request button that uses the Deeplink
Requesting behavior & the provided RidesParameters
Uses a default RidesClient
- parameter rideParameters: The RideParameters for this button. These parameters are used to request a ride when the button is tapped.
- returns: An initialized RideRequestButton
*/
@objc public convenience init(rideParameters: RideParameters) {
self.init(client: RidesClient(), rideParameters: rideParameters, requestingBehavior: DeeplinkRequestingBehavior())
}
/**
The RideRequestButton initializer.
Defaults to using the current location for pickup
Uses a default RidesClient
- parameter requestingBehavior: The RideRequesting object to use for requesting a ride.
- returns: An initialized RideRequestButton
*/
@objc public convenience init(requestingBehavior: RideRequesting) {
self.init(client: RidesClient(), rideParameters: RideParametersBuilder().build(), requestingBehavior: requestingBehavior)
}
//Mark: UberButton
/**
The Request button initializer.
Defaults to using the current location for pickup
Defaults to DeeplinkRequestingBehavior, which links into the Uber app
Uses a default RidesClient
- returns: An initialized RideRequestButton
*/
@objc public convenience init() {
self.init(client: RidesClient(), rideParameters: RideParametersBuilder().build(), requestingBehavior: DeeplinkRequestingBehavior())
}
/**
Setup the RideRequestButton by adding a target to the button and setting the login completion block
*/
override public func setup() {
super.setup()
addTarget(self, action: #selector(uberButtonTapped(_:)), for: .touchUpInside)
sizeToFit()
}
/**
Adds the Metadata Label to the button
*/
override public func addSubviews() {
super.addSubviews()
addSubview(uberMetadataLabel)
}
/**
Updates the content of the button. Sets the image icon and font, as well as the text
*/
override public func setContent() {
super.setContent()
uberMetadataLabel.numberOfLines = 2
uberMetadataLabel.textColor = colorStyle == .black ? ColorUtil.colorForUberButtonColor(.uberWhite) : ColorUtil.colorForUberButtonColor(.uberBlack)
uberMetadataLabel.textAlignment = .right
uberTitleLabel.font = UIFont(name: "HelveticaNeue-Medium", size: 15) ?? UIFont.systemFont(ofSize: 16)
let titleText = NSLocalizedString("Ride there with Uber", bundle: Bundle(for: type(of: self)), comment: "Request button description")
uberTitleLabel.text = titleText
let logo = getImage(name: "Badge")
uberImageView.image = logo
uberImageView.contentMode = .center
}
/**
Adds the layout constraints for the ride request button.
*/
override public func setConstraints() {
uberTitleLabel.translatesAutoresizingMaskIntoConstraints = false
uberImageView.translatesAutoresizingMaskIntoConstraints = false
uberMetadataLabel.translatesAutoresizingMaskIntoConstraints = false
let views = ["image": uberImageView, "titleLabel": uberTitleLabel, "metadataLabel": uberMetadataLabel]
let metrics = ["edgePadding": horizontalEdgePadding, "verticalPadding": verticalPadding, "imageLabelPadding": imageLabelPadding, "middlePadding": horizontalEdgePadding*2]
uberImageView.setContentHuggingPriority(UILayoutPriority.defaultHigh, for: .horizontal)
uberTitleLabel.setContentHuggingPriority(UILayoutPriority.defaultHigh, for: .horizontal)
uberTitleLabel.setContentHuggingPriority(UILayoutPriority.defaultHigh, for: .vertical)
uberMetadataLabel.setContentHuggingPriority(UILayoutPriority.defaultLow, for: .horizontal)
let horizontalConstraints: [NSLayoutConstraint] = NSLayoutConstraint.constraints(withVisualFormat: "H:|-edgePadding-[image]-imageLabelPadding-[titleLabel]-middlePadding-[metadataLabel]-edgePadding-|", options: NSLayoutConstraint.FormatOptions(rawValue: 0), metrics: metrics, views: views)
let verticalConstraints: [NSLayoutConstraint] = NSLayoutConstraint.constraints(withVisualFormat: "V:|-verticalPadding-[image]-verticalPadding-|", options: .alignAllLeading, metrics: metrics, views: views)
let titleLabelCenterConstraint = NSLayoutConstraint(item: self,
attribute: .centerY,
relatedBy: .equal,
toItem: uberTitleLabel,
attribute: .centerY,
multiplier: 1.0,
constant: opticalCorrection)
let metadataLabelCenterConstraint = NSLayoutConstraint(item: self,
attribute: .centerY,
relatedBy: .equal,
toItem: uberMetadataLabel,
attribute: .centerY,
multiplier: 1.0,
constant: 0)
let imageViewCenterConstraint = NSLayoutConstraint(item: self,
attribute: .centerY,
relatedBy: .equal,
toItem: uberImageView,
attribute: .centerY,
multiplier: 1.0,
constant: 0)
addConstraints(horizontalConstraints)
addConstraints(verticalConstraints)
addConstraints([titleLabelCenterConstraint, metadataLabelCenterConstraint, imageViewCenterConstraint])
}
override open func colorStyleDidUpdate(_ style: UberButtonColorStyle) {
super.colorStyleDidUpdate(style)
switch style {
case .black:
uberMetadataLabel.textColor = ColorUtil.colorForUberButtonColor(.uberWhite)
case .white :
uberMetadataLabel.textColor = ColorUtil.colorForUberButtonColor(.uberBlack)
}
}
//Mark: UIView
override public func sizeThatFits(_ size: CGSize) -> CGSize {
let logoSize = uberImageView.image?.size ?? CGSize.zero
let titleSize = uberTitleLabel.intrinsicContentSize
let metadataSize = uberMetadataLabel.intrinsicContentSize
var width: CGFloat = 4*horizontalEdgePadding + imageLabelPadding + logoSize.width + titleSize.width
var height: CGFloat = 2*verticalPadding + max(logoSize.height, titleSize.height)
if let _ = metadata.productID {
width += metadataSize.width
height = max(height, metadataSize.height)
}
return CGSize(width: width, height: height)
}
//Mark: Public Interface
/**
Manual refresh for the ride information on the button. The product ID must be set in order to show any metadata.
*/
@objc public func loadRideInformation() {
guard client != nil else {
delegate?.rideRequestButton(self, didReceiveError: createValidationFailedError())
return
}
metadata.productID = rideParameters.productID
metadata.pickupLatitude = rideParameters.pickupLocation?.coordinate.latitude
metadata.pickupLongitude = rideParameters.pickupLocation?.coordinate.longitude
metadata.dropoffLatitude = rideParameters.dropoffLocation?.coordinate.latitude
metadata.dropoffLongitude = rideParameters.dropoffLocation?.coordinate.longitude
setMetadata()
}
//Mark: Internal Interface
// Initiate deeplink when button is tapped
@objc func uberButtonTapped(_ sender: UIButton) {
rideParameters.source = RideRequestButton.sourceString
requestBehavior.requestRide(parameters: rideParameters)
}
//Mark: Private Interface
/**
Helper function that sets appropriate attributes on multi-line label.
- parameter title: The main title of the label. (ex. "3 MINS AWAY" or "Get a Ride")
- parameter subtitle: The subtitle of the label. (ex. "$6-8 for uberX")
- parameter surge: Whether the price estimate should include a surge image. Default false.
*/
private func setMultilineAttributedString(title: String, subtitle: String = "", surge: Bool = false) {
let metadataFont = UIFont(name: "HelveticaNeue-Regular", size: 12) ?? UIFont.systemFont(ofSize: 12)
let attrString = NSMutableAttributedString(string: title)
// If there is a price estimate to include, add a new line
if !subtitle.isEmpty {
attrString.append(NSAttributedString(string: "\n"))
// If the price estimate is higher due to a surge, add the surge icon
if surge == true {
let attachment = getSurgeAttachment()
// Adjust bounds to center the text attachment
attachment.bounds = CGRect(x: 0, y: metadataFont.descender-opticalCorrection, width: attachment.image?.size.width ?? 0, height: attachment.image!.size.height)
let surgeImage = NSAttributedString(attachment: attachment)
attrString.append(surgeImage)
attrString.append(NSAttributedString(string: " "))
// Adding the text attachment increases the space between lines so set the max line height
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
paragraphStyle.maximumLineHeight = 16
attrString.addAttribute(NSAttributedString.Key.paragraphStyle, value:paragraphStyle, range:NSMakeRange(0, attrString.length))
}
attrString.append(NSAttributedString(string: "\(subtitle)"))
}
attrString.addAttribute(NSAttributedString.Key.font, value: metadataFont, range: (attrString.string as NSString).range(of: title))
attrString.addAttribute(NSAttributedString.Key.font, value: metadataFont, range: (attrString.string as NSString).range(of: subtitle))
if attrString.string.isEmpty {
uberTitleLabel.text = NSLocalizedString("Ride there with Uber", bundle: Bundle(for: type(of: self)), comment: "Request button description")
} else {
uberTitleLabel.text = NSLocalizedString("Get a ride", bundle: Bundle(for: type(of: self)), comment: "Request button shorter description")
}
uberMetadataLabel.attributedText = attrString
}
private func getSurgeAttachment() -> NSTextAttachment {
let attachment = NSTextAttachment()
switch colorStyle {
case .black:
attachment.image = getImage(name: "Surge-WhiteOutline")
case .white:
attachment.image = getImage(name: "Surge-BlackOutline")
}
return attachment
}
private func createValidationFailedError() -> UberError {
return UberError(status: 422, code: "validation_failed", title: "Invalid Request")
}
/**
Sets metadata on button by fetching all required information.
*/
private func setMetadata() {
/**
* These are all required for the following requests.
*/
guard let client = client, let pickupLatitude = metadata.pickupLatitude, let pickupLongitude = metadata.pickupLongitude, let productID = metadata.productID else {
delegate?.rideRequestButton(self, didReceiveError: createValidationFailedError())
return
}
let downloadGroup = DispatchGroup()
downloadGroup.enter()
var errors = [UberError]()
let pickupLocation = CLLocation(latitude: pickupLatitude, longitude: pickupLongitude)
// Set the information on the button label once all information is retrieved.
downloadGroup.notify(queue: DispatchQueue.main, execute: {
var titleText = ""
var subtitleText = ""
if let timeEstimate = self.metadata.timeEstimate?.estimate {
let mins = timeEstimate / 60
if mins == 1 {
titleText = String(format: NSLocalizedString("%d min away", bundle: Bundle(for: type(of: self)), comment: "Estimate is for car one minute away"), mins).uppercased(with: Locale.current)
} else {
titleText = String(format: NSLocalizedString("%d mins away", bundle: Bundle(for: type(of: self)), comment: "Estimate is for car multiple minutes away"), mins).uppercased(with: Locale.current)
}
}
var surge = false
for estimate in self.metadata.priceEstimates {
if let price = estimate.estimate,
let productName = estimate.name,
estimate.productID == productID {
if let surgeMultiplier = estimate.surgeMultiplier,
surgeMultiplier > 1.0 {
surge = true
}
let priceEstimateString = String(format: NSLocalizedString("%1$@ for %2$@", bundle: Bundle(for: type(of: self)), comment: "Price estimate string for an Uber product"), price, productName)
if titleText.isEmpty {
titleText = priceEstimateString
} else {
subtitleText = priceEstimateString
}
break
}
}
if !titleText.isEmpty {
self.setMultilineAttributedString(title: titleText, subtitle: subtitleText, surge: surge)
}
for error in errors {
self.delegate?.rideRequestButton(self, didReceiveError: error)
}
self.delegate?.rideRequestButtonDidLoadRideInformation(self)
})
// Get time estimate for productID
let timeEstimatesCompletion: ([TimeEstimate], Response) -> () = { timeEstimates, response in
if let error = response.error {
errors.append(error)
downloadGroup.leave()
return
}
self.metadata.timeEstimate = timeEstimates.first
self.metadata.productName = timeEstimates.first?.name
downloadGroup.leave()
}
// If dropoff location was set, get price estimates.
if let dropoffLatitude = metadata.dropoffLatitude, let dropoffLongitude = metadata.dropoffLongitude {
downloadGroup.enter()
let dropoffLocation = CLLocation(latitude: dropoffLatitude, longitude: dropoffLongitude)
let priceEstimatesCompletion: ([PriceEstimate], Response) -> () = {priceEstimates, response in
if let error = response.error {
errors.append(error)
downloadGroup.leave()
return
}
self.metadata.priceEstimates = priceEstimates
downloadGroup.leave()
}
client.fetchPriceEstimates(pickupLocation: pickupLocation, dropoffLocation: dropoffLocation, completion:priceEstimatesCompletion )
}
client.fetchTimeEstimates(pickupLocation: pickupLocation, productID:productID, completion: timeEstimatesCompletion)
}
// get image from media directory
private func getImage(name: String) -> UIImage {
let bundle = Bundle(for: RideRequestButton.self)
let image = UIImage(named: name, in: bundle, compatibleWith: nil)
return image!
}
}
/**
* Stores information about current product and its metadata as the information is retrieved.
*/
struct ButtonMetadata {
var productID: String?
var productName: String?
var pickupLatitude: Double?
var pickupLongitude: Double?
var dropoffLatitude: Double?
var dropoffLongitude: Double?
var timeEstimate: TimeEstimate?
private var priceEstimateList: [PriceEstimate]?
var priceEstimates: [PriceEstimate] {
get {
return priceEstimateList ?? []
}
set {
priceEstimateList = newValue
}
}
}