Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Visual Instruction Presentation Customization #1530

Merged
merged 8 commits into from
Jul 5, 2018
64 changes: 44 additions & 20 deletions Examples/Swift/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ private typealias RouteRequestFailure = ((NSError) -> Void)
enum ExampleMode {
case `default`
case custom
case customInstruction
case styled
case multipleWaypoints
}
Expand Down Expand Up @@ -95,23 +96,28 @@ class ViewController: UIViewController, MGLMapViewDelegate, CLLocationManagerDel
startButton.isEnabled = false

alertController = UIAlertController(title: "Start Navigation", message: "Select the navigation type", preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: "Default UI", style: .default, handler: { (action) in
self.startBasicNavigation()
}))
alertController.addAction(UIAlertAction(title: "DayStyle UI", style: .default, handler: { (action) in
self.startNavigation(styles: [DayStyle()])
}))
alertController.addAction(UIAlertAction(title: "NightStyle UI", style: .default, handler: { (action) in
self.startNavigation(styles: [NightStyle()])
}))
alertController.addAction(UIAlertAction(title: "Custom UI", style: .default, handler: { (action) in
self.startCustomNavigation()
}))
alertController.addAction(UIAlertAction(title: "Styled UI", style: .default, handler: { (action) in
self.startStyledNavigation()
}))

alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))

typealias ActionHandler = (UIAlertAction) -> Void

let basic: ActionHandler = {_ in self.startBasicNavigation() }
let day: ActionHandler = {_ in self.startNavigation(styles: [DayStyle()]) }
let night: ActionHandler = {_ in self.startNavigation(styles: [NightStyle()]) }
let custom: ActionHandler = {_ in self.startCustomNavigation() }
let customInstruction: ActionHandler = {_ in self.startCustomInstructionNavigation() }
let styled: ActionHandler = {_ in self.startStyledNavigation() }

let actionPayloads: [(String, UIAlertActionStyle, ActionHandler?)] = [("Default UI", .default, basic),
("DayStyle UI", .default, day),
("NightStyle UI", .default, night),
("Custom UI", .default, custom),
("Custom Instructions", .default, customInstruction),
("Styled UI", .default, styled),
("Cancel", .cancel, nil)]

let actions = actionPayloads.map { payload in UIAlertAction(title: payload.0, style: payload.1, handler: payload.2)}

actions.forEach(alertController.addAction(_:))


