From 1b264ac3a2c9e9e8d732c8f7c7a6f5568bb045dd Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 4 Dec 2019 05:05:28 +0400 Subject: [PATCH] Location view improvements --- submodules/Geocoding/Sources/Geocoding.swift | 13 + submodules/LocationUI/BUCK | 1 + .../Sources/LocationAnnotation.swift | 37 +- .../Sources/LocationInfoListItem.swift | 264 +++++++++++ .../Sources/LocationMapHeaderNode.swift | 18 +- .../LocationUI/Sources/LocationMapNode.swift | 9 +- .../Sources/LocationOptionsNode.swift | 10 +- .../Sources/LocationPickerController.swift | 8 +- .../LocationPickerControllerNode.swift | 30 +- .../LocationUI/Sources/LocationUtils.swift | 73 +++ .../Sources/LocationViewController.swift | 188 ++++++++ .../Sources/LocationViewControllerNode.swift | 431 ++++++++++++++++++ .../Sources/SolidRoundedButtonNode.swift | 69 ++- .../TelegramUI/OpenChatMessage.swift | 12 +- 14 files changed, 1089 insertions(+), 74 deletions(-) create mode 100644 submodules/LocationUI/Sources/LocationInfoListItem.swift create mode 100644 submodules/LocationUI/Sources/LocationViewController.swift create mode 100644 submodules/LocationUI/Sources/LocationViewControllerNode.swift diff --git a/submodules/Geocoding/Sources/Geocoding.swift b/submodules/Geocoding/Sources/Geocoding.swift index 495f6fc63d..a391e26b3c 100644 --- a/submodules/Geocoding/Sources/Geocoding.swift +++ b/submodules/Geocoding/Sources/Geocoding.swift @@ -37,6 +37,19 @@ public struct ReverseGeocodedPlacemark { public let city: String? public let country: String? + public var compactDisplayAddress: String? { + if let street = self.street { + return street + } + if let city = self.city { + return city + } + if let country = self.country { + return country + } + return nil + } + public var fullAddress: String { var components: [String] = [] if let street = self.street { diff --git a/submodules/LocationUI/BUCK b/submodules/LocationUI/BUCK index afc86f6299..9e3d6bbf1f 100644 --- a/submodules/LocationUI/BUCK +++ b/submodules/LocationUI/BUCK @@ -34,6 +34,7 @@ static_library( "//submodules/ChatListSearchItemHeader:ChatListSearchItemHeader", "//submodules/PhoneNumberFormat:PhoneNumberFormat", "//submodules/PersistentStringHash:PersistentStringHash", + "//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode", ], frameworks = [ "$SDKROOT/System/Library/Frameworks/Foundation.framework", diff --git a/submodules/LocationUI/Sources/LocationAnnotation.swift b/submodules/LocationUI/Sources/LocationAnnotation.swift index 32d1d82596..4789fbee85 100644 --- a/submodules/LocationUI/Sources/LocationAnnotation.swift +++ b/submodules/LocationUI/Sources/LocationAnnotation.swift @@ -33,6 +33,7 @@ class LocationPinAnnotation: NSObject, MKAnnotation { var coordinate: CLLocationCoordinate2D let location: TelegramMediaMap? let peer: Peer? + let forcedSelection: Bool var title: String? = "" var subtitle: String? = "" @@ -43,17 +44,29 @@ class LocationPinAnnotation: NSObject, MKAnnotation { self.location = nil self.peer = peer self.coordinate = kCLLocationCoordinate2DInvalid + self.forcedSelection = false super.init() } - init(account: Account, theme: PresentationTheme, location: TelegramMediaMap) { + init(account: Account, theme: PresentationTheme, location: TelegramMediaMap, forcedSelection: Bool = false) { self.account = account self.theme = theme self.location = location self.peer = nil self.coordinate = location.coordinate + self.forcedSelection = forcedSelection super.init() } + + var id: String { + if let peer = self.peer { + return "\(peer.id.toInt64())" + } else if let venueId = self.location?.venue?.id { + return venueId + } else { + return String(format: "%.5f_%.5f", self.coordinate.latitude, self.coordinate.longitude) + } + } } class LocationPinAnnotationLayer: CALayer { @@ -142,8 +155,14 @@ class LocationPinAnnotationView: MKAnnotationView { } var defaultZPosition: CGFloat { - if let annotation = self.annotation as? LocationPinAnnotation, let venueType = annotation.location?.venue?.type, ["home", "work"].contains(venueType) { - return -0.5 + if let annotation = self.annotation as? LocationPinAnnotation { + if annotation.forcedSelection { + return 0.0 + } else if let venueType = annotation.location?.venue?.type, ["home", "work"].contains(venueType) { + return -0.5 + } else { + return -1.0 + } } else { return -1.0 } @@ -179,6 +198,10 @@ class LocationPinAnnotationView: MKAnnotationView { self.shadowNode.isHidden = true self.smallNode.isHidden = false } + + if annotation.forcedSelection { + self.setSelected(true, animated: false) + } } } } @@ -192,6 +215,12 @@ class LocationPinAnnotationView: MKAnnotationView { override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) + if let annotation = self.annotation as? LocationPinAnnotation { + if annotation.forcedSelection && !selected { + return + } + } + if animated { self.layoutSubviews() @@ -529,7 +558,7 @@ class LocationPinAnnotationView: MKAnnotationView { if !self.appeared { self.appeared = true - if let annotation = annotation as? LocationPinAnnotation, annotation.location != nil { + if let annotation = annotation as? LocationPinAnnotation, annotation.location != nil && !annotation.forcedSelection { self.animateAppearance() } } diff --git a/submodules/LocationUI/Sources/LocationInfoListItem.swift b/submodules/LocationUI/Sources/LocationInfoListItem.swift new file mode 100644 index 0000000000..cefe3c3ee4 --- /dev/null +++ b/submodules/LocationUI/Sources/LocationInfoListItem.swift @@ -0,0 +1,264 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Postbox +import Display +import SwiftSignalKit +import TelegramCore +import SyncCore +import TelegramPresentationData +import ItemListUI +import LocationResources +import AppBundle +import SolidRoundedButtonNode + +final class LocationInfoListItem: ListViewItem { + let presentationData: ItemListPresentationData + let account: Account + let location: TelegramMediaMap + let address: String? + let distance: String? + let eta: String? + let action: () -> Void + let getDirections: () -> Void + + public init(presentationData: ItemListPresentationData, account: Account, location: TelegramMediaMap, address: String?, distance: String?, eta: String?, action: @escaping () -> Void, getDirections: @escaping () -> Void) { + self.presentationData = presentationData + self.account = account + self.location = location + self.address = address + self.distance = distance + self.eta = eta + self.action = action + self.getDirections = getDirections + } + + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = LocationInfoListItemNode() + let makeLayout = node.asyncLayout() + let (nodeLayout, nodeApply) = makeLayout(self, params) + node.contentSize = nodeLayout.contentSize + node.insets = nodeLayout.insets + + completion(node, nodeApply) + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? LocationInfoListItemNode { + let layout = nodeValue.asyncLayout() + async { + let (nodeLayout, apply) = layout(self, params) + Queue.mainQueue().async { + completion(nodeLayout, { info in + apply().1(info) + }) + } + } + } + } + } + + public var selectable: Bool { + return false + } +} + +final class LocationInfoListItemNode: ListViewItemNode { + private let backgroundNode: ASDisplayNode + private var titleNode: TextNode? + private var subtitleNode: TextNode? + private let venueIconNode: TransformImageNode + private let buttonNode: HighlightableButtonNode + private var directionsButtonNode: SolidRoundedButtonNode? + + private var item: LocationInfoListItem? + private var layoutParams: ListViewItemLayoutParams? + + required init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.buttonNode = HighlightableButtonNode() + self.venueIconNode = TransformImageNode() + self.venueIconNode.isUserInteractionEnabled = false + + super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false) + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.buttonNode) + self.addSubnode(self.venueIconNode) + + self.buttonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.titleNode?.layer.removeAnimation(forKey: "opacity") + strongSelf.titleNode?.alpha = 0.4 + strongSelf.subtitleNode?.layer.removeAnimation(forKey: "opacity") + strongSelf.subtitleNode?.alpha = 0.4 + strongSelf.venueIconNode.layer.removeAnimation(forKey: "opacity") + strongSelf.venueIconNode.alpha = 0.4 + } else { + strongSelf.titleNode?.alpha = 1.0 + strongSelf.titleNode?.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + strongSelf.subtitleNode?.alpha = 1.0 + strongSelf.subtitleNode?.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + strongSelf.venueIconNode.alpha = 1.0 + strongSelf.venueIconNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + } + + self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + } + + override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = self.item { + let makeLayout = self.asyncLayout() + let (nodeLayout, nodeApply) = makeLayout(item, params) + self.contentSize = nodeLayout.contentSize + self.insets = nodeLayout.insets + let _ = nodeApply() + } + } + + func asyncLayout() -> (_ item: LocationInfoListItem, _ params: ListViewItemLayoutParams) -> (ListViewItemNodeLayout, () -> (Signal?, (ListViewItemApply) -> Void)) { + let currentItem = self.item + + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode) + let iconLayout = self.venueIconNode.asyncLayout() + + return { [weak self] item, params in + let leftInset: CGFloat = 75.0 + params.leftInset + let rightInset: CGFloat = params.rightInset + let verticalInset: CGFloat = 14.0 + let iconSize: CGFloat = 48.0 + let inset: CGFloat = 15.0 + + let titleFont = Font.medium(item.presentationData.fontSize.itemListBaseFontSize) + let subtitleFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0)) + + let title: String + let subtitle: String + var subtitleComponents: [String] = [] + + if let venue = item.location.venue { + title = venue.title + } else { + title = item.presentationData.strings.Map_Location + } + + if let address = item.address { + subtitleComponents.append(address) + } + if let distance = item.distance { + subtitleComponents.append(distance) + } + + subtitle = subtitleComponents.joined(separator: " • ") + + let titleAttributedString = NSAttributedString(string: title, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor) + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 15.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + + let subtitleAttributedString = NSAttributedString(string: subtitle, font: subtitleFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor) + let (subtitleLayout, subtitleApply) = makeSubtitleLayout(TextNodeLayoutArguments(attributedString: subtitleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 15.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + + let titleSpacing: CGFloat = 1.0 + let bottomInset: CGFloat = 4.0 + let contentSize = CGSize(width: params.width, height: max(126.0, verticalInset * 2.0 + titleLayout.size.height + titleSpacing + subtitleLayout.size.height + bottomInset)) + let nodeLayout = ListViewItemNodeLayout(contentSize: contentSize, insets: UIEdgeInsets()) + + return (nodeLayout, { [weak self] in + var updatedTheme: PresentationTheme? + if currentItem?.presentationData.theme !== item.presentationData.theme { + updatedTheme = item.presentationData.theme + } + + var updatedLocation: TelegramMediaMap? + if currentItem?.location.venue?.id != item.location.venue?.id || updatedTheme != nil { + updatedLocation = item.location + } + + return (nil, { _ in + if let strongSelf = self { + strongSelf.item = item + strongSelf.layoutParams = params + + if let _ = updatedTheme { + strongSelf.backgroundNode.backgroundColor = item.presentationData.theme.list.plainBackgroundColor + strongSelf.directionsButtonNode?.updateTheme(SolidRoundedButtonTheme(theme: item.presentationData.theme)) + } + + if let updatedLocation = updatedLocation { + strongSelf.venueIconNode.setSignal(venueIcon(postbox: item.account.postbox, type: updatedLocation.venue?.type ?? "", background: true)) + } + + let iconApply = iconLayout(TransformImageArguments(corners: ImageCorners(), imageSize: CGSize(width: iconSize, height: iconSize), boundingSize: CGSize(width: iconSize, height: iconSize), intrinsicInsets: UIEdgeInsets())) + iconApply() + + let titleNode = titleApply() + if strongSelf.titleNode == nil { + titleNode.isUserInteractionEnabled = false + strongSelf.titleNode = titleNode + strongSelf.addSubnode(titleNode) + } + + let subtitleNode = subtitleApply() + if strongSelf.subtitleNode == nil { + subtitleNode.isUserInteractionEnabled = false + strongSelf.subtitleNode = subtitleNode + strongSelf.addSubnode(subtitleNode) + } + + let directionsButtonNode: SolidRoundedButtonNode + if let currentDirectionsButtonNode = strongSelf.directionsButtonNode { + directionsButtonNode = currentDirectionsButtonNode + } else { + directionsButtonNode = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(theme: item.presentationData.theme), height: 50.0, cornerRadius: 10.0) + directionsButtonNode.title = item.presentationData.strings.Map_Directions + directionsButtonNode.pressed = { + item.getDirections() + } + strongSelf.addSubnode(directionsButtonNode) + strongSelf.directionsButtonNode = directionsButtonNode + } + directionsButtonNode.subtitle = item.eta + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: verticalInset), size: titleLayout.size) + titleNode.frame = titleFrame + + let subtitleFrame = CGRect(origin: CGPoint(x: leftInset, y: verticalInset + titleLayout.size.height + titleSpacing), size: subtitleLayout.size) + subtitleNode.frame = subtitleFrame + + let separatorHeight = UIScreenPixel + let topHighlightInset: CGFloat = separatorHeight + + let iconNodeFrame = CGRect(origin: CGPoint(x: params.leftInset + inset, y: 10.0), size: CGSize(width: iconSize, height: iconSize)) + strongSelf.venueIconNode.frame = iconNodeFrame + + let directionsWidth = contentSize.width - inset * 2.0 + let directionsHeight = directionsButtonNode.updateLayout(width: directionsWidth, transition: .immediate) + directionsButtonNode.frame = CGRect(x: inset, y: iconNodeFrame.maxY + 14.0, width: directionsWidth, height: directionsHeight) + + strongSelf.buttonNode.frame = CGRect(x: 0.0, y: 0.0, width: contentSize.width, height: 72.0) + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: contentSize.width, height: contentSize.height)) + } + }) + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.5) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.5, removeOnCompletion: false) + } + + @objc private func buttonPressed() { + self.item?.action() + } +} diff --git a/submodules/LocationUI/Sources/LocationMapHeaderNode.swift b/submodules/LocationUI/Sources/LocationMapHeaderNode.swift index 03853820d5..619add57c5 100644 --- a/submodules/LocationUI/Sources/LocationMapHeaderNode.swift +++ b/submodules/LocationUI/Sources/LocationMapHeaderNode.swift @@ -36,7 +36,8 @@ private func generateShadowImage(theme: PresentationTheme, highlighted: Bool) -> final class LocationMapHeaderNode: ASDisplayNode { private var presentationData: PresentationData - private let interaction: LocationPickerInteraction + private let toggleMapModeSelection: () -> Void + private let goToUserLocation: () -> Void let mapNode: LocationMapNode private let optionsBackgroundNode: ASImageNode @@ -44,9 +45,10 @@ final class LocationMapHeaderNode: ASDisplayNode { private let locationButtonNode: HighlightableButtonNode private let shadowNode: ASImageNode - init(presentationData: PresentationData, interaction: LocationPickerInteraction) { + init(presentationData: PresentationData, toggleMapModeSelection: @escaping () -> Void, goToUserLocation: @escaping () -> Void) { self.presentationData = presentationData - self.interaction = interaction + self.toggleMapModeSelection = toggleMapModeSelection + self.goToUserLocation = goToUserLocation self.mapNode = LocationMapNode() @@ -84,9 +86,9 @@ final class LocationMapHeaderNode: ASDisplayNode { self.locationButtonNode.addTarget(self, action: #selector(self.locationPressed), forControlEvents: .touchUpInside) } - func updateState(_ state: LocationPickerState) { - self.mapNode.mapMode = state.mapMode - self.infoButtonNode.isSelected = state.displayingMapModeOptions + func updateState(mapMode: LocationMapMode, displayingMapModeOptions: Bool) { + self.mapNode.mapMode = mapMode + self.infoButtonNode.isSelected = displayingMapModeOptions } func updatePresentationData(_ presentationData: PresentationData) { @@ -123,10 +125,10 @@ final class LocationMapHeaderNode: ASDisplayNode { } @objc private func infoPressed() { - self.interaction.toggleMapModeSelection() + self.toggleMapModeSelection() } @objc private func locationPressed() { - self.interaction.goToUserLocation() + self.goToUserLocation() } } diff --git a/submodules/LocationUI/Sources/LocationMapNode.swift b/submodules/LocationUI/Sources/LocationMapNode.swift index 25ff47896b..9b5a11475b 100644 --- a/submodules/LocationUI/Sources/LocationMapNode.swift +++ b/submodules/LocationUI/Sources/LocationMapNode.swift @@ -4,6 +4,7 @@ import SwiftSignalKit import MapKit let defaultMapSpan = MKCoordinateSpan(latitudeDelta: 0.016, longitudeDelta: 0.016) +let viewMapSpan = MKCoordinateSpan(latitudeDelta: 0.008, longitudeDelta: 0.008) private let pinOffset = CGPoint(x: 0.0, y: 33.0) public enum LocationMapMode { @@ -331,9 +332,7 @@ final class LocationMapNode: ASDisplayNode, MKMapViewDelegate { var dict: [String: LocationPinAnnotation] = [:] for annotation in self.annotations { - if let identifier = annotation.location?.venue?.id { - dict[identifier] = annotation - } + dict[annotation.id] = annotation } var annotationsToRemove = Set() @@ -342,9 +341,9 @@ final class LocationMapNode: ASDisplayNode, MKMapViewDelegate { continue } - if let identifier = annotation.location?.venue?.id, let updatedAnnotation = dict[identifier] { + if let updatedAnnotation = dict[annotation.id] { annotation.coordinate = updatedAnnotation.coordinate - dict[identifier] = nil + dict[annotation.id] = nil } else { annotationsToRemove.insert(annotation) } diff --git a/submodules/LocationUI/Sources/LocationOptionsNode.swift b/submodules/LocationUI/Sources/LocationOptionsNode.swift index ea83e6cf57..9b48138e96 100644 --- a/submodules/LocationUI/Sources/LocationOptionsNode.swift +++ b/submodules/LocationUI/Sources/LocationOptionsNode.swift @@ -14,11 +14,9 @@ final class LocationOptionsNode: ASDisplayNode { private let backgroundNode: ASDisplayNode private let separatorNode: ASDisplayNode private let segmentedControlNode: SegmentedControlNode - private let interaction: LocationPickerInteraction - init(presentationData: PresentationData, interaction: LocationPickerInteraction) { + init(presentationData: PresentationData, updateMapMode: @escaping (LocationMapMode) -> Void) { self.presentationData = presentationData - self.interaction = interaction self.backgroundNode = ASDisplayNode() self.backgroundNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.backgroundColor @@ -39,11 +37,11 @@ final class LocationOptionsNode: ASDisplayNode { } switch index { case 0: - strongSelf.interaction.updateMapMode(.map) + updateMapMode(.map) case 1: - strongSelf.interaction.updateMapMode(.sattelite) + updateMapMode(.sattelite) case 2: - strongSelf.interaction.updateMapMode(.hybrid) + updateMapMode(.hybrid) default: break } diff --git a/submodules/LocationUI/Sources/LocationPickerController.swift b/submodules/LocationUI/Sources/LocationPickerController.swift index 7f57b8f30a..42fbef88c9 100644 --- a/submodules/LocationUI/Sources/LocationPickerController.swift +++ b/submodules/LocationUI/Sources/LocationPickerController.swift @@ -103,7 +103,7 @@ public final class LocationPickerController: ViewController { let locationWithTimeout: (CLLocationCoordinate2D, Int32?) -> TelegramMediaMap = { coordinate, timeout in return TelegramMediaMap(latitude: coordinate.latitude, longitude: coordinate.longitude, geoPlace: nil, venue: nil, liveBroadcastingTimeout: timeout) } - + self.interaction = LocationPickerInteraction(sendLocation: { [weak self] coordinate in guard let strongSelf = self else { return @@ -133,21 +133,21 @@ public final class LocationPickerController: ViewController { ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor15Minutes, color: .accent, action: { [weak self, weak controller] in controller?.dismissAnimated() if let strongSelf = self { - strongSelf.completion(locationWithTimeout(coordinate, 15 * 60), nil) + strongSelf.completion(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: 15 * 60), nil) strongSelf.dismiss() } }), ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor1Hour, color: .accent, action: { [weak self, weak controller] in controller?.dismissAnimated() if let strongSelf = self { - strongSelf.completion(locationWithTimeout(coordinate, 60 * 60 - 1), nil) + strongSelf.completion(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: 60 * 60 - 1), nil) strongSelf.dismiss() } }), ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor8Hours, color: .accent, action: { [weak self, weak controller] in controller?.dismissAnimated() if let strongSelf = self { - strongSelf.completion(locationWithTimeout(coordinate, 8 * 60 * 60), nil) + strongSelf.completion(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: 8 * 60 * 60), nil) strongSelf.dismiss() } }) diff --git a/submodules/LocationUI/Sources/LocationPickerControllerNode.swift b/submodules/LocationUI/Sources/LocationPickerControllerNode.swift index 8b251c7319..4667a0e1fe 100644 --- a/submodules/LocationUI/Sources/LocationPickerControllerNode.swift +++ b/submodules/LocationUI/Sources/LocationPickerControllerNode.swift @@ -277,10 +277,10 @@ final class LocationPickerControllerNode: ViewControllerTracingNode { self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorFollowsOverscroll = true - self.headerNode = LocationMapHeaderNode(presentationData: presentationData, interaction: interaction) + self.headerNode = LocationMapHeaderNode(presentationData: presentationData, toggleMapModeSelection: interaction.toggleMapModeSelection, goToUserLocation: interaction.goToUserLocation) self.headerNode.mapNode.isRotateEnabled = false - self.optionsNode = LocationOptionsNode(presentationData: presentationData, interaction: interaction) + self.optionsNode = LocationOptionsNode(presentationData: presentationData, updateMapMode: interaction.updateMapMode) self.activityIndicator = ActivityIndicator(type: .custom(self.presentationData.theme.list.itemSecondaryTextColor, 22.0, 1.0, false)) @@ -302,28 +302,6 @@ final class LocationPickerControllerNode: ViewControllerTracingNode { self.addSubnode(self.shadeNode) let userLocation: Signal = self.headerNode.mapNode.userLocation - let filteredUserLocation: Signal = userLocation - |> reduceLeft(value: nil) { current, updated, emit -> CLLocation? in - if let current = current { - if let updated = updated { - if updated.distance(from: current) > 250 || (updated.horizontalAccuracy < 50.0 && updated.horizontalAccuracy < current.horizontalAccuracy) { - emit(updated) - return updated - } else { - return current - } - } else { - return current - } - } else { - if let updated = updated, updated.horizontalAccuracy > 0.0 { - emit(updated) - return updated - } else { - return nil - } - } - } let personalAddresses = self.context.account.postbox.peerView(id: self.context.account.peerId) |> mapToSignal { view -> Signal<(DeviceContactAddressData?, DeviceContactAddressData?)?, NoError> in @@ -407,7 +385,7 @@ final class LocationPickerControllerNode: ViewControllerTracingNode { let venues: Signal<[TelegramMediaMap]?, NoError> = .single(nil) |> then( - filteredUserLocation + 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) @@ -505,7 +483,7 @@ final class LocationPickerControllerNode: ViewControllerTracingNode { let transition = preparedTransition(from: previousEntries ?? [], to: entries, isLoading: venues == nil, crossFade: crossFade, account: context.account, presentationData: presentationData, interaction: strongSelf.interaction) strongSelf.enqueueTransition(transition) - strongSelf.headerNode.updateState(state) + strongSelf.headerNode.updateState(mapMode: state.mapMode, displayingMapModeOptions: state.displayingMapModeOptions) let previousUserLocation = previousUserLocation.swap(userLocation) switch state.selectedLocation { diff --git a/submodules/LocationUI/Sources/LocationUtils.swift b/submodules/LocationUI/Sources/LocationUtils.swift index 4aa516a555..8c1a3dce47 100644 --- a/submodules/LocationUI/Sources/LocationUtils.swift +++ b/submodules/LocationUI/Sources/LocationUtils.swift @@ -7,6 +7,10 @@ import TelegramStringFormatting import MapKit extension TelegramMediaMap { + convenience init(coordinate: CLLocationCoordinate2D, liveBroadcastingTimeout: Int32? = nil) { + self.init(latitude: coordinate.latitude, longitude: coordinate.longitude, geoPlace: nil, venue: nil, liveBroadcastingTimeout: liveBroadcastingTimeout) + } + var coordinate: CLLocationCoordinate2D { return CLLocationCoordinate2D(latitude: self.latitude, longitude: self.longitude) } @@ -76,3 +80,72 @@ func stringForDistance(strings: PresentationStrings, distance: CLLocationDistanc } return distanceFormatter.string(fromDistance: distance) } + +func stringForEstimatedDuration(strings: PresentationStrings, eta: Double) -> String? { + if eta > 0.0 && eta < 60.0 * 60.0 * 10.0 { + var eta = max(eta, 60.0) + let minutes = Int32(eta / 60.0) % 60 + let hours = Int32(eta / 3600.0) + + let string: String + if hours > 1 { + if hours == 1 && minutes == 0 { + string = strings.Map_ETAHours(1) + } else { + string = strings.Map_ETAHours(9999).replacingOccurrences(of: "9999", with: String(format: "%d:%02d", arguments: [hours, minutes])) + } + } else { + string = strings.Map_ETAMinutes(minutes) + } + return strings.Map_DirectionsDriveEta(string).0 + } else { + return nil + } +} + +func throttledUserLocation(_ userLocation: Signal) -> Signal { + return userLocation + |> reduceLeft(value: nil) { current, updated, emit -> CLLocation? in + if let current = current { + if let updated = updated { + if updated.distance(from: current) > 250 || (updated.horizontalAccuracy < 50.0 && updated.horizontalAccuracy < current.horizontalAccuracy) { + emit(updated) + return updated + } else { + return current + } + } else { + return current + } + } else { + if let updated = updated, updated.horizontalAccuracy > 0.0 { + emit(updated) + return updated + } else { + return nil + } + } + } +} + +func driveEta(coordinate: CLLocationCoordinate2D) -> Signal { + return Signal { subscriber in + let destinationPlacemark = MKPlacemark(coordinate: coordinate, addressDictionary: nil) + let destination = MKMapItem(placemark: destinationPlacemark) + + let request = MKDirections.Request() + request.source = MKMapItem.forCurrentLocation() + request.destination = destination + request.transportType = .automobile + request.requestsAlternateRoutes = false + + let directions = MKDirections(request: request) + directions.calculateETA { response, error in + subscriber.putNext(response?.expectedTravelTime) + subscriber.putCompletion() + } + return ActionDisposable { + directions.cancel() + } + } +} diff --git a/submodules/LocationUI/Sources/LocationViewController.swift b/submodules/LocationUI/Sources/LocationViewController.swift new file mode 100644 index 0000000000..1e9f171847 --- /dev/null +++ b/submodules/LocationUI/Sources/LocationViewController.swift @@ -0,0 +1,188 @@ +import Foundation +import UIKit +import Display +import LegacyComponents +import TelegramCore +import SyncCore +import Postbox +import SwiftSignalKit +import TelegramPresentationData +import AccountContext +import AppBundle +import CoreLocation +import PresentationDataUtils +import OpenInExternalAppUI +import ShareController + +public class LocationViewParams { + let sendLiveLocation: (TelegramMediaMap) -> Void + let stopLiveLocation: () -> Void + let openUrl: (String) -> Void + let openPeer: (Peer) -> Void + + public init(sendLiveLocation: @escaping (TelegramMediaMap) -> Void, stopLiveLocation: @escaping () -> Void, openUrl: @escaping (String) -> Void, openPeer: @escaping (Peer) -> Void) { + self.sendLiveLocation = sendLiveLocation + self.stopLiveLocation = stopLiveLocation + self.openUrl = openUrl + self.openPeer = openPeer + } +} + +class LocationViewInteraction { + let toggleMapModeSelection: () -> Void + let updateMapMode: (LocationMapMode) -> Void + let goToUserLocation: () -> Void + let goToCoordinate: (CLLocationCoordinate2D) -> Void + let requestDirections: () -> Void + let share: () -> Void + + init(toggleMapModeSelection: @escaping () -> Void, updateMapMode: @escaping (LocationMapMode) -> Void, goToUserLocation: @escaping () -> Void, goToCoordinate: @escaping (CLLocationCoordinate2D) -> Void, requestDirections: @escaping () -> Void, share: @escaping () -> Void) { + self.toggleMapModeSelection = toggleMapModeSelection + self.updateMapMode = updateMapMode + self.goToUserLocation = goToUserLocation + self.goToCoordinate = goToCoordinate + self.requestDirections = requestDirections + self.share = share + } +} + +public final class LocationViewController: ViewController { + private var controllerNode: LocationViewControllerNode { + return self.displayNode as! LocationViewControllerNode + } + private let context: AccountContext + private var mapMedia: TelegramMediaMap + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + private var interaction: LocationViewInteraction? + + public init(context: AccountContext, mapMedia: TelegramMediaMap, params: LocationViewParams) { + self.context = context + self.mapMedia = mapMedia + + self.presentationData = context.sharedContext.currentPresentationData.with { $0 } + + super.init(navigationBarPresentationData: NavigationBarPresentationData(theme: NavigationBarTheme(rootControllerTheme: self.presentationData.theme).withUpdatedSeparatorColor(.clear), strings: NavigationBarStrings(presentationStrings: self.presentationData.strings))) + + self.navigationPresentation = .modal + + self.title = self.presentationData.strings.Map_LocationTitle + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(self.cancelPressed)) + self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationShareIcon(self.presentationData.theme), style: .plain, target: self, action: #selector(self.sharePressed)) + self.navigationItem.rightBarButtonItem?.accessibilityLabel = self.presentationData.strings.VoiceOver_MessageContextShare + + self.presentationDataDisposable = (context.sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + guard let strongSelf = self, strongSelf.presentationData.theme !== presentationData.theme else { + return + } + strongSelf.presentationData = presentationData + + strongSelf.navigationBar?.updatePresentationData(NavigationBarPresentationData(theme: NavigationBarTheme(rootControllerTheme: strongSelf.presentationData.theme).withUpdatedSeparatorColor(.clear), strings: NavigationBarStrings(presentationStrings: strongSelf.presentationData.strings))) + + strongSelf.navigationItem.rightBarButtonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationShareIcon(strongSelf.presentationData.theme), style: .plain, target: strongSelf, action: #selector(strongSelf.sharePressed)) + + if strongSelf.isNodeLoaded { + strongSelf.controllerNode.updatePresentationData(presentationData) + } + }) + + self.interaction = LocationViewInteraction(toggleMapModeSelection: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.controllerNode.updateState { state in + var state = state + state.displayingMapModeOptions = !state.displayingMapModeOptions + return state + } + }, updateMapMode: { [weak self] mode in + guard let strongSelf = self else { + return + } + strongSelf.controllerNode.updateState { state in + var state = state + state.mapMode = mode + state.displayingMapModeOptions = false + return state + } + }, goToUserLocation: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.controllerNode.updateState { state in + var state = state + state.displayingMapModeOptions = false + state.selectedLocation = .user + return state + } + }, goToCoordinate: { [weak self] coordinate in + guard let strongSelf = self else { + return + } + strongSelf.controllerNode.updateState { state in + var state = state + state.displayingMapModeOptions = false + state.selectedLocation = .coordinate(coordinate) + return state + } + }, requestDirections: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.present(OpenInActionSheetController(context: context, item: .location(location: mapMedia, withDirections: true), additionalAction: nil, openUrl: params.openUrl), in: .window(.root), with: nil) + }, share: { [weak self] in + guard let strongSelf = self else { + return + } + let shareAction = OpenInControllerAction(title: strongSelf.presentationData.strings.Conversation_ContextMenuShare, action: { + strongSelf.present(ShareController(context: context, subject: .mapMedia(mapMedia), externalShare: true), in: .window(.root), with: nil) + }) + strongSelf.present(OpenInActionSheetController(context: context, item: .location(location: mapMedia, withDirections: false), additionalAction: shareAction, openUrl: params.openUrl), in: .window(.root), with: nil) + }) + + self.scrollToTop = { [weak self] in + if let strongSelf = self { + strongSelf.controllerNode.scrollToTop() + } + } + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.presentationDataDisposable?.dispose() + } + + override public func loadDisplayNode() { + super.loadDisplayNode() + guard let interaction = self.interaction else { + return + } + + self.displayNode = LocationViewControllerNode(context: self.context, presentationData: self.presentationData, mapMedia: self.mapMedia, interaction: interaction) + self.displayNodeDidLoad() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationHeight: self.navigationHeight, transition: transition) + } + + @objc private func cancelPressed() { + self.dismiss() + } + + @objc private func sharePressed() { + self.interaction?.share() + } + + @objc private func showAllPressed() { + self.dismiss() + } +} + diff --git a/submodules/LocationUI/Sources/LocationViewControllerNode.swift b/submodules/LocationUI/Sources/LocationViewControllerNode.swift new file mode 100644 index 0000000000..0185eae26a --- /dev/null +++ b/submodules/LocationUI/Sources/LocationViewControllerNode.swift @@ -0,0 +1,431 @@ +import Foundation +import UIKit +import Display +import LegacyComponents +import TelegramCore +import SyncCore +import Postbox +import SwiftSignalKit +import MergeLists +import ItemListUI +import ItemListVenueItem +import TelegramPresentationData +import AccountContext +import AppBundle +import CoreLocation +import Geocoding + +private func areMessagesEqual(_ lhsMessage: Message, _ rhsMessage: Message) -> Bool { + if lhsMessage.stableVersion != rhsMessage.stableVersion { + return false + } + if lhsMessage.id != rhsMessage.id || lhsMessage.flags != rhsMessage.flags { + return false + } + return true +} + +private struct LocationViewTransaction { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private enum LocationViewEntryId: Hashable { + case info + case toggleLiveLocation + case liveLocation(PeerId) +} + +private enum LocationViewEntry: Comparable, Identifiable { + case info(PresentationTheme, TelegramMediaMap, String?, Double?, Double?) + case toggleLiveLocation(PresentationTheme, String, String) + case liveLocation(PresentationTheme, Message, Int) + + var stableId: LocationViewEntryId { + switch self { + case .info: + return .info + case .toggleLiveLocation: + return .toggleLiveLocation + case let .liveLocation(_, message, _): + return .liveLocation(message.id.peerId) + } + } + + static func ==(lhs: LocationViewEntry, rhs: LocationViewEntry) -> Bool { + switch lhs { + case let .info(lhsTheme, lhsLocation, lhsAddress, lhsDistance, lhsTime): + if case let .info(rhsTheme, rhsLocation, rhsAddress, rhsDistance, rhsTime) = rhs, lhsTheme === rhsTheme, lhsLocation.venue?.id == rhsLocation.venue?.id, lhsAddress == rhsAddress, lhsDistance == rhsDistance, lhsTime == rhsTime { + return true + } else { + return false + } + case let .toggleLiveLocation(lhsTheme, lhsTitle, lhsSubtitle): + if case let .toggleLiveLocation(rhsTheme, rhsTitle, rhsSubtitle) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle { + return true + } else { + return false + } + case let .liveLocation(lhsTheme, lhsMessage, lhsIndex): + if case let .liveLocation(rhsTheme, rhsMessage, rhsIndex) = rhs, lhsTheme === rhsTheme, areMessagesEqual(lhsMessage, rhsMessage), lhsIndex == rhsIndex { + return true + } else { + return false + } + } + } + + static func <(lhs: LocationViewEntry, rhs: LocationViewEntry) -> Bool { + switch lhs { + case .info: + switch rhs { + case .info: + return false + case .toggleLiveLocation, .liveLocation: + return true + } + case .toggleLiveLocation: + switch rhs { + case .info, .toggleLiveLocation: + return false + case .liveLocation: + return true + } + case let .liveLocation(_, _, lhsIndex): + switch rhs { + case .info, .toggleLiveLocation: + return false + case let .liveLocation(_, _, rhsIndex): + return lhsIndex < rhsIndex + } + } + } + + func item(account: Account, presentationData: PresentationData, interaction: LocationViewInteraction?) -> ListViewItem { + switch self { + case let .info(theme, location, address, distance, time): + let addressString: String? + if let address = address { + addressString = address + } else { + addressString = presentationData.strings.Map_Locating + } + let distanceString: String? + if let distance = distance { + distanceString = distance < 10 ? presentationData.strings.Map_YouAreHere : presentationData.strings.Map_DistanceAway(stringForDistance(strings: presentationData.strings, distance: distance)).0 + } else { + distanceString = nil + } + let eta = time.flatMap { stringForEstimatedDuration(strings: presentationData.strings, eta: $0) } + return LocationInfoListItem(presentationData: ItemListPresentationData(presentationData), account: account, location: location, address: addressString, distance: distanceString, eta: eta, action: { + interaction?.goToCoordinate(location.coordinate) + }, getDirections: { + interaction?.requestDirections() + }) + case let .toggleLiveLocation(theme, title, subtitle): + return LocationActionListItem(presentationData: ItemListPresentationData(presentationData), account: account, title: title, subtitle: subtitle, icon: .liveLocation, action: { +// if let coordinate = coordinate { +// interaction?.sendLiveLocation(coordinate) +// } + }) + case let .liveLocation(theme, message, _): + return ItemListTextItem(presentationData: ItemListPresentationData(presentationData), text: .plain(""), sectionId: 0) + } + } +} + +private func preparedTransition(from fromEntries: [LocationViewEntry], to toEntries: [LocationViewEntry], account: Account, presentationData: PresentationData, interaction: LocationViewInteraction?) -> LocationViewTransaction { + 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 LocationViewTransaction(deletions: deletions, insertions: insertions, updates: updates) +} + +enum LocationViewLocation: Equatable { + case initial + case user + case coordinate(CLLocationCoordinate2D) + case custom +} + +struct LocationViewState { + var mapMode: LocationMapMode + var displayingMapModeOptions: Bool + var selectedLocation: LocationViewLocation + + init() { + self.mapMode = .map + self.displayingMapModeOptions = false + self.selectedLocation = .initial + } +} + +final class LocationViewControllerNode: ViewControllerTracingNode { + private let context: AccountContext + private var presentationData: PresentationData + private let presentationDataPromise: Promise + private var mapMedia: TelegramMediaMap + private let interaction: LocationViewInteraction + + private let listNode: ListView + private let headerNode: LocationMapHeaderNode + private let optionsNode: LocationOptionsNode + + private var enqueuedTransitions: [LocationViewTransaction] = [] + + private var disposable: Disposable? + private var state: LocationViewState + private let statePromise: Promise + private var geocodingDisposable = MetaDisposable() + + private var validLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)? + private var listOffset: CGFloat? + + init(context: AccountContext, presentationData: PresentationData, mapMedia: TelegramMediaMap, interaction: LocationViewInteraction) { + self.context = context + self.presentationData = presentationData + self.presentationDataPromise = Promise(presentationData) + self.mapMedia = mapMedia + self.interaction = interaction + + self.state = LocationViewState() + 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.headerNode = LocationMapHeaderNode(presentationData: presentationData, toggleMapModeSelection: interaction.toggleMapModeSelection, goToUserLocation: interaction.goToUserLocation) + self.headerNode.mapNode.isRotateEnabled = false + + self.optionsNode = LocationOptionsNode(presentationData: presentationData, updateMapMode: interaction.updateMapMode) + + super.init() + + self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor + + self.addSubnode(self.listNode) + self.addSubnode(self.headerNode) + self.addSubnode(self.optionsNode) + + let distance: Signal = .single(nil) + |> then( + throttledUserLocation(self.headerNode.mapNode.userLocation) + |> map { userLocation -> Double? in + let location = CLLocation(latitude: mapMedia.latitude, longitude: mapMedia.longitude) + return userLocation.flatMap { location.distance(from: $0) } + } + ) + let address: Signal + var eta: Signal = .single(nil) + |> then( + driveEta(coordinate: mapMedia.coordinate) + ) + if let venue = mapMedia.venue, let venueAddress = venue.address, !venueAddress.isEmpty { + address = .single(venueAddress) + } else if mapMedia.liveBroadcastingTimeout == nil { + address = .single(nil) + |> then( + reverseGeocodeLocation(latitude: mapMedia.latitude, longitude: mapMedia.longitude) + |> map { placemark -> String? in + return placemark?.compactDisplayAddress ?? "" + } + ) + } else { + address = .single(nil) + eta = .single(nil) + } + + let previousState = Atomic(value: nil) + let previousAnnotations = Atomic<[LocationPinAnnotation]>(value: []) + let previousEntries = Atomic<[LocationViewEntry]?>(value: nil) + + self.disposable = (combineLatest(self.presentationDataPromise.get(), self.statePromise.get(), self.headerNode.mapNode.userLocation, distance, address, eta) + |> deliverOnMainQueue).start(next: { [weak self] presentationData, state, userLocation, distance, address, eta in + if let strongSelf = self { + var entries: [LocationViewEntry] = [] + + entries.append(.info(presentationData.theme, mapMedia, address, distance, eta)) + + let previousEntries = previousEntries.swap(entries) + let previousState = previousState.swap(state) + + let transition = preparedTransition(from: previousEntries ?? [], to: entries, account: context.account, presentationData: presentationData, interaction: strongSelf.interaction) + strongSelf.enqueueTransition(transition) + + strongSelf.headerNode.updateState(mapMode: state.mapMode, displayingMapModeOptions: state.displayingMapModeOptions) + + switch state.selectedLocation { + case .initial: + if previousState?.selectedLocation != .initial { + strongSelf.headerNode.mapNode.setMapCenter(coordinate: mapMedia.coordinate, span: viewMapSpan, animated: previousState != nil) + } + case let .coordinate(coordinate): + if let previousState = previousState, case let .coordinate(previousCoordinate) = previousState.selectedLocation, previousCoordinate == coordinate { + } else { + strongSelf.headerNode.mapNode.setMapCenter(coordinate: coordinate, span: viewMapSpan, animated: true) + } + case .user: + if previousState?.selectedLocation != .user, let userLocation = userLocation { + strongSelf.headerNode.mapNode.setMapCenter(coordinate: userLocation.coordinate, isUserLocation: true, animated: true) + } + case .custom: + break + } + + let annotations: [LocationPinAnnotation] = [LocationPinAnnotation(account: context.account, theme: presentationData.theme, location: mapMedia, forcedSelection: true)] + + 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 + } + + if updateLayout { + strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationBarHeight, transition: transition) + } + } + } + }) + + 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) + } + + 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 = .custom + 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.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor + self.headerNode.updatePresentationData(self.presentationData) + self.optionsNode.updatePresentationData(self.presentationData) + } + + func updateState(_ f: (LocationViewState) -> LocationViewState) { + self.state = f(self.state) + self.statePromise.set(.single(self.state)) + } + + private func enqueueTransition(_ transition: LocationViewTransaction) { + 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() + + + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + }) + } + + func scrollToTop() { + 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 }) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) { + let isFirstLayout = self.validLayout == nil + self.validLayout = (layout, navigationHeight) + + 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 overlap: CGFloat = 6.0 + let topInset: CGFloat = layout.size.height - layout.intrinsicInsets.bottom - 126.0 - overlap + 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, topPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, offset: 0.0, size: headerFrame.size, transition: transition) + + let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) + + 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: nil, 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 }) + + let listFrame: CGRect = CGRect(origin: CGPoint(), size: layout.size) + transition.updateFrame(node: self.listNode, frame: listFrame) + + 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) + } +} diff --git a/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift b/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift index df8a02a682..03e5cda274 100644 --- a/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift +++ b/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift @@ -21,7 +21,8 @@ public final class SolidRoundedButtonNode: ASDisplayNode { private let buttonBackgroundNode: ASImageNode private let buttonGlossNode: SolidRoundedButtonGlossNode private let buttonNode: HighlightTrackingButtonNode - private let labelNode: ImmediateTextNode + private let titleNode: ImmediateTextNode + private let subtitleNode: ImmediateTextNode private let iconNode: ASImageNode private let buttonHeight: CGFloat @@ -38,6 +39,14 @@ public final class SolidRoundedButtonNode: ASDisplayNode { } } + public var subtitle: String? { + didSet { + if let width = self.validLayout { + _ = self.updateLayout(width: width, previousSubtitle: oldValue, transition: .immediate) + } + } + } + public init(title: String? = nil, icon: UIImage? = nil, theme: SolidRoundedButtonTheme, height: CGFloat = 48.0, cornerRadius: CGFloat = 24.0, gloss: Bool = false) { self.theme = theme self.buttonHeight = height @@ -54,8 +63,11 @@ public final class SolidRoundedButtonNode: ASDisplayNode { self.buttonNode = HighlightTrackingButtonNode() - self.labelNode = ImmediateTextNode() - self.labelNode.isUserInteractionEnabled = false + self.titleNode = ImmediateTextNode() + self.titleNode.isUserInteractionEnabled = false + + self.subtitleNode = ImmediateTextNode() + self.subtitleNode.isUserInteractionEnabled = false self.iconNode = ASImageNode() self.iconNode.displayWithoutProcessing = true @@ -69,7 +81,8 @@ public final class SolidRoundedButtonNode: ASDisplayNode { self.addSubnode(self.buttonGlossNode) } self.addSubnode(self.buttonNode) - self.addSubnode(self.labelNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.subtitleNode) self.addSubnode(self.iconNode) self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) @@ -78,15 +91,19 @@ public final class SolidRoundedButtonNode: ASDisplayNode { if highlighted { strongSelf.buttonBackgroundNode.layer.removeAnimation(forKey: "opacity") strongSelf.buttonBackgroundNode.alpha = 0.55 - strongSelf.labelNode.layer.removeAnimation(forKey: "opacity") - strongSelf.labelNode.alpha = 0.55 + strongSelf.titleNode.layer.removeAnimation(forKey: "opacity") + strongSelf.titleNode.alpha = 0.55 + strongSelf.subtitleNode.layer.removeAnimation(forKey: "opacity") + strongSelf.subtitleNode.alpha = 0.55 strongSelf.iconNode.layer.removeAnimation(forKey: "opacity") strongSelf.iconNode.alpha = 0.55 } else { strongSelf.buttonBackgroundNode.alpha = 1.0 strongSelf.buttonBackgroundNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) - strongSelf.labelNode.alpha = 1.0 - strongSelf.labelNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) + strongSelf.titleNode.alpha = 1.0 + strongSelf.titleNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) + strongSelf.subtitleNode.alpha = 1.0 + strongSelf.subtitleNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) strongSelf.iconNode.alpha = 1.0 strongSelf.iconNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) } @@ -102,10 +119,15 @@ public final class SolidRoundedButtonNode: ASDisplayNode { self.buttonBackgroundNode.image = generateStretchableFilledCircleImage(radius: self.buttonCornerRadius, color: theme.backgroundColor) self.buttonGlossNode.color = theme.foregroundColor - self.labelNode.attributedText = NSAttributedString(string: self.title ?? "", font: Font.semibold(17.0), textColor: theme.foregroundColor) + self.titleNode.attributedText = NSAttributedString(string: self.title ?? "", font: Font.semibold(17.0), textColor: theme.foregroundColor) + self.subtitleNode.attributedText = NSAttributedString(string: self.subtitle ?? "", font: Font.regular(14.0), textColor: theme.foregroundColor) } public func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + return self.updateLayout(width: width, previousSubtitle: nil, transition: transition) + } + + private func updateLayout(width: CGFloat, previousSubtitle: String?, transition: ContainedViewLayoutTransition) -> CGFloat { self.validLayout = width let buttonSize = CGSize(width: width, height: self.buttonHeight) @@ -114,16 +136,16 @@ public final class SolidRoundedButtonNode: ASDisplayNode { transition.updateFrame(node: self.buttonGlossNode, frame: buttonFrame) transition.updateFrame(node: self.buttonNode, frame: buttonFrame) - if self.title != self.labelNode.attributedText?.string { - self.labelNode.attributedText = NSAttributedString(string: self.title ?? "", font: Font.semibold(17.0), textColor: self.theme.foregroundColor) + if self.title != self.titleNode.attributedText?.string { + self.titleNode.attributedText = NSAttributedString(string: self.title ?? "", font: Font.semibold(17.0), textColor: self.theme.foregroundColor) } let iconSize = self.iconNode.image?.size ?? CGSize() - let labelSize = self.labelNode.updateLayout(buttonSize) + let titleSize = self.titleNode.updateLayout(buttonSize) let iconSpacing: CGFloat = 8.0 - var contentWidth: CGFloat = labelSize.width + var contentWidth: CGFloat = titleSize.width if !iconSize.width.isZero { contentWidth += iconSize.width + iconSpacing } @@ -133,8 +155,25 @@ public final class SolidRoundedButtonNode: ASDisplayNode { nextContentOrigin += iconSize.width + iconSpacing } - let labelFrame = CGRect(origin: CGPoint(x: buttonFrame.minX + nextContentOrigin, y: buttonFrame.minY + floor((buttonFrame.height - labelSize.height) / 2.0)), size: labelSize) - transition.updateFrame(node: self.labelNode, frame: labelFrame) + let spacingOffset: CGFloat = 9.0 + var verticalInset: CGFloat = self.subtitle == nil ? floor((buttonFrame.height - titleSize.height) / 2.0) : floor((buttonFrame.height - titleSize.height) / 2.0) - spacingOffset + + let titleFrame = CGRect(origin: CGPoint(x: buttonFrame.minX + nextContentOrigin, y: buttonFrame.minY + verticalInset), size: titleSize) + transition.updateFrame(node: self.titleNode, frame: titleFrame) + + if self.subtitle != self.subtitleNode.attributedText?.string { + self.subtitleNode.attributedText = NSAttributedString(string: self.subtitle ?? "", font: Font.regular(14.0), textColor: self.theme.foregroundColor) + } + + let subtitleSize = self.subtitleNode.updateLayout(buttonSize) + let subtitleFrame = CGRect(origin: CGPoint(x: buttonFrame.minX + floor((buttonFrame.width - subtitleSize.width) / 2.0), y: buttonFrame.minY + floor((buttonFrame.height - titleSize.height) / 2.0) + spacingOffset + 2.0), size: subtitleSize) + transition.updateFrame(node: self.subtitleNode, frame: subtitleFrame) + + if previousSubtitle == nil && self.subtitle != nil { + self.titleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: spacingOffset / 2.0), to: CGPoint(), duration: 0.3, additive: true) + self.subtitleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -spacingOffset / 2.0), to: CGPoint(), duration: 0.3, additive: true) + self.subtitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + } return buttonSize.height } diff --git a/submodules/TelegramUI/TelegramUI/OpenChatMessage.swift b/submodules/TelegramUI/TelegramUI/OpenChatMessage.swift index b022fb92be..65ca9e0dd9 100644 --- a/submodules/TelegramUI/TelegramUI/OpenChatMessage.swift +++ b/submodules/TelegramUI/TelegramUI/OpenChatMessage.swift @@ -283,15 +283,15 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool { case let .map(mapMedia): params.dismissInput() - let controller = legacyLocationController(message: params.message, mapMedia: mapMedia, context: params.context, openPeer: { peer in - params.openPeer(peer, .info) - }, sendLiveLocation: { coordinate, period in - let outMessage: EnqueueMessage = .message(text: "", attributes: [], mediaReference: .standalone(media: TelegramMediaMap(latitude: coordinate.latitude, longitude: coordinate.longitude, geoPlace: nil, venue: nil, liveBroadcastingTimeout: period)), replyToMessageId: nil, localGroupingKey: nil) + let controllerParams = LocationViewParams(sendLiveLocation: { location in + let outMessage: EnqueueMessage = .message(text: "", attributes: [], mediaReference: .standalone(media: location), replyToMessageId: nil, localGroupingKey: nil) params.enqueueMessage(outMessage) }, stopLiveLocation: { params.context.liveLocationManager?.cancelLiveLocation(peerId: params.message.id.peerId) - }, openUrl: params.openUrl) - controller.navigationPresentation = .modal + }, openUrl: params.openUrl, openPeer: { peer in + params.openPeer(peer, .info) + }) + let controller = LocationViewController(context: params.context, mapMedia: mapMedia, params: controllerParams) params.navigationController?.pushViewController(controller) return true case let .stickerPack(reference):