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:
SBiOSoftWhare 2022-03-16 15:22:04 +01:00 committed by GitHub
commit 0f8d5045d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 316 additions and 109 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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: {

View file

@ -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()
}
}

View file

@ -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
View file

@ -0,0 +1 @@
Location sharing: Support multiple user annotation views on the map.