if let popoverController = alertController.popoverPresentationController {
popoverController.sourceView = self.startButton
Expand Down Expand Up @@ -227,10 +233,10 @@ class ViewController: UIViewController, MGLMapViewDelegate, CLLocationManagerDel

// MARK: Basic Navigation

func startBasicNavigation() {
func startBasicNavigation(mode: ExampleMode = .default) {
guard let route = currentRoute else { return }

exampleMode = .default
exampleMode = mode

let navigationViewController = NavigationViewController(for: route, locationManager: navigationLocationManager())
navigationViewController.delegate = self
Expand All @@ -249,8 +255,12 @@ class ViewController: UIViewController, MGLMapViewDelegate, CLLocationManagerDel
presentAndRemoveMapview(navigationViewController)
}

// MARK: Custom Instruction Example
func startCustomInstructionNavigation() {
startBasicNavigation(mode: .customInstruction)
}

// MARK: Custom Navigation UI

func startCustomNavigation() {
guard let route = self.currentRoute else { return }

Expand Down Expand Up @@ -442,6 +452,20 @@ extension ViewController: NavigationViewControllerDelegate {
}
}

// Mark: VisualInstructionDelegate
extension ViewController: VisualInstructionDelegate {
func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? {
guard exampleMode == .customInstruction else { return nil }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With VoiceController(_:WillSpeak:Instruction:), returning nil omits the voice instruction. I'm guess that's not the case here? In reality, I can't really think of a use case where a developer would want to show nothing though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's kind of the idea. The utility of "I only want to override default behavior under specific circumstances" seems to outweigh the utility of "it's nice to say 'show nothing' via nil instead of NSAttributedString()". In this specific example you could also implement default behavior by returning presented, but I think we should get into the general habit of using a "returning nil means the delegate has no answer to the delegator, and thus default behavior should be used" pattern in our delegation, as otherwise you kind of force the developer to implement logic if they implement the optional delegate function. Not a super great pattern.


let range = NSRange(location: 0, length: presented.length)
let mutable = NSMutableAttributedString(attributedString: presented)

mutable.mutableString.applyTransform(.latinToKatakana, reverse: false, range: range, updatedRange: nil)

return mutable
}
}

/**
To find more pieces of the UI to customize, checkout DayStyle.swift.
*/
Expand Down
22 changes: 21 additions & 1 deletion MapboxNavigation/InstructionLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ open class InstructionLabel: StylableLabel, InstructionPresenterDataSource {
var shieldHeight: CGFloat = 30
var imageRepository: ImageRepository = .shared
var imageDownloadCompletion: (() -> Void)?
weak var instructionDelegate: VisualInstructionDelegate?

var instruction: VisualInstruction? {
didSet {
Expand All @@ -28,10 +29,29 @@ open class InstructionLabel: StylableLabel, InstructionPresenterDataSource {

let presenter = InstructionPresenter(instruction, dataSource: self, imageRepository: imageRepository, downloadCompletion: update)

attributedText = presenter.attributedText()
let attributed = presenter.attributedText()
attributedText = instructionDelegate?.label?(self, willPresent: instruction, as: attributed) ?? attributed
instructionPresenter = presenter
}
}

private var instructionPresenter: InstructionPresenter?
}

/**
The `VoiceControllerDelegate` protocol defines a method that allows an object to customize presented visual instructions.
*/
@objc(MBVisualInstructionDelegate)
public protocol VisualInstructionDelegate: class {

/**
Called when an InstructionLabel will present a visual instruction.

- parameter label: The label that the instruction will be presented on.
- parameter instruction: the `VisualInstruction` that will be presented.
- parameter presented: the formatted string that is provided by the instruction presenter
- returns: optionally, a customized NSAttributedString that will be presented instead of the default, or if nil, the default behavior will be used.
*/
@objc(label:willPresentVisualInstruction:asAttributedString:)
optional func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString?
}
7 changes: 7 additions & 0 deletions MapboxNavigation/InstructionsBannerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ open class BaseInstructionsBannerView: UIControl {
}
}

weak var instructionDelegate: VisualInstructionDelegate? {
didSet {
primaryLabel.instructionDelegate = instructionDelegate
secondaryLabel.instructionDelegate = instructionDelegate
}
}

var centerYConstraints = [NSLayoutConstraint]()
var baselineConstraints = [NSLayoutConstraint]()

Expand Down
2 changes: 2 additions & 0 deletions MapboxNavigation/InstructionsBannerViewLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ extension BaseInstructionsBannerView {
self.distanceLabel = distanceLabel

let primaryLabel = PrimaryLabel()
primaryLabel.instructionDelegate = instructionDelegate
primaryLabel.translatesAutoresizingMaskIntoConstraints = false
primaryLabel.allowsDefaultTighteningForTruncation = true
primaryLabel.adjustsFontSizeToFitWidth = true
Expand All @@ -33,6 +34,7 @@ extension BaseInstructionsBannerView {
self.primaryLabel = primaryLabel

let secondaryLabel = SecondaryLabel()
secondaryLabel.instructionDelegate = instructionDelegate
secondaryLabel.translatesAutoresizingMaskIntoConstraints = false
secondaryLabel.allowsDefaultTighteningForTruncation = true
secondaryLabel.numberOfLines = 1
Expand Down
4 changes: 3 additions & 1 deletion MapboxNavigation/NavigationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,12 @@ open class NavigationView: UIView {
mapView.navigationMapDelegate = delegate
mapView.courseTrackingDelegate = delegate
instructionsBannerView.delegate = delegate
instructionsBannerView.instructionDelegate = delegate
nextBannerView.instructionDelegate = delegate
statusView.delegate = delegate
}
}

protocol NavigationViewDelegate: NavigationMapViewDelegate, MGLMapViewDelegate, StatusViewDelegate, InstructionsBannerViewDelegate, NavigationMapViewCourseTrackingDelegate {
protocol NavigationViewDelegate: NavigationMapViewDelegate, MGLMapViewDelegate, StatusViewDelegate, InstructionsBannerViewDelegate, NavigationMapViewCourseTrackingDelegate, VisualInstructionDelegate {
func navigationView(_ view: NavigationView, didTapCancelButton: CancelButton)
}
6 changes: 5 additions & 1 deletion MapboxNavigation/NavigationViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Mapbox
The `NavigationViewControllerDelegate` provides methods for configuring the map view shown by a `NavigationViewController` and responding to the cancellation of a navigation session.
*/
@objc(MBNavigationViewControllerDelegate)
public protocol NavigationViewControllerDelegate {
public protocol NavigationViewControllerDelegate: VisualInstructionDelegate {
/**
Called when the navigation view controller is dismissed, such as when the user ends a trip.

Expand Down Expand Up @@ -538,6 +538,10 @@ extension NavigationViewController: RouteMapViewControllerDelegate {
}
return roadName
}

@objc public func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious, why does NavigationViewController need to implement the delegate method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just plumbing the delegate method through the view-controller hierarchy, just like we do here for a NavigationMapViewDelegate method or here for the same method (label(_:willPresent:as:)), being routed through RouteMapViewController.

It's not really accurate to say that the NavigationViewController is implementing the delegate method so much as it's merely passing the message onto its delegate.

return delegate?.label?(label, willPresent: instruction, as: presented)
}
}

//MARK: - RouteControllerDelegate
Expand Down
6 changes: 6 additions & 0 deletions MapboxNavigation/NextBannerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ open class NextBannerView: UIView {
weak var maneuverView: ManeuverView!
weak var instructionLabel: NextInstructionLabel!
weak var bottomSeparatorView: SeparatorView!
weak var instructionDelegate: VisualInstructionDelegate? {
didSet {
instructionLabel.instructionDelegate = instructionDelegate
}
}

override init(frame: CGRect) {
super.init(frame: frame)
Expand All @@ -39,6 +44,7 @@ open class NextBannerView: UIView {
self.maneuverView = maneuverView

let instructionLabel = NextInstructionLabel()
instructionLabel.instructionDelegate = instructionDelegate
instructionLabel.shieldHeight = instructionLabel.font.pointSize
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(instructionLabel)
Expand Down
6 changes: 5 additions & 1 deletion MapboxNavigation/RouteMapViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,10 @@ extension RouteMapViewController: NavigationViewDelegate {
delegate?.mapViewDidFinishLoadingMap?(mapView)
}

func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? {
return delegate?.label?(label, willPresent: instruction, as: presented)
}

// MARK: NavigationMapViewCourseTrackingDelegate
func navigationMapViewDidStartTrackingCourse(_ mapView: NavigationMapView) {
navigationView.resumeButton.isHidden = true
Expand Down Expand Up @@ -958,7 +962,7 @@ fileprivate extension UIViewAnimationOptions {
}
}
}
@objc protocol RouteMapViewControllerDelegate: NavigationMapViewDelegate, MGLMapViewDelegate {
@objc protocol RouteMapViewControllerDelegate: NavigationMapViewDelegate, MGLMapViewDelegate, VisualInstructionDelegate {

func mapViewControllerDidOpenFeedback(_ mapViewController: RouteMapViewController)
func mapViewControllerDidCancelFeedback(_ mapViewController: RouteMapViewController)
Expand Down
42 changes: 42 additions & 0 deletions MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ func makeVisualInstruction(_ maneuverType: ManeuverType = .arrive,

class InstructionsBannerViewIntegrationTests: XCTestCase {

private lazy var reverseDelegate = TextReversingDelegate()
private lazy var silentDelegate = DefaultBehaviorDelegate()

lazy var imageRepository: ImageRepository = {
let repo = ImageRepository.shared
repo.sessionConfiguration = URLSessionConfiguration.default
Expand All @@ -45,6 +48,8 @@ class InstructionsBannerViewIntegrationTests: XCTestCase {
VisualInstructionComponent(type: .text, text: "Ankh-Morpork Highway 1", imageURL: nil, abbreviation: nil, abbreviationPriority: NSNotFound)
]

lazy var typicalInstruction: VisualInstructionBanner = makeVisualInstruction(primaryInstruction: [VisualInstructionComponent(type: .text, text: "Main Street", imageURL: nil, abbreviation: "Main St", abbreviationPriority: 0)], secondaryInstruction: nil)

private func resetImageCache() {
let semaphore = DispatchSemaphore(value: 0)
imageRepository.resetImageCache {
Expand All @@ -69,6 +74,27 @@ class InstructionsBannerViewIntegrationTests: XCTestCase {

super.tearDown()
}

func testCustomVisualInstructionDelegate() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also add a test for returning nil.

Copy link
Contributor Author

@JThramer JThramer Jul 3, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Test for nil response

let view = instructionsView()
view.instructionDelegate = reverseDelegate

view.set(typicalInstruction)

XCTAssert(view.primaryLabel.attributedText?.string == "teertS niaM")

}

func testCustomDelegateReturningNilTriggersDefaultBehavior() {
let view = instructionsView()
view.instructionDelegate = silentDelegate

view.set(typicalInstruction)

XCTAssert(view.primaryLabel.attributedText?.string == "Main Street")

}


func testDelimiterIsShownWhenShieldsNotLoaded() {
let view = instructionsView()
Expand Down Expand Up @@ -291,3 +317,19 @@ class InstructionsBannerViewIntegrationTests: XCTestCase {
}

}

private class TextReversingDelegate: VisualInstructionDelegate {
func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? {
let forwards = Array(presented.string)
let reverse = String(forwards.reversed())
var range = NSRange(location: 0, length: presented.string.count)
let attributes = presented.attributes(at: 0, effectiveRange: &range)
return NSAttributedString(string: reverse, attributes: attributes)
}
}

private class DefaultBehaviorDelegate: VisualInstructionDelegate {
func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? {
return nil
}
}