diff --git a/Sources/MapItemPicker/Controllers/MapItemController/MapItemController.swift b/Sources/MapItemPicker/Controllers/MapItemController/MapItemController.swift index 6bf01e1..0a675f5 100644 --- a/Sources/MapItemPicker/Controllers/MapItemController/MapItemController.swift +++ b/Sources/MapItemPicker/Controllers/MapItemController/MapItemController.swift @@ -13,7 +13,11 @@ class MapItemController: NSObject, ObservableObject, Identifiable { return false } - return self.item == other.item + return self.hashValue == other.hashValue + } + + override var hash: Int { + item.hashValue } @BackgroundPublished var item: MapItem diff --git a/Sources/MapItemPicker/Controllers/MapItemSearchController.swift b/Sources/MapItemPicker/Controllers/MapItemSearchController.swift index e31292c..b2c41eb 100644 --- a/Sources/MapItemPicker/Controllers/MapItemSearchController.swift +++ b/Sources/MapItemPicker/Controllers/MapItemSearchController.swift @@ -90,12 +90,13 @@ class MapItemSearchController: NSObject, ObservableObject { guard let currentRegion = coordinator?.region, let otherItemsResultRegion, - let otherItemsRequestRegion + let otherItemsRequestRegion, + coordinator?.selectedMapItem == nil && coordinator?.selectedMapItemCluster == nil else { return } let regionChange = currentRegion.span.longitudeDelta / otherItemsRequestRegion.span.longitudeDelta if - !MKMapRect(otherItemsResultRegion).contains(MKMapPoint(currentRegion.center)) || + !MKMapRect(otherItemsRequestRegion).contains(MKMapPoint(currentRegion.center)) || !(0.65...1.5).contains(regionChange) { reload() diff --git a/Sources/MapItemPicker/Controllers/MapItemSearchViewCoordinator.swift b/Sources/MapItemPicker/Controllers/MapItemSearchViewCoordinator.swift index dd3c9b8..ba6f79a 100644 --- a/Sources/MapItemPicker/Controllers/MapItemSearchViewCoordinator.swift +++ b/Sources/MapItemPicker/Controllers/MapItemSearchViewCoordinator.swift @@ -39,8 +39,13 @@ public class MapItemPickerController: NSObject, ObservableObject { } func manuallySet(selectedMapItem: MapItemController?) { - self.selectedMapItem = selectedMapItem - reloadSelectedAnnotation() + // This usually happens within a view update so we use a Task here + Task { @MainActor in + guard let mapView = currentMapView else { return } + + self.selectedMapItem = selectedMapItem + reloadSelectedAnnotation() + } } func reloadSelectedAnnotation() { @@ -86,7 +91,8 @@ extension MapItemPickerController: MKMapViewDelegate { } else if let user = annotation as? MKUserLocation { return mapView.dequeueReusableAnnotationView(withIdentifier: "userLocation") ?? MKUserLocationView(annotation: user, reuseIdentifier: "userLocation") - } else if let cluster = annotation as? MKClusterAnnotation, let coordinators = cluster.memberAnnotations as? [MapItemController] { + } else if let cluster = annotation as? MKClusterAnnotation, cluster.memberAnnotations.contains(where: { $0 is MapItemController }) { + let coordinators = cluster.memberAnnotations.filter({ $0 is MapItemController }) as! [MapItemController] let occurancesByColor: [UIColor: Int]? = coordinators.reduce(into: [:]) { partialResult, coordinator in partialResult[coordinator.item.uiColor, default: 0] += 1 } @@ -127,23 +133,27 @@ extension MapItemPickerController: MKMapViewDelegate { annotationSelectionHandler(annotation) } - self.selectedMapItem = selectedMapItem + Task { @MainActor in + self.selectedMapItem = selectedMapItem + } } // This function is necessary since the annotation handed to `mapView(_ mapView: MKMapView, didDeselect annotation: MKAnnotation)` is sometimes nil. Casting this within the original function works in debug builds, but not in release builds (due to optimization, propably). private func didDeselect(optional annotation: MKAnnotation?) { guard let annotation = annotation else { return } - if let cluster = annotation as? MKClusterAnnotation, cluster == selectedMapItemCluster { - selectedMapItemCluster = nil - } else if - let eq1 = annotation as? MapAnnotationEquatable, - let eq2 = selectedMapItem as? MapAnnotationEquatable, - eq1.annotationIsEqual(to: eq2) - { - selectedMapItem = nil - } else if annotation === selectedMapItem { - selectedMapItem = nil + Task { @MainActor in + if let cluster = annotation as? MKClusterAnnotation, cluster == selectedMapItemCluster { + selectedMapItemCluster = nil + } else if + let eq1 = annotation as? MapAnnotationEquatable, + let eq2 = selectedMapItem as? MapAnnotationEquatable, + eq1.annotationIsEqual(to: eq2) + { + selectedMapItem = nil + } else if annotation === selectedMapItem { + selectedMapItem = nil + } } } @@ -209,7 +219,7 @@ extension MapItemPickerController { edgePadding: .init( top: 16, left: 16, - bottom: currentPresentationDetent == miniDetentIdentifier ? miniDetentHeight : standardDetentHeight, + bottom: (currentPresentationDetent == miniDetentIdentifier ? miniDetentHeight : standardDetentHeight) + 16, right: TopRightButtons.Constants.size + TopRightButtons.Constants.padding * 2 ), animated: animated diff --git a/Sources/MapItemPicker/Data/MapItem/MapItem.swift b/Sources/MapItemPicker/Data/MapItem/MapItem.swift index 37e093f..649779d 100644 --- a/Sources/MapItemPicker/Data/MapItem/MapItem.swift +++ b/Sources/MapItemPicker/Data/MapItem/MapItem.swift @@ -4,7 +4,7 @@ import SwiftUI import AddressBook import SchafKit -public struct MapItem: Equatable, Hashable, Codable { +public struct MapItem: Codable { public init(name: String, location: CLLocationCoordinate2D, region: CLCodableCircularRegion? = nil, featureAnnotationType: FeatureType? = nil, category: MapItemCategory? = nil, notes: String? = nil, street: String? = nil, housenumber: String? = nil, postcode: String? = nil, cityRegion: String? = nil, city: String? = nil, state: String? = nil, stateRegion: String? = nil, country: String? = nil, phone: String? = nil, website: String? = nil, wikidataBrand: String? = nil, wikipediaBrand: String? = nil, hasVegetarianFood: ExclusivityBool? = nil, hasVeganFood: ExclusivityBool? = nil, indoorSeating: PlaceBool? = nil, outdoorSeating: PlaceBool? = nil, internetAccess: InternetAccessType? = nil, smoking: PlaceBool? = nil, takeaway: ExclusivityBool? = nil, wheelchair: WheelchairBool? = nil, level: String? = nil, openingHours: OpeningHours? = nil) { self.name = name self.location = location @@ -255,3 +255,18 @@ class MapItemMKPlacemark: MKPlacemark { // TODO: Country Code } + +extension MapItem: Hashable, Equatable { + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(category) + hasher.combine(city) + hasher.combine(stateRegion) + hasher.combine(state) + hasher.combine(country) + } + + public static func ==(lhs: MapItem, rhs: MapItem) -> Bool { + lhs.hashValue == rhs.hashValue + } +} diff --git a/Sources/MapItemPicker/Data/MapItem/MapItemCategory.swift b/Sources/MapItemPicker/Data/MapItem/MapItemCategory.swift index 3206ad4..b2c7e76 100644 --- a/Sources/MapItemPicker/Data/MapItem/MapItemCategory.swift +++ b/Sources/MapItemPicker/Data/MapItem/MapItemCategory.swift @@ -92,97 +92,178 @@ public enum MapItemCategory: String, Codable, CaseIterable, Identifiable { } var nativeCategory: MKPointOfInterestCategory { + switch self { + case .airport: .airport + case .amusementPark: .amusementPark + case .aquarium: .aquarium + case .atm: .atm + case .bakery: .bakery + case .bank: .bank + case .beach: .beach + case .brewery: .brewery + case .cafe: .cafe + case .campground: .campground + case .carRental: .carRental + case .evCharger: .evCharger + case .fireStation: .fireStation + case .fitnessCenter: .fitnessCenter + case .foodMarket: .foodMarket + case .gasStation: .gasStation + case .hospital: .hospital + case .hotel: .hotel + case .laundry: .laundry + case .library: .library + case .marina: .marina + case .movieTheater: .movieTheater + case .museum: .museum + case .nationalPark: .nationalPark + case .nightlife: .nightlife + case .park: .park + case .parking: .parking + case .pharmacy: .pharmacy + case .police: .police + case .postOffice: .postOffice + case .publicTransport: .publicTransport + case .restaurant: .restaurant + case .restroom: .restroom + case .school: .school + case .stadium: .stadium + case .store: .store + case .theater: .theater + case .university: .university + case .winery: .winery + case .zoo: .zoo + } + } + + public var id: String { rawValue } + + public var name: String { + "category.\(rawValue)".moduleLocalized + } + + public var circledImageName: String? { switch self { case .airport: - return .airport + return "airplane.circle.fill" case .amusementPark: - return .amusementPark + return nil // No equivalent for "sparkles.circle.fill" case .aquarium: - return .aquarium + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return "fish.circle.fill" + } + return "mappin.circle.fill" case .atm: - return .atm + return "dollarsign.circle.fill" case .bakery: - return .bakery + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return nil // No equivalent for "birthday.cake.circle.fill" + } + return "mappin.circle.fill" case .bank: - return .bank + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return "person.bust.circle.fill" + } + return "dollarsign.circle.fill" case .beach: - return .beach + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return nil // No "beach.umbrella.circle.fill" + } + return "drop.circle.fill" case .brewery: - return .brewery + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return nil // No "wineglass.circle.fill" + } + return nil case .cafe: - return .cafe + return nil // No "cup.and.saucer.circle.fill" case .campground: - return .campground + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return nil // No "tent.circle.fill" + } + return nil case .carRental: - return .carRental + return nil // No "car.2.circle.fill" case .evCharger: - return .evCharger + return nil // No "powerplug.circle.fill" case .fireStation: - return .fireStation + return "flame.circle.fill" case .fitnessCenter: - return .fitnessCenter + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return nil // No "dumbbell.circle.fill" + } + return nil case .foodMarket: - return .foodMarket + return nil // No "basket.circle.fill" case .gasStation: - return .gasStation + return "fuelpump.circle.fill" case .hospital: - return .hospital + return "cross.circle.fill" case .hotel: - return .hotel + return "bed.double.circle.fill" case .laundry: - return .laundry + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + return "tshirt.circle.fill" + } + return nil case .library: - return .library + return "books.vertical.circle.fill" case .marina: - return .marina + return nil // No "ferry.circle.fill" case .movieTheater: - return .movieTheater + return "theatermasks.circle.fill" case .museum: - return .museum + return "building.columns.circle.fill" case .nationalPark: - return .nationalPark + return "star.circle.fill" case .nightlife: - return .nightlife + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) { + return "figure.dance.circle.fill" + } + return nil case .park: - return .park + if #available(iOS 16.1, macOS 13.0, tvOS 16.1, watchOS 9.1, *) { + return "tree.circle.fill" + } + return "leaf.circle.fill" case .parking: - return .parking + return "parkingsign.circle.fill" case .pharmacy: - return .pharmacy + return "pills.circle.fill" case .police: - return .police + return "shield.circle.fill" case .postOffice: - return .postOffice + return "envelope.circle.fill" case .publicTransport: - return .publicTransport + return "tram.circle.fill" case .restaurant: - return .restaurant + return "fork.knife.circle.fill" case .restroom: - return .restroom + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return "toilet.circle.fill" + } + return "mappin.circle.fill" case .school: - return .school + return "graduationcap.circle.fill" case .stadium: - return .stadium + return "sportscourt.circle.fill" case .store: - return .store + return "bag.circle.fill" case .theater: - return .theater + return "theatermasks.circle.fill" case .university: - return .university + return "graduationcap.circle.fill" case .winery: - return .winery + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + return nil // No "wineglass.circle.fill" + } + return nil // No "takeoutbag.and.cup.and.straw.fill" case .zoo: - return .zoo + return nil // No "tortoise.circle.fill" } } - public var id: String { rawValue } - - var name: String { - "category.\(rawValue)".moduleLocalized - } - - var imageName: String { + public var imageName: String { switch self { case .airport: return "airplane" @@ -237,7 +318,7 @@ public enum MapItemCategory: String, Codable, CaseIterable, Identifiable { } return "sportscourt.fill" case .foodMarket: - return "fork.knife" + return "basket.fill" case .gasStation: return "fuelpump.fill" case .hospital: @@ -303,24 +384,30 @@ public enum MapItemCategory: String, Codable, CaseIterable, Identifiable { } } - var color: UIColor { + public var color: UIColor { switch self { - case .bakery, .brewery, .cafe, .foodMarket, .laundry, .nightlife, .store: - return .orange - case .amusementPark, .aquarium, .movieTheater, .museum, .restaurant, .theater, .winery, .zoo: - return .systemPink - case .atm, .bank, .carRental, .police, .postOffice: - return .gray - case .airport, .fitnessCenter, .gasStation, .parking, .publicTransport, .beach, .marina: - return .init(red: 0.33, green: 0.33, blue: 1) // lightblue - case .campground, .evCharger, .nationalPark, .park, .stadium: - return .init(red: 0, green: 0.75, blue: 0) // darkgreen - case .fireStation, .hospital, .pharmacy: - return .red - case .hotel, .restroom: - return .purple - case .library, .school, .university: - return .brown + case .bakery, .brewery, .cafe, .restaurant, .nightlife: + return .systemOrange + case .laundry, .foodMarket, .store: + return .systemYellow + case .amusementPark, .aquarium, .movieTheater, .museum, .theater, .winery, .zoo: + return .systemPink + case .atm, .bank, .carRental, .police, .postOffice: + return .systemGray + case .fitnessCenter, .beach, .marina: + return .systemCyan + case .airport, .gasStation, .parking, .publicTransport: + return .systemBlue + case .evCharger: + return .systemMint + case .campground, .nationalPark, .park, .stadium: + return .systemGreen + case .fireStation, .hospital, .pharmacy: + return .systemRed + case .hotel, .restroom: + return .systemPurple + case .library, .school, .university: + return .systemBrown } } } diff --git a/Sources/MapItemPicker/UI/Components/MapControllerHolder.swift b/Sources/MapItemPicker/UI/Components/MapControllerHolder.swift index 01742cf..058ce4d 100644 --- a/Sources/MapItemPicker/UI/Components/MapControllerHolder.swift +++ b/Sources/MapItemPicker/UI/Components/MapControllerHolder.swift @@ -53,7 +53,10 @@ struct MapControllerHolder: UIViewControll } func refreshAnnotations(view: MKMapView) { - let newAnnotations: [MKAnnotation] = (coordinator.searcher.completionItems ?? coordinator.searcher.items) + annotations + var newAnnotations: [MKAnnotation] = (coordinator.searcher.completionItems ?? coordinator.searcher.items) + annotations + if let selected = coordinator.selectedMapItem, !newAnnotations.contains(annotation: selected) { + newAnnotations.append(selected) + } let oldAnnotations = view.annotations let annotationsToAdd = newAnnotations.filter({ !oldAnnotations.contains(annotation: $0) }) @@ -68,6 +71,11 @@ struct MapControllerHolder: UIViewControll view.removeAnnotations(annotationsToRemove) view.addAnnotations(annotationsToAdd) + + // Sometimes the selectedAnnotation was not in the annotations before and thus has to be selected now. + if let selected = coordinator.selectedMapItem, annotationsToAdd.contains(annotation: selected) { + coordinator.reloadSelectedAnnotation() + } } func refreshOverlays(view: MKMapView) { diff --git a/Sources/MapItemPicker/UI/Components/MapViewController.swift b/Sources/MapItemPicker/UI/Components/MapViewController.swift index 39e2ee1..5aaa248 100644 --- a/Sources/MapItemPicker/UI/Components/MapViewController.swift +++ b/Sources/MapItemPicker/UI/Components/MapViewController.swift @@ -2,7 +2,7 @@ import SwiftUI import MapKit let miniDetentHeight: CGFloat = 80 -let standardDetentHeight: CGFloat = 300 +let standardDetentHeight: CGFloat = 400 let miniDetentIdentifier = UISheetPresentationController.Detent.Identifier("mini") let standardDetentIdentifier = UISheetPresentationController.Detent.Identifier("standard") let bigDetentIdentifier = UISheetPresentationController.Detent.Identifier("big") @@ -204,6 +204,7 @@ class MapViewController: UIViewController controller.modalPresentationStyle = .pageSheet let presentationController = controller.sheetPresentationController! presentationController.prefersGrabberVisible = true + presentationController.prefersScrollingExpandsWhenScrolledToEdge = false presentationController.detents = standardDetents presentationController.selectedDetentIdentifier = standardDetentIdentifier if #available(iOS 16, *) {