Signal-iOS/Signal/src/ViewControllers/LocationPicker.swift
2020-05-27 17:34:32 -07:00

543 lines
21 KiB
Swift

//
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//
// Originally based on https://github.com/almassapargali/LocationPicker
//
// Created by Almas Sapargali on 7/29/15.
// Parts Copyright (c) 2015 almassapargali. All rights reserved.
import UIKit
import MapKit
import CoreLocation
import PromiseKit
@objc
public protocol LocationPickerDelegate {
func didPickLocation(_ locationPicker: LocationPicker, location: Location)
}
@objc
public class LocationPicker: UIViewController {
@objc public weak var delegate: LocationPickerDelegate?
public var location: Location? { didSet { updateAnnotation() } }
private let locationManager = CLLocationManager()
private let geocoder = CLGeocoder()
private var localSearch: MKLocalSearch?
private lazy var mapView = MKMapView()
private lazy var resultsController: LocationSearchResults = {
let locationResults = LocationSearchResults()
locationResults.onSelectLocation = { [weak self] in self?.selectedLocation($0) }
return locationResults
}()
private lazy var searchController: UISearchController = {
let searchController = UISearchController(searchResultsController: resultsController)
searchController.searchResultsUpdater = self
searchController.hidesNavigationBarDuringPresentation = false
return searchController
}()
private lazy var searchBar: UISearchBar = {
let searchBar = self.searchController.searchBar
searchBar.placeholder = NSLocalizedString("LOCATION_PICKER_SEARCH_PLACEHOLDER",
comment: "A string indicating that the user can search for a location")
return searchBar
}()
private let supportsTranslucentBars: Bool = {
// On iOS 13.1 and later, translucent search bars
// within the nav item's searchController work correctly.
// Prior to that, they have a weird behavior when the
// search bar becomes first responder that we want to avoid.
guard #available(iOS 13.1, *) else { return false }
return true
}()
private static let SearchTermKey = "SearchTermKey"
private var searchTimer: Timer?
deinit {
searchTimer?.invalidate()
localSearch?.cancel()
geocoder.cancelGeocode()
}
open override func loadView() {
view = mapView
let currentLocationButton = UIButton()
currentLocationButton.backgroundColor = UIColor.black.withAlphaComponent(0.7)
currentLocationButton.clipsToBounds = true
currentLocationButton.layer.cornerRadius = 24
// This icon doesn't look right when it's actually centered due to its odd shape.
currentLocationButton.setTemplateImageName("current-location-outline-24", tintColor: .white)
currentLocationButton.contentEdgeInsets = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 2)
view.addSubview(currentLocationButton)
currentLocationButton.autoSetDimensions(to: CGSize(square: 48))
currentLocationButton.autoPinEdge(toSuperviewSafeArea: .trailing, withInset: 15)
currentLocationButton.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 15)
currentLocationButton.addTarget(self, action: #selector(didPressCurrentLocation), for: .touchUpInside)
}
open override func viewDidLoad() {
super.viewDidLoad()
title = NSLocalizedString("LOCATION_PICKER_TITLE", comment: "The title for the location picker view")
navigationItem.leftBarButtonItem = UIBarButtonItem(
image: UIImage(named: "x-24")?.withRenderingMode(.alwaysTemplate),
style: .plain,
target: self,
action: #selector(cancelButtonPressed)
)
locationManager.delegate = self
mapView.delegate = self
OWSSearchBar.applyTheme(to: searchBar)
searchBar.isTranslucent = supportsTranslucentBars
// When the search bar isn't translucent, it doesn't allow
// setting the textField's backgroundColor. Instead, we need
// to use the background image.
let backgroundImage = UIImage(
color: Theme.searchFieldBackgroundColor,
size: CGSize(square: 36)
).withCornerRadius(10)
searchBar.setSearchFieldBackgroundImage(backgroundImage, for: .normal)
searchBar.searchTextPositionAdjustment = UIOffset(horizontal: 8.0, vertical: 0.0)
searchBar.textField?.backgroundColor = .clear
navigationItem.searchController = searchController
definesPresentationContext = true
// Select a new location by long pressing
let locationSelectGesture = UILongPressGestureRecognizer(target: self, action: #selector(addLocation))
mapView.addGestureRecognizer(locationSelectGesture)
// If we don't have location access granted, this does nothing.
// If we do, this will start the map at the user's current location.
mapView.showsUserLocation = true
showCurrentLocation(requestAuthorizationIfNecessary: false)
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.navigationBar.isTranslucent = supportsTranslucentBars
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(true)
navigationController?.navigationBar.isTranslucent = true
}
@objc func cancelButtonPressed(_ sender: UIButton) {
if let navigation = navigationController, navigation.viewControllers.count > 1 {
navigation.popViewController(animated: true)
} else {
presentingViewController?.dismiss(animated: true, completion: nil)
}
}
@objc func didPressCurrentLocation() {
showCurrentLocation()
}
func showCurrentLocation(requestAuthorizationIfNecessary: Bool = true) {
if requestAuthorizationIfNecessary { requestAuthorization() }
locationManager.startUpdatingLocation()
}
func requestAuthorization() {
switch CLLocationManager.authorizationStatus() {
case .authorizedWhenInUse, .authorizedAlways:
// We are already authorized, do nothing!
break
case .notDetermined:
locationManager.requestWhenInUseAuthorization()
case .denied, .restricted:
// The user previous explicitly denied access. Point them to settings to re-enable.
let alert = ActionSheetController(
title: NSLocalizedString("MISSING_LOCATION_PERMISSION_TITLE",
comment: "Alert title indicating the user has denied location permissios"),
message: NSLocalizedString("MISSING_LOCATION_PERMISSION_MESSAGE",
comment: "Alert body indicating the user has denied location permissios")
)
let openSettingsAction = ActionSheetAction(
title: CommonStrings.openSettingsButton,
style: .default
) { _ in UIApplication.shared.openSystemSettings() }
alert.addAction(openSettingsAction)
let dismissAction = ActionSheetAction(title: CommonStrings.dismissButton, style: .cancel, handler: nil)
alert.addAction(dismissAction)
presentActionSheet(alert)
@unknown default:
owsFailDebug("Unknown")
}
}
func updateAnnotation() {
mapView.removeAnnotations(mapView.annotations)
if let location = location {
mapView.addAnnotation(location)
mapView.selectAnnotation(location, animated: true)
}
}
func showCoordinates(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
// The amount of meters +/- the selected coordinate we want to ensure are visible on screen.
let metersOffset: CLLocationDistance = 600
let region = MKCoordinateRegion(center: coordinate, latitudinalMeters: metersOffset, longitudinalMeters: metersOffset)
mapView.setRegion(region, animated: animated)
}
func selectLocation(location: CLLocation) {
// add point annotation to map
let annotation = MKPointAnnotation()
annotation.coordinate = location.coordinate
mapView.addAnnotation(annotation)
geocoder.cancelGeocode()
geocoder.reverseGeocodeLocation(location) { response, error in
let error = error as NSError?
let geocodeCanceled = error?.domain == kCLErrorDomain && error?.code == CLError.Code.geocodeCanceled.rawValue
if let error = error, !geocodeCanceled {
// show error and remove annotation
let alert = ActionSheetController(title: nil, message: error.localizedDescription)
alert.addAction(ActionSheetAction(title: CommonStrings.okayButton,
style: .cancel, handler: { _ in }))
self.present(alert, animated: true) {
self.mapView.removeAnnotation(annotation)
}
} else if let placemark = response?.first {
// get POI name from placemark if any
let name = placemark.areasOfInterest?.first
// pass user selected location too
self.location = Location(name: name, location: location, placemark: placemark)
}
}
}
}
extension LocationPicker: CLLocationManagerDelegate {
public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
// The user requested we select their current location, do so and then stop listening for location updates.
guard let location = locations.first else {
return owsFailDebug("Unexpectedly received location update with no location")
}
// Only animate if this is not the first location we're showing.
let shouldAnimate = self.location != nil
showCoordinates(location.coordinate, animated: shouldAnimate)
selectLocation(location: location)
manager.stopUpdatingLocation()
}
public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
// If location permission was just granted, show the current location
guard status == .authorizedWhenInUse else { return }
showCurrentLocation()
}
}
// MARK: Searching
extension LocationPicker: UISearchResultsUpdating {
public func updateSearchResults(for searchController: UISearchController) {
guard let term = searchController.searchBar.text else { return }
// clear old results
showItemsForSearchResult(nil)
let searchTerm = term.trimmingCharacters(in: CharacterSet.whitespaces)
if !searchTerm.isEmpty {
// Search after a slight delay to debounce while the user is typing.
searchTimer = Timer.weakScheduledTimer(withTimeInterval: 0.1,
target: self,
selector: #selector(searchFromTimer),
userInfo: [LocationPicker.SearchTermKey: searchTerm],
repeats: false)
} else {
searchTimer?.invalidate()
searchTimer = nil
}
}
@objc func searchFromTimer(_ timer: Timer) {
guard let userInfo = timer.userInfo as? [String: AnyObject],
let term = userInfo[LocationPicker.SearchTermKey] as? String else {
return owsFailDebug("Unexpectedly attempted to search with no term")
}
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = term
if let location = locationManager.location {
// If we have a currently selected location, search relative to that location,
// where the +/- degrees of the region we're searching around is reflected
// by the latitude and longitude delta below.
let latlongDelta: CLLocationDegrees = 2
request.region = MKCoordinateRegion(
center: location.coordinate,
span: MKCoordinateSpan(latitudeDelta: latlongDelta, longitudeDelta: latlongDelta)
)
}
localSearch?.cancel()
localSearch = MKLocalSearch(request: request)
localSearch?.start { [weak self] response, _ in
self?.showItemsForSearchResult(response)
}
}
func showItemsForSearchResult(_ searchResult: MKLocalSearch.Response?) {
resultsController.locations = searchResult?.mapItems.map { Location(name: $0.name, placemark: $0.placemark) } ?? []
resultsController.tableView.reloadData()
}
func selectedLocation(_ location: Location) {
// dismiss search results
dismiss(animated: true) {
// set location, this also adds annotation
self.location = location
self.showCoordinates(location.coordinate)
}
}
}
// MARK: Selecting location with gesture
extension LocationPicker {
@objc func addLocation(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer.state == .began {
let point = gestureRecognizer.location(in: mapView)
let coordinates = mapView.convert(point, toCoordinateFrom: mapView)
let location = CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude)
selectLocation(location: location)
}
}
}
// MARK: MKMapViewDelegate
extension LocationPicker: MKMapViewDelegate {
public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
if annotation is MKUserLocation { return nil }
let pin = MKPinAnnotationView(annotation: annotation, reuseIdentifier: "annotation")
pin.pinTintColor = Theme.accentBlueColor
pin.animatesDrop = annotation is MKPointAnnotation
pin.rightCalloutAccessoryView = sendLocationButton()
pin.canShowCallout = true
return pin
}
func sendLocationButton() -> UIButton {
let button = UIButton(frame: CGRect(x: 0, y: 0, width: 24, height: 24))
button.setTemplateImageName("send-solid-24", tintColor: Theme.accentBlueColor)
return button
}
public func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
if let location = location {
delegate?.didPickLocation(self, location: location)
}
if let navigation = navigationController, navigation.viewControllers.count > 1 {
navigation.popViewController(animated: true)
} else {
presentingViewController?.dismiss(animated: true, completion: nil)
}
}
public func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
if let userPin = views.first(where: { $0.annotation is MKUserLocation }) {
userPin.canShowCallout = false
}
}
}
// MARK: UISearchBarDelegate
class LocationSearchResults: UITableViewController {
var locations: [Location] = []
var onSelectLocation: ((Location) -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
extendedLayoutIncludesOpaqueBars = true
tableView.backgroundColor = Theme.backgroundColor
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return locations.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "LocationCell")
?? UITableViewCell(style: .subtitle, reuseIdentifier: "LocationCell")
let location = locations[indexPath.row]
cell.textLabel?.text = location.name
cell.textLabel?.textColor = Theme.primaryTextColor
cell.detailTextLabel?.text = location.address
cell.detailTextLabel?.textColor = Theme.secondaryTextAndIconColor
cell.backgroundColor = Theme.backgroundColor
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
onSelectLocation?(locations[indexPath.row])
}
}
@objc
public class Location: NSObject {
public let name: String?
// difference from placemark location is that if location was reverse geocoded,
// then location point to user selected location
public let location: CLLocation
public let placemark: CLPlacemark
public var address: String? {
guard let addressDictionary = placemark.addressDictionary,
let lines = addressDictionary["FormattedAddressLines"] as? [String] else { return nil }
return lines.joined(separator: ", ")
}
public var urlString: String {
return "https://maps.google.com/maps?q=\(coordinate.latitude)%2C\(coordinate.longitude)"
}
enum LocationError: Error {
case assertion
}
public func generateSnapshot() -> Promise<UIImage> {
return Promise { resolver in
let options = MKMapSnapshotter.Options()
// this is the plus/minus meter range from the given coordinate
// that we'd like to capture in our map snapshot.
let metersOffset: CLLocationDistance = 300
options.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: metersOffset, longitudinalMeters: metersOffset)
// The output size will be 256 * the device's scale. We don't adjust the
// scale directly on the options to ensure a consistent size because it
// produces poor results on some devices.
options.size = CGSize(square: 256)
MKMapSnapshotter(options: options).start(with: .global()) { snapshot, error in
guard error == nil else {
owsFailDebug("Unexpectedly failed to capture map snapshot \(error!)")
return resolver.reject(LocationError.assertion)
}
guard let snapshot = snapshot else {
owsFailDebug("snapshot unexpectedly nil")
return resolver.reject(LocationError.assertion)
}
// Draw our location pin on the snapshot
UIGraphicsBeginImageContextWithOptions(options.size, true, 0)
snapshot.image.draw(at: .zero)
let pinView = MKPinAnnotationView(annotation: nil, reuseIdentifier: nil)
pinView.pinTintColor = Theme.accentBlueColor
let pinImage = pinView.image
var point = snapshot.point(for: self.coordinate)
let pinCenterOffset = pinView.centerOffset
point.x -= pinView.bounds.size.width / 2
point.y -= pinView.bounds.size.height / 2
point.x += pinCenterOffset.x
point.y += pinCenterOffset.y
pinImage?.draw(at: point)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard let finalImage = image else {
owsFailDebug("image unexpectedly nil")
return resolver.reject(LocationError.assertion)
}
resolver.fulfill(finalImage)
}
}
}
public init(name: String?, location: CLLocation? = nil, placemark: CLPlacemark) {
self.name = name
self.location = location ?? placemark.location!
self.placemark = placemark
}
@objc
public func prepareAttachmentObjc() -> AnyPromise {
return AnyPromise(prepareAttachment())
}
public func prepareAttachment() -> Promise<SignalAttachment> {
return generateSnapshot().map(on: .global()) { image in
guard let jpegData = image.jpegData(compressionQuality: 1.0) else {
throw LocationError.assertion
}
let dataSource = DataSourceValue.dataSource(with: jpegData, utiType: kUTTypeJPEG as String)
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .original)
}
}
@objc
public var messageText: String {
// The message body will look something like:
//
// Place Name, 123 Street Name
//
// https://maps.google.com/maps
if let address = address {
return address + "\n\n" + urlString
} else {
return urlString
}
}
}
extension Location: MKAnnotation {
@objc public var coordinate: CLLocationCoordinate2D {
return location.coordinate
}
public var title: String? {
if let name = name {
return name
} else if let addressDictionary = placemark.addressDictionary,
let lines = addressDictionary["FormattedAddressLines"] as? [String],
let firstLine = lines.first {
return firstLine
} else {
return "\(coordinate.latitude), \(coordinate.longitude)"
}
}
}