diff --git a/apple/DemoApp/Demo/DemoNavigationView.swift b/apple/DemoApp/Demo/DemoNavigationView.swift index 10a3622b..c9d09885 100644 --- a/apple/DemoApp/Demo/DemoNavigationView.swift +++ b/apple/DemoApp/Demo/DemoNavigationView.swift @@ -66,20 +66,21 @@ struct DemoNavigationView: View { NavigationStack { DynamicallyOrientingNavigationView( styleURL: style, - navigationState: ferrostarCore.state, camera: $camera, - snappedZoom: .constant(18), - useSnappedCamera: $snappedCamera - ) { - let source = ShapeSource(identifier: "userLocation") { - // Demonstrate how to add a dynamic overlay; - // also incidentally shows the extent of puck lag - if let userLocation = locationProvider.lastLocation { - MLNPointFeature(coordinate: userLocation.clLocation.coordinate) + navigationState: ferrostarCore.state, + onTapExit: { stopNavigation() }, + makeMapContent: { + let source = ShapeSource(identifier: "userLocation") { + // Demonstrate how to add a dynamic overlay; + // also incidentally shows the extent of puck lag + if let userLocation = locationProvider.lastLocation { + MLNPointFeature(coordinate: userLocation.clLocation.coordinate) + } } + CircleStyleLayer(identifier: "foo", source: source) } - CircleStyleLayer(identifier: "foo", source: source) - } onTapExit: { stopNavigation() } + ) + .innerGrid( topCenter: { if let errorMessage { NavigationUIBanner(severity: .error) { @@ -93,7 +94,8 @@ struct DemoNavigationView: View { Text("Loading route...") } } - } bottomTrailing: { + }, + bottomTrailing: { VStack { Text(locationLabel) .font(.caption) @@ -130,9 +132,10 @@ struct DemoNavigationView: View { } } } - .task { - await getRoutes() - } + ) + .task { + await getRoutes() + } } } diff --git a/apple/Sources/FerrostarMapLibreUI/Extensions/MapViewCamera.swift b/apple/Sources/FerrostarMapLibreUI/Extensions/MapViewCamera.swift new file mode 100644 index 00000000..335d6e5f --- /dev/null +++ b/apple/Sources/FerrostarMapLibreUI/Extensions/MapViewCamera.swift @@ -0,0 +1,23 @@ +import Foundation +import MapLibreSwiftUI + +public extension MapViewCamera { + /// Is the camera currently tracking (navigating) + var isTrackingUserLocationWithCourse: Bool { + if case .trackingUserLocationWithCourse = state { + return true + } + return false + } + + /// The default camera for automotive navigation. + /// + /// - Parameters: + /// - zoom: The zoom value (default is 18.0) + /// - pitch: The pitch (default is 45.0) + /// - Returns: The configured MapViewCamera + static func automotiveNavigation(zoom: Double = 18.0, pitch: Double = 45.0) -> MapViewCamera { + MapViewCamera.trackUserLocationWithCourse(zoom: zoom, + pitch: pitch) + } +} diff --git a/apple/Sources/FerrostarMapLibreUI/Models/NavigationMapViewContentInsetMode.swift b/apple/Sources/FerrostarMapLibreUI/Models/NavigationMapViewContentInsetMode.swift index 329811b2..eba6104e 100644 --- a/apple/Sources/FerrostarMapLibreUI/Models/NavigationMapViewContentInsetMode.swift +++ b/apple/Sources/FerrostarMapLibreUI/Models/NavigationMapViewContentInsetMode.swift @@ -6,7 +6,7 @@ public enum NavigationMapViewContentInsetMode { /// where the user location should appear toward the bottom of the map. /// /// This is used to accommodate a left InstructionView - case landscape(within: GeometryProxy, verticalPct: CGFloat = 0.75, horizontalPct: CGFloat = 0.75) + case landscape(within: GeometryProxy, verticalPct: CGFloat = 0.75, horizontalPct: CGFloat = 0.5) /// A predefined mode for landscape navigation map views /// where the user location should appear toward the bottom of the map. @@ -17,6 +17,15 @@ public enum NavigationMapViewContentInsetMode { /// Custom edge insets to manually control where the center of the map is. case edgeInset(UIEdgeInsets) + public init(orientation: UIDeviceOrientation, geometry: GeometryProxy) { + switch orientation { + case .landscapeLeft, .landscapeRight: + self = .landscape(within: geometry) + default: + self = .portrait(within: geometry) + } + } + var uiEdgeInsets: UIEdgeInsets { switch self { case let .landscape(geometry, verticalPct, horizontalPct): diff --git a/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift index afc81c83..6bc7673a 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift @@ -7,90 +7,121 @@ import MapLibreSwiftUI import SwiftUI /// A navigation view that dynamically switches between portrait and landscape orientations. -public struct DynamicallyOrientingNavigationView< - TopCenter: View, - TopTrailing: View, - MidLeading: View, - BottomTrailing: View ->: View { +public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingInnerGridView { @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection - // TODO: Add orientation handling once the landscape view is constructed. - @State private var orientation = UIDeviceOrientation.unknown + @State private var orientation = UIDevice.current.orientation let styleURL: URL - // TODO: Configurable camera and user "puck" rotation modes + @Binding var camera: MapViewCamera + let navigationCamera: MapViewCamera private var navigationState: NavigationState? private let userLayers: [StyleLayerDefinition] - var topCenter: TopCenter - var topTrailing: TopTrailing - var midLeading: MidLeading - var bottomTrailing: BottomTrailing - - @Binding var camera: MapViewCamera - @Binding var snappedZoom: Double - @Binding var useSnappedCamera: Bool + public var topCenter: (() -> AnyView)? + public var topTrailing: (() -> AnyView)? + public var midLeading: (() -> AnyView)? + public var bottomTrailing: (() -> AnyView)? var onTapExit: (() -> Void)? - /// Initialize a map view tuned for turn by turn navigation. + /// Create a dynamically orienting navigation view. This view automatically arranges child views for both portait + /// and landscape orientations. /// /// - Parameters: - /// - styleURL: The style URL for the map. This can dynamically change between light and dark mode. - /// - navigationState: The ferrostar navigations state. This is used primarily to drive user location on the map. - /// - camera: The camera which is controlled by the navigation state, but may also be pushed to for other cases - /// (e.g. user pan). - /// - snappedZoom: The zoom for the snapped camera. This can be fixed, customized or controlled by the camera. - /// - useSnappedCamera: Whether to use the ferrostar snapped camera or the camer binding itself. - /// - distanceFormatter: The formatter for distances in instruction views. + /// - styleURL: The map's style url. + /// - camera: The camera binding that represents the current camera on the map. + /// - navigationCamera: The default navigation camera. This sets the initial camera & is also used when the center + /// on user button it tapped. + /// - navigationState: The current ferrostar navigation state provided by ferrostar core. + /// - onTapExit: An optional behavior to run when the ArrivalView exit button is tapped. When nil (default) the + /// exit button is hidden. + /// - makeMapContent: Custom maplibre symbols to display on the map view. public init( styleURL: URL, - navigationState: NavigationState?, camera: Binding, - snappedZoom: Binding, - useSnappedCamera: Binding, - @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] }, + navigationCamera: MapViewCamera = .automotiveNavigation(), + navigationState: NavigationState?, onTapExit: (() -> Void)? = nil, - @ViewBuilder topCenter: () -> TopCenter = { Spacer() }, - @ViewBuilder topTrailing: () -> TopTrailing = { Spacer() }, - @ViewBuilder midLeading: () -> MidLeading = { Spacer() }, - @ViewBuilder bottomTrailing: () -> BottomTrailing = { Spacer() } + @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { self.styleURL = styleURL self.navigationState = navigationState self.onTapExit = onTapExit userLayers = makeMapContent() - self.topCenter = topCenter() - self.topTrailing = topTrailing() - self.midLeading = midLeading() - self.bottomTrailing = bottomTrailing() _camera = camera - _snappedZoom = snappedZoom - _useSnappedCamera = useSnappedCamera + self.navigationCamera = navigationCamera } public var body: some View { - switch orientation { - case .landscapeLeft, .landscapeRight: - Text("TODO") - default: - PortraitNavigationView( - styleURL: styleURL, - navigationState: navigationState, - camera: $camera, - snappedZoom: $snappedZoom, - useSnappedCamera: $useSnappedCamera, - onTapExit: onTapExit, - makeMapContent: { userLayers }, - topCenter: { topCenter }, - topTrailing: { topTrailing }, - midLeading: { midLeading }, - bottomTrailing: { bottomTrailing } - ) + GeometryReader { geometry in + ZStack { + NavigationMapView( + styleURL: styleURL, + camera: $camera, + navigationState: navigationState, + onStyleLoaded: { _ in + camera = navigationCamera + } + ) { + userLayers + } + .navigationMapViewContentInset(NavigationMapViewContentInsetMode( + orientation: orientation, + geometry: geometry + )) + + switch orientation { + case .landscapeLeft, .landscapeRight: + LandscapeNavigationOverlayView( + navigationState: navigationState, + speedLimit: nil, + showZoom: true, + onZoomIn: { camera.incrementZoom(by: 1) }, + onZoomOut: { camera.incrementZoom(by: -1) }, + showCentering: !camera.isTrackingUserLocationWithCourse, + onCenter: { camera = navigationCamera }, + onTapExit: onTapExit + ) + .innerGrid { + topCenter?() + } topTrailing: { + topTrailing?() + } midLeading: { + midLeading?() + } bottomTrailing: { + bottomTrailing?() + } + default: + PortraitNavigationOverlayView( + navigationState: navigationState, + speedLimit: nil, + showZoom: true, + onZoomIn: { camera.incrementZoom(by: 1) }, + onZoomOut: { camera.incrementZoom(by: -1) }, + showCentering: !camera.isTrackingUserLocationWithCourse, + onCenter: { camera = navigationCamera }, + onTapExit: onTapExit + ) + .innerGrid { + topCenter?() + } topTrailing: { + topTrailing?() + } midLeading: { + midLeading?() + } bottomTrailing: { + bottomTrailing?() + } + } + } + } + .onReceive( + NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) + ) { _ in + orientation = UIDevice.current.orientation } } } @@ -105,10 +136,8 @@ public struct DynamicallyOrientingNavigationView< return DynamicallyOrientingNavigationView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, - navigationState: state, camera: .constant(.center(state.snappedLocation.clLocation.coordinate, zoom: 12)), - snappedZoom: .constant(18), - useSnappedCamera: .constant(true) + navigationState: state ) .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) } @@ -122,10 +151,8 @@ public struct DynamicallyOrientingNavigationView< return DynamicallyOrientingNavigationView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, - navigationState: state, camera: .constant(.center(state.snappedLocation.clLocation.coordinate, zoom: 12)), - snappedZoom: .constant(18), - useSnappedCamera: .constant(true) + navigationState: state ) .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) } diff --git a/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift new file mode 100644 index 00000000..b03395a1 --- /dev/null +++ b/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift @@ -0,0 +1,129 @@ +import FerrostarCore +import FerrostarSwiftUI +import MapKit +import MapLibre +import MapLibreSwiftDSL +import MapLibreSwiftUI +import SwiftUI + +/// A landscape orientation navigation view that includes the InstructionsView and ArrivalView on the +/// leading half of the screen. +public struct LandscapeNavigationView: View { + @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection + + let styleURL: URL + @Binding var camera: MapViewCamera + let navigationCamera: MapViewCamera + + private var navigationState: NavigationState? + private let userLayers: [StyleLayerDefinition] + + public var topCenter: (() -> AnyView)? + public var topTrailing: (() -> AnyView)? + public var midLeading: (() -> AnyView)? + public var bottomTrailing: (() -> AnyView)? + + var onTapExit: (() -> Void)? + + /// Create a landscape navigation view. This view is optimized for display on a landscape screen where the + /// instructions are on the leading half of the screen + /// and the user puck and route are on the trailing half of the screen. + /// + /// - Parameters: + /// - styleURL: The map's style url. + /// - camera: The camera binding that represents the current camera on the map. + /// - navigationCamera: The default navigation camera. This sets the initial camera & is also used when the center + /// on user button it tapped. + /// - navigationState: The current ferrostar navigation state provided by ferrostar core. + /// - onTapExit: An optional behavior to run when the ArrivalView exit button is tapped. When nil (default) the + /// exit button is hidden. + /// - makeMapContent: Custom maplibre symbols to display on the map view. + public init( + styleURL: URL, + camera: Binding, + navigationCamera: MapViewCamera = .automotiveNavigation(), + navigationState: NavigationState?, + onTapExit: (() -> Void)? = nil, + @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] } + ) { + self.styleURL = styleURL + self.navigationState = navigationState + self.onTapExit = onTapExit + + userLayers = makeMapContent() + _camera = camera + self.navigationCamera = navigationCamera + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + NavigationMapView( + styleURL: styleURL, + camera: $camera, + navigationState: navigationState, + onStyleLoaded: { _ in + camera = navigationCamera + } + ) { + userLayers + } + .navigationMapViewContentInset(.landscape(within: geometry)) + + LandscapeNavigationOverlayView( + navigationState: navigationState, + speedLimit: nil, + showZoom: true, + onZoomIn: { camera.incrementZoom(by: 1) }, + onZoomOut: { camera.incrementZoom(by: -1) }, + showCentering: !camera.isTrackingUserLocationWithCourse, + onCenter: { camera = navigationCamera }, + onTapExit: onTapExit + ) + .innerGrid { + topCenter?() + } topTrailing: { + topTrailing?() + } midLeading: { + midLeading?() + } bottomTrailing: { + bottomTrailing?() + } + } + } + } +} + +@available(iOS 17, *) +#Preview("Landscape Navigation View (Imperial)", traits: .landscapeLeft) { + // TODO: Make map URL configurable but gitignored + let state = NavigationState.modifiedPedestrianExample(droppingNWaypoints: 4) + + let formatter = MKDistanceFormatter() + formatter.locale = Locale(identifier: "en-US") + formatter.units = .imperial + + return LandscapeNavigationView( + styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, + camera: .constant(.center(state.snappedLocation.clLocation.coordinate, zoom: 12)), + navigationState: state + ) + .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) +} + +@available(iOS 17, *) +#Preview("Landscape Navigation View (Metric)", traits: .landscapeLeft) { + // TODO: Make map URL configurable but gitignored + let state = NavigationState.modifiedPedestrianExample(droppingNWaypoints: 4) + + let formatter = MKDistanceFormatter() + formatter.locale = Locale(identifier: "en-US") + formatter.units = .metric + + return LandscapeNavigationView( + styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, + camera: .constant(.center(state.snappedLocation.clLocation.coordinate, zoom: 12)), + navigationState: state + ) + .navigationFormatterCollection(FoundationFormatterCollection(distanceFormatter: formatter)) +} diff --git a/apple/Sources/FerrostarMapLibreUI/Views/NavigationMapView.swift b/apple/Sources/FerrostarMapLibreUI/Views/NavigationMapView.swift index eb2b02a7..84207a7b 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/NavigationMapView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/NavigationMapView.swift @@ -15,6 +15,7 @@ import SwiftUI public struct NavigationMapView: View { let styleURL: URL var mapViewContentInset: UIEdgeInsets = .zero + var onStyleLoaded: (MLNStyle) -> Void let userLayers: [StyleLayerDefinition] // TODO: Configurable camera and user "puck" rotation modes @@ -27,50 +28,25 @@ public struct NavigationMapView: View { @Binding var camera: MapViewCamera - /// The snapped camera zoom. This is used to override the camera zoom whenever snapping is active. - @Binding var snappedZoom: Double - - /// Whether to snap the camera on the next navigation status update. When this is false, - /// the user can browse the map freely. - @Binding var useSnappedCamera: Bool - - /// The MapViewPort is used to construct the camera at the end of a drag gesture. - @State private var mapViewPort: MapViewPort? - - /// The breakway velocity is used on the drag gesture to determine when allow a drag to - /// disable the snapped camera (assuming it's not constant(true). - /// - /// Tune this value to reduce the number of accidental drags that detach the camera - /// from the snapped user location. - private let breakwayVelocity: CGFloat - /// Initialize a map view tuned for turn by turn navigation. /// /// - Parameters: - /// - styleURL: The style URL for the map. This can dynamically change between light and dark mode. - /// - navigationState: The ferrostar navigations state. This is used primarily to drive user location on the map. - /// - camera: The camera which is controlled by the navigation state, but may also be pushed to for other cases - /// (e.g. user pan). - /// - snappedZoom: The zoom for the snapped camera. This can be fixed, customized or controlled by the camera. - /// - useSnappedCamera: Whether to use the ferrostar snapped camera or the camer binding itself. - /// - snappingBreakawayVelocity: The drag gesture velocity used to disable snapping. This can be tuned to prevent - /// accidental drags. - /// - content: Any additional MapLibre symbols to show on the map. + /// - styleURL: The map's style url. + /// - camera: The camera binding that represents the current camera on the map. + /// - navigationState: The current ferrostar navigation state provided by ferrostar core. + /// - onStyleLoaded: The map's style has loaded and the camera can be manipulated (e.g. to user tracking). + /// - makeMapContent: Custom maplibre symbols to display on the map view. public init( styleURL: URL, - navigationState: NavigationState?, camera: Binding, - snappedZoom: Binding, - useSnappedCamera: Binding, - snappingBreakawayVelocity: CGFloat = 25, + navigationState: NavigationState?, + onStyleLoaded: @escaping ((MLNStyle) -> Void), @MapViewContentBuilder _ makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { self.styleURL = styleURL - self.navigationState = navigationState _camera = camera - _snappedZoom = snappedZoom - _useSnappedCamera = useSnappedCamera - breakwayVelocity = snappingBreakawayVelocity + self.navigationState = navigationState + self.onStyleLoaded = onStyleLoaded userLayers = makeMapContent() } @@ -102,24 +78,7 @@ public struct NavigationMapView: View { .mapControls { // No controls } - .onMapViewPortUpdate { mapViewPort in - self.mapViewPort = mapViewPort - } - .gesture( - DragGesture() - .onChanged { gesture in - guard abs(gesture.velocity.width) > breakwayVelocity - || abs(gesture.velocity.height) > breakwayVelocity - else { - return - } - - useSnappedCamera = false - if let mapViewPort { - camera = mapViewPort.asMapViewCamera() - } - } - ) + .onStyleLoaded(onStyleLoaded) .ignoresSafeArea(.all) } @@ -131,14 +90,6 @@ public struct NavigationMapView: View { .clLocationCoordinate2D || locationManager.lastLocation.course != snappedLocation.clLocation.course { locationManager.lastLocation = snappedLocation.clLocation - - // TODO: Be less forceful about this. - DispatchQueue.main.async { - if useSnappedCamera { - camera = .trackUserLocationWithCourse(zoom: snappedZoom, - pitch: 45.0) - } - } } } } @@ -149,9 +100,8 @@ public struct NavigationMapView: View { return NavigationMapView( styleURL: URL(string: "https://demotiles.maplibre.org/style.json")!, - navigationState: state, camera: .constant(.center(state.snappedLocation.clLocation.coordinate, zoom: 12)), - snappedZoom: .constant(18), - useSnappedCamera: .constant(true) + navigationState: state, + onStyleLoaded: { _ in } ) } diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift new file mode 100644 index 00000000..29a4292b --- /dev/null +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift @@ -0,0 +1,96 @@ +import FerrostarCore +import FerrostarSwiftUI +import MapKit +import MapLibre +import MapLibreSwiftDSL +import MapLibreSwiftUI +import SwiftUI + +struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView { + @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection + + private var navigationState: NavigationState? + + var topCenter: (() -> AnyView)? + var topTrailing: (() -> AnyView)? + var midLeading: (() -> AnyView)? + var bottomTrailing: (() -> AnyView)? + + var speedLimit: Measurement? + var showZoom: Bool + var onZoomIn: () -> Void + var onZoomOut: () -> Void + var showCentering: Bool + var onCenter: () -> Void + var onTapExit: (() -> Void)? + + init( + navigationState: NavigationState?, + speedLimit: Measurement? = nil, + showZoom: Bool = false, + onZoomIn: @escaping () -> Void = {}, + onZoomOut: @escaping () -> Void = {}, + showCentering: Bool = false, + onCenter: @escaping () -> Void = {}, + onTapExit: (() -> Void)? = nil + ) { + self.navigationState = navigationState + self.speedLimit = speedLimit + self.showZoom = showZoom + self.onZoomIn = onZoomIn + self.onZoomOut = onZoomOut + self.showCentering = showCentering + self.onCenter = onCenter + self.onTapExit = onTapExit + } + + var body: some View { + HStack { + VStack { + if let navigationState, + let visualInstructions = navigationState.visualInstruction + { + InstructionsView( + visualInstruction: visualInstructions, + distanceFormatter: formatterCollection.distanceFormatter, + distanceToNextManeuver: navigationState.progress?.distanceToNextManeuver + ) + .padding(.top, 16) + } + + Spacer() + + if let progress = navigationState?.progress { + ArrivalView( + progress: progress, + onTapExit: onTapExit + ) + } + } + + Spacer().frame(width: 16) + + // The inner content is displayed vertically full screen + // when both the visualInstructions and progress are nil. + // It will automatically reduce height if and when either + // view appears + NavigatingInnerGridView( + speedLimit: speedLimit, + showZoom: showZoom, + onZoomIn: onZoomIn, + onZoomOut: onZoomOut, + showCentering: showCentering, + onCenter: onCenter + ) + .innerGrid { + topCenter?() + } topTrailing: { + topTrailing?() + } midLeading: { + midLeading?() + } bottomTrailing: { + bottomTrailing?() + } + } + } +} diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift new file mode 100644 index 00000000..8bb52d6d --- /dev/null +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift @@ -0,0 +1,92 @@ +import FerrostarCore +import FerrostarSwiftUI +import MapKit +import MapLibre +import MapLibreSwiftDSL +import MapLibreSwiftUI +import SwiftUI + +struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView { + @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection + + private var navigationState: NavigationState? + + var topCenter: (() -> AnyView)? + var topTrailing: (() -> AnyView)? + var midLeading: (() -> AnyView)? + var bottomTrailing: (() -> AnyView)? + + var speedLimit: Measurement? + var showZoom: Bool + var onZoomIn: () -> Void + var onZoomOut: () -> Void + var showCentering: Bool + var onCenter: () -> Void + var onTapExit: (() -> Void)? + + init( + navigationState: NavigationState?, + speedLimit: Measurement? = nil, + showZoom: Bool = false, + onZoomIn: @escaping () -> Void = {}, + onZoomOut: @escaping () -> Void = {}, + showCentering: Bool = false, + onCenter: @escaping () -> Void = {}, + onTapExit: (() -> Void)? = nil + ) { + self.navigationState = navigationState + self.speedLimit = speedLimit + self.showZoom = showZoom + self.onZoomIn = onZoomIn + self.onZoomOut = onZoomOut + self.showCentering = showCentering + self.onCenter = onCenter + self.onTapExit = onTapExit + } + + var body: some View { + VStack { + if let navigationState, + let visualInstructions = navigationState.visualInstruction + { + InstructionsView( + visualInstruction: visualInstructions, + distanceFormatter: formatterCollection.distanceFormatter, + distanceToNextManeuver: navigationState.progress?.distanceToNextManeuver + ) + .padding(.horizontal, 16) + } + + // The inner content is displayed vertically full screen + // when both the visualInstructions and progress are nil. + // It will automatically reduce height if and when either + // view appears + NavigatingInnerGridView( + speedLimit: speedLimit, + showZoom: showZoom, + onZoomIn: onZoomIn, + onZoomOut: onZoomOut, + showCentering: showCentering, + onCenter: onCenter + ) + .innerGrid { + topCenter?() + } topTrailing: { + topTrailing?() + } midLeading: { + midLeading?() + } bottomTrailing: { + bottomTrailing?() + } + .padding(.horizontal, 16) + + if let progress = navigationState?.progress { + ArrivalView( + progress: progress, + onTapExit: onTapExit + ) + .padding(.horizontal, 16) + } + } + } +} diff --git a/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift index 7c7c02ca..96192399 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift @@ -7,7 +7,7 @@ import MapLibreSwiftUI import SwiftUI /// A portrait orientation navigation view that includes the InstructionsView at the top. -public struct PortraitNavigationView: View { +public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView { @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection let styleURL: URL @@ -16,43 +16,45 @@ public struct PortraitNavigationView AnyView)? + public var topTrailing: (() -> AnyView)? + public var midLeading: (() -> AnyView)? + public var bottomTrailing: (() -> AnyView)? @Binding var camera: MapViewCamera - @Binding var snappedZoom: Double - @Binding var useSnappedCamera: Bool + let navigationCamera: MapViewCamera var onTapExit: (() -> Void)? + /// Create a portrait navigation view. This view is optimized for display on a portrait screen where the + /// instructions and arrival view are on the top and bottom of the screen. + /// The user puck and route are optimized for the center of the screen. + /// + /// - Parameters: + /// - styleURL: The map's style url. + /// - camera: The camera binding that represents the current camera on the map. + /// - navigationCamera: The default navigation camera. This sets the initial camera & is also used when the center + /// on user button it tapped. + /// - navigationState: The current ferrostar navigation state provided by ferrostar core. + /// - onTapExit: An optional behavior to run when the ArrivalView exit button is tapped. When nil (default) the + /// exit button is hidden. + /// - makeMapContent: Custom maplibre symbols to display on the map view. public init( styleURL: URL, - navigationState: NavigationState?, camera: Binding, - snappedZoom: Binding, - useSnappedCamera: Binding, + navigationCamera: MapViewCamera = .automotiveNavigation(), + navigationState: NavigationState?, onTapExit: (() -> Void)? = nil, - @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] }, - @ViewBuilder topCenter: () -> TopCenter = { Spacer() }, - @ViewBuilder topTrailing: () -> TopTrailing = { Spacer() }, - @ViewBuilder midLeading: () -> MidLeading = { Spacer() }, - @ViewBuilder bottomTrailing: () -> BottomTrailing = { Spacer() } + @MapViewContentBuilder makeMapContent: () -> [StyleLayerDefinition] = { [] } ) { self.styleURL = styleURL self.navigationState = navigationState self.onTapExit = onTapExit userLayers = makeMapContent() - self.topCenter = topCenter() - self.topTrailing = topTrailing() - self.midLeading = midLeading() - self.bottomTrailing = bottomTrailing() _camera = camera - _snappedZoom = snappedZoom - _useSnappedCamera = useSnappedCamera + self.navigationCamera = navigationCamera } public var body: some View { @@ -60,52 +62,34 @@ public struct PortraitNavigationView AnyView)? { get set } + var topTrailing: (() -> AnyView)? { get set } + var midLeading: (() -> AnyView)? { get set } + var bottomTrailing: (() -> AnyView)? { get set } +} + +public extension CustomizableNavigatingInnerGridView { + /// Customize views on the navigating inner grid view that are not already being used. + /// + /// - Parameters: + /// - topCenter: The top center view content. + /// - topTrailing: The top trailing view content. + /// - midLeading: The mid leading view content. + /// - bottomTrailing: The bottom trailing view content. + /// - Returns: The modified view. + func innerGrid( + @ViewBuilder topCenter: @escaping () -> some View = { Spacer() }, + @ViewBuilder topTrailing: @escaping () -> some View = { Spacer() }, + @ViewBuilder midLeading: @escaping () -> some View = { Spacer() }, + @ViewBuilder bottomTrailing: @escaping () -> some View = { Spacer() } + ) -> some View { + var newSelf = self + newSelf.topCenter = { AnyView(topCenter()) } + newSelf.topTrailing = { AnyView(topTrailing()) } + newSelf.midLeading = { AnyView(midLeading()) } + newSelf.bottomTrailing = { AnyView(bottomTrailing()) } + return newSelf + } +} diff --git a/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift b/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift index 9d75b969..48200dcc 100644 --- a/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift +++ b/apple/Sources/FerrostarSwiftUI/Views/GridViews/NavigatingInnerGridView.swift @@ -3,12 +3,7 @@ import SwiftUI /// When navigation is underway, we use this standardized grid view with pre-defined metadata and interactions. /// This is the default UI and can be customized to some extent. If you need more customization, /// use the ``InnerGridView``. -public struct NavigatingInnerGridView< - TopCenter: View, - TopTrailing: View, - MidLeading: View, - BottomTrailing: View ->: View { +public struct NavigatingInnerGridView: View, CustomizableNavigatingInnerGridView { @Environment(\.navigationFormatterCollection) var formatterCollection: any FormatterCollection var speedLimit: Measurement? @@ -22,10 +17,10 @@ public struct NavigatingInnerGridView< // MARK: Customizable Containers - var topCenter: TopCenter - var topTrailing: TopTrailing - var midLeading: MidLeading - var bottomTrailing: BottomTrailing + public var topCenter: (() -> AnyView)? + public var topTrailing: (() -> AnyView)? + public var midLeading: (() -> AnyView)? + public var bottomTrailing: (() -> AnyView)? /// The default navigation inner grid view. /// @@ -44,22 +39,14 @@ public struct NavigatingInnerGridView< /// - onTapCenter: The action that occurs when the user taps the centering control. Typically re-centering the /// user. /// - topCenter: The customizable top center view. This is recommended for navigation alerts (e.g. toast style - /// notices). - /// - topTrailing: The customizable top trailing view. This can be used for custom interactions or metadata views. - /// - midLeading: The customizable mid leading view. This can be used for custom interactions or metadata views. - /// - bottomTrailing: The customizable bottom leading view. This can be used for custom interactions or metadata - /// views. + /// notices). public init( speedLimit: Measurement? = nil, showZoom: Bool = false, onZoomIn: @escaping () -> Void = {}, onZoomOut: @escaping () -> Void = {}, showCentering: Bool = false, - onCenter: @escaping () -> Void = {}, - @ViewBuilder topCenter: () -> TopCenter = { Spacer() }, - @ViewBuilder topTrailing: () -> TopTrailing = { Spacer() }, - @ViewBuilder midLeading: () -> MidLeading = { Spacer() }, - @ViewBuilder bottomTrailing: () -> BottomTrailing = { Spacer() } + onCenter: @escaping () -> Void = {} ) { self.speedLimit = speedLimit self.showZoom = showZoom @@ -67,11 +54,6 @@ public struct NavigatingInnerGridView< self.onZoomOut = onZoomOut self.showCentering = showCentering self.onCenter = onCenter - - self.topCenter = topCenter() - self.topTrailing = topTrailing() - self.midLeading = midLeading() - self.bottomTrailing = bottomTrailing() } public var body: some View { @@ -85,9 +67,9 @@ public struct NavigatingInnerGridView< ) } }, - topCenter: { topCenter }, - topTrailing: { topTrailing }, - midLeading: { midLeading }, + topCenter: { topCenter?() }, + topTrailing: { topTrailing?() }, + midLeading: { midLeading?() }, midCenter: { // This view does not allow center content. Spacer() @@ -117,7 +99,7 @@ public struct NavigatingInnerGridView< // This view does not allow center content to prevent overlaying the puck. Spacer() }, - bottomTrailing: { bottomTrailing } + bottomTrailing: { bottomTrailing?() } ) } } diff --git a/apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift b/apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift index 22f6f17a..54002f1e 100644 --- a/apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift +++ b/apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift @@ -79,7 +79,6 @@ public struct InstructionsView: View { } .background(primaryRowTheme.backgroundColor) .clipShape(.rect(cornerRadius: 12)) - .padding() .shadow(radius: 12) } @@ -97,7 +96,7 @@ public struct InstructionsView: View { germanFormatter.locale = Locale(identifier: "de_DE") germanFormatter.units = .metric - return VStack { + return VStack(spacing: 16) { InstructionsView( visualInstruction: VisualInstruction( primaryContent: VisualInstructionContent( @@ -147,5 +146,6 @@ public struct InstructionsView: View { Spacer() } + .padding() .background(Color.green) } diff --git a/apple/Tests/FerrostarSwiftUITests/Views/InstructionsViewTests.swift b/apple/Tests/FerrostarSwiftUITests/Views/InstructionsViewTests.swift index a7dc6f1a..b4d8cc4b 100644 --- a/apple/Tests/FerrostarSwiftUITests/Views/InstructionsViewTests.swift +++ b/apple/Tests/FerrostarSwiftUITests/Views/InstructionsViewTests.swift @@ -25,6 +25,7 @@ final class InstructionsViewTests: XCTestCase { primaryRowTheme: TestingInstructionRowTheme(), secondaryRowTheme: TestingInstructionRowTheme() ) + .padding() } } @@ -45,6 +46,7 @@ final class InstructionsViewTests: XCTestCase { primaryRowTheme: TestingInstructionRowTheme(), secondaryRowTheme: TestingInstructionRowTheme() ) + .padding() } } @@ -66,6 +68,7 @@ final class InstructionsViewTests: XCTestCase { secondaryRowTheme: TestingInstructionRowTheme(), showPillControl: true ) + .padding() } } @@ -87,6 +90,7 @@ final class InstructionsViewTests: XCTestCase { primaryRowTheme: TestingInstructionRowTheme(), secondaryRowTheme: TestingInstructionRowTheme() ) + .padding() } } } diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/testViennaStyleSpeedLimitInGridView.1.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/testViennaStyleSpeedLimitInGridView.1.png index 2bbe7490..c2640c19 100644 Binary files a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/testViennaStyleSpeedLimitInGridView.1.png and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/NavigatingInnerGridViewTests/testViennaStyleSpeedLimitInGridView.1.png differ