Location view improvements

This commit is contained in:
Ilya Laktyushin 2019-12-04 05:05:28 +04:00
parent 86b181d390
commit 1b264ac3a2
14 changed files with 1089 additions and 74 deletions

View File

@ -37,6 +37,19 @@ public struct ReverseGeocodedPlacemark {
public let city: String? public let city: String?
public let country: 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 { public var fullAddress: String {
var components: [String] = [] var components: [String] = []
if let street = self.street { if let street = self.street {

View File

@ -34,6 +34,7 @@ static_library(
"//submodules/ChatListSearchItemHeader:ChatListSearchItemHeader", "//submodules/ChatListSearchItemHeader:ChatListSearchItemHeader",
"//submodules/PhoneNumberFormat:PhoneNumberFormat", "//submodules/PhoneNumberFormat:PhoneNumberFormat",
"//submodules/PersistentStringHash:PersistentStringHash", "//submodules/PersistentStringHash:PersistentStringHash",
"//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode",
], ],
frameworks = [ frameworks = [
"$SDKROOT/System/Library/Frameworks/Foundation.framework", "$SDKROOT/System/Library/Frameworks/Foundation.framework",

View File

@ -33,6 +33,7 @@ class LocationPinAnnotation: NSObject, MKAnnotation {
var coordinate: CLLocationCoordinate2D var coordinate: CLLocationCoordinate2D
let location: TelegramMediaMap? let location: TelegramMediaMap?
let peer: Peer? let peer: Peer?
let forcedSelection: Bool
var title: String? = "" var title: String? = ""
var subtitle: String? = "" var subtitle: String? = ""
@ -43,17 +44,29 @@ class LocationPinAnnotation: NSObject, MKAnnotation {
self.location = nil self.location = nil
self.peer = peer self.peer = peer
self.coordinate = kCLLocationCoordinate2DInvalid self.coordinate = kCLLocationCoordinate2DInvalid
self.forcedSelection = false
super.init() super.init()
} }
init(account: Account, theme: PresentationTheme, location: TelegramMediaMap) { init(account: Account, theme: PresentationTheme, location: TelegramMediaMap, forcedSelection: Bool = false) {
self.account = account self.account = account
self.theme = theme self.theme = theme
self.location = location self.location = location
self.peer = nil self.peer = nil
self.coordinate = location.coordinate self.coordinate = location.coordinate
self.forcedSelection = forcedSelection
super.init() 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 { class LocationPinAnnotationLayer: CALayer {
@ -142,8 +155,14 @@ class LocationPinAnnotationView: MKAnnotationView {
} }
var defaultZPosition: CGFloat { var defaultZPosition: CGFloat {
if let annotation = self.annotation as? LocationPinAnnotation, let venueType = annotation.location?.venue?.type, ["home", "work"].contains(venueType) { if let annotation = self.annotation as? LocationPinAnnotation {
return -0.5 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 { } else {
return -1.0 return -1.0
} }
@ -179,6 +198,10 @@ class LocationPinAnnotationView: MKAnnotationView {
self.shadowNode.isHidden = true self.shadowNode.isHidden = true
self.smallNode.isHidden = false 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) { override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated) super.setSelected(selected, animated: animated)
if let annotation = self.annotation as? LocationPinAnnotation {
if annotation.forcedSelection && !selected {
return
}
}
if animated { if animated {
self.layoutSubviews() self.layoutSubviews()
@ -529,7 +558,7 @@ class LocationPinAnnotationView: MKAnnotationView {
if !self.appeared { if !self.appeared {
self.appeared = true 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() self.animateAppearance()
} }
} }

View File

@ -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<Void, NoError>?, (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<Void, NoError>?, (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()
}
}

View File

@ -36,7 +36,8 @@ private func generateShadowImage(theme: PresentationTheme, highlighted: Bool) ->
final class LocationMapHeaderNode: ASDisplayNode { final class LocationMapHeaderNode: ASDisplayNode {
private var presentationData: PresentationData private var presentationData: PresentationData
private let interaction: LocationPickerInteraction private let toggleMapModeSelection: () -> Void
private let goToUserLocation: () -> Void
let mapNode: LocationMapNode let mapNode: LocationMapNode
private let optionsBackgroundNode: ASImageNode private let optionsBackgroundNode: ASImageNode
@ -44,9 +45,10 @@ final class LocationMapHeaderNode: ASDisplayNode {
private let locationButtonNode: HighlightableButtonNode private let locationButtonNode: HighlightableButtonNode
private let shadowNode: ASImageNode private let shadowNode: ASImageNode
init(presentationData: PresentationData, interaction: LocationPickerInteraction) { init(presentationData: PresentationData, toggleMapModeSelection: @escaping () -> Void, goToUserLocation: @escaping () -> Void) {
self.presentationData = presentationData self.presentationData = presentationData
self.interaction = interaction self.toggleMapModeSelection = toggleMapModeSelection
self.goToUserLocation = goToUserLocation
self.mapNode = LocationMapNode() self.mapNode = LocationMapNode()
@ -84,9 +86,9 @@ final class LocationMapHeaderNode: ASDisplayNode {
self.locationButtonNode.addTarget(self, action: #selector(self.locationPressed), forControlEvents: .touchUpInside) self.locationButtonNode.addTarget(self, action: #selector(self.locationPressed), forControlEvents: .touchUpInside)
} }
func updateState(_ state: LocationPickerState) { func updateState(mapMode: LocationMapMode, displayingMapModeOptions: Bool) {
self.mapNode.mapMode = state.mapMode self.mapNode.mapMode = mapMode
self.infoButtonNode.isSelected = state.displayingMapModeOptions self.infoButtonNode.isSelected = displayingMapModeOptions
} }
func updatePresentationData(_ presentationData: PresentationData) { func updatePresentationData(_ presentationData: PresentationData) {
@ -123,10 +125,10 @@ final class LocationMapHeaderNode: ASDisplayNode {
} }
@objc private func infoPressed() { @objc private func infoPressed() {
self.interaction.toggleMapModeSelection() self.toggleMapModeSelection()
} }
@objc private func locationPressed() { @objc private func locationPressed() {
self.interaction.goToUserLocation() self.goToUserLocation()
} }
} }

View File

@ -4,6 +4,7 @@ import SwiftSignalKit
import MapKit import MapKit
let defaultMapSpan = MKCoordinateSpan(latitudeDelta: 0.016, longitudeDelta: 0.016) 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) private let pinOffset = CGPoint(x: 0.0, y: 33.0)
public enum LocationMapMode { public enum LocationMapMode {
@ -331,9 +332,7 @@ final class LocationMapNode: ASDisplayNode, MKMapViewDelegate {
var dict: [String: LocationPinAnnotation] = [:] var dict: [String: LocationPinAnnotation] = [:]
for annotation in self.annotations { for annotation in self.annotations {
if let identifier = annotation.location?.venue?.id { dict[annotation.id] = annotation
dict[identifier] = annotation
}
} }
var annotationsToRemove = Set<LocationPinAnnotation>() var annotationsToRemove = Set<LocationPinAnnotation>()
@ -342,9 +341,9 @@ final class LocationMapNode: ASDisplayNode, MKMapViewDelegate {
continue continue
} }
if let identifier = annotation.location?.venue?.id, let updatedAnnotation = dict[identifier] { if let updatedAnnotation = dict[annotation.id] {
annotation.coordinate = updatedAnnotation.coordinate annotation.coordinate = updatedAnnotation.coordinate
dict[identifier] = nil dict[annotation.id] = nil
} else { } else {
annotationsToRemove.insert(annotation) annotationsToRemove.insert(annotation)
} }

View File

@ -14,11 +14,9 @@ final class LocationOptionsNode: ASDisplayNode {
private let backgroundNode: ASDisplayNode private let backgroundNode: ASDisplayNode
private let separatorNode: ASDisplayNode private let separatorNode: ASDisplayNode
private let segmentedControlNode: SegmentedControlNode private let segmentedControlNode: SegmentedControlNode
private let interaction: LocationPickerInteraction
init(presentationData: PresentationData, interaction: LocationPickerInteraction) { init(presentationData: PresentationData, updateMapMode: @escaping (LocationMapMode) -> Void) {
self.presentationData = presentationData self.presentationData = presentationData
self.interaction = interaction
self.backgroundNode = ASDisplayNode() self.backgroundNode = ASDisplayNode()
self.backgroundNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.backgroundColor self.backgroundNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.backgroundColor
@ -39,11 +37,11 @@ final class LocationOptionsNode: ASDisplayNode {
} }
switch index { switch index {
case 0: case 0:
strongSelf.interaction.updateMapMode(.map) updateMapMode(.map)
case 1: case 1:
strongSelf.interaction.updateMapMode(.sattelite) updateMapMode(.sattelite)
case 2: case 2:
strongSelf.interaction.updateMapMode(.hybrid) updateMapMode(.hybrid)
default: default:
break break
} }

View File

@ -133,21 +133,21 @@ public final class LocationPickerController: ViewController {
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor15Minutes, color: .accent, action: { [weak self, weak controller] in ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor15Minutes, color: .accent, action: { [weak self, weak controller] in
controller?.dismissAnimated() controller?.dismissAnimated()
if let strongSelf = self { if let strongSelf = self {
strongSelf.completion(locationWithTimeout(coordinate, 15 * 60), nil) strongSelf.completion(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: 15 * 60), nil)
strongSelf.dismiss() strongSelf.dismiss()
} }
}), }),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor1Hour, color: .accent, action: { [weak self, weak controller] in ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor1Hour, color: .accent, action: { [weak self, weak controller] in
controller?.dismissAnimated() controller?.dismissAnimated()
if let strongSelf = self { if let strongSelf = self {
strongSelf.completion(locationWithTimeout(coordinate, 60 * 60 - 1), nil) strongSelf.completion(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: 60 * 60 - 1), nil)
strongSelf.dismiss() strongSelf.dismiss()
} }
}), }),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor8Hours, color: .accent, action: { [weak self, weak controller] in ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationFor8Hours, color: .accent, action: { [weak self, weak controller] in
controller?.dismissAnimated() controller?.dismissAnimated()
if let strongSelf = self { if let strongSelf = self {
strongSelf.completion(locationWithTimeout(coordinate, 8 * 60 * 60), nil) strongSelf.completion(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: 8 * 60 * 60), nil)
strongSelf.dismiss() strongSelf.dismiss()
} }
}) })

View File

@ -277,10 +277,10 @@ final class LocationPickerControllerNode: ViewControllerTracingNode {
self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3)
self.listNode.verticalScrollIndicatorFollowsOverscroll = true 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.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)) 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) self.addSubnode(self.shadeNode)
let userLocation: Signal<CLLocation?, NoError> = self.headerNode.mapNode.userLocation let userLocation: Signal<CLLocation?, NoError> = self.headerNode.mapNode.userLocation
let filteredUserLocation: Signal<CLLocation?, NoError> = userLocation
|> reduceLeft(value: nil) { current, updated, emit -> CLLocation? in
if let current = current {
if let updated = updated {
if updated.distance(from: current) > 250 || (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) let personalAddresses = self.context.account.postbox.peerView(id: self.context.account.peerId)
|> mapToSignal { view -> Signal<(DeviceContactAddressData?, DeviceContactAddressData?)?, NoError> in |> mapToSignal { view -> Signal<(DeviceContactAddressData?, DeviceContactAddressData?)?, NoError> in
@ -407,7 +385,7 @@ final class LocationPickerControllerNode: ViewControllerTracingNode {
let venues: Signal<[TelegramMediaMap]?, NoError> = .single(nil) let venues: Signal<[TelegramMediaMap]?, NoError> = .single(nil)
|> then( |> then(
filteredUserLocation throttledUserLocation(userLocation)
|> mapToSignal { location -> Signal<[TelegramMediaMap]?, NoError> in |> mapToSignal { location -> Signal<[TelegramMediaMap]?, NoError> in
if let location = location, location.horizontalAccuracy > 0 { if let location = location, location.horizontalAccuracy > 0 {
return combineLatest(nearbyVenues(account: context.account, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude), personalVenues) 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) let transition = preparedTransition(from: previousEntries ?? [], to: entries, isLoading: venues == nil, crossFade: crossFade, account: context.account, presentationData: presentationData, interaction: strongSelf.interaction)
strongSelf.enqueueTransition(transition) strongSelf.enqueueTransition(transition)
strongSelf.headerNode.updateState(state) strongSelf.headerNode.updateState(mapMode: state.mapMode, displayingMapModeOptions: state.displayingMapModeOptions)
let previousUserLocation = previousUserLocation.swap(userLocation) let previousUserLocation = previousUserLocation.swap(userLocation)
switch state.selectedLocation { switch state.selectedLocation {

View File

@ -7,6 +7,10 @@ import TelegramStringFormatting
import MapKit import MapKit
extension TelegramMediaMap { 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 { var coordinate: CLLocationCoordinate2D {
return CLLocationCoordinate2D(latitude: self.latitude, longitude: self.longitude) return CLLocationCoordinate2D(latitude: self.latitude, longitude: self.longitude)
} }
@ -76,3 +80,72 @@ func stringForDistance(strings: PresentationStrings, distance: CLLocationDistanc
} }
return distanceFormatter.string(fromDistance: distance) 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<CLLocation?, NoError>) -> Signal<CLLocation?, NoError> {
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<Double?, NoError> {
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()
}
}
}

View File

@ -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()
}
}

View File

@ -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<PresentationData>
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<LocationViewState>
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<Double?, NoError> = .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<String?, NoError>
var eta: Signal<Double?, NoError> = .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<LocationViewState?>(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)
}
}

View File

@ -21,7 +21,8 @@ public final class SolidRoundedButtonNode: ASDisplayNode {
private let buttonBackgroundNode: ASImageNode private let buttonBackgroundNode: ASImageNode
private let buttonGlossNode: SolidRoundedButtonGlossNode private let buttonGlossNode: SolidRoundedButtonGlossNode
private let buttonNode: HighlightTrackingButtonNode private let buttonNode: HighlightTrackingButtonNode
private let labelNode: ImmediateTextNode private let titleNode: ImmediateTextNode
private let subtitleNode: ImmediateTextNode
private let iconNode: ASImageNode private let iconNode: ASImageNode
private let buttonHeight: CGFloat 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) { 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.theme = theme
self.buttonHeight = height self.buttonHeight = height
@ -54,8 +63,11 @@ public final class SolidRoundedButtonNode: ASDisplayNode {
self.buttonNode = HighlightTrackingButtonNode() self.buttonNode = HighlightTrackingButtonNode()
self.labelNode = ImmediateTextNode() self.titleNode = ImmediateTextNode()
self.labelNode.isUserInteractionEnabled = false self.titleNode.isUserInteractionEnabled = false
self.subtitleNode = ImmediateTextNode()
self.subtitleNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode() self.iconNode = ASImageNode()
self.iconNode.displayWithoutProcessing = true self.iconNode.displayWithoutProcessing = true
@ -69,7 +81,8 @@ public final class SolidRoundedButtonNode: ASDisplayNode {
self.addSubnode(self.buttonGlossNode) self.addSubnode(self.buttonGlossNode)
} }
self.addSubnode(self.buttonNode) self.addSubnode(self.buttonNode)
self.addSubnode(self.labelNode) self.addSubnode(self.titleNode)
self.addSubnode(self.subtitleNode)
self.addSubnode(self.iconNode) self.addSubnode(self.iconNode)
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
@ -78,15 +91,19 @@ public final class SolidRoundedButtonNode: ASDisplayNode {
if highlighted { if highlighted {
strongSelf.buttonBackgroundNode.layer.removeAnimation(forKey: "opacity") strongSelf.buttonBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.buttonBackgroundNode.alpha = 0.55 strongSelf.buttonBackgroundNode.alpha = 0.55
strongSelf.labelNode.layer.removeAnimation(forKey: "opacity") strongSelf.titleNode.layer.removeAnimation(forKey: "opacity")
strongSelf.labelNode.alpha = 0.55 strongSelf.titleNode.alpha = 0.55
strongSelf.subtitleNode.layer.removeAnimation(forKey: "opacity")
strongSelf.subtitleNode.alpha = 0.55
strongSelf.iconNode.layer.removeAnimation(forKey: "opacity") strongSelf.iconNode.layer.removeAnimation(forKey: "opacity")
strongSelf.iconNode.alpha = 0.55 strongSelf.iconNode.alpha = 0.55
} else { } else {
strongSelf.buttonBackgroundNode.alpha = 1.0 strongSelf.buttonBackgroundNode.alpha = 1.0
strongSelf.buttonBackgroundNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) strongSelf.buttonBackgroundNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2)
strongSelf.labelNode.alpha = 1.0 strongSelf.titleNode.alpha = 1.0
strongSelf.labelNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) 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.alpha = 1.0
strongSelf.iconNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) 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.buttonBackgroundNode.image = generateStretchableFilledCircleImage(radius: self.buttonCornerRadius, color: theme.backgroundColor)
self.buttonGlossNode.color = theme.foregroundColor 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 { 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 self.validLayout = width
let buttonSize = CGSize(width: width, height: self.buttonHeight) 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.buttonGlossNode, frame: buttonFrame)
transition.updateFrame(node: self.buttonNode, frame: buttonFrame) transition.updateFrame(node: self.buttonNode, frame: buttonFrame)
if self.title != self.labelNode.attributedText?.string { if self.title != self.titleNode.attributedText?.string {
self.labelNode.attributedText = NSAttributedString(string: self.title ?? "", font: Font.semibold(17.0), textColor: self.theme.foregroundColor) self.titleNode.attributedText = NSAttributedString(string: self.title ?? "", font: Font.semibold(17.0), textColor: self.theme.foregroundColor)
} }
let iconSize = self.iconNode.image?.size ?? CGSize() let iconSize = self.iconNode.image?.size ?? CGSize()
let labelSize = self.labelNode.updateLayout(buttonSize) let titleSize = self.titleNode.updateLayout(buttonSize)
let iconSpacing: CGFloat = 8.0 let iconSpacing: CGFloat = 8.0
var contentWidth: CGFloat = labelSize.width var contentWidth: CGFloat = titleSize.width
if !iconSize.width.isZero { if !iconSize.width.isZero {
contentWidth += iconSize.width + iconSpacing contentWidth += iconSize.width + iconSpacing
} }
@ -133,8 +155,25 @@ public final class SolidRoundedButtonNode: ASDisplayNode {
nextContentOrigin += iconSize.width + iconSpacing 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) let spacingOffset: CGFloat = 9.0
transition.updateFrame(node: self.labelNode, frame: labelFrame) 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 return buttonSize.height
} }

View File

@ -283,15 +283,15 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool {
case let .map(mapMedia): case let .map(mapMedia):
params.dismissInput() params.dismissInput()
let controller = legacyLocationController(message: params.message, mapMedia: mapMedia, context: params.context, openPeer: { peer in let controllerParams = LocationViewParams(sendLiveLocation: { location in
params.openPeer(peer, .info) let outMessage: EnqueueMessage = .message(text: "", attributes: [], mediaReference: .standalone(media: location), replyToMessageId: nil, localGroupingKey: nil)
}, 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)
params.enqueueMessage(outMessage) params.enqueueMessage(outMessage)
}, stopLiveLocation: { }, stopLiveLocation: {
params.context.liveLocationManager?.cancelLiveLocation(peerId: params.message.id.peerId) params.context.liveLocationManager?.cancelLiveLocation(peerId: params.message.id.peerId)
}, openUrl: params.openUrl) }, openUrl: params.openUrl, openPeer: { peer in
controller.navigationPresentation = .modal params.openPeer(peer, .info)
})
let controller = LocationViewController(context: params.context, mapMedia: mapMedia, params: controllerParams)
params.navigationController?.pushViewController(controller) params.navigationController?.pushViewController(controller)
return true return true
case let .stickerPack(reference): case let .stickerPack(reference):