From 33f35da40c6db148ad12a86b3ccb926a5785b65e Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 19 Sep 2024 20:58:14 -0700 Subject: [PATCH] [iOS] Pull down cue sheet shows remaining steps Also added some test fixture factories - am I going overboard? --- .../Mock/TestFixtureFactory.swift | 92 +++++++ .../LandscapeNavigationOverlayView.swift | 35 ++- .../PortraitNavigationOverlayView.swift | 82 +++--- .../Views/InstructionsView.swift | 148 ++++++++--- ...ltIconographyManeuverInstructionView.swift | 2 + .../Views/TopDrawerView.swift | 241 ++++++++++++++++++ .../Views/InstructionsViewTests.swift | 3 +- 7 files changed, 516 insertions(+), 87 deletions(-) create mode 100644 apple/Sources/FerrostarCore/Mock/TestFixtureFactory.swift create mode 100644 apple/Sources/FerrostarSwiftUI/Views/TopDrawerView.swift diff --git a/apple/Sources/FerrostarCore/Mock/TestFixtureFactory.swift b/apple/Sources/FerrostarCore/Mock/TestFixtureFactory.swift new file mode 100644 index 00000000..e9e890cd --- /dev/null +++ b/apple/Sources/FerrostarCore/Mock/TestFixtureFactory.swift @@ -0,0 +1,92 @@ +import FerrostarCoreFFI +import Foundation + +public protocol TestFixtureFactory { + associatedtype Output + func build(_ n: Int) -> Output +} + +public extension TestFixtureFactory { + func buildMany(_ n: Int) -> [Output] { + (0 ... n).map { build($0) } + } +} + +public struct VisualInstructionContentFactory: TestFixtureFactory { + public init() {} + + public var textBuilder: (Int) -> String = { n in RoadNameFactory().build(n) } + public func text(_ builder: @escaping (Int) -> String) -> Self { + var copy = self + copy.textBuilder = builder + return copy + } + + public func build(_ n: Int = 0) -> VisualInstructionContent { + VisualInstructionContent( + text: textBuilder(n), + maneuverType: .turn, + maneuverModifier: .left, + roundaboutExitDegrees: nil + ) + } +} + +public struct VisualInstructionFactory: TestFixtureFactory { + public init() {} + + public var primaryContentBuilder: (Int) -> VisualInstructionContent = { n in + VisualInstructionContentFactory().build(n) + } + + public var secondaryContentBuilder: (Int) -> VisualInstructionContent? = { _ in nil } + + public func secondaryContent(_ builder: @escaping (Int) -> VisualInstructionContent) -> Self { + var copy = self + copy.secondaryContentBuilder = builder + return copy + } + + public func build(_ n: Int = 0) -> VisualInstruction { + VisualInstruction( + primaryContent: primaryContentBuilder(n), + secondaryContent: secondaryContentBuilder(n), + triggerDistanceBeforeManeuver: 42.0 + ) + } +} + +public struct RouteStepFactory: TestFixtureFactory { + public init() {} + public var visualInstructionBuilder: (Int) -> VisualInstruction = { n in VisualInstructionFactory().build(n) } + public var roadNameBuilder: (Int) -> String = { n in RoadNameFactory().build(n) } + + public func build(_ n: Int = 0) -> RouteStep { + RouteStep( + geometry: [], + distance: 100, + duration: 99, + roadName: roadNameBuilder(n), + instruction: "Walk west on \(roadNameBuilder(n))", + visualInstructions: [visualInstructionBuilder(n)], + spokenInstructions: [] + ) + } +} + +public struct RoadNameFactory: TestFixtureFactory { + public init() {} + public var baseNameBuilder: (Int) -> String = { _ in "Ave" } + + public func baseName(_ builder: @escaping (Int) -> String) -> Self { + var copy = self + copy.baseNameBuilder = builder + return copy + } + + public func build(_ n: Int = 0) -> String { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .ordinal + return "\(numberFormatter.string(from: NSNumber(value: n + 1))!) \(baseNameBuilder(n))" + } +} diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift index 6db87460..c97f527e 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift @@ -46,24 +46,33 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView var body: some View { HStack { - VStack { - if case let .navigating(_, _, _, _, progress: progress, _, visualInstruction: visualInstruction, - _) = navigationState?.tripState, + ZStack(alignment: .top) { + VStack { + Spacer() + if case let .navigating(_, _, _, _, progress: progress, _, _, _) = navigationState?.tripState { + ArrivalView( + progress: progress, + onTapExit: onTapExit + ) + } + } + if case let .navigating( + _, + _, + remainingSteps: remainingSteps, + _, + progress: progress, + _, + visualInstruction: visualInstruction, + _ + ) = navigationState?.tripState, let visualInstruction { InstructionsView( visualInstruction: visualInstruction, distanceFormatter: formatterCollection.distanceFormatter, - distanceToNextManeuver: progress.distanceToNextManeuver - ) - } - - Spacer() - - if case let .navigating(_, _, _, _, progress: progress, _, _, _) = navigationState?.tripState { - ArrivalView( - progress: progress, - onTapExit: onTapExit + distanceToNextManeuver: progress.distanceToNextManeuver, + remainingSteps: remainingSteps ) } } diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift index d4c69920..294f90e9 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift @@ -18,6 +18,8 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView var speedLimit: Measurement? var showZoom: Bool + @State + private var instructionsViewSizeWhenNotExpanded: CGSize = .zero var onZoomIn: () -> Void var onZoomOut: () -> Void var showCentering: Bool @@ -45,44 +47,58 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView } var body: some View { - VStack { - if case let .navigating(_, _, _, _, progress: progress, _, visualInstruction: visualInstruction, - _) = navigationState?.tripState, + ZStack(alignment: .top) { + VStack { + Spacer() + + // 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?() + } + + if case let .navigating(_, _, _, _, progress: progress, _, _, _) = navigationState?.tripState { + ArrivalView( + progress: progress, + onTapExit: onTapExit + ) + } + }.padding(.top, instructionsViewSizeWhenNotExpanded.height) + + if case let .navigating( + _, + _, + remainingSteps: remainingSteps, + _, + progress: progress, + _, + visualInstruction: visualInstruction, + _ + ) = navigationState?.tripState, let visualInstruction { InstructionsView( visualInstruction: visualInstruction, distanceFormatter: formatterCollection.distanceFormatter, - distanceToNextManeuver: progress.distanceToNextManeuver - ) - } - - // 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?() - } - - if case let .navigating(_, _, _, _, progress: progress, _, _, _) = navigationState?.tripState { - ArrivalView( - progress: progress, - onTapExit: onTapExit + distanceToNextManeuver: progress.distanceToNextManeuver, + remainingSteps: remainingSteps, + sizeWhenNotExpanded: $instructionsViewSizeWhenNotExpanded ) } } diff --git a/apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift b/apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift index eb023896..2a3bd506 100644 --- a/apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift +++ b/apple/Sources/FerrostarSwiftUI/Views/InstructionsView.swift @@ -1,4 +1,5 @@ import CoreLocation +import FerrostarCore import FerrostarCoreFFI import MapKit import SwiftUI @@ -8,6 +9,7 @@ public struct InstructionsView: View { private let visualInstruction: VisualInstruction private let distanceToNextManeuver: CLLocationDistance? private let distanceFormatter: Formatter + private let remainingSteps: [RouteStep]? private let primaryRowTheme: InstructionRowTheme private let secondaryRowTheme: InstructionRowTheme @@ -15,7 +17,9 @@ public struct InstructionsView: View { visualInstruction.secondaryContent != nil } - private let showPillControl: Bool + private let handlePadding: CGFloat = 12 + @Binding + private var sizeWhenNotExpanded: CGSize /// Create a visual instruction banner view. This view automatically displays the secondary /// instruction if there is one. @@ -24,72 +28,106 @@ public struct InstructionsView: View { /// - visualInstruction: The visual instruction to display. /// - distanceFormatter: The formatter which controls distance localization. /// - distanceToNextManeuver: The distance remaining for the step. + /// - remainingSteps: All steps remaining in the route, including the current step /// - primaryRowTheme: The theme for the primary instruction. /// - secondaryRowTheme: The theme for the secondary instruction. - /// - showPillControl: If true, shows a pill control (to indicate an action/expansion). + /// - sizeWhenNotExpanded: Size of the InstructionsView when minimized - useful for allocating space for this view + /// in your layout. public init( visualInstruction: VisualInstruction, distanceFormatter: Formatter = DefaultFormatters.distanceFormatter, distanceToNextManeuver: CLLocationDistance? = nil, + remainingSteps: [RouteStep]? = nil, primaryRowTheme: InstructionRowTheme = DefaultInstructionRowTheme(), secondaryRowTheme: InstructionRowTheme = DefaultSecondaryInstructionRowTheme(), - showPillControl: Bool = false + sizeWhenNotExpanded: Binding = .constant(.zero) ) { self.visualInstruction = visualInstruction self.distanceFormatter = distanceFormatter self.distanceToNextManeuver = distanceToNextManeuver + self.remainingSteps = remainingSteps self.primaryRowTheme = primaryRowTheme self.secondaryRowTheme = secondaryRowTheme - self.showPillControl = showPillControl + _sizeWhenNotExpanded = sizeWhenNotExpanded } - public var body: some View { - VStack { + var nextVisualInstructions: [(VisualInstruction, RouteStep)] { + guard let remainingSteps, !remainingSteps.isEmpty else { + return [] + } + return remainingSteps[1...].compactMap { step in + guard let visualInstruction = step.visualInstructions.first else { + return nil + } + return (visualInstruction, step) + } + } + + public var expandedContent: AnyView? { + guard !nextVisualInstructions.isEmpty else { + return nil + } + return AnyView(ForEach(Array(nextVisualInstructions.enumerated()), id: \.0) { enumerated in + let (visualInstruction, step): (VisualInstruction, RouteStep) = enumerated.1 + Divider().padding(.leading, 16) DefaultIconographyManeuverInstructionView( text: visualInstruction.primaryContent.text, maneuverType: visualInstruction.primaryContent.maneuverType, maneuverModifier: visualInstruction.primaryContent.maneuverModifier, distanceFormatter: distanceFormatter, - distanceToNextManeuver: distanceToNextManeuver, + distanceToNextManeuver: step.distance == 0 ? nil : step.distance, theme: primaryRowTheme ) .font(.title2.bold()) .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 0) + }) + } - if let secondaryContent = visualInstruction.secondaryContent { - VStack { + public var body: some View { + TopDrawerView( + backgroundColor: primaryRowTheme.backgroundColor, + persistentContent: { + VStack(spacing: 0) { DefaultIconographyManeuverInstructionView( - text: secondaryContent.text, - maneuverType: secondaryContent.maneuverType, - maneuverModifier: secondaryContent.maneuverModifier, + text: visualInstruction.primaryContent.text, + maneuverType: visualInstruction.primaryContent.maneuverType, + maneuverModifier: visualInstruction.primaryContent.maneuverModifier, distanceFormatter: distanceFormatter, - theme: secondaryRowTheme + distanceToNextManeuver: distanceToNextManeuver == 0 ? nil : distanceToNextManeuver, + theme: primaryRowTheme ) + .font(.title2.bold()) .padding(.horizontal, 16) - .padding(.top, 8) - - // TODO: Show the pill when interactivity is enabled - pillControl(isActive: showPillControl) - } - .background(secondaryRowTheme.backgroundColor) - } else { - // TODO: Show the pill when interactivity is enabled - pillControl(isActive: showPillControl) - } - } - .background(primaryRowTheme.backgroundColor) - .clipShape(.rect(cornerRadius: 12)) - .shadow(radius: 12) - } + .padding(.vertical, 8) + .padding(.bottom, hasSecondary ? 0 : expandedContent == nil ? 0 : handlePadding) - /// The pill control that is shown at the bottom of the Instructions View. - @ViewBuilder fileprivate func pillControl(isActive: Bool) -> some View { - RoundedRectangle(cornerRadius: 3) - .frame(width: 24, height: isActive ? 6 : 0) - .opacity(isActive ? 0.1 : 0.0) - .padding(.bottom, 8) + if let secondaryContent = visualInstruction.secondaryContent { + DefaultIconographyManeuverInstructionView( + text: secondaryContent.text, + maneuverType: secondaryContent.maneuverType, + maneuverModifier: secondaryContent.maneuverModifier, + distanceFormatter: distanceFormatter, + theme: secondaryRowTheme + ) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .padding(.bottom, handlePadding) + .background(secondaryRowTheme.backgroundColor) + } + }.overlay( + GeometryReader { geometry in + Color.clear.onAppear { + sizeWhenNotExpanded = geometry.size + }.onChange(of: geometry.size) { newValue in + sizeWhenNotExpanded = newValue + }.onDisappear { + sizeWhenNotExpanded = .zero + } + } + ) + }, + expandedContent: { expandedContent } + ) } } @@ -142,8 +180,7 @@ public struct InstructionsView: View { triggerDistanceBeforeManeuver: 123 ), distanceFormatter: germanFormatter, - distanceToNextManeuver: 1500.0, - showPillControl: true + distanceToNextManeuver: 1500.0 ) InstructionsView( @@ -161,8 +198,39 @@ public struct InstructionsView: View { roundaboutExitDegrees: nil ), triggerDistanceBeforeManeuver: 123 - ), - showPillControl: true + ) + ) + + Spacer() + } + .padding() + .background(Color.green) +} + +#Preview("Many steps") { + VStack(spacing: 16) { + InstructionsView( + visualInstruction: VisualInstructionFactory().build(), + distanceToNextManeuver: 1500, + remainingSteps: RouteStepFactory().buildMany(10) + ) + + Spacer() + } + .padding() + .background(Color.green) +} + +#Preview("Many steps with secondary") { + VStack(spacing: 16) { + InstructionsView( + visualInstruction: VisualInstructionFactory().secondaryContent { n in + VisualInstructionContentFactory().text { n in + RoadNameFactory().baseName { _ in "Street" }.build(n) + }.build(n) + }.build(), + distanceToNextManeuver: 1500, + remainingSteps: RouteStepFactory().buildMany(10) ) Spacer() diff --git a/apple/Sources/FerrostarSwiftUI/Views/Maneuver/DefaultIconographyManeuverInstructionView.swift b/apple/Sources/FerrostarSwiftUI/Views/Maneuver/DefaultIconographyManeuverInstructionView.swift index 6593743d..4aa0b299 100644 --- a/apple/Sources/FerrostarSwiftUI/Views/Maneuver/DefaultIconographyManeuverInstructionView.swift +++ b/apple/Sources/FerrostarSwiftUI/Views/Maneuver/DefaultIconographyManeuverInstructionView.swift @@ -54,6 +54,8 @@ public struct DefaultIconographyManeuverInstructionView: View { maneuverModifier: maneuverModifier ) .frame(maxWidth: 48) + // REVIEW: without this, the first image in the vstack was rendering very small. Curiously subsequent items in the vstack looked reasonable. + .aspectRatio(contentMode: .fill) } } } diff --git a/apple/Sources/FerrostarSwiftUI/Views/TopDrawerView.swift b/apple/Sources/FerrostarSwiftUI/Views/TopDrawerView.swift new file mode 100644 index 00000000..f6aad032 --- /dev/null +++ b/apple/Sources/FerrostarSwiftUI/Views/TopDrawerView.swift @@ -0,0 +1,241 @@ +import SwiftUI + +/// An expandable drawer view that you can pull down to expose more content. +/// +/// The `PersistentContent` is always visible. +/// When `ExpandedContent` is present, tapping or dragging on the drawer's handle expands the view to fill its +/// container, exposing the `ExpandedContent` in a scrollable view. +struct TopDrawerView: View { + var backgroundColor: Color + + var persistentContent: () -> PersistentContent + var expandedContent: () -> ExpandedContent? + + /// - Parameters: + /// - backgroundColor: Applied to the context around both the persistent and expanded content + /// - persistentContent: This content is always visible. When the drawer is closed, the handle appears just below + /// the persistent content. + /// - expandedContent: This content is only visible when the drawer is expanded. If you omit `expandedContent`, no + /// handle will be visible. + init(backgroundColor: Color, + @ViewBuilder persistentContent: @escaping () -> PersistentContent, + @ViewBuilder expandedContent: @escaping () -> ExpandedContent?) + { + self.backgroundColor = backgroundColor + self.persistentContent = persistentContent + self.expandedContent = expandedContent + } + + @State + private var isExpanded = false + + @State + private var dragOffset: CGFloat = 0 + + private var content: AnyView { + guard let expandedContent = expandedContent() else { + return AnyView(persistentContent()) + } + + let scrollView = ScrollView { + // Pad to ensure the bottom of the ScrollView's content is not covered by the handle. + expandedContent.padding(.bottom, 50) + } + + var framedScrollView = if isExpanded { + AnyView(scrollView.frame(idealHeight: CGFloat.infinity)) + } else { + AnyView(scrollView.frame(height: max(0, dragOffset))) + } + + let paddingAboveHandle = 6.0 + return AnyView(ZStack(alignment: .bottom) { + VStack(spacing: 0) { + persistentContent() + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded = !isExpanded + } + } + if #available(iOS 16.4, *) { + framedScrollView.scrollBounceBehavior(.basedOnSize) + } else { + framedScrollView + } + }.padding(.bottom, paddingAboveHandle) + Handle(isExpanded: $isExpanded, dragOffset: $dragOffset, backgroundTopPadding: paddingAboveHandle) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded = !isExpanded + } + } + }) + } + + public var body: some View { + content + .background(backgroundColor) + .cornerRadius(12) + .shadow(radius: 12) + // Interactive dismiss + .padding(.bottom, isExpanded ? max(0, -dragOffset) : 0) + } +} + +extension TopDrawerView where ExpandedContent == EmptyView { + init(backgroundColor: Color, + @ViewBuilder persistentContent: @escaping () -> PersistentContent) + { + self.backgroundColor = backgroundColor + self.persistentContent = persistentContent + expandedContent = { nil } + } +} + +private struct Handle: View { + @Binding + var isExpanded: Bool + + @Binding + var dragOffset: CGFloat + + var backgroundTopPadding: CGFloat + + // Style + var foregroundColor: Color = .gray + + var body: some View { + HStack { + Spacer() + if isExpanded { + Image(systemName: "chevron.up") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(foregroundColor) + .padding(.bottom, 16) + } else { + Capsule() + .foregroundStyle(foregroundColor) + .frame(width: 50, height: 6) + .padding(.bottom, 8) + } + Spacer() + } + .padding(.top, backgroundTopPadding) + .background(BlurView(style: .light)) + // Increase the hit area for the DragGesture with a transparent overlap + .padding(.top, 20) + // Required for gesture to "hit" over clear padding + .contentShape(Rectangle()) + .gesture(DragGesture(coordinateSpace: .global) + .onChanged { gesture in + dragOffset = gesture.translation.height + } + .onEnded { gesture in + let predictedDragOffset = gesture.predictedEndTranslation.height + withAnimation(.easeInOut(duration: 0.2)) { + // If the user has dragged sufficiently far or with sufficient gusto, consider it an expansion. + if isExpanded { + isExpanded = predictedDragOffset > -200 + } else { + isExpanded = predictedDragOffset > 200 + } + dragOffset = 0 + } + }) + } +} + +private struct BlurView: UIViewRepresentable { + var style: UIBlurEffect.Style + + func makeUIView(context _: Context) -> UIVisualEffectView { + let blurEffect = UIBlurEffect(style: style) + let blurView = UIVisualEffectView(effect: blurEffect) + return blurView + } + + func updateUIView(_: UIVisualEffectView, context _: Context) { + // no-op + } +} + +#Preview("floating") { + VStack { + TopDrawerView( + backgroundColor: .white, + persistentContent: { + HStack { + Spacer() + Text("Persistent Content") + Spacer() + }.padding() + }, + expandedContent: { + HStack { + Spacer() + VStack { + Text("Expanded Content 1") + Text("Expanded Content 2") + Text("Expanded Content 3") + Text("Expanded Content 4") + Text("Expanded Content 5") + Text("Expanded Content 6") + } + Spacer() + } + } + ) + .padding(.horizontal) + Spacer() + }.background(.green) +} + +#Preview("floating without expanded content") { + VStack { + TopDrawerView( + backgroundColor: .white, + persistentContent: { + HStack { + Spacer() + Text("Persistent Content") + Spacer() + }.padding() + } + ) + .padding(.horizontal) + Spacer() + }.background(.green) +} + +// Only suitable for non-notched devices +#Preview("to edge") { + VStack { + TopDrawerView( + backgroundColor: .white, + persistentContent: { + HStack { + Spacer() + Text("Persistent Content") + Spacer() + }.padding() + }, + expandedContent: { + HStack { + Spacer() + VStack { + Text("Expanded Content 1") + Text("Expanded Content 2") + Text("Expanded Content 3") + Text("Expanded Content 4") + Text("Expanded Content 5") + Text("Expanded Content 6") + } + Spacer() + } + } + ) + .ignoresSafeArea() + Spacer() + }.background(.green) +} diff --git a/apple/Tests/FerrostarSwiftUITests/Views/InstructionsViewTests.swift b/apple/Tests/FerrostarSwiftUITests/Views/InstructionsViewTests.swift index 975ca0da..a1ae0793 100644 --- a/apple/Tests/FerrostarSwiftUITests/Views/InstructionsViewTests.swift +++ b/apple/Tests/FerrostarSwiftUITests/Views/InstructionsViewTests.swift @@ -1,4 +1,5 @@ import XCTest +@testable import FerrostarCore @testable import FerrostarCoreFFI @testable import FerrostarSwiftUI @@ -84,7 +85,7 @@ final class InstructionsViewTests: XCTestCase { triggerDistanceBeforeManeuver: 123 ), distanceFormatter: americanDistanceFormatter, - showPillControl: true + remainingSteps: RouteStepFactory().buildMany(3) ) .padding() }