Swiftgram/submodules/LocationUI/Sources/LocationPickerControllerNode.swift
2019-11-25 14:09:43 +04:00

610 lines
29 KiB
Swift

import Foundation
import UIKit
import Display
import LegacyComponents
import TelegramCore
import SyncCore
import Postbox
import SwiftSignalKit
import MergeLists
import ItemListUI
import ItemListVenueItem
import ActivityIndicator
import TelegramPresentationData
import AccountContext
import AppBundle
import CoreLocation
import Geocoding
private struct LocationPickerTransaction {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let isLoading: Bool
}
private enum LocationPickerEntryId: Hashable {
case location
case liveLocation
case header
case venue(String)
case attribution
}
private enum LocationPickerEntry: Comparable, Identifiable {
case location(PresentationTheme, String, String, TelegramMediaMap?, CLLocationCoordinate2D?)
case liveLocation(PresentationTheme, String, String, CLLocationCoordinate2D?)
case header(PresentationTheme, String)
case venue(PresentationTheme, TelegramMediaMap, Int)
case attribution(PresentationTheme)
var stableId: LocationPickerEntryId {
switch self {
case .location:
return .location
case .liveLocation:
return .liveLocation
case .header:
return .header
case let .venue(_, venue, _):
return .venue(venue.venue?.id ?? "")
case .attribution:
return .attribution
}
}
static func ==(lhs: LocationPickerEntry, rhs: LocationPickerEntry) -> Bool {
switch lhs {
case let .location(lhsTheme, lhsTitle, lhsSubtitle, lhsVenue, lhsCoordinate):
if case let .location(rhsTheme, rhsTitle, rhsSubtitle, rhsVenue, rhsCoordinate) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsVenue?.venue?.id == rhsVenue?.venue?.id, lhsCoordinate == rhsCoordinate {
return true
} else {
return false
}
case let .liveLocation(lhsTheme, lhsTitle, lhsSubtitle, lhsCoordinate):
if case let .liveLocation(rhsTheme, rhsTitle, rhsSubtitle, rhsCoordinate) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsCoordinate == rhsCoordinate {
return true
} else {
return false
}
case let .header(lhsTheme, lhsTitle):
if case let .header(rhsTheme, rhsTitle) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle {
return true
} else {
return false
}
case let .venue(lhsTheme, lhsVenue, lhsIndex):
if case let .venue(rhsTheme, rhsVenue, rhsIndex) = rhs, lhsTheme === rhsTheme, lhsVenue.venue?.id == rhsVenue.venue?.id, lhsIndex == rhsIndex {
return true
} else {
return false
}
case let .attribution(lhsTheme):
if case let .attribution(rhsTheme) = rhs, lhsTheme === rhsTheme {
return true
} else {
return false
}
}
}
static func <(lhs: LocationPickerEntry, rhs: LocationPickerEntry) -> Bool {
switch lhs {
case .location:
switch rhs {
case .location:
return false
case .liveLocation, .header, .venue, .attribution:
return true
}
case .liveLocation:
switch rhs {
case .location, .liveLocation:
return false
case .header, .venue, .attribution:
return true
}
case .header:
switch rhs {
case .location, .liveLocation, .header:
return false
case .venue, .attribution:
return true
}
case let .venue(_, _, lhsIndex):
switch rhs {
case .location, .liveLocation, .header:
return false
case let .venue(_, _, rhsIndex):
return lhsIndex < rhsIndex
case .attribution:
return true
}
case .attribution:
return false
}
}
func item(account: Account, presentationData: PresentationData, interaction: LocationPickerInteraction?) -> ListViewItem {
switch self {
case let .location(theme, title, subtitle, venue, coordinate):
let icon: LocationActionListItemIcon
if let venue = venue {
icon = .venue(venue)
} else {
icon = .location
}
return LocationActionListItem(presentationData: ItemListPresentationData(presentationData), account: account, title: title, subtitle: subtitle, icon: icon, action: {
if let coordinate = coordinate {
interaction?.sendLocation(coordinate)
}
})
case let .liveLocation(theme, title, subtitle, coordinate):
return LocationActionListItem(presentationData: ItemListPresentationData(presentationData), account: account, title: title, subtitle: subtitle, icon: .liveLocation, action: {
if let coordinate = coordinate {
interaction?.sendLiveLocation(coordinate)
}
})
case let .header(theme, title):
return LocationSectionHeaderItem(presentationData: ItemListPresentationData(presentationData), title: title)
case let .venue(theme, venue, _):
return ItemListVenueItem(presentationData: ItemListPresentationData(presentationData), account: account, venue: venue, sectionId: 0, style: .plain, action: {
interaction?.sendVenue(venue)
})
case let .attribution(theme):
return LocationAttributionItem(presentationData: ItemListPresentationData(presentationData))
}
}
}
private func preparedTransition(from fromEntries: [LocationPickerEntry], to toEntries: [LocationPickerEntry], isLoading: Bool, account: Account, presentationData: PresentationData, interaction: LocationPickerInteraction?) -> LocationPickerTransaction {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, presentationData: presentationData, interaction: interaction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, presentationData: presentationData, interaction: interaction), directionHint: nil) }
return LocationPickerTransaction(deletions: deletions, insertions: insertions, updates: updates, isLoading: isLoading)
}
enum LocationPickerLocation {
case none
case selecting
case location(CLLocationCoordinate2D, String?)
case venue(TelegramMediaMap)
var isCustom: Bool {
switch self {
case .none:
return false
default:
return true
}
}
}
struct LocationPickerState {
var mapMode: LocationMapMode
var displayingMapModeOptions: Bool
var selectedLocation: LocationPickerLocation
init() {
self.mapMode = .map
self.displayingMapModeOptions = false
self.selectedLocation = .none
}
}
final class LocationPickerControllerNode: ViewControllerTracingNode {
private let context: AccountContext
private var presentationData: PresentationData
private let presentationDataPromise: Promise<PresentationData>
private let mode: LocationPickerMode
private let interaction: LocationPickerInteraction
private let listNode: ListView
private let headerNode: LocationMapHeaderNode
private let activityIndicator: ActivityIndicator
private let optionsNode: LocationOptionsNode
private(set) var searchContainerNode: LocationSearchContainerNode?
private var enqueuedTransitions: [(LocationPickerTransaction, Bool)] = []
private var disposable: Disposable?
private var state: LocationPickerState
private let statePromise: Promise<LocationPickerState>
private var geocodingDisposable = MetaDisposable()
private var validLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)?
private var listOffset: CGFloat?
var present: ((ViewController, Any?) -> Void)?
init(context: AccountContext, presentationData: PresentationData, mode: LocationPickerMode, interaction: LocationPickerInteraction) {
self.context = context
self.presentationData = presentationData
self.presentationDataPromise = Promise(presentationData)
self.mode = mode
self.interaction = interaction
self.state = LocationPickerState()
self.statePromise = Promise(self.state)
self.listNode = ListView()
self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3)
self.listNode.verticalScrollIndicatorFollowsOverscroll = true
self.headerNode = LocationMapHeaderNode(presentationData: presentationData, interaction: interaction)
self.headerNode.mapNode.isRotateEnabled = false
self.optionsNode = LocationOptionsNode(presentationData: presentationData, interaction: interaction)
self.activityIndicator = ActivityIndicator(type: .custom(self.presentationData.theme.list.itemSecondaryTextColor, 22.0, 1.0, false))
super.init()
self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.addSubnode(self.listNode)
self.addSubnode(self.headerNode)
self.addSubnode(self.optionsNode)
self.listNode.addSubnode(self.activityIndicator)
let userLocation: Signal<CLLocation?, NoError> = self.headerNode.mapNode.userLocation
let filteredUserLocation: Signal<CLLocation?, NoError> = userLocation
|> reduceLeft(value: nil) { current, updated, emit -> CLLocation? in
if let current = current {
if let updated = updated {
if updated.distance(from: current) > 250 {
emit(updated)
return updated
} else {
return current
}
} else {
return current
}
} else {
if let updated = updated, updated.horizontalAccuracy > 0.0 && updated.horizontalAccuracy < 50.0 {
emit(updated)
return updated
} else {
return nil
}
}
}
let venues: Signal<[TelegramMediaMap]?, NoError> = .single(nil)
|> then(
filteredUserLocation
|> mapToSignal { location -> Signal<[TelegramMediaMap]?, NoError> in
if let location = location, location.horizontalAccuracy > 0 {
return nearbyVenues(account: context.account, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude)
|> map(Optional.init)
} else {
return .single(nil)
}
}
)
let previousState = Atomic<LocationPickerState>(value: self.state)
let previousUserLocation = Atomic<CLLocation?>(value: nil)
let previousAnnotations = Atomic<[LocationPinAnnotation]>(value: [])
let previousEntries = Atomic<[LocationPickerEntry]?>(value: nil)
self.disposable = (combineLatest(self.presentationDataPromise.get(), self.statePromise.get(), userLocation, venues)
|> deliverOnMainQueue).start(next: { [weak self] presentationData, state, userLocation, venues in
if let strongSelf = self {
var entries: [LocationPickerEntry] = []
switch state.selectedLocation {
case let .location(coordinate, address):
entries.append(.location(presentationData.theme, presentationData.strings.Map_SendThisLocation, address ?? presentationData.strings.Map_Locating, nil, coordinate))
case .selecting:
entries.append(.location(presentationData.theme, presentationData.strings.Map_SendThisLocation, presentationData.strings.Map_Locating, nil, nil))
case let .venue(venue):
entries.append(.location(presentationData.theme, presentationData.strings.Map_SendThisPlace, venue.venue?.title ?? "", venue, venue.coordinate))
case .none:
let title: String
switch strongSelf.mode {
case .share:
title = presentationData.strings.Map_SendMyCurrentLocation
case .pick:
title = presentationData.strings.Map_SetThisLocation
}
entries.append(.location(presentationData.theme, title, (userLocation?.horizontalAccuracy).flatMap { presentationData.strings.Map_AccurateTo(stringForDistance(strings: presentationData.strings, distance: $0)).0 } ?? presentationData.strings.Map_Locating, nil, userLocation?.coordinate))
}
if case .share(_, _, true) = mode {
entries.append(.liveLocation(presentationData.theme, presentationData.strings.Map_ShareLiveLocation, presentationData.strings.Map_ShareLiveLocationHelp, userLocation?.coordinate))
}
entries.append(.header(presentationData.theme, presentationData.strings.Map_ChooseAPlace.uppercased()))
if let venues = venues {
var index: Int = 0
for venue in venues {
entries.append(.venue(presentationData.theme, venue, index))
index += 1
}
if !venues.isEmpty {
entries.append(.attribution(presentationData.theme))
}
}
let previousEntries = previousEntries.swap(entries)
let transition = preparedTransition(from: previousEntries ?? [], to: entries, isLoading: venues == nil, account: context.account, presentationData: presentationData, interaction: strongSelf.interaction)
strongSelf.enqueueTransition(transition, firstTime: false)
strongSelf.headerNode.updateState(state)
let previousUserLocation = previousUserLocation.swap(userLocation)
switch state.selectedLocation {
case .none:
if let userLocation = userLocation {
strongSelf.headerNode.mapNode.setMapCenter(coordinate: userLocation.coordinate, animated: previousUserLocation != nil)
}
strongSelf.headerNode.mapNode.resetAnnotationSelection()
case .selecting:
strongSelf.headerNode.mapNode.resetAnnotationSelection()
case let .venue(venue):
strongSelf.headerNode.mapNode.setMapCenter(coordinate: venue.coordinate, animated: true)
default:
break
}
let annotations: [LocationPinAnnotation]
if let venues = venues {
annotations = venues.compactMap { LocationPinAnnotation(account: context.account, theme: presentationData.theme, location: $0) }
} else {
annotations = []
}
let previousAnnotations = previousAnnotations.swap(annotations)
if annotations != previousAnnotations {
strongSelf.headerNode.mapNode.annotations = annotations
}
let previousState = previousState.swap(state)
if let (layout, navigationBarHeight) = strongSelf.validLayout {
var updateLayout = false
var transition: ContainedViewLayoutTransition = .animated(duration: 0.45, curve: .spring)
if previousState.displayingMapModeOptions != state.displayingMapModeOptions {
updateLayout = true
} else if previousState.selectedLocation.isCustom != state.selectedLocation.isCustom {
updateLayout = true
}
if updateLayout {
strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationBarHeight, transition: transition)
}
}
if case let .location(coordinate, address) = state.selectedLocation, address == nil {
strongSelf.geocodingDisposable.set((reverseGeocodeLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|> deliverOnMainQueue).start(next: { [weak self] placemark in
if let strongSelf = self {
strongSelf.updateState { state in
var state = state
state.selectedLocation = .location(coordinate, placemark?.fullAddress)
return state
}
}
}))
} else {
strongSelf.geocodingDisposable.set(nil)
}
}
})
self.listNode.updateFloatingHeaderOffset = { [weak self] offset, listTransition in
guard let strongSelf = self, let (layout, navigationBarHeight) = strongSelf.validLayout else {
return
}
let overlap: CGFloat = 6.0
strongSelf.listOffset = max(0.0, offset)
let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: max(0.0, offset + overlap)))
listTransition.updateFrame(node: strongSelf.headerNode, frame: headerFrame)
strongSelf.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationBarHeight, padding: strongSelf.state.displayingMapModeOptions ? 38.0 : 0.0, size: headerFrame.size, transition: listTransition)
strongSelf.layoutActivityIndicator(transition: listTransition)
}
self.listNode.beganInteractiveDragging = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.updateState { state in
var state = state
state.displayingMapModeOptions = false
return state
}
}
self.headerNode.mapNode.beganInteractiveDragging = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.updateState { state in
var state = state
state.displayingMapModeOptions = false
state.selectedLocation = .selecting
return state
}
}
self.headerNode.mapNode.endedInteractiveDragging = { [weak self] coordinate in
guard let strongSelf = self else {
return
}
strongSelf.updateState { state in
var state = state
if case .selecting = state.selectedLocation {
state.selectedLocation = .location(coordinate, nil)
}
return state
}
}
self.headerNode.mapNode.annotationSelected = { [weak self] annotation in
guard let strongSelf = self else {
return
}
strongSelf.updateState { state in
var state = state
state.displayingMapModeOptions = false
state.selectedLocation = annotation.flatMap { .venue($0.location) } ?? .none
return state
}
}
}
deinit {
self.disposable?.dispose()
self.geocodingDisposable.dispose()
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
self.presentationDataPromise.set(.single(presentationData))
self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.headerNode.updatePresentationData(self.presentationData)
self.optionsNode.updatePresentationData(self.presentationData)
self.searchContainerNode?.updatePresentationData(self.presentationData)
}
func updateState(_ f: (LocationPickerState) -> LocationPickerState) {
self.state = f(self.state)
self.statePromise.set(.single(self.state))
}
private func enqueueTransition(_ transition: LocationPickerTransaction, firstTime: Bool) {
self.enqueuedTransitions.append((transition, firstTime))
if let _ = self.validLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
guard let layout = self.validLayout, let (transition, firstTime) = self.enqueuedTransitions.first else {
return
}
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
if firstTime {
options.insert(.PreferSynchronousDrawing)
} else {
options.insert(.AnimateCrossfade)
}
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.activityIndicator.isHidden = !transition.isLoading
}
})
}
func activateSearch(navigationBar: NavigationBar) {
guard let (layout, navigationBarHeight) = self.validLayout, self.searchContainerNode == nil, let coordinate = self.headerNode.mapNode.mapCenterCoordinate else {
return
}
let searchContainerNode = LocationSearchContainerNode(context: self.context, coordinate: coordinate, interaction: self.interaction)
self.insertSubnode(searchContainerNode, belowSubnode: navigationBar)
self.searchContainerNode = searchContainerNode
searchContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.containerLayoutUpdated(layout, navigationHeight: navigationBarHeight, transition: .immediate)
}
func deactivateSearch() {
guard let searchContainerNode = self.searchContainerNode else {
return
}
searchContainerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak searchContainerNode] _ in
searchContainerNode?.removeFromSupernode()
})
self.searchContainerNode = nil
}
func scrollToTop() {
if let searchContainerNode = self.searchContainerNode {
searchContainerNode.scrollToTop()
} else {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
}
private func layoutActivityIndicator(transition: ContainedViewLayoutTransition) {
guard let (layout, navigationHeight) = self.validLayout else {
return
}
let topInset: CGFloat = floor((layout.size.height - navigationHeight) / 2.0 + navigationHeight)
let headerHeight: CGFloat
if let listOffset = self.listOffset {
headerHeight = max(0.0, listOffset)
} else {
headerHeight = topInset
}
let indicatorSize = self.activityIndicator.measure(CGSize(width: 100.0, height: 100.0))
transition.updateFrame(node: self.activityIndicator, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - indicatorSize.width) / 2.0), y: headerHeight + 140.0 + floor((layout.size.height - headerHeight - 140.0 - 50.0) / 2.0)), size: indicatorSize))
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) {
let isFirstLayout = self.validLayout == nil
self.validLayout = (layout, navigationHeight)
let optionsHeight: CGFloat = 38.0
let topInset: CGFloat = floor((layout.size.height - navigationHeight) / 2.0 + navigationHeight)
let overlap: CGFloat = 6.0
let headerHeight: CGFloat
if let listOffset = self.listOffset {
headerHeight = max(0.0, listOffset + overlap)
} else {
headerHeight = topInset + overlap
}
let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: headerHeight))
transition.updateFrame(node: self.headerNode, frame: headerFrame)
self.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationHeight, padding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, size: headerFrame.size, transition: transition)
transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: layout.size))
var insets = layout.insets(options: [.input])
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: topInset, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0), headerInsets: UIEdgeInsets(top: navigationHeight, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0), scrollIndicatorInsets: UIEdgeInsets(top: topInset + 3.0, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.listNode.scrollEnabled = !self.state.selectedLocation.isCustom
self.layoutActivityIndicator(transition: transition)
if isFirstLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
let optionsOffset: CGFloat = self.state.displayingMapModeOptions ? navigationHeight : navigationHeight - optionsHeight
let optionsFrame = CGRect(x: 0.0, y: optionsOffset, width: layout.size.width, height: optionsHeight)
transition.updateFrame(node: self.optionsNode, frame: optionsFrame)
self.optionsNode.updateLayout(size: optionsFrame.size, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, transition: transition)
if let searchContainerNode = self.searchContainerNode {
searchContainerNode.frame = CGRect(origin: CGPoint(), size: layout.size)
searchContainerNode.containerLayoutUpdated(ContainerViewLayout(size: layout.size, metrics: LayoutMetrics(), deviceMetrics: layout.deviceMetrics, intrinsicInsets: layout.intrinsicInsets, safeInsets: layout.safeInsets, statusBarHeight: nil, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver), navigationBarHeight: navigationHeight, transition: transition)
}
}
}