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 import PhoneNumberFormat private struct LocationPickerTransaction { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let isLoading: Bool let isEmpty: Bool let crossFade: 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 venue = venue { interaction?.sendVenue(venue) } else if let coordinate = coordinate { interaction?.sendLocation(coordinate) } }, highlighted: { highlighted in interaction?.updateSendActionHighlight(highlighted) }) 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, _): let venueType = venue.venue?.type ?? "" return ItemListVenueItem(presentationData: ItemListPresentationData(presentationData), account: account, venue: venue, style: .plain, action: { interaction?.sendVenue(venue) }, infoAction: ["home", "work"].contains(venueType) ? { interaction?.openHomeWorkInfo() } : nil) case let .attribution(theme): return LocationAttributionItem(presentationData: ItemListPresentationData(presentationData)) } } } private func preparedTransition(from fromEntries: [LocationPickerEntry], to toEntries: [LocationPickerEntry], isLoading: Bool, isEmpty: Bool, crossFade: 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, isEmpty: isEmpty, crossFade: crossFade) } enum LocationPickerLocation: Equatable { case none case selecting case location(CLLocationCoordinate2D, String?) case venue(TelegramMediaMap) var isCustom: Bool { switch self { case .selecting, .location: return true default: return false } } public static func ==(lhs: LocationPickerLocation, rhs: LocationPickerLocation) -> Bool { switch lhs { case .none: if case .none = rhs { return true } else { return false } case .selecting: if case .selecting = rhs { return true } else { return false } case let .location(lhsCoordinate, lhsAddress): if case let .location(rhsCoordinate, rhsAddress) = rhs, lhsCoordinate == rhsCoordinate, lhsAddress == rhsAddress { return true } else { return false } case let .venue(lhsVenue): if case let .venue(rhsVenue) = rhs, lhsVenue.venue?.id == rhsVenue.venue?.id { return true } else { return false } } } } struct LocationPickerState { var mapMode: LocationMapMode var displayingMapModeOptions: Bool var selectedLocation: LocationPickerLocation var forceSelection: Bool var searchingVenuesAround: Bool init() { self.mapMode = .map self.displayingMapModeOptions = false self.selectedLocation = .none self.forceSelection = false self.searchingVenuesAround = false } } final class LocationPickerControllerNode: ViewControllerTracingNode { private let context: AccountContext private var presentationData: PresentationData private let presentationDataPromise: Promise private let mode: LocationPickerMode private let interaction: LocationPickerInteraction private let listNode: ListView private let emptyResultsTextNode: ImmediateTextNode private let headerNode: LocationMapHeaderNode private let activityIndicator: ActivityIndicator private let shadeNode: ASDisplayNode private let innerShadeNode: ASDisplayNode private let optionsNode: LocationOptionsNode private(set) var searchContainerNode: LocationSearchContainerNode? private var enqueuedTransitions: [LocationPickerTransaction] = [] private var disposable: Disposable? private var state: LocationPickerState private let statePromise: Promise private var geocodingDisposable = MetaDisposable() private let searchVenuesPromise = Promise() private var validLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)? private var listOffset: CGFloat? 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.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorFollowsOverscroll = true self.emptyResultsTextNode = ImmediateTextNode() self.emptyResultsTextNode.maximumNumberOfLines = 0 self.emptyResultsTextNode.textAlignment = .center self.emptyResultsTextNode.isHidden = true self.headerNode = LocationMapHeaderNode(presentationData: presentationData, toggleMapModeSelection: interaction.toggleMapModeSelection, goToUserLocation: interaction.goToUserLocation, showPlacesInThisArea: interaction.showPlacesInThisArea) self.headerNode.mapNode.isRotateEnabled = false self.optionsNode = LocationOptionsNode(presentationData: presentationData, updateMapMode: interaction.updateMapMode) self.activityIndicator = ActivityIndicator(type: .custom(self.presentationData.theme.list.itemSecondaryTextColor, 22.0, 1.0, false)) self.shadeNode = ASDisplayNode() self.shadeNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.shadeNode.alpha = 0.0 self.innerShadeNode = ASDisplayNode() self.innerShadeNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor 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) self.listNode.addSubnode(self.emptyResultsTextNode) self.shadeNode.addSubnode(self.innerShadeNode) self.addSubnode(self.shadeNode) let userLocation: Signal = self.headerNode.mapNode.userLocation let personalAddresses = self.context.account.postbox.peerView(id: self.context.account.peerId) |> mapToSignal { view -> Signal<(DeviceContactAddressData?, DeviceContactAddressData?)?, NoError> in if let user = peerViewMainPeer(view) as? TelegramUser, let phoneNumber = user.phone { return ((context.sharedContext.contactDataManager?.basicData() ?? .single([:])) |> take(1)) |> mapToSignal { basicData -> Signal in var stableId: String? let queryPhoneNumber = formatPhoneNumber(phoneNumber) outer: for (id, data) in basicData { for phoneNumber in data.phoneNumbers { if formatPhoneNumber(phoneNumber.value) == queryPhoneNumber { stableId = id break outer } } } if let stableId = stableId { return (context.sharedContext.contactDataManager?.extendedData(stableId: stableId) ?? .single(nil)) |> take(1) |> map { extendedData -> DeviceContactExtendedData? in return extendedData } } else { return .single(nil) } } |> map { extendedData -> (DeviceContactAddressData?, DeviceContactAddressData?)? in if let extendedData = extendedData { var homeAddress: DeviceContactAddressData? var workAddress: DeviceContactAddressData? for address in extendedData.addresses { if address.label == "_$!!$_" { homeAddress = address } else if address.label == "_$!!$_" { workAddress = address } } return (homeAddress, workAddress) } else { return nil } } } else { return .single(nil) } } let personalVenues: Signal<[TelegramMediaMap]?, NoError> = .single(nil) |> then( personalAddresses |> mapToSignal { homeAndWorkAddresses -> Signal<[TelegramMediaMap]?, NoError> in if let (homeAddress, workAddress) = homeAndWorkAddresses { let home: Signal<(Double, Double)?, NoError> let work: Signal<(Double, Double)?, NoError> if let address = homeAddress { home = geocodeAddress(postbox: context.account.postbox, address: address) } else { home = .single(nil) } if let address = workAddress { work = geocodeAddress(postbox: context.account.postbox, address: address) } else { work = .single(nil) } return combineLatest(home, work) |> map { homeCoordinate, workCoordinate -> [TelegramMediaMap]? in var venues: [TelegramMediaMap] = [] if let (latitude, longitude) = homeCoordinate, let address = homeAddress { venues.append(TelegramMediaMap(latitude: latitude, longitude: longitude, geoPlace: nil, venue: MapVenue(title: presentationData.strings.Map_Home, address: address.displayString, provider: nil, id: "home", type: "home"), liveBroadcastingTimeout: nil)) } if let (latitude, longitude) = workCoordinate, let address = workAddress { venues.append(TelegramMediaMap(latitude: latitude, longitude: longitude, geoPlace: nil, venue: MapVenue(title: presentationData.strings.Map_Work, address: address.displayString, provider: nil, id: "work", type: "work"), liveBroadcastingTimeout: nil)) } return venues } } else { return .single(nil) } } ) let venues: Signal<[TelegramMediaMap]?, NoError> = .single(nil) |> then( throttledUserLocation(userLocation) |> mapToSignal { location -> Signal<[TelegramMediaMap]?, NoError> in if let location = location, location.horizontalAccuracy > 0 { return combineLatest(nearbyVenues(account: context.account, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude), personalVenues) |> map { nearbyVenues, personalVenues -> [TelegramMediaMap]? in var resultVenues: [TelegramMediaMap] = [] if let personalVenues = personalVenues { for venue in personalVenues { let venueLocation = CLLocation(latitude: venue.latitude, longitude: venue.longitude) if venueLocation.distance(from: location) <= 1000 { resultVenues.append(venue) } } } resultVenues.append(contentsOf: nearbyVenues) return resultVenues } } else { return .single(nil) } } ) let foundVenues: Signal<([TelegramMediaMap], CLLocation)?, NoError> = .single(nil) |> then( self.searchVenuesPromise.get() |> distinctUntilChanged |> mapToSignal { coordinate -> Signal<([TelegramMediaMap], CLLocation)?, NoError> in if let coordinate = coordinate { return (.single(nil) |> then( nearbyVenues(account: context.account, latitude: coordinate.latitude, longitude: coordinate.longitude) |> map { venues -> ([TelegramMediaMap], CLLocation)? in return (venues, CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) } )) } else { return .single(nil) } } ) let previousState = Atomic(value: self.state) let previousUserLocation = Atomic(value: nil) let previousAnnotations = Atomic<[LocationPinAnnotation]>(value: []) let previousEntries = Atomic<[LocationPickerEntry]?>(value: nil) self.disposable = (combineLatest(self.presentationDataPromise.get(), self.statePromise.get(), userLocation, venues, foundVenues) |> deliverOnMainQueue).start(next: { [weak self] presentationData, state, userLocation, venues, foundVenuesAndLocation in if let strongSelf = self { let (foundVenues, foundVenuesLocation) = foundVenuesAndLocation ?? (nil, nil) var entries: [LocationPickerEntry] = [] switch state.selectedLocation { case let .location(coordinate, address): let title: String switch strongSelf.mode { case .share: title = presentationData.strings.Map_SendThisLocation case .pick: title = presentationData.strings.Map_SetThisLocation } entries.append(.location(presentationData.theme, title, address ?? presentationData.strings.Map_Locating, nil, coordinate)) case .selecting: let title: String switch strongSelf.mode { case .share: title = presentationData.strings.Map_SendThisLocation case .pick: title = presentationData.strings.Map_SetThisLocation } entries.append(.location(presentationData.theme, title, presentationData.strings.Map_Locating, nil, nil)) case let .venue(venue): let title: String switch strongSelf.mode { case .share: title = presentationData.strings.Map_SendThisPlace case .pick: title = presentationData.strings.Map_SetThisPlace } entries.append(.location(presentationData.theme, title, 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())) var displayedVenues = foundVenues != nil || state.searchingVenuesAround ? foundVenues : venues if let venues = displayedVenues { 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 previousState = previousState.swap(state) var crossFade = false if previousEntries?.count != entries.count || previousState.selectedLocation != state.selectedLocation { crossFade = true } let transition = preparedTransition(from: previousEntries ?? [], to: entries, isLoading: displayedVenues == nil, isEmpty: displayedVenues?.isEmpty ?? false, crossFade: crossFade, account: context.account, presentationData: presentationData, interaction: strongSelf.interaction) strongSelf.enqueueTransition(transition) var displayingPlacesButton = false let previousUserLocation = previousUserLocation.swap(userLocation) switch state.selectedLocation { case .none: if let userLocation = userLocation { strongSelf.headerNode.mapNode.setMapCenter(coordinate: userLocation.coordinate, isUserLocation: true, animated: previousUserLocation != nil) } strongSelf.headerNode.mapNode.resetAnnotationSelection() case .selecting: strongSelf.headerNode.mapNode.resetAnnotationSelection() case let .location(coordinate, address): var updateMap = false switch previousState.selectedLocation { case .none, .venue: updateMap = true case let .location(previousCoordinate, address): if previousCoordinate != coordinate { updateMap = true } default: break } if updateMap { strongSelf.headerNode.mapNode.setMapCenter(coordinate: coordinate, isUserLocation: false, hidePicker: false, animated: true) strongSelf.headerNode.mapNode.switchToPicking(animated: false) } if address != nil { if foundVenues == nil && !state.searchingVenuesAround { displayingPlacesButton = true } else if let previousLocation = foundVenuesLocation { let currentLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) if currentLocation.distance(from: previousLocation) > 300 { displayingPlacesButton = true } } } case let .venue(venue): strongSelf.headerNode.mapNode.setMapCenter(coordinate: venue.coordinate, hidePicker: true, animated: true) } strongSelf.headerNode.updateState(mapMode: state.mapMode, displayingMapModeOptions: state.displayingMapModeOptions, displayingPlacesButton: displayingPlacesButton, animated: true) let annotations: [LocationPinAnnotation] if let venues = displayedVenues { annotations = venues.compactMap { LocationPinAnnotation(context: context, theme: presentationData.theme, location: $0) } } else { annotations = [] } let previousAnnotations = previousAnnotations.swap(annotations) if annotations != previousAnnotations { strongSelf.headerNode.mapNode.annotations = annotations } 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 } else if previousState.searchingVenuesAround != state.searchingVenuesAround { 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 { var address = placemark?.fullAddress ?? "" if address.isEmpty { address = presentationData.strings.Map_Unknown } strongSelf.updateState { state in var state = state state.selectedLocation = .location(coordinate, address) return state } } })) } else { strongSelf.geocodingDisposable.set(nil) } } }) if case let .share(_, selfPeer, _) = self.mode, let peer = selfPeer { self.headerNode.mapNode.userLocationAnnotation = LocationPinAnnotation(context: context, theme: self.presentationData.theme, peer: peer) self.headerNode.mapNode.hasPickerAnnotation = true } self.listNode.updateFloatingHeaderOffset = { [weak self] offset, listTransition in guard let strongSelf = self, let (layout, navigationBarHeight) = strongSelf.validLayout, strongSelf.listNode.scrollEnabled 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, topPadding: strongSelf.state.displayingMapModeOptions ? 38.0 : 0.0, offset: 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 state.searchingVenuesAround = false 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) state.searchingVenuesAround = false } 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?.location.flatMap { .venue($0) } ?? .none if annotation == nil { state.searchingVenuesAround = false } return state } } self.headerNode.mapNode.userLocationAnnotationSelected = { [weak self] in if let strongSelf = self { strongSelf.goToUserLocation() } } } 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.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.headerNode.updatePresentationData(self.presentationData) self.optionsNode.updatePresentationData(self.presentationData) self.shadeNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor self.innerShadeNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor 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) { self.enqueuedTransitions.append(transition) if let _ = self.validLayout { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } private func dequeueTransition() { guard let layout = self.validLayout, let transition = self.enqueuedTransitions.first else { return } self.enqueuedTransitions.remove(at: 0) var options = ListViewDeleteAndInsertOptions() if transition.crossFade { 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 strongSelf.emptyResultsTextNode.isHidden = transition.isLoading || !transition.isEmpty strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.Map_NoPlacesNearby, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor) strongSelf.layoutActivityIndicator(transition: .immediate) } }) } func activateSearch(navigationBar: NavigationBar) -> Signal { guard let (layout, navigationBarHeight) = self.validLayout, self.searchContainerNode == nil, let coordinate = self.headerNode.mapNode.mapCenterCoordinate else { return .complete() } 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) return searchContainerNode.isSearching } 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)) let actionsInset: CGFloat = 148.0 transition.updateFrame(node: self.activityIndicator, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - indicatorSize.width) / 2.0), y: headerHeight + actionsInset + floor((layout.size.height - headerHeight - actionsInset - indicatorSize.height - layout.intrinsicInsets.bottom) / 2.0)), size: indicatorSize)) let padding: CGFloat = 16.0 let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude)) transition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - emptyTextSize.width) / 2.0), y: headerHeight + actionsInset + floor((layout.size.height - headerHeight - actionsInset - emptyTextSize.height - layout.intrinsicInsets.bottom) / 2.0)), size: emptyTextSize)) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) { let isFirstLayout = self.validLayout == nil self.validLayout = (layout, navigationHeight) let isPickingLocation = (self.state.selectedLocation.isCustom || self.state.forceSelection) && !self.state.searchingVenuesAround let optionsHeight: CGFloat = 38.0 var actionHeight: CGFloat? self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? LocationActionListItemNode { if actionHeight == nil { actionHeight = itemNode.frame.height } } } let topInset: CGFloat = floor((layout.size.height - navigationHeight) / 2.0 + navigationHeight) let overlap: CGFloat = 6.0 let headerHeight: CGFloat if isPickingLocation, let actionHeight = actionHeight { self.listOffset = topInset headerHeight = layout.size.height - actionHeight - layout.intrinsicInsets.bottom + overlap - 2.0 } else 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, topPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, offset: 0.0, size: headerFrame.size, transition: transition) let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) let scrollToItem: ListViewScrollToItem? if isPickingLocation { scrollToItem = ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: curve, directionHint: .Up) } else { scrollToItem = nil } let insets = UIEdgeInsets(top: topInset, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: scrollToItem, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, 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 = !isPickingLocation var listFrame: CGRect = CGRect(origin: CGPoint(), size: layout.size) if isPickingLocation { listFrame.origin.y = headerHeight - topInset - overlap } transition.updateFrame(node: self.listNode, frame: listFrame) transition.updateAlpha(node: self.shadeNode, alpha: isPickingLocation ? 1.0 : 0.0) transition.updateFrame(node: self.shadeNode, frame: CGRect(x: 0.0, y: listFrame.minY + topInset + (actionHeight ?? 0.0) - 3.0, width: layout.size.width, height: 10000.0)) self.shadeNode.isUserInteractionEnabled = isPickingLocation self.innerShadeNode.frame = CGRect(x: 0.0, y: 4.0, width: layout.size.width, height: 10000.0) self.innerShadeNode.alpha = layout.intrinsicInsets.bottom > 0.0 ? 1.0 : 0.0 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) } } func updateSendActionHighlight(_ highlighted: Bool) { self.headerNode.updateHighlight(highlighted) self.shadeNode.backgroundColor = highlighted ? self.presentationData.theme.list.itemHighlightedBackgroundColor : self.presentationData.theme.list.plainBackgroundColor } func goToUserLocation() { self.searchVenuesPromise.set(.single(nil)) self.updateState { state in var state = state state.displayingMapModeOptions = false state.selectedLocation = .none state.searchingVenuesAround = false return state } } func requestPlacesAtSelectedLocation() { if case let .location(coordinate, _) = self.state.selectedLocation { self.headerNode.mapNode.setMapCenter(coordinate: coordinate, animated: true) self.searchVenuesPromise.set(.single(coordinate)) self.updateState { state in var state = state state.searchingVenuesAround = true return state } } } }