From 38a24422eaa52fcebfbb76508c8afba9e7db387f Mon Sep 17 00:00:00 2001 From: Jerrad Thramer Date: Fri, 29 Jun 2018 14:31:29 -0600 Subject: [PATCH 1/8] Adding delegate method to customize attributed string that is computed by `InstructionPresenter`. --- MapboxNavigation/InstructionLabel.swift | 21 ++++++++++++++++++- MapboxNavigation/InstructionsBannerView.swift | 7 +++++++ .../InstructionsBannerViewLayout.swift | 2 ++ MapboxNavigation/NavigationView.swift | 4 +++- .../NavigationViewController.swift | 6 +++++- MapboxNavigation/NextBannerView.swift | 6 ++++++ MapboxNavigation/RouteMapViewController.swift | 6 +++++- 7 files changed, 48 insertions(+), 4 deletions(-) diff --git a/MapboxNavigation/InstructionLabel.swift b/MapboxNavigation/InstructionLabel.swift index 3232ecc3c16..72867e32509 100644 --- a/MapboxNavigation/InstructionLabel.swift +++ b/MapboxNavigation/InstructionLabel.swift @@ -12,6 +12,7 @@ open class InstructionLabel: StylableLabel, InstructionPresenterDataSource { var shieldHeight: CGFloat = 30 var imageRepository: ImageRepository = .shared var imageDownloadCompletion: (() -> Void)? + var instructionDelegate: VisualInstructionDelegate? var instruction: VisualInstruction? { didSet { @@ -28,10 +29,28 @@ 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 optional func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? +} diff --git a/MapboxNavigation/InstructionsBannerView.swift b/MapboxNavigation/InstructionsBannerView.swift index 239a8bd1a33..290b2c3c9ee 100644 --- a/MapboxNavigation/InstructionsBannerView.swift +++ b/MapboxNavigation/InstructionsBannerView.swift @@ -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]() diff --git a/MapboxNavigation/InstructionsBannerViewLayout.swift b/MapboxNavigation/InstructionsBannerViewLayout.swift index 64bdb7ae68b..d87f66d4db3 100644 --- a/MapboxNavigation/InstructionsBannerViewLayout.swift +++ b/MapboxNavigation/InstructionsBannerViewLayout.swift @@ -23,6 +23,7 @@ extension BaseInstructionsBannerView { self.distanceLabel = distanceLabel let primaryLabel = PrimaryLabel() + primaryLabel.instructionDelegate = instructionDelegate primaryLabel.translatesAutoresizingMaskIntoConstraints = false primaryLabel.allowsDefaultTighteningForTruncation = true primaryLabel.adjustsFontSizeToFitWidth = true @@ -33,6 +34,7 @@ extension BaseInstructionsBannerView { self.primaryLabel = primaryLabel let secondaryLabel = SecondaryLabel() + secondaryLabel.instructionDelegate = instructionDelegate secondaryLabel.translatesAutoresizingMaskIntoConstraints = false secondaryLabel.allowsDefaultTighteningForTruncation = true secondaryLabel.numberOfLines = 1 diff --git a/MapboxNavigation/NavigationView.swift b/MapboxNavigation/NavigationView.swift index 3c4a972f590..1dc7a83b9dd 100644 --- a/MapboxNavigation/NavigationView.swift +++ b/MapboxNavigation/NavigationView.swift @@ -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) } diff --git a/MapboxNavigation/NavigationViewController.swift b/MapboxNavigation/NavigationViewController.swift index 5985a347a68..c339f9b2daa 100644 --- a/MapboxNavigation/NavigationViewController.swift +++ b/MapboxNavigation/NavigationViewController.swift @@ -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. @@ -538,6 +538,10 @@ extension NavigationViewController: RouteMapViewControllerDelegate { } return roadName } + + @objc public func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? { + return delegate?.label?(label, willPresent: instruction, as: presented) + } } //MARK: - RouteControllerDelegate diff --git a/MapboxNavigation/NextBannerView.swift b/MapboxNavigation/NextBannerView.swift index 2394a48c137..92b2f9f7c9f 100644 --- a/MapboxNavigation/NextBannerView.swift +++ b/MapboxNavigation/NextBannerView.swift @@ -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) @@ -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) diff --git a/MapboxNavigation/RouteMapViewController.swift b/MapboxNavigation/RouteMapViewController.swift index db18508e92e..390c4e0dd1e 100644 --- a/MapboxNavigation/RouteMapViewController.swift +++ b/MapboxNavigation/RouteMapViewController.swift @@ -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 @@ -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) From ebc036c2146ba3aeaf74c9925a34cc54f9067fa5 Mon Sep 17 00:00:00 2001 From: Jerrad Thramer Date: Mon, 2 Jul 2018 15:21:12 -0600 Subject: [PATCH 2/8] Adding test for VisualInstructionDelegate. --- ...structionsBannerViewIntegrationTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift b/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift index ce292f22f46..8e02b7a4335 100644 --- a/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift +++ b/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift @@ -45,6 +45,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 { @@ -69,6 +71,16 @@ class InstructionsBannerViewIntegrationTests: XCTestCase { super.tearDown() } + + func testCustomVisualInstructionDelegate() { + let view = instructionsView() + view.instructionDelegate = TextReversingDelegate() + + view.set(typicalInstruction) + + XCTAssert(view.primaryLabel.attributedText?.string == "teertS niaM") + + } func testDelimiterIsShownWhenShieldsNotLoaded() { let view = instructionsView() @@ -291,3 +303,13 @@ 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) + } +} From b128e67b29e0d74d7972b316f2a9e2dd59989181 Mon Sep 17 00:00:00 2001 From: Jerrad Thramer Date: Mon, 2 Jul 2018 16:38:07 -0600 Subject: [PATCH 3/8] Adding OBJ-C method name that's more in-line with convention. --- MapboxNavigation/InstructionLabel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MapboxNavigation/InstructionLabel.swift b/MapboxNavigation/InstructionLabel.swift index 72867e32509..6e5c2a7896a 100644 --- a/MapboxNavigation/InstructionLabel.swift +++ b/MapboxNavigation/InstructionLabel.swift @@ -52,5 +52,6 @@ public protocol VisualInstructionDelegate: class { - 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 optional func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? + @objc(label:willPresentVisualInstruction:asAttributedString:) + optional func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? } From eb392378f82b033fa7ff4cf638bd7395cdad3646 Mon Sep 17 00:00:00 2001 From: Jerrad Thramer Date: Tue, 3 Jul 2018 14:43:36 -0600 Subject: [PATCH 4/8] Adding example. --- Examples/Swift/ViewController.swift | 64 ++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/Examples/Swift/ViewController.swift b/Examples/Swift/ViewController.swift index a5dfb40ec20..c72dbb2def3 100644 --- a/Examples/Swift/ViewController.swift +++ b/Examples/Swift/ViewController.swift @@ -12,6 +12,7 @@ private typealias RouteRequestFailure = ((NSError) -> Void) enum ExampleMode { case `default` case custom + case customInstruction case styled case multipleWaypoints } @@ -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 + + var basic: ActionHandler = {_ in self.startBasicNavigation() } + var day: ActionHandler = {_ in self.startNavigation(styles: [DayStyle()]) } + var night: ActionHandler = {_ in self.startNavigation(styles: [NightStyle()]) } + var custom: ActionHandler = {_ in self.startCustomNavigation() } + var customInstruction: ActionHandler = {_ in self.startCustomInstructionNavigation() } + var 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 @@ -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 @@ -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 } @@ -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 } + + 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. */ From f397d9ce7093c865711ac2f84590738303ab11da Mon Sep 17 00:00:00 2001 From: Jerrad Thramer Date: Tue, 3 Jul 2018 16:23:56 -0600 Subject: [PATCH 5/8] Making these constant since they are never mutated --- Examples/Swift/ViewController.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Examples/Swift/ViewController.swift b/Examples/Swift/ViewController.swift index c72dbb2def3..92fb1941cf5 100644 --- a/Examples/Swift/ViewController.swift +++ b/Examples/Swift/ViewController.swift @@ -99,12 +99,12 @@ class ViewController: UIViewController, MGLMapViewDelegate, CLLocationManagerDel typealias ActionHandler = (UIAlertAction) -> Void - var basic: ActionHandler = {_ in self.startBasicNavigation() } - var day: ActionHandler = {_ in self.startNavigation(styles: [DayStyle()]) } - var night: ActionHandler = {_ in self.startNavigation(styles: [NightStyle()]) } - var custom: ActionHandler = {_ in self.startCustomNavigation() } - var customInstruction: ActionHandler = {_ in self.startCustomInstructionNavigation() } - var styled: ActionHandler = {_ in self.startStyledNavigation() } + 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), From 876505f0aad6a2fbef12afac6fd7956e82c4743a Mon Sep 17 00:00:00 2001 From: Jerrad Thramer Date: Tue, 3 Jul 2018 16:24:46 -0600 Subject: [PATCH 6/8] Adding test that ensures default behavior is used when delegate returns no answer. --- ...InstructionsBannerViewIntegrationTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift b/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift index 8e02b7a4335..f8b19206f75 100644 --- a/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift +++ b/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift @@ -81,6 +81,17 @@ class InstructionsBannerViewIntegrationTests: XCTestCase { XCTAssert(view.primaryLabel.attributedText?.string == "teertS niaM") } + + func testCustomDelegateReturningNilTriggersDefaultBehavior() { + let view = instructionsView() + view.instructionDelegate = DefaultBehaviorDelegate() + + view.set(typicalInstruction) + + XCTAssert(view.primaryLabel.attributedText?.string == "Main Street") + + } + func testDelimiterIsShownWhenShieldsNotLoaded() { let view = instructionsView() @@ -313,3 +324,9 @@ private class TextReversingDelegate: VisualInstructionDelegate { return NSAttributedString(string: reverse, attributes: attributes) } } + +private class DefaultBehaviorDelegate: VisualInstructionDelegate { + func label(_ label: InstructionLabel, willPresent instruction: VisualInstruction, as presented: NSAttributedString) -> NSAttributedString? { + return nil + } +} From 90e801231eca630d55e9377d4204f10795ed3684 Mon Sep 17 00:00:00 2001 From: Jerrad Thramer Date: Thu, 5 Jul 2018 11:47:46 -0600 Subject: [PATCH 7/8] Fixing issue where InstructionLabel was retaining strong ownership of it's visual instruction delegate. --- MapboxNavigation/InstructionLabel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MapboxNavigation/InstructionLabel.swift b/MapboxNavigation/InstructionLabel.swift index 6e5c2a7896a..c9e0a8e08de 100644 --- a/MapboxNavigation/InstructionLabel.swift +++ b/MapboxNavigation/InstructionLabel.swift @@ -12,7 +12,7 @@ open class InstructionLabel: StylableLabel, InstructionPresenterDataSource { var shieldHeight: CGFloat = 30 var imageRepository: ImageRepository = .shared var imageDownloadCompletion: (() -> Void)? - var instructionDelegate: VisualInstructionDelegate? + weak var instructionDelegate: VisualInstructionDelegate? var instruction: VisualInstruction? { didSet { From 10c9ec5f884ac0470ccaa028800f79bbbde5d216 Mon Sep 17 00:00:00 2001 From: Jerrad Thramer Date: Thu, 5 Jul 2018 11:48:57 -0600 Subject: [PATCH 8/8] Providing local ownership of newly orphaned delegates in integration test. --- .../InstructionsBannerViewIntegrationTests.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift b/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift index f8b19206f75..96bff8d3f95 100644 --- a/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift +++ b/MapboxNavigationTests/InstructionsBannerViewIntegrationTests.swift @@ -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 @@ -74,7 +77,7 @@ class InstructionsBannerViewIntegrationTests: XCTestCase { func testCustomVisualInstructionDelegate() { let view = instructionsView() - view.instructionDelegate = TextReversingDelegate() + view.instructionDelegate = reverseDelegate view.set(typicalInstruction) @@ -84,7 +87,7 @@ class InstructionsBannerViewIntegrationTests: XCTestCase { func testCustomDelegateReturningNilTriggersDefaultBehavior() { let view = instructionsView() - view.instructionDelegate = DefaultBehaviorDelegate() + view.instructionDelegate = silentDelegate view.set(typicalInstruction)