mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-28 23:32:41 +00:00
Merge pull request #5828 from vector-im/steve/5827_map_multiple_annot
Location sharing: Support multiple user annotation views on the map
This commit is contained in:
commit
0f8d5045d2
10 changed files with 316 additions and 109 deletions
|
@ -71,6 +71,8 @@ final class LocationSharingCoordinator: Coordinator, Presentable {
|
|||
case .cancel:
|
||||
self.completion?()
|
||||
case .share(let latitude, let longitude):
|
||||
|
||||
// Show share sheet on existing location display
|
||||
if let location = self.parameters.location {
|
||||
self.locationSharingHostingController.present(Self.shareLocationActivityController(location), animated: true)
|
||||
return
|
||||
|
|
|
@ -38,14 +38,33 @@ enum LocationSharingViewError {
|
|||
|
||||
@available(iOS 14, *)
|
||||
struct LocationSharingViewState: BindableState {
|
||||
|
||||
/// Map style URL
|
||||
let mapStyleURL: URL
|
||||
let avatarData: AvatarInputProtocol
|
||||
let location: CLLocationCoordinate2D?
|
||||
|
||||
/// Current user avatarData
|
||||
let userAvatarData: AvatarInputProtocol
|
||||
|
||||
/// User map annotation to display existing location
|
||||
let userAnnotation: UserLocationAnnotation?
|
||||
|
||||
/// Map annotations to display on map
|
||||
var annotations: [UserLocationAnnotation]
|
||||
|
||||
/// Map annotation to focus on
|
||||
var highlightedAnnotation: UserLocationAnnotation?
|
||||
|
||||
var showLoadingIndicator: Bool = false
|
||||
|
||||
/// True to indicate to show and follow current user location
|
||||
var showsUserLocation: Bool = false
|
||||
|
||||
var shareButtonVisible: Bool {
|
||||
return location == nil
|
||||
return self.displayExistingLocation == false
|
||||
}
|
||||
|
||||
var displayExistingLocation: Bool {
|
||||
return userAnnotation != nil
|
||||
}
|
||||
|
||||
var shareButtonEnabled: Bool {
|
||||
|
|
|
@ -36,7 +36,32 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie
|
|||
// MARK: - Setup
|
||||
|
||||
init(mapStyleURL: URL, avatarData: AvatarInputProtocol, location: CLLocationCoordinate2D? = nil) {
|
||||
let viewState = LocationSharingViewState(mapStyleURL: mapStyleURL, avatarData: avatarData, location: location)
|
||||
|
||||
var userAnnotation: UserLocationAnnotation?
|
||||
var annotations: [UserLocationAnnotation] = []
|
||||
var highlightedAnnotation: UserLocationAnnotation?
|
||||
var showsUserLocation: Bool = false
|
||||
|
||||
// Displaying an existing location
|
||||
if let userCoordinate = location {
|
||||
let userLocationAnnotation = UserLocationAnnotation(avatarData: avatarData, coordinate: userCoordinate)
|
||||
|
||||
annotations.append(userLocationAnnotation)
|
||||
highlightedAnnotation = userLocationAnnotation
|
||||
|
||||
userAnnotation = userLocationAnnotation
|
||||
} else {
|
||||
// Share current location
|
||||
showsUserLocation = true
|
||||
}
|
||||
|
||||
let viewState = LocationSharingViewState(mapStyleURL: mapStyleURL,
|
||||
userAvatarData: avatarData,
|
||||
userAnnotation: userAnnotation,
|
||||
annotations: annotations,
|
||||
highlightedAnnotation: highlightedAnnotation,
|
||||
showsUserLocation: showsUserLocation)
|
||||
|
||||
super.init(initialViewState: viewState)
|
||||
|
||||
state.errorSubject.sink { [weak self] error in
|
||||
|
@ -52,11 +77,13 @@ class LocationSharingViewModel: LocationSharingViewModelType, LocationSharingVie
|
|||
case .cancel:
|
||||
completion?(.cancel)
|
||||
case .share:
|
||||
if let location = state.location {
|
||||
// Share existing location
|
||||
if let location = state.userAnnotation?.coordinate {
|
||||
completion?(.share(latitude: location.latitude, longitude: location.longitude))
|
||||
return
|
||||
}
|
||||
|
||||
// Share current user location
|
||||
guard let location = state.bindings.userLocation else {
|
||||
processError(.failedLocatingUser)
|
||||
return
|
||||
|
|
|
@ -33,9 +33,9 @@ class LocationSharingViewModelTests: XCTestCase {
|
|||
XCTAssertFalse(viewModel.context.viewState.showLoadingIndicator)
|
||||
|
||||
XCTAssertNotNil(viewModel.context.viewState.mapStyleURL)
|
||||
XCTAssertNotNil(viewModel.context.viewState.avatarData)
|
||||
XCTAssertNotNil(viewModel.context.viewState.userAvatarData)
|
||||
|
||||
XCTAssertNil(viewModel.context.viewState.location)
|
||||
XCTAssertNil(viewModel.context.viewState.userAnnotation)
|
||||
XCTAssertNil(viewModel.context.viewState.bindings.userLocation)
|
||||
XCTAssertNil(viewModel.context.viewState.bindings.alertInfo)
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ class LocationSharingViewModelTests: XCTestCase {
|
|||
let viewModel = buildViewModel(withLocation: false)
|
||||
|
||||
XCTAssertNil(viewModel.context.viewState.bindings.userLocation)
|
||||
XCTAssertNil(viewModel.context.viewState.location)
|
||||
XCTAssertNil(viewModel.context.viewState.userAnnotation)
|
||||
|
||||
viewModel.context.send(viewAction: .share)
|
||||
|
||||
|
@ -79,8 +79,8 @@ class LocationSharingViewModelTests: XCTestCase {
|
|||
viewModel.completion = { result in
|
||||
switch result {
|
||||
case .share(let latitude, let longitude):
|
||||
XCTAssertEqual(latitude, viewModel.context.viewState.location?.latitude)
|
||||
XCTAssertEqual(longitude, viewModel.context.viewState.location?.longitude)
|
||||
XCTAssertEqual(latitude, viewModel.context.viewState.userAnnotation?.coordinate.latitude)
|
||||
XCTAssertEqual(longitude, viewModel.context.viewState.userAnnotation?.coordinate.longitude)
|
||||
expectation.fulfill()
|
||||
case .cancel:
|
||||
XCTFail()
|
||||
|
@ -88,7 +88,7 @@ class LocationSharingViewModelTests: XCTestCase {
|
|||
}
|
||||
|
||||
XCTAssertNil(viewModel.context.viewState.bindings.userLocation)
|
||||
XCTAssertNotNil(viewModel.context.viewState.location)
|
||||
XCTAssertNotNil(viewModel.context.viewState.userAnnotation)
|
||||
|
||||
viewModel.context.send(viewAction: .share)
|
||||
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// Copyright 2021 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 Foundation
|
||||
import Mapbox
|
||||
|
||||
class UserLocationAnnotation: NSObject, MGLAnnotation {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let avatarData: AvatarInputProtocol
|
||||
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(avatarData: AvatarInputProtocol,
|
||||
coordinate: CLLocationCoordinate2D) {
|
||||
|
||||
self.coordinate = coordinate
|
||||
self.avatarData = avatarData
|
||||
|
||||
super.init()
|
||||
}
|
||||
}
|
|
@ -20,121 +20,141 @@ import Mapbox
|
|||
|
||||
@available(iOS 14, *)
|
||||
struct LocationSharingMapView: UIViewRepresentable {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private struct Constants {
|
||||
static let mapZoomLevel = 15.0
|
||||
}
|
||||
|
||||
let tileServerMapURL: URL
|
||||
let avatarData: AvatarInputProtocol
|
||||
let location: CLLocationCoordinate2D?
|
||||
// MARK: - Properties
|
||||
|
||||
let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
|
||||
/// Map style URL (https://docs.mapbox.com/api/maps/styles/)
|
||||
let tileServerMapURL: URL
|
||||
|
||||
/// Map annotations
|
||||
let annotations: [UserLocationAnnotation]
|
||||
|
||||
/// Map annotation to focus on
|
||||
let highlightedAnnotation: UserLocationAnnotation?
|
||||
|
||||
/// Current user avatar data, used to replace current location annotation view with the user avatar
|
||||
let userAvatarData: AvatarInputProtocol?
|
||||
|
||||
/// True to indicate to show and follow current user location
|
||||
var showsUserLocation: Bool = false
|
||||
|
||||
/// Last user location if `showsUserLocation` has been enabled
|
||||
@Binding var userLocation: CLLocationCoordinate2D?
|
||||
|
||||
func makeUIView(context: Context) -> some UIView {
|
||||
let mapView = MGLMapView(frame: .zero, styleURL: tileServerMapURL)
|
||||
/// Publish view errors if any
|
||||
let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
|
||||
|
||||
// MARK: - UIViewRepresentable
|
||||
|
||||
func makeUIView(context: Context) -> MGLMapView {
|
||||
|
||||
let mapView = self.makeMapView()
|
||||
mapView.delegate = context.coordinator
|
||||
return mapView
|
||||
}
|
||||
|
||||
func updateUIView(_ mapView: MGLMapView, context: Context) {
|
||||
|
||||
mapView.vc_removeAllAnnotations()
|
||||
mapView.addAnnotations(self.annotations)
|
||||
|
||||
if let highlightedAnnotation = self.highlightedAnnotation {
|
||||
mapView.setCenter(highlightedAnnotation.coordinate, zoomLevel: Constants.mapZoomLevel, animated: false)
|
||||
}
|
||||
|
||||
if self.showsUserLocation {
|
||||
mapView.showsUserLocation = true
|
||||
mapView.userTrackingMode = .follow
|
||||
} else {
|
||||
mapView.showsUserLocation = false
|
||||
mapView.userTrackingMode = .none
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func makeMapView() -> MGLMapView {
|
||||
let mapView = MGLMapView(frame: .zero, styleURL: tileServerMapURL)
|
||||
|
||||
mapView.logoView.isHidden = true
|
||||
mapView.attributionButton.isHidden = true
|
||||
|
||||
if let location = location {
|
||||
mapView.setCenter(location, zoomLevel: Constants.mapZoomLevel, animated: false)
|
||||
|
||||
let pointAnnotation = MGLPointAnnotation()
|
||||
pointAnnotation.coordinate = location
|
||||
mapView.addAnnotation(pointAnnotation)
|
||||
} else {
|
||||
mapView.showsUserLocation = true
|
||||
mapView.userTrackingMode = .follow
|
||||
}
|
||||
|
||||
return mapView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||
|
||||
}
|
||||
|
||||
func makeCoordinator() -> LocationSharingMapViewCoordinator {
|
||||
LocationSharingMapViewCoordinator(avatarData: avatarData,
|
||||
errorSubject: errorSubject,
|
||||
userLocation: $userLocation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
@available(iOS 14, *)
|
||||
class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate {
|
||||
extension LocationSharingMapView {
|
||||
|
||||
private let avatarData: AvatarInputProtocol
|
||||
private let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
|
||||
@Binding private var userLocation: CLLocationCoordinate2D?
|
||||
class Coordinator: NSObject, MGLMapViewDelegate {
|
||||
|
||||
init(avatarData: AvatarInputProtocol,
|
||||
errorSubject: PassthroughSubject<LocationSharingViewError, Never>,
|
||||
userLocation: Binding<CLLocationCoordinate2D?>) {
|
||||
self.avatarData = avatarData
|
||||
self.errorSubject = errorSubject
|
||||
self._userLocation = userLocation
|
||||
}
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: - MGLMapViewDelegate
|
||||
var locationSharingMapView: LocationSharingMapView
|
||||
|
||||
func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
|
||||
return UserLocationAnnotatonView(avatarData: avatarData)
|
||||
}
|
||||
// MARK: - Setup
|
||||
|
||||
func mapViewDidFailLoadingMap(_ mapView: MGLMapView, withError error: Error) {
|
||||
errorSubject.send(.failedLoadingMap)
|
||||
}
|
||||
init(_ locationSharingMapView: LocationSharingMapView) {
|
||||
self.locationSharingMapView = locationSharingMapView
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, didFailToLocateUserWithError error: Error) {
|
||||
guard mapView.showsUserLocation else {
|
||||
return
|
||||
// MARK: - MGLMapViewDelegate
|
||||
|
||||
func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
|
||||
|
||||
if let userLocationAnnotation = annotation as? UserLocationAnnotation {
|
||||
return UserLocationAnnotatonView(userLocationAnnotation: userLocationAnnotation)
|
||||
} else if annotation is MGLUserLocation, let currentUserAvatarData = locationSharingMapView.userAvatarData {
|
||||
// Replace default current location annotation view with a UserLocationAnnotatonView
|
||||
return UserLocationAnnotatonView(avatarData: currentUserAvatarData)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
errorSubject.send(.failedLocatingUser)
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, didUpdate userLocation: MGLUserLocation?) {
|
||||
self.userLocation = userLocation?.coordinate
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, didChangeLocationManagerAuthorization manager: MGLLocationManager) {
|
||||
guard mapView.showsUserLocation else {
|
||||
return
|
||||
func mapViewDidFailLoadingMap(_ mapView: MGLMapView, withError error: Error) {
|
||||
locationSharingMapView.errorSubject.send(.failedLoadingMap)
|
||||
}
|
||||
|
||||
switch manager.authorizationStatus {
|
||||
case .restricted:
|
||||
fallthrough
|
||||
case .denied:
|
||||
errorSubject.send(.invalidLocationAuthorization)
|
||||
default:
|
||||
break
|
||||
func mapView(_ mapView: MGLMapView, didUpdate userLocation: MGLUserLocation?) {
|
||||
locationSharingMapView.userLocation = userLocation?.coordinate
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MGLMapView, didChangeLocationManagerAuthorization manager: MGLLocationManager) {
|
||||
guard mapView.showsUserLocation else {
|
||||
return
|
||||
}
|
||||
|
||||
switch manager.authorizationStatus {
|
||||
case .restricted:
|
||||
fallthrough
|
||||
case .denied:
|
||||
locationSharingMapView.errorSubject.send(.invalidLocationAuthorization)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
private class UserLocationAnnotatonView: MGLUserLocationAnnotationView {
|
||||
// MARK: - MGLMapView convenient methods
|
||||
extension MGLMapView {
|
||||
|
||||
init(avatarData: AvatarInputProtocol) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
guard let avatarImageView = UIHostingController(rootView: LocationSharingUserMarkerView(avatarData: avatarData)).view else {
|
||||
func vc_removeAllAnnotations() {
|
||||
guard let annotations = self.annotations else {
|
||||
return
|
||||
}
|
||||
|
||||
addSubview(avatarImageView)
|
||||
|
||||
addConstraints([topAnchor.constraint(equalTo: avatarImageView.topAnchor),
|
||||
leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor),
|
||||
bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
|
||||
trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor)])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
self.removeAnnotations(annotations)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,17 +34,14 @@ struct LocationSharingView: View {
|
|||
NavigationView {
|
||||
ZStack(alignment: .bottom) {
|
||||
LocationSharingMapView(tileServerMapURL: context.viewState.mapStyleURL,
|
||||
avatarData: context.viewState.avatarData,
|
||||
location: context.viewState.location,
|
||||
errorSubject: context.viewState.errorSubject,
|
||||
userLocation: $context.userLocation)
|
||||
annotations: context.viewState.annotations,
|
||||
highlightedAnnotation: context.viewState.highlightedAnnotation,
|
||||
userAvatarData: context.viewState.userAvatarData,
|
||||
showsUserLocation: context.viewState.showsUserLocation,
|
||||
userLocation: $context.userLocation,
|
||||
errorSubject: context.viewState.errorSubject)
|
||||
.ignoresSafeArea()
|
||||
|
||||
HStack {
|
||||
Link("© MapTiler", destination: URL(string: "https://www.maptiler.com/copyright/")!)
|
||||
Link("© OpenStreetMap contributors", destination: URL(string: "https://www.openstreetmap.org/copyright")!)
|
||||
}
|
||||
.font(theme.fonts.caption1)
|
||||
MapCreditsView()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
|
@ -58,7 +55,7 @@ struct LocationSharingView: View {
|
|||
.foregroundColor(theme.colors.primaryContent)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if context.viewState.location != nil {
|
||||
if context.viewState.displayExistingLocation {
|
||||
Button {
|
||||
context.send(viewAction: .share)
|
||||
} label: {
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// Copyright 2021 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
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct MapCreditsView: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Link("© MapTiler", destination: URL(string: "https://www.maptiler.com/copyright/")!)
|
||||
Link("© OpenStreetMap contributors", destination: URL(string: "https://www.openstreetmap.org/copyright")!)
|
||||
}
|
||||
.font(theme.fonts.caption1)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct MapCreditsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MapCreditsView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// Copyright 2021 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 Foundation
|
||||
import SwiftUI
|
||||
import Mapbox
|
||||
|
||||
@available(iOS 14, *)
|
||||
class UserLocationAnnotatonView: MGLUserLocationAnnotationView {
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(avatarData: AvatarInputProtocol) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.addUserMarkerView(with: avatarData)
|
||||
}
|
||||
|
||||
init(userLocationAnnotation: UserLocationAnnotation) {
|
||||
|
||||
// TODO: Use a reuseIdentifier
|
||||
super.init(annotation: userLocationAnnotation, reuseIdentifier: nil)
|
||||
|
||||
self.addUserMarkerView(with: userLocationAnnotation.avatarData)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func addUserMarkerView(with avatarData: AvatarInputProtocol) {
|
||||
|
||||
guard let avatarImageView = UIHostingController(rootView: LocationSharingUserMarkerView(avatarData: avatarData)).view else {
|
||||
return
|
||||
}
|
||||
|
||||
addSubview(avatarImageView)
|
||||
|
||||
addConstraints([topAnchor.constraint(equalTo: avatarImageView.topAnchor),
|
||||
leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor),
|
||||
bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
|
||||
trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor)])
|
||||
}
|
||||
}
|
1
changelog.d/5827.change
Normal file
1
changelog.d/5827.change
Normal file
|
@ -0,0 +1 @@
|
|||
Location sharing: Support multiple user annotation views on the map.
|
Loading…
Reference in a new issue