mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Open map from timeline (#1199)
* Add navigation to expaneded map * Add MapLibreMapView.Options * Add AppActivityView * Add ShareToMapsAppActivity * Add share sheet presentation * Add localisations * Cleanup * Fix UT build errors * Revert breaking change * Fix UIView setup * Add support for location’s description * Show popover on iPad * Restore assets * More cleanup
This commit is contained in:
@@ -18,83 +18,42 @@ import Foundation
|
||||
import Mapbox
|
||||
import SwiftUI
|
||||
|
||||
/// Base class to handle a map annotation
|
||||
class LocationAnnotation: NSObject, MGLAnnotation {
|
||||
// MARK: - Properties
|
||||
|
||||
// Title property is needed to enable annotation selection and callout view showing
|
||||
var title: String?
|
||||
|
||||
final class LocationAnnotation: NSObject, MGLAnnotation {
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
let anchorPoint: CGPoint
|
||||
let view: AnyView
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(coordinate: CLLocationCoordinate2D) {
|
||||
init(coordinate: CLLocationCoordinate2D,
|
||||
anchorPoint: CGPoint = .init(x: 0.5, y: 0.5),
|
||||
@ViewBuilder label: () -> some View) {
|
||||
self.coordinate = coordinate
|
||||
self.anchorPoint = anchorPoint
|
||||
view = AnyView(label())
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
/// POI map annotation
|
||||
class PinLocationAnnotation: LocationAnnotation { }
|
||||
|
||||
class LocationAnnotationView: MGLUserLocationAnnotationView {
|
||||
private enum Constants {
|
||||
static let defaultFrame = CGRect(x: 0, y: 0, width: 46, height: 46)
|
||||
}
|
||||
|
||||
final class LocationAnnotationView: MGLUserLocationAnnotationView {
|
||||
// MARK: - Setup
|
||||
|
||||
override init(annotation: MGLAnnotation?, reuseIdentifier: String?) {
|
||||
super.init(annotation: annotation, reuseIdentifier:
|
||||
reuseIdentifier)
|
||||
frame = Constants.defaultFrame
|
||||
}
|
||||
|
||||
convenience init(userPinLocationAnnotation: MGLAnnotation) {
|
||||
self.init(annotation: userPinLocationAnnotation, reuseIdentifier: "userPinLocation")
|
||||
|
||||
addUserView()
|
||||
}
|
||||
|
||||
convenience init(pinLocationAnnotation: PinLocationAnnotation) {
|
||||
self.init(annotation: pinLocationAnnotation, reuseIdentifier: nil)
|
||||
|
||||
addPinView()
|
||||
convenience init(annotation: LocationAnnotation) {
|
||||
self.init(annotation: annotation, reuseIdentifier: "\(Self.self)")
|
||||
let view: UIView = UIHostingController(rootView: annotation.view).view
|
||||
view.backgroundColor = .clear
|
||||
view.anchorPoint = annotation.anchorPoint
|
||||
addSubview(view)
|
||||
view.bounds.size = view.intrinsicContentSize
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func addUserView() {
|
||||
guard let pinView = UIHostingController(rootView: Image(systemName: "circle.fill")
|
||||
.resizable()
|
||||
.foregroundColor(.compound.iconPrimary)).view else {
|
||||
return
|
||||
}
|
||||
|
||||
addMarkerView(pinView)
|
||||
}
|
||||
|
||||
private func addPinView() {
|
||||
guard let pinView = UIHostingController(rootView: Image(systemName: "mappin")
|
||||
.resizable()
|
||||
.foregroundColor(.compound.iconPrimary)).view else {
|
||||
return
|
||||
}
|
||||
|
||||
addMarkerView(pinView)
|
||||
}
|
||||
|
||||
private func addMarkerView(_ markerView: UIView) {
|
||||
markerView.backgroundColor = .clear
|
||||
|
||||
addSubview(markerView)
|
||||
|
||||
markerView.frame = bounds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,19 @@ import Mapbox
|
||||
import SwiftUI
|
||||
|
||||
struct MapLibreMapView: UIViewRepresentable {
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constants {
|
||||
static let mapZoomLevel = 15.0
|
||||
static let mapZoomLevelWithoutPermission = 5.0
|
||||
struct Options {
|
||||
/// The initial zoom level
|
||||
let zoomLevel: Double
|
||||
/// The initial map center
|
||||
let mapCenter: CLLocationCoordinate2D?
|
||||
/// Map annotations
|
||||
let annotations: [LocationAnnotation]
|
||||
|
||||
init(zoomLevel: Double, mapCenter: CLLocationCoordinate2D? = nil, annotations: [LocationAnnotation] = []) {
|
||||
self.zoomLevel = zoomLevel
|
||||
self.mapCenter = mapCenter
|
||||
self.annotations = annotations
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
@@ -31,6 +39,8 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let builder: MapTilerStyleBuilderProtocol
|
||||
|
||||
let options: Options
|
||||
|
||||
/// Behavior mode of the current user's location, can be hidden, only shown and shown following the user
|
||||
var showsUserLocationMode: ShowUserLocationMode = .hide
|
||||
@@ -52,12 +62,13 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
let panGesture = UIPanGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.didPan))
|
||||
panGesture.delegate = context.coordinator
|
||||
mapView.addGestureRecognizer(panGesture)
|
||||
mapView.zoomLevel = Constants.mapZoomLevelWithoutPermission
|
||||
setupMap(mapView: mapView, with: options)
|
||||
return mapView
|
||||
}
|
||||
|
||||
func updateUIView(_ mapView: MGLMapView, context: Context) {
|
||||
mapView.removeAllAnnotations()
|
||||
mapView.addAnnotations(options.annotations)
|
||||
|
||||
if colorScheme == .dark {
|
||||
mapView.styleURL = builder.dynamicMapURL(for: .dark)
|
||||
@@ -73,6 +84,13 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupMap(mapView: MGLMapView, with options: Options) {
|
||||
mapView.zoomLevel = options.zoomLevel
|
||||
if let mapCenter = options.mapCenter {
|
||||
mapView.centerCoordinate = mapCenter
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMapView() -> MGLMapView {
|
||||
let mapView = MGLMapView(frame: .zero, styleURL: colorScheme == .dark ? builder.dynamicMapURL(for: .dark) : builder.dynamicMapURL(for: .light))
|
||||
@@ -85,7 +103,7 @@ struct MapLibreMapView: UIViewRepresentable {
|
||||
|
||||
private func showUserLocation(in mapView: MGLMapView) {
|
||||
switch showsUserLocationMode {
|
||||
case .follow:
|
||||
case .showAndFollow:
|
||||
mapView.showsUserLocation = true
|
||||
mapView.userTrackingMode = .follow
|
||||
case .show:
|
||||
@@ -115,12 +133,10 @@ extension MapLibreMapView {
|
||||
// MARK: - MGLMapViewDelegate
|
||||
|
||||
func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
|
||||
if let pinLocationAnnotation = annotation as? PinLocationAnnotation {
|
||||
return LocationAnnotationView(pinLocationAnnotation: pinLocationAnnotation)
|
||||
} else if annotation is MGLUserLocation {
|
||||
return LocationAnnotationView(userPinLocationAnnotation: annotation)
|
||||
guard let annotation = annotation as? LocationAnnotation else {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return LocationAnnotationView(annotation: annotation)
|
||||
}
|
||||
|
||||
func mapViewDidFailLoadingMap(_ mapView: MGLMapView, withError error: Error) {
|
||||
@@ -143,7 +159,10 @@ extension MapLibreMapView {
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, regionDidChangeAnimated animated: Bool) {
|
||||
mapLibreView.mapCenterCoordinate = mapView.centerCoordinate
|
||||
// Fixes: "Publishing changes from within view updates is not allowed, this will cause undefined behavior."
|
||||
DispatchQueue.main.async { [mapLibreView] in
|
||||
mapLibreView.mapCenterCoordinate = mapView.centerCoordinate
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Callout
|
||||
|
||||
@@ -20,10 +20,10 @@ import Foundation
|
||||
Behavior mode of the current user's location, can be hidden, only shown and shown following the user
|
||||
*/
|
||||
enum ShowUserLocationMode {
|
||||
/// this mode will show the user pin in map and track him, panning the map automatically
|
||||
case follow
|
||||
/// this mode will show the user pin in map
|
||||
case show
|
||||
/// this mode will show the user pin in map and track him, panning the map automatically
|
||||
case showAndFollow
|
||||
/// this mode will not show the user pin in map
|
||||
case hide
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import UIKit
|
||||
|
||||
final class ShareToMapsAppActivity: UIActivity {
|
||||
enum MapsAppType: CaseIterable {
|
||||
case apple
|
||||
case google
|
||||
case osm
|
||||
}
|
||||
|
||||
private let type: MapsAppType
|
||||
private let location: CLLocationCoordinate2D
|
||||
|
||||
init(type: MapsAppType, location: CLLocationCoordinate2D) {
|
||||
self.type = type
|
||||
self.location = location
|
||||
super.init()
|
||||
}
|
||||
|
||||
override private init() {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override var activityTitle: String? {
|
||||
type.activityTitle
|
||||
}
|
||||
|
||||
var activityCategory: UIActivity.Category {
|
||||
.action
|
||||
}
|
||||
|
||||
override var activityType: UIActivity.ActivityType {
|
||||
.shareToMapsApp
|
||||
}
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func prepare(withActivityItems activityItems: [Any]) {
|
||||
UIApplication.shared.open(type.activityURL(for: location), options: [:]) { [weak self] result in
|
||||
self?.activityDidFinish(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ShareToMapsAppActivity.MapsAppType {
|
||||
func activityURL(for location: CLLocationCoordinate2D) -> URL {
|
||||
switch self {
|
||||
case .apple:
|
||||
// swiftlint:disable:next force_unwrapping
|
||||
return URL(string: "https://maps.apple.com?ll=\(location.latitude),\(location.longitude)&q=Pin")!
|
||||
case .google:
|
||||
// swiftlint:disable:next force_unwrapping
|
||||
return URL(string: "https://www.google.com/maps/search/?api=1&query=\(location.latitude),\(location.longitude)")!
|
||||
case .osm:
|
||||
// swiftlint:disable:next force_unwrapping
|
||||
return URL(string: "https://www.openstreetmap.org/?mlat=\(location.latitude)&mlon=\(location.longitude)")!
|
||||
}
|
||||
}
|
||||
|
||||
var activityTitle: String {
|
||||
switch self {
|
||||
case .apple:
|
||||
return L10n.screenShareOpenAppleMaps
|
||||
case .google:
|
||||
return L10n.screenShareOpenGoogleMaps
|
||||
case .osm:
|
||||
return L10n.screenShareOpenOsmMaps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIActivity.ActivityType {
|
||||
static let shareToMapsApp = UIActivity.ActivityType("ElementX.ShareToMapsApp")
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AppActivityView: UIViewControllerRepresentable {
|
||||
typealias UIViewControllerType = UIActivityViewController
|
||||
typealias CompletionType = (Result<(activity: UIActivity.ActivityType, items: [Any]?), Error>) -> Void
|
||||
|
||||
private let activityItems: [Any]
|
||||
private let applicationActivities: [UIActivity]?
|
||||
private var excludedActivityTypes: [UIActivity.ActivityType]
|
||||
private var onCancel: (() -> Void)?
|
||||
private var onComplete: CompletionType?
|
||||
|
||||
public init(activityItems: [Any],
|
||||
applicationActivities: [UIActivity]? = nil,
|
||||
excludedActivityTypes: [UIActivity.ActivityType] = [],
|
||||
onCancel: (() -> Void)? = nil,
|
||||
onComplete: CompletionType? = nil) {
|
||||
self.activityItems = activityItems
|
||||
self.applicationActivities = applicationActivities
|
||||
self.excludedActivityTypes = excludedActivityTypes
|
||||
self.onCancel = onCancel
|
||||
self.onComplete = onComplete
|
||||
}
|
||||
|
||||
public func makeUIViewController(context: Context) -> UIViewControllerType {
|
||||
let viewController = UIViewControllerType(activityItems: activityItems, applicationActivities: applicationActivities)
|
||||
viewController.excludedActivityTypes = excludedActivityTypes
|
||||
|
||||
viewController.completionWithItemsHandler = { activity, completed, items, error in
|
||||
if let error {
|
||||
onComplete?(.failure(error))
|
||||
} else if let activity, completed {
|
||||
onComplete?(.success((activity, items)))
|
||||
} else if !completed {
|
||||
onCancel?()
|
||||
} else {
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
return viewController
|
||||
}
|
||||
|
||||
public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { }
|
||||
|
||||
public static func dismantleUIViewController(_ uiViewController: UIViewControllerType, coordinator: Coordinator) {
|
||||
uiViewController.completionWithItemsHandler = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user