Skip to content

Commit

Permalink
Merge pull request #5828 from vector-im/steve/5827_map_multiple_annot
Browse files Browse the repository at this point in the history
Location sharing: Support multiple user annotation views on the map
  • Loading branch information
SBiOSoftWhare authored Mar 16, 2022
2 parents 0d5bd23 + 2dcd713 commit 0f8d504
Show file tree
Hide file tree
Showing 10 changed files with 311 additions and 104 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)

Expand All @@ -79,16 +79,16 @@ 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()
}
}

XCTAssertNil(viewModel.context.viewState.bindings.userLocation)
XCTAssertNotNil(viewModel.context.viewState.location)
XCTAssertNotNil(viewModel.context.viewState.userAnnotation)

viewModel.context.send(viewAction: .share)

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,121 +20,141 @@ import Mapbox

@available(iOS 14, *)
struct LocationSharingMapView: UIViewRepresentable {

// MARK: - Constants

private struct Constants {
static let mapZoomLevel = 15.0
}

// MARK: - Properties

/// Map style URL (https://docs.mapbox.com/api/maps/styles/)
let tileServerMapURL: URL
let avatarData: AvatarInputProtocol
let location: CLLocationCoordinate2D?

let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
/// 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?

/// Publish view errors if any
let errorSubject: PassthroughSubject<LocationSharingViewError, Never>

// MARK: - UIViewRepresentable

func makeUIView(context: Context) -> MGLMapView {

func makeUIView(context: Context) -> some UIView {
let mapView = MGLMapView(frame: .zero, styleURL: tileServerMapURL)
let mapView = self.makeMapView()
mapView.delegate = context.coordinator
return mapView
}

func updateUIView(_ mapView: MGLMapView, context: Context) {

mapView.logoView.isHidden = true
mapView.attributionButton.isHidden = true
mapView.vc_removeAllAnnotations()
mapView.addAnnotations(self.annotations)

if let location = location {
mapView.setCenter(location, zoomLevel: Constants.mapZoomLevel, animated: false)

let pointAnnotation = MGLPointAnnotation()
pointAnnotation.coordinate = location
mapView.addAnnotation(pointAnnotation)
} else {
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
}

return mapView
}

func updateUIView(_ uiView: UIViewType, context: Context) {

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

func makeCoordinator() -> LocationSharingMapViewCoordinator {
LocationSharingMapViewCoordinator(avatarData: avatarData,
errorSubject: errorSubject,
userLocation: $userLocation)
// MARK: - Private

private func makeMapView() -> MGLMapView {
let mapView = MGLMapView(frame: .zero, styleURL: tileServerMapURL)

mapView.logoView.isHidden = true
mapView.attributionButton.isHidden = true

return mapView
}
}

// MARK: - Coordinator
@available(iOS 14, *)
class LocationSharingMapViewCoordinator: NSObject, MGLMapViewDelegate {

private let avatarData: AvatarInputProtocol
private let errorSubject: PassthroughSubject<LocationSharingViewError, Never>
@Binding private var userLocation: CLLocationCoordinate2D?

init(avatarData: AvatarInputProtocol,
errorSubject: PassthroughSubject<LocationSharingViewError, Never>,
userLocation: Binding<CLLocationCoordinate2D?>) {
self.avatarData = avatarData
self.errorSubject = errorSubject
self._userLocation = userLocation
}

// MARK: - MGLMapViewDelegate

func mapView(_ mapView: MGLMapView, viewFor annotation: MGLAnnotation) -> MGLAnnotationView? {
return UserLocationAnnotatonView(avatarData: avatarData)
}

func mapViewDidFailLoadingMap(_ mapView: MGLMapView, withError error: Error) {
errorSubject.send(.failedLoadingMap)
}
extension LocationSharingMapView {

func mapView(_ mapView: MGLMapView, didFailToLocateUserWithError error: Error) {
guard mapView.showsUserLocation else {
return
class Coordinator: NSObject, MGLMapViewDelegate {

// MARK: - Properties

var locationSharingMapView: LocationSharingMapView

// MARK: - Setup

init(_ locationSharingMapView: LocationSharingMapView) {
self.locationSharingMapView = locationSharingMapView
}

// 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)
}
}
Loading

0 comments on commit 0f8d504

Please sign in to comment.