Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions OTPKit/Sources/OTPKit/Core/Map/IntermediateStopAnnotationView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// IntermediateStopAnnotationView.swift
// OTPKit
//
// Created by OTPKit on 2025-12-04.
//

import MapKit
import UIKit

/// Custom annotation view for displaying intermediate transit stops as small dots with labels
class IntermediateStopAnnotationView: MKAnnotationView {
private let dotView = UIView()
private let label = UILabel()
private let dotSize: CGFloat = 10
private var currentTitle: String?
private var currentBorderColor: UIColor?

override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
setupView()
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}

private func setupView() {
canShowCallout = false

dotView.backgroundColor = .white
dotView.layer.cornerRadius = dotSize / 2
dotView.layer.borderWidth = 2
dotView.layer.borderColor = UIColor.gray.cgColor
addSubview(dotView)

addSubview(label)
}

private func updateLabelColors() {
guard let title = currentTitle else { return }

let isDarkMode = traitCollection.userInterfaceStyle == .dark
let foregroundColor = isDarkMode
? UIColor.white.withAlphaComponent(0.9)
: UIColor.black.withAlphaComponent(0.9)
let strokeColor = isDarkMode ? UIColor.black : UIColor.white

let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont(name: "HelveticaNeue-CondensedBold", size: 15) as Any,
.foregroundColor: foregroundColor,
.strokeColor: strokeColor,
.strokeWidth: -4.0 // Negative = fill + stroke
]
label.attributedText = NSAttributedString(string: title, attributes: attributes)
}

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
updateLabelColors()
}
}

func configure(title: String?, borderColor: UIColor?) {
currentTitle = title
currentBorderColor = borderColor
dotView.layer.borderColor = (borderColor ?? .gray).cgColor
updateLabelColors()
label.sizeToFit()

// Layout: dot on left, label to the right with spacing
let spacing: CGFloat = 6
let totalWidth = dotSize + spacing + label.frame.width

frame = CGRect(x: 0, y: 0, width: totalWidth, height: max(dotSize, label.frame.height))
dotView.frame = CGRect(x: 0, y: (frame.height - dotSize) / 2, width: dotSize, height: dotSize)
label.frame = CGRect(x: dotSize + spacing, y: 0, width: label.frame.width, height: frame.height)

// Center on the dot position
centerOffset = CGPoint(x: (totalWidth - dotSize) / 2, y: 0)
}

/// Updates label visibility based on zoom level
/// - Parameter showLabel: Whether to show the label
func updateLabelVisibility(showLabel: Bool) {
label.isHidden = !showLabel

if showLabel {
// Full width with label
let spacing: CGFloat = 6
let totalWidth = dotSize + spacing + label.frame.width
frame = CGRect(x: 0, y: 0, width: totalWidth, height: max(dotSize, label.frame.height))
centerOffset = CGPoint(x: (totalWidth - dotSize) / 2, y: 0)
} else {
// Just the dot
frame = CGRect(x: 0, y: 0, width: dotSize, height: dotSize)
dotView.frame = bounds
centerOffset = CGPoint(x: 0, y: 0)
}
}
}

// MARK: - Preview

import SwiftUI

#Preview("Light Mode") {
IntermediateStopAnnotationPreview(
title: "24th Ave E & E McGraw St",
borderColor: .orange
)
.preferredColorScheme(.light)
}

#Preview("Dark Mode") {
IntermediateStopAnnotationPreview(
title: "24th Ave E & E McGraw St",
borderColor: .orange
)
.preferredColorScheme(.dark)
}

#Preview("Dot Only") {
IntermediateStopAnnotationPreview(
title: "24th Ave E & E McGraw St",
borderColor: .blue,
showLabel: false
)
}

private struct IntermediateStopAnnotationPreview: UIViewRepresentable {
let title: String
let borderColor: UIColor
var showLabel: Bool = true

func makeUIView(context: Context) -> UIView {
let container = UIView()
container.backgroundColor = .systemBackground

let annotationView = IntermediateStopAnnotationView(annotation: nil, reuseIdentifier: nil)
annotationView.configure(title: title, borderColor: borderColor)
annotationView.updateLabelVisibility(showLabel: showLabel)

container.addSubview(annotationView)
annotationView.center = CGPoint(x: 150, y: 30)

return container
}

func updateUIView(_ uiView: UIView, context: Context) {}
}
79 changes: 62 additions & 17 deletions OTPKit/Sources/OTPKit/Core/Map/MKMapViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public class MKMapViewAdapter: NSObject, OTPMapProvider {
private var tapHandler: ((CLLocationCoordinate2D) -> Void)?
private var annotationSelectedHandler: ((String) -> Void)?

/// Zoom threshold for showing intermediate stop labels (latitudeDelta)
/// Labels are shown when zoomed in closer than this value
private let intermediateStopLabelZoomThreshold: Double = 0.02

// MARK: - Initialization

/// Creates an adapter for the provided MKMapView
Expand Down Expand Up @@ -234,6 +238,49 @@ extension MKMapViewAdapter: MKMapViewDelegate {
return MKOverlayRenderer(overlay: overlay)
}

fileprivate func buildRouteLegendAnnotationView(_ mapView: MKMapView, _ annotation: any MKAnnotation, _ otpAnnotation: OTPPointAnnotation) -> MKAnnotationView? {
let identifier = "RouteNameAnnotation"
var routeView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? RouteNameAnnotationView

if routeView == nil {
routeView = RouteNameAnnotationView(annotation: annotation, reuseIdentifier: identifier)
} else {
routeView?.annotation = annotation
}

if let routeName = otpAnnotation.routeName {
routeView?.configure(
routeName: routeName,
backgroundColor: otpAnnotation.routeBackgroundColor,
textColor: otpAnnotation.routeTextColor
)
}

return routeView
}

fileprivate func buildIntermediateStopAnnotationView(_ mapView: MKMapView, _ annotation: any MKAnnotation, _ otpAnnotation: OTPPointAnnotation) -> MKAnnotationView? {
let identifier = "IntermediateStopAnnotation"
var dotView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
as? IntermediateStopAnnotationView

if dotView == nil {
dotView = IntermediateStopAnnotationView(annotation: annotation,
reuseIdentifier: identifier)
} else {
dotView?.annotation = annotation
}

dotView?.configure(title: otpAnnotation.title,
borderColor: otpAnnotation.routeBackgroundColor)

// Set initial label visibility based on current zoom level
let showLabels = mapView.region.span.latitudeDelta < intermediateStopLabelZoomThreshold
dotView?.updateLabelVisibility(showLabel: showLabels)

return dotView
}

public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// Don't customize user location
if annotation is MKUserLocation {
Expand All @@ -246,24 +293,12 @@ extension MKMapViewAdapter: MKMapViewDelegate {

// Use custom view for route legend annotations
if otpAnnotation.annotationType == .routeLegend {
let identifier = "RouteNameAnnotation"
var routeView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? RouteNameAnnotationView

if routeView == nil {
routeView = RouteNameAnnotationView(annotation: annotation, reuseIdentifier: identifier)
} else {
routeView?.annotation = annotation
}

if let routeName = otpAnnotation.routeName {
routeView?.configure(
routeName: routeName,
backgroundColor: otpAnnotation.routeBackgroundColor,
textColor: otpAnnotation.routeTextColor
)
}
return buildRouteLegendAnnotationView(mapView, annotation, otpAnnotation)
}

return routeView
// Use small dot view for intermediate stop annotations
if otpAnnotation.annotationType == .intermediateStop {
return buildIntermediateStopAnnotationView(mapView, annotation, otpAnnotation)
}

// Use standard marker view for other annotation types
Expand Down Expand Up @@ -293,6 +328,16 @@ extension MKMapViewAdapter: MKMapViewDelegate {
guard let otpAnnotation = view.annotation as? OTPPointAnnotation else { return }
annotationSelectedHandler?(otpAnnotation.identifier)
}

public func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
let showLabels = mapView.region.span.latitudeDelta < intermediateStopLabelZoomThreshold

for annotation in mapView.annotations {
if let view = mapView.view(for: annotation) as? IntermediateStopAnnotationView {
view.updateLabelVisibility(showLabel: showLabels)
}
}
}
}

// MARK: - UIGestureRecognizerDelegate
Expand Down
14 changes: 11 additions & 3 deletions OTPKit/Tests/TripPlannerViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ struct TripPlannerViewModelTests {
func resetTripPlannerClearsAllState() {
let viewModel = createViewModel()

// Set up some state
// Set up some state (trip-specific, not persisted preferences)
viewModel.selectedOrigin = TestHelpers.location(title: "Origin")
viewModel.selectedDestination = TestHelpers.location(title: "Destination")
viewModel.tripPlanResponse = TestHelpers.response(with: [])
Expand All @@ -459,11 +459,19 @@ struct TripPlannerViewModelTests {
viewModel.showingError = true
viewModel.isLoading = true
viewModel.selectedTransportMode = .bike
viewModel.isWheelchairAccessible = true
viewModel.maxWalkingDistance = .halfMile
viewModel.timePreference = .arriveBy
viewModel.departureTime = Date()
viewModel.departureDate = Date()
// Note: Not modifying isWheelchairAccessible/maxWalkingDistance/routePreference
// as these are persisted to UserDefaults asynchronously, which would create
// a race condition with resetTripPlanner()'s reload from UserDefaults

// Clear UserDefaults again right before reset to avoid race conditions
// with async saves from other tests
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "OTPKit.TripOptions.wheelchairAccessible")
defaults.removeObject(forKey: "OTPKit.TripOptions.maxWalkingDistance")
defaults.removeObject(forKey: "OTPKit.TripOptions.routePreference")

// Reset
viewModel.resetTripPlanner()
Expand Down
Loading