Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,70 @@
import Foundation
import UIKit
import SwiftSignalKit
import TelegramCore
import TelegramUIPreferences
import PersistentStringHash
import AccountContext
import Geocoding
public final class CachedGeocode: Codable {
public let latitude: Double
public let longitude: Double
public init(latitude: Double, longitude: Double) {
self.latitude = latitude
self.longitude = longitude
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: StringCodingKey.self)
self.latitude = try container.decode(Double.self, forKey: "lat")
self.longitude = try container.decode(Double.self, forKey: "lon")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: StringCodingKey.self)
try container.encode(self.latitude, forKey: "lat")
try container.encode(self.longitude, forKey: "lon")
}
}
private func cachedGeocode(engine: TelegramEngine, address: DeviceContactAddressData) -> Signal<CachedGeocode?, NoError> {
let key = EngineDataBuffer(length: 8)
key.setInt64(0, value: Int64(bitPattern: address.string.persistentHashValue))
return engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.cachedGeocodes, id: key))
|> map { entry -> CachedGeocode? in
return entry?.get(CachedGeocode.self)
}
}
private func updateCachedGeocode(engine: TelegramEngine, address: DeviceContactAddressData, latitude: Double, longitude: Double) -> Signal<(Double, Double), NoError> {
let key = EngineDataBuffer(length: 8)
key.setInt64(0, value: Int64(bitPattern: address.string.persistentHashValue))
return engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.cachedGeocodes, id: key, item: CachedGeocode(latitude: latitude, longitude: longitude))
|> map { _ -> (Double, Double) in }
|> then(.single((latitude, longitude)))
}
public func geocodeAddress(engine: TelegramEngine, address: DeviceContactAddressData) -> Signal<(Double, Double)?, NoError> {
return cachedGeocode(engine: engine, address: address)
|> mapToSignal { cached -> Signal<(Double, Double)?, NoError> in
if let cached = cached {
return .single((cached.latitude, cached.longitude))
} else {
return geocodeLocation(address: address.asPostalAddress)
|> mapToSignal { coordinate in
if let (latitude, longitude) = coordinate {
return updateCachedGeocode(engine: engine, address: address, latitude: latitude, longitude: longitude)
|> map(Optional.init)
} else {
return .single(nil)
}
}
}
}
}
@@ -0,0 +1,437 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import ItemListUI
import LocationResources
import AppBundle
import LiveLocationTimerNode
import TelegramStringFormatting
public enum LocationActionListItemIcon: Equatable {
case location
case liveLocation
case stopLiveLocation
case extendLiveLocation
case venue(TelegramMediaMap)
public static func ==(lhs: LocationActionListItemIcon, rhs: LocationActionListItemIcon) -> Bool {
switch lhs {
case .location:
if case .location = rhs {
return true
} else {
return false
}
case .liveLocation:
if case .liveLocation = rhs {
return true
} else {
return false
}
case .stopLiveLocation:
if case .stopLiveLocation = rhs {
return true
} else {
return false
}
case .extendLiveLocation:
if case .extendLiveLocation = rhs {
return true
} else {
return false
}
case let .venue(lhsVenue):
if case let .venue(rhsVenue) = rhs, lhsVenue.venue?.id == rhsVenue.venue?.id {
return true
} else {
return false
}
}
}
}
private func generateLocationIcon(theme: PresentationTheme) -> UIImage {
return generateImage(CGSize(width: 40.0, height: 40.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(theme.chat.inputPanel.actionControlFillColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
if let image = generateTintedImage(image: UIImage(bundleImageName: "Location/SendLocationIcon"), color: theme.chat.inputPanel.actionControlForegroundColor) {
context.draw(image.cgImage!, in: CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size))
}
})!
}
private enum LiveLocationIconType {
case start
case stop
case extend
}
private func generateLiveLocationIcon(theme: PresentationTheme, type: LiveLocationIconType) -> UIImage {
return generateImage(CGSize(width: 40.0, height: 40.0), rotatedContext: { size, context in
let imageName: String
let color: UIColor
switch type {
case .start:
imageName = "Location/SendLiveLocationIcon"
color = UIColor(rgb: 0x6cc138)
case .stop:
imageName = "Location/SendLocationIcon"
color = UIColor(rgb: 0xff6464)
case .extend:
imageName = "Location/SendLocationIcon"
color = UIColor(rgb: 0x6cc138)
}
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
if let image = generateTintedImage(image: UIImage(bundleImageName: imageName), color: theme.chat.inputPanel.actionControlForegroundColor) {
context.draw(image.cgImage!, in: CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size))
if case .extend = type {
context.setStrokeColor(theme.chat.inputPanel.actionControlForegroundColor.cgColor)
context.setLineWidth(2.0 - UIScreenPixel)
context.setLineCap(.round)
let length: CGFloat = 6.0 + UIScreenPixel
context.move(to: CGPoint(x: 8.0 + 0.0, y: image.size.height / 2.0))
context.addLine(to: CGPoint(x: 8.0 + length, y: image.size.height / 2.0))
context.strokePath()
context.move(to: CGPoint(x: 8.0 + length / 2.0, y: image.size.height / 2.0 - length / 2.0))
context.addLine(to: CGPoint(x: 8.0 + length / 2.0, y: image.size.height / 2.0 + length / 2.0))
context.strokePath()
} else if case .stop = type {
context.setStrokeColor(color.cgColor)
context.setLineWidth(5.0)
context.move(to: CGPoint(x: 10.0, y: size.height - 10.0))
context.addLine(to: CGPoint(x: size.width - 10.0, y: 10.0))
context.strokePath()
context.setStrokeColor(theme.chat.inputPanel.actionControlForegroundColor.cgColor)
context.setLineWidth(2.0 - UIScreenPixel)
context.setLineCap(.round)
context.move(to: CGPoint(x: 12.0, y: size.height - 12.0))
context.addLine(to: CGPoint(x: size.width - 12.0, y: 12.0))
context.strokePath()
}
}
})!
}
final class LocationActionListItem: ListViewItem {
let presentationData: ItemListPresentationData
let engine: TelegramEngine
let title: String
let subtitle: String
let icon: LocationActionListItemIcon
let beginTimeAndTimeout: (Double, Double)?
let action: () -> Void
let highlighted: (Bool) -> Void
public init(presentationData: ItemListPresentationData, engine: TelegramEngine, title: String, subtitle: String, icon: LocationActionListItemIcon, beginTimeAndTimeout: (Double, Double)?, action: @escaping () -> Void, highlighted: @escaping (Bool) -> Void = { _ in }) {
self.presentationData = presentationData
self.engine = engine
self.title = title
self.subtitle = subtitle
self.icon = icon
self.beginTimeAndTimeout = beginTimeAndTimeout
self.action = action
self.highlighted = highlighted
}
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 = LocationActionListItemNode()
let makeLayout = node.asyncLayout()
let (nodeLayout, nodeApply) = makeLayout(self, params, nextItem is LocationActionListItem || nextItem is LocationLiveListItem)
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? LocationActionListItemNode {
let layout = nodeValue.asyncLayout()
async {
let (nodeLayout, apply) = layout(self, params, nextItem is LocationActionListItem || nextItem is LocationLiveListItem)
Queue.mainQueue().async {
completion(nodeLayout, { info in
apply().1(info)
})
}
}
}
}
}
public var selectable: Bool {
return true
}
public func selected(listView: ListView) {
listView.clearHighlightAnimated(false)
self.action()
}
}
final class LocationActionListItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let separatorNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private var titleNode: TextNode?
private var subtitleNode: TextNode?
private let iconNode: ASImageNode
private let venueIconNode: TransformImageNode
private var timerNode: ChatMessageLiveLocationTimerNode?
private var wavesNode: LiveLocationWavesNode?
private var item: LocationActionListItem?
private var layoutParams: ListViewItemLayoutParams?
required init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.venueIconNode = TransformImageNode()
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.separatorNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.venueIconNode)
}
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, nextItem is LocationActionListItem)
self.contentSize = nodeLayout.contentSize
self.insets = nodeLayout.insets
let _ = nodeApply()
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
self.item?.highlighted(highlighted)
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode)
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
func asyncLayout() -> (_ item: LocationActionListItem, _ params: ListViewItemLayoutParams, _ hasSeparator: Bool) -> (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, hasSeparator in
let leftInset: CGFloat = 65.0 + params.leftInset
let rightInset: CGFloat = params.rightInset
let verticalInset: CGFloat = 8.0
let iconSize: CGFloat = 40.0
let titleFont = Font.semibold(item.presentationData.fontSize.itemListBaseFontSize)
let subtitleFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0))
let titleAttributedString = NSAttributedString(string: item.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: item.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 = hasSeparator ? 0.0 : 4.0
var contentSize = CGSize(width: params.width, height: verticalInset * 2.0 + titleLayout.size.height + titleSpacing + subtitleLayout.size.height + bottomInset)
if hasSeparator {
contentSize.height = max(52.0, contentSize.height)
}
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 updatedIcon: LocationActionListItemIcon?
if currentItem?.icon != item.icon || updatedTheme != nil {
updatedIcon = item.icon
}
return (nil, { _ in
if let strongSelf = self {
strongSelf.item = item
strongSelf.layoutParams = params
if let _ = updatedTheme {
strongSelf.separatorNode.backgroundColor = item.presentationData.theme.list.itemPlainSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.presentationData.theme.list.plainBackgroundColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor
}
var arguments: TransformImageCustomArguments?
if let updatedIcon = updatedIcon {
switch updatedIcon {
case .location:
strongSelf.iconNode.isHidden = false
strongSelf.venueIconNode.isHidden = true
strongSelf.iconNode.image = generateLocationIcon(theme: item.presentationData.theme)
case .liveLocation:
strongSelf.iconNode.isHidden = false
strongSelf.venueIconNode.isHidden = true
strongSelf.iconNode.image = generateLiveLocationIcon(theme: item.presentationData.theme, type: .start)
case .stopLiveLocation:
strongSelf.iconNode.isHidden = false
strongSelf.venueIconNode.isHidden = true
strongSelf.iconNode.image = generateLiveLocationIcon(theme: item.presentationData.theme, type: .stop)
case .extendLiveLocation:
strongSelf.iconNode.isHidden = false
strongSelf.venueIconNode.isHidden = true
strongSelf.iconNode.image = generateLiveLocationIcon(theme: item.presentationData.theme, type: .extend)
case let .venue(venue):
strongSelf.iconNode.isHidden = true
strongSelf.venueIconNode.isHidden = false
let type = venue.venue?.type
var flag: String?
if let venue = venue.venue, venue.provider == "city", let countryCode = venue.id {
flag = flagEmoji(countryCode: countryCode)
}
if venue.venue?.provider == "city" {
arguments = VenueIconArguments(defaultBackgroundColor: item.presentationData.theme.chat.inputPanel.actionControlFillColor, defaultForegroundColor: .white)
}
strongSelf.venueIconNode.setSignal(venueIcon(engine: item.engine, type: type ?? "", flag: flag, background: true))
}
switch updatedIcon {
case .stopLiveLocation, .extendLiveLocation:
let wavesNode = LiveLocationWavesNode(color: item.presentationData.theme.chat.inputPanel.actionControlForegroundColor, extend: updatedIcon == .extendLiveLocation)
strongSelf.addSubnode(wavesNode)
strongSelf.wavesNode = wavesNode
default:
strongSelf.wavesNode?.removeFromSupernode()
strongSelf.wavesNode = nil
}
strongSelf.wavesNode?.color = item.presentationData.theme.chat.inputPanel.actionControlForegroundColor
}
let iconApply = iconLayout(TransformImageArguments(corners: ImageCorners(), imageSize: CGSize(width: iconSize, height: iconSize), boundingSize: CGSize(width: iconSize, height: iconSize), intrinsicInsets: UIEdgeInsets(), custom: arguments))
iconApply()
let titleNode = titleApply()
if strongSelf.titleNode == nil {
strongSelf.titleNode = titleNode
strongSelf.addSubnode(titleNode)
}
let subtitleNode = subtitleApply()
if strongSelf.subtitleNode == nil {
strongSelf.subtitleNode = subtitleNode
strongSelf.addSubnode(subtitleNode)
}
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 separatorRightInset: CGFloat = 16.0
let iconNodeFrame = CGRect(origin: CGPoint(x: params.leftInset + 15.0, y: floorToScreenPixels((contentSize.height - bottomInset - iconSize) / 2.0)), size: CGSize(width: iconSize, height: iconSize))
strongSelf.iconNode.frame = iconNodeFrame
strongSelf.venueIconNode.frame = iconNodeFrame
strongSelf.wavesNode?.frame = CGRect(origin: CGPoint(x: params.leftInset + 11.0, y: floorToScreenPixels((contentSize.height - bottomInset - iconSize) / 2.0) - 4.0), size: CGSize(width: 48.0, height: 48.0))
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: contentSize.width, height: contentSize.height))
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top - topHighlightInset), size: CGSize(width: contentSize.width, height: contentSize.height + topHighlightInset))
strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - separatorHeight), size: CGSize(width: nodeLayout.size.width - leftInset - params.rightInset - separatorRightInset, height: separatorHeight))
strongSelf.separatorNode.isHidden = !hasSeparator
if let (beginTimestamp, timeout) = item.beginTimeAndTimeout {
let timerNode: ChatMessageLiveLocationTimerNode
if let current = strongSelf.timerNode {
timerNode = current
} else {
timerNode = ChatMessageLiveLocationTimerNode()
strongSelf.addSubnode(timerNode)
strongSelf.timerNode = timerNode
}
let timerSize = CGSize(width: 28.0, height: 28.0)
timerNode.update(backgroundColor: item.presentationData.theme.list.itemAccentColor.withAlphaComponent(0.4), foregroundColor: item.presentationData.theme.list.itemAccentColor, textColor: item.presentationData.theme.list.itemAccentColor, beginTimestamp: beginTimestamp, timeout: Int32(timeout) == liveLocationIndefinitePeriod ? -1.0 : timeout, strings: item.presentationData.strings)
timerNode.frame = CGRect(origin: CGPoint(x: contentSize.width - 16.0 - timerSize.width, y: floorToScreenPixels((contentSize.height - timerSize.height) / 2.0) - 2.0), size: timerSize)
} else if let timerNode = strongSelf.timerNode {
strongSelf.timerNode = nil
timerNode.removeFromSupernode()
}
}
})
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
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)
}
}
@@ -0,0 +1,737 @@
import Foundation
import UIKit
import MapKit
import Display
import SwiftSignalKit
import TelegramCore
import AvatarNode
import AppBundle
import TelegramPresentationData
import LocationResources
import AccountContext
let locationPinReuseIdentifier = "locationPin"
private func generateSmallBackgroundImage(color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 56.0, height: 56.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setShadow(offset: CGSize(), blur: 4.0, color: UIColor(rgb: 0x000000, alpha: 0.5).cgColor)
context.setFillColor(UIColor.white.cgColor)
context.fillEllipse(in: CGRect(x: 16.0, y: 16.0, width: 24.0, height: 24.0))
context.setShadow(offset: CGSize(), blur: 0.0, color: nil)
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(x: 17.0 + UIScreenPixel, y: 17.0 + UIScreenPixel, width: 22.0 - 2.0 * UIScreenPixel, height: 22.0 - 2.0 * UIScreenPixel))
})
}
public class LocationPinAnnotation: NSObject, MKAnnotation {
let context: AccountContext
let theme: PresentationTheme
public var coordinate: CLLocationCoordinate2D {
willSet {
self.willChangeValue(forKey: "coordinate")
}
didSet {
self.didChangeValue(forKey: "coordinate")
}
}
let location: TelegramMediaMap?
let queryId: Int64?
let resultId: String?
let peer: EnginePeer?
let message: EngineMessage?
let forcedSelection: Bool
@objc dynamic var heading: NSNumber? {
willSet {
self.willChangeValue(forKey: "heading")
}
didSet {
self.didChangeValue(forKey: "heading")
}
}
var isSelf = false
var selfPeer: EnginePeer?
public var title: String? = ""
public var subtitle: String? = ""
public init(context: AccountContext, theme: PresentationTheme, peer: EnginePeer?) {
self.context = context
self.theme = theme
self.location = nil
self.queryId = nil
self.resultId = nil
self.peer = peer
self.message = nil
self.coordinate = kCLLocationCoordinate2DInvalid
self.forcedSelection = false
super.init()
}
public init(context: AccountContext, theme: PresentationTheme, location: TelegramMediaMap, queryId: Int64?, resultId: String?, forcedSelection: Bool = false) {
self.context = context
self.theme = theme
self.location = location
self.queryId = queryId
self.resultId = resultId
self.peer = nil
self.message = nil
self.coordinate = location.coordinate
self.forcedSelection = forcedSelection
super.init()
}
public init(context: AccountContext, theme: PresentationTheme, message: EngineMessage, selfPeer: EnginePeer?, isSelf: Bool, heading: Int32?) {
self.context = context
self.theme = theme
self.location = nil
self.queryId = nil
self.resultId = nil
self.peer = nil
self.isSelf = isSelf
self.message = message
if let location = getLocation(from: message) {
self.coordinate = location.coordinate
} else {
self.coordinate = kCLLocationCoordinate2DInvalid
}
self.selfPeer = selfPeer
self.forcedSelection = false
self.heading = heading.flatMap { NSNumber(value: $0) }
super.init()
}
public var id: String {
if let message = self.message {
return "\(message.id.id)"
} else 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 {
var customZPosition: CGFloat?
override var zPosition: CGFloat {
get {
if let zPosition = self.customZPosition {
return zPosition
} else {
return super.zPosition
}
} set {
super.zPosition = newValue
}
}
}
private func addPulseAnimations(layer: CALayer) {
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
scaleAnimation.values = [0.0 as NSNumber, 0.72 as NSNumber, 1.0 as NSNumber, 1.0 as NSNumber]
scaleAnimation.keyTimes = [0.0 as NSNumber, 0.49 as NSNumber, 0.88 as NSNumber, 1.0 as NSNumber]
scaleAnimation.duration = 3.0
scaleAnimation.repeatCount = Float.infinity
scaleAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
scaleAnimation.beginTime = 1.0
layer.add(scaleAnimation, forKey: "pulse-scale")
let opacityAnimation = CAKeyframeAnimation(keyPath: "opacity")
opacityAnimation.values = [1.0 as NSNumber, 0.2 as NSNumber, 0.0 as NSNumber, 0.0 as NSNumber]
opacityAnimation.keyTimes = [0.0 as NSNumber, 0.4 as NSNumber, 0.62 as NSNumber, 1.0 as NSNumber]
opacityAnimation.duration = 3.0
opacityAnimation.repeatCount = Float.infinity
opacityAnimation.beginTime = 1.0
layer.add(opacityAnimation, forKey: "pulse-opacity")
}
private func removePulseAnimations(layer: CALayer) {
layer.removeAnimation(forKey: "pulse-scale")
layer.removeAnimation(forKey: "pulse-opacity")
}
public class LocationPinAnnotationView: MKAnnotationView {
let shadowNode: ASImageNode
let pulseNode: ASImageNode
let backgroundNode: ASImageNode
let arrowNode: ASImageNode
let smallNode: ASImageNode
let iconNode: TransformImageNode
let smallIconNode: TransformImageNode
let dotNode: ASImageNode
var avatarNode: AvatarNode?
var strokeLabelNode: ImmediateTextNode?
var labelNode: ImmediateTextNode?
var initialized = false
var appeared = false
var animating = false
var hasPulse = false
var headingKvoToken: NSKeyValueObservation?
override public class var layerClass: AnyClass {
return LocationPinAnnotationLayer.self
}
public func setZPosition(_ zPosition: CGFloat?) {
if let layer = self.layer as? LocationPinAnnotationLayer {
layer.customZPosition = zPosition
}
}
public init(annotation: LocationPinAnnotation) {
self.shadowNode = ASImageNode()
self.shadowNode.image = UIImage(bundleImageName: "Location/PinShadow")
if let image = self.shadowNode.image {
self.shadowNode.bounds = CGRect(origin: CGPoint(), size: image.size)
}
self.pulseNode = ASImageNode()
self.pulseNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 120.0, height: 120.0))
self.pulseNode.image = generateFilledCircleImage(diameter: 120.0, color: UIColor(rgb: 0x0088ff, alpha: 0.27))
self.pulseNode.isHidden = true
self.arrowNode = ASImageNode()
self.arrowNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 88.0, height: 88.0))
self.arrowNode.image = generateHeadingArrowImage()
self.arrowNode.isHidden = true
self.backgroundNode = ASImageNode()
self.backgroundNode.image = UIImage(bundleImageName: "Location/PinBackground")
if let image = self.backgroundNode.image {
self.backgroundNode.bounds = CGRect(origin: CGPoint(), size: image.size)
}
self.smallNode = ASImageNode()
self.smallNode.image = UIImage(bundleImageName: "Location/PinSmallBackground")
if let image = self.smallNode.image {
self.smallNode.bounds = CGRect(origin: CGPoint(), size: image.size)
}
self.iconNode = TransformImageNode()
self.iconNode.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 60.0, height: 60.0))
self.smallIconNode = TransformImageNode()
self.smallIconNode.frame = CGRect(origin: CGPoint(x: 15.0, y: 15.0), size: CGSize(width: 26.0, height: 26.0))
self.dotNode = ASImageNode()
self.dotNode.image = generateFilledCircleImage(diameter: 6.0, color: annotation.theme.list.itemAccentColor)
if let image = self.dotNode.image {
self.dotNode.bounds = CGRect(origin: CGPoint(), size: image.size)
}
super.init(annotation: annotation, reuseIdentifier: locationPinReuseIdentifier)
self.addSubnode(self.dotNode)
self.addSubnode(self.shadowNode)
self.addSubnode(self.arrowNode)
self.shadowNode.addSubnode(self.backgroundNode)
self.backgroundNode.addSubnode(self.iconNode)
self.addSubnode(self.smallNode)
self.smallNode.addSubnode(self.smallIconNode)
self.annotation = annotation
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.headingKvoToken?.invalidate()
}
public var defaultZPosition: CGFloat {
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
}
}
override public var annotation: MKAnnotation? {
didSet {
if let annotation = self.annotation as? LocationPinAnnotation {
if let message = annotation.message {
self.iconNode.isHidden = true
self.dotNode.isHidden = false
self.backgroundNode.image = UIImage(bundleImageName: "Location/PinBackground")
if let author = message.author {
self.setPeer(context: annotation.context, theme: annotation.theme, peer: author)
} else if let selfPeer = annotation.selfPeer {
self.setPeer(context: annotation.context, theme: annotation.theme, peer: selfPeer)
}
if !self.isSelected {
self.dotNode.alpha = 0.0
self.shadowNode.isHidden = true
self.smallNode.isHidden = false
}
if let headingKvoToken = self.headingKvoToken {
self.headingKvoToken = nil
headingKvoToken.invalidate()
}
self.headingKvoToken = annotation.observe(\.heading, options: .new) { [weak self] (_, change) in
guard let heading = change.newValue else {
return
}
self?.updateHeading(heading)
}
}
else if let peer = annotation.peer {
self.iconNode.isHidden = true
self.dotNode.isHidden = true
self.backgroundNode.image = UIImage(bundleImageName: "Location/PinBackground")
self.setPeer(context: annotation.context, theme: annotation.theme, peer: peer)
self.setSelected(true, animated: false)
if let headingKvoToken = self.headingKvoToken {
self.headingKvoToken = nil
headingKvoToken.invalidate()
}
self.updateHeading(nil)
} else if let location = annotation.location {
let venueType = location.venue?.type ?? ""
let color = venueType.isEmpty ? annotation.theme.list.itemAccentColor : venueIconColor(type: venueType)
self.backgroundNode.image = generateTintedImage(image: UIImage(bundleImageName: "Location/PinBackground"), color: color)
self.iconNode.setSignal(venueIcon(engine: annotation.context.engine, type: venueType, background: false))
self.smallIconNode.setSignal(venueIcon(engine: annotation.context.engine, type: venueType, background: false))
self.smallNode.image = generateSmallBackgroundImage(color: color)
self.dotNode.image = generateFilledCircleImage(diameter: 6.0, color: color)
self.iconNode.isHidden = false
self.dotNode.isHidden = false
if !self.isSelected {
self.dotNode.alpha = 0.0
self.shadowNode.isHidden = true
self.smallNode.isHidden = false
}
if annotation.forcedSelection {
self.setSelected(true, animated: false)
}
if let avatarNode = self.avatarNode {
self.avatarNode = nil
avatarNode.removeFromSupernode()
}
if self.initialized && !self.appeared {
self.appeared = true
self.animateAppearance()
}
if let headingKvoToken = self.headingKvoToken {
self.headingKvoToken = nil
headingKvoToken.invalidate()
}
self.updateHeading(nil)
}
}
}
}
private func updateHeading(_ heading: NSNumber?) {
if let heading = heading?.int32Value {
self.arrowNode.isHidden = false
self.arrowNode.transform = CATransform3DMakeRotation(CGFloat(heading) / 180.0 * CGFloat.pi, 0.0, 0.0, 1.0)
} else {
self.arrowNode.isHidden = true
self.arrowNode.transform = CATransform3DIdentity
}
}
override public func prepareForReuse() {
self.previousPeerId = nil
self.smallNode.isHidden = true
self.backgroundNode.isHidden = false
self.appeared = false
}
override public 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()
self.animating = true
if selected {
let avatarSnapshot = self.avatarNode?.view.snapshotContentTree()
if let avatarSnapshot = avatarSnapshot, let avatarNode = self.avatarNode {
self.smallNode.view.addSubview(avatarSnapshot)
avatarSnapshot.layer.transform = avatarNode.transform
avatarSnapshot.center = CGPoint(x: self.smallNode.frame.width / 2.0, y: self.smallNode.frame.height / 2.0)
avatarNode.transform = CATransform3DIdentity
self.backgroundNode.addSubnode(avatarNode)
avatarNode.position = CGPoint(x: self.backgroundNode.frame.width / 2.0, y: self.backgroundNode.frame.height / 2.0 - 5.0)
}
self.shadowNode.position = CGPoint(x: self.shadowNode.position.x, y: self.shadowNode.position.y + self.shadowNode.frame.height / 2.0)
self.shadowNode.anchorPoint = CGPoint(x: 0.5, y: 1.0)
self.shadowNode.isHidden = false
self.shadowNode.transform = CATransform3DMakeScale(0.1, 0.1, 1.0)
UIView.animate(withDuration: 0.35, delay: 0.0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.5, options: [], animations: {
self.smallNode.transform = CATransform3DMakeScale(0.001, 0.001, 1.0)
self.shadowNode.transform = CATransform3DIdentity
if self.dotNode.isHidden {
self.smallNode.alpha = 0.0
}
}) { _ in
self.animating = false
self.shadowNode.anchorPoint = CGPoint(x: 0.5, y: 0.5)
self.smallNode.isHidden = true
self.smallNode.transform = CATransform3DIdentity
if let avatarNode = self.avatarNode {
self.addSubnode(avatarNode)
avatarSnapshot?.removeFromSuperview()
}
}
self.dotNode.alpha = 1.0
self.dotNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
if let annotation = self.annotation as? LocationPinAnnotation, let venue = annotation.location?.venue {
var textColor = UIColor.black
var strokeTextColor = UIColor.white
if #available(iOS 13.0, *) {
if self.traitCollection.userInterfaceStyle == .dark {
textColor = .white
strokeTextColor = .black
}
}
let strokeLabelNode = ImmediateTextNode()
strokeLabelNode.displaysAsynchronously = false
strokeLabelNode.isUserInteractionEnabled = false
strokeLabelNode.attributedText = NSAttributedString(string: venue.title, font: Font.medium(10), textColor: strokeTextColor)
strokeLabelNode.maximumNumberOfLines = 2
strokeLabelNode.textAlignment = .center
strokeLabelNode.truncationType = .end
strokeLabelNode.textStroke = (strokeTextColor, 2.0 - UIScreenPixel)
self.strokeLabelNode = strokeLabelNode
self.addSubnode(strokeLabelNode)
let labelNode = ImmediateTextNode()
labelNode.displaysAsynchronously = false
labelNode.isUserInteractionEnabled = false
labelNode.attributedText = NSAttributedString(string: venue.title, font: Font.medium(10), textColor: textColor)
labelNode.maximumNumberOfLines = 2
labelNode.textAlignment = .center
labelNode.truncationType = .end
self.labelNode = labelNode
self.addSubnode(labelNode)
var size = labelNode.updateLayout(CGSize(width: 120.0, height: CGFloat.greatestFiniteMagnitude))
size.height += 2.0
labelNode.bounds = CGRect(origin: CGPoint(), size: size)
labelNode.position = CGPoint(x: 0.0, y: 10.0 + floor(size.height / 2.0))
var strokeSize = strokeLabelNode.updateLayout(CGSize(width: 120.0, height: CGFloat.greatestFiniteMagnitude))
strokeSize.height += 2.0
strokeLabelNode.bounds = CGRect(origin: CGPoint(), size: strokeSize)
strokeLabelNode.position = CGPoint(x: 0.0, y: 10.0 + floor(strokeSize.height / 2.0))
strokeLabelNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
labelNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
} else {
self.strokeLabelNode?.removeFromSupernode()
self.strokeLabelNode = nil
self.labelNode?.removeFromSupernode()
self.labelNode = nil
}
} else {
let avatarSnapshot = self.avatarNode?.view.snapshotContentTree()
if let avatarSnapshot = avatarSnapshot, let avatarNode = self.avatarNode {
self.backgroundNode.view.addSubview(avatarSnapshot)
avatarSnapshot.layer.transform = avatarNode.transform
avatarSnapshot.center = CGPoint(x: self.backgroundNode.frame.width / 2.0, y: self.backgroundNode.frame.height / 2.0 - 5.0)
avatarNode.transform = CATransform3DMakeScale(0.64, 0.64, 1.0)
self.smallNode.addSubnode(avatarNode)
avatarNode.position = CGPoint(x: self.smallNode.frame.width / 2.0, y: self.smallNode.frame.height / 2.0)
}
self.smallNode.isHidden = false
self.smallNode.transform = CATransform3DMakeScale(0.01, 0.01, 1.0)
self.shadowNode.position = CGPoint(x: self.shadowNode.position.x, y: self.shadowNode.position.y + self.shadowNode.frame.height / 2.0)
self.shadowNode.anchorPoint = CGPoint(x: 0.5, y: 1.0)
UIView.animate(withDuration: 0.35, delay: 0.0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.5, options: [], animations: {
self.smallNode.transform = CATransform3DIdentity
self.shadowNode.transform = CATransform3DMakeScale(0.1, 0.1, 1.0)
if self.dotNode.isHidden {
self.smallNode.alpha = 1.0
}
}) { _ in
self.animating = false
self.shadowNode.anchorPoint = CGPoint(x: 0.5, y: 0.5)
self.shadowNode.isHidden = true
self.shadowNode.transform = CATransform3DIdentity
if let avatarNode = self.avatarNode {
self.addSubnode(avatarNode)
avatarSnapshot?.removeFromSuperview()
}
}
let previousAlpha = self.dotNode.alpha
self.dotNode.alpha = 0.0
self.dotNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2)
if let labelNode = self.labelNode {
self.labelNode = nil
labelNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in
labelNode.removeFromSupernode()
})
if let strokeLabelNode = self.strokeLabelNode {
self.strokeLabelNode = nil
strokeLabelNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in
strokeLabelNode.removeFromSupernode()
})
}
}
}
} else {
self.smallNode.isHidden = selected
self.shadowNode.isHidden = !selected
self.dotNode.alpha = selected ? 1.0 : 0.0
self.smallNode.alpha = 1.0
if !selected {
self.labelNode?.removeFromSupernode()
self.labelNode = nil
self.strokeLabelNode?.removeFromSupernode()
self.strokeLabelNode = nil
}
self.layoutSubviews()
}
}
var previousPeerId: EnginePeer.Id?
public func setPeer(context: AccountContext, theme: PresentationTheme, peer: EnginePeer) {
let avatarNode: AvatarNode
if let currentAvatarNode = self.avatarNode {
avatarNode = currentAvatarNode
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 24.0))
avatarNode.isLayerBacked = false
avatarNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 55.0, height: 55.0))
avatarNode.position = CGPoint()
self.avatarNode = avatarNode
self.addSubnode(avatarNode)
}
if self.previousPeerId != peer.id {
self.previousPeerId = peer.id
avatarNode.setPeer(context: context, theme: theme, peer: peer)
}
}
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if let labelNode = self.labelNode {
var textColor = UIColor.black
var strokeTextColor = UIColor.white
if #available(iOS 13.0, *) {
if self.traitCollection.userInterfaceStyle == .dark {
textColor = .white
strokeTextColor = .black
}
}
labelNode.attributedText = NSAttributedString(string: labelNode.attributedText?.string ?? "", font: Font.medium(10), textColor: textColor)
let _ = labelNode.updateLayout(CGSize(width: 120.0, height: CGFloat.greatestFiniteMagnitude))
if let strokeLabelNode = self.strokeLabelNode {
strokeLabelNode.attributedText = NSAttributedString(string: labelNode.attributedText?.string ?? "", font: Font.bold(10), textColor: strokeTextColor)
let _ = strokeLabelNode.updateLayout(CGSize(width: 120.0, height: CGFloat.greatestFiniteMagnitude))
}
}
}
var isRaised = false
public func setRaised(_ raised: Bool, animated: Bool, completion: @escaping () -> Void = {}) {
guard raised != self.isRaised else {
return
}
self.isRaised = raised
self.shadowNode.layer.removeAllAnimations()
if animated {
self.animating = true
if raised {
let previousPosition = self.shadowNode.position
self.shadowNode.position = CGPoint(x: UIScreenPixel, y: -66.0)
self.shadowNode.layer.animatePosition(from: previousPosition, to: self.shadowNode.position, duration: 0.2, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring) { finished in
self.animating = false
if finished {
completion()
}
}
} else {
UIView.animate(withDuration: 0.2, delay: 0.0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.0, options: [.allowAnimatedContent], animations: {
self.shadowNode.position = CGPoint(x: UIScreenPixel, y: -36.0)
}) { finished in
self.animating = false
if finished {
completion()
}
}
}
} else {
self.shadowNode.position = CGPoint(x: UIScreenPixel, y: raised ? -66.0 : -36.0)
completion()
}
}
public func setCustom(_ custom: Bool, animated: Bool) {
if let annotation = self.annotation as? LocationPinAnnotation {
self.iconNode.setSignal(venueIcon(engine: annotation.context.engine, type: "", background: false))
}
if let avatarNode = self.avatarNode {
self.backgroundNode.addSubnode(avatarNode)
avatarNode.position = CGPoint(x: self.backgroundNode.frame.width / 2.0, y: self.backgroundNode.frame.height / 2.0 - 5.0)
}
self.shadowNode.position = CGPoint(x: UIScreenPixel, y: -36.0)
self.backgroundNode.position = CGPoint(x: self.shadowNode.frame.width / 2.0, y: self.shadowNode.frame.height / 2.0)
self.iconNode.position = CGPoint(x: self.shadowNode.frame.width / 2.0, y: self.shadowNode.frame.height / 2.0 - 5.0)
let transition = {
let color: UIColor
if custom, let annotation = self.annotation as? LocationPinAnnotation {
color = annotation.theme.list.itemAccentColor
} else {
color = .white
}
self.backgroundNode.image = generateTintedImage(image: UIImage(bundleImageName: "Location/PinBackground"), color: color)
self.avatarNode?.isHidden = custom
self.iconNode.isHidden = !custom
}
let completion = {
if !custom, let avatarNode = self.avatarNode {
self.addSubnode(avatarNode)
}
}
if animated {
self.animating = true
Queue.mainQueue().after(0.01) {
UIView.transition(with: self.backgroundNode.view, duration: 0.2, options: [.transitionCrossDissolve, .allowAnimatedContent], animations: {
transition()
}) { finished in
completion()
self.animating = false
}
}
} else {
transition()
completion()
}
self.setNeedsLayout()
self.dotNode.isHidden = !custom
}
public func animateAppearance() {
guard let annotation = self.annotation as? LocationPinAnnotation, annotation.location != nil && !annotation.forcedSelection else {
return
}
self.smallNode.transform = CATransform3DMakeScale(0.1, 0.1, 1.0)
let avatarNodeTransform = self.avatarNode?.transform
self.avatarNode?.transform = CATransform3DMakeScale(0.1, 0.1, 1.0)
UIView.animate(withDuration: 0.55, delay: 0.0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.5, options: [], animations: {
self.smallNode.transform = CATransform3DIdentity
if let avatarNodeTransform = avatarNodeTransform {
self.avatarNode?.transform = avatarNodeTransform
}
}) { _ in
}
}
override public func layoutSubviews() {
super.layoutSubviews()
guard !self.animating else {
return
}
self.dotNode.position = CGPoint()
self.pulseNode.position = CGPoint()
self.arrowNode.position = CGPoint()
self.smallNode.position = CGPoint()
self.shadowNode.position = CGPoint(x: UIScreenPixel, y: self.isRaised ? -66.0 : -36.0)
self.backgroundNode.position = CGPoint(x: self.shadowNode.frame.width / 2.0, y: self.shadowNode.frame.height / 2.0)
self.iconNode.position = CGPoint(x: self.shadowNode.frame.width / 2.0, y: self.shadowNode.frame.height / 2.0 - 5.0)
let smallIconLayout = self.smallIconNode.asyncLayout()
let smallIconApply = smallIconLayout(TransformImageArguments(corners: ImageCorners(), imageSize: self.smallIconNode.bounds.size, boundingSize: self.smallIconNode.bounds.size, intrinsicInsets: UIEdgeInsets()))
smallIconApply()
var arguments: VenueIconArguments?
if let annotation = self.annotation as? LocationPinAnnotation {
arguments = VenueIconArguments(defaultBackgroundColor: annotation.theme.chat.inputPanel.actionControlFillColor, defaultForegroundColor: annotation.theme.chat.inputPanel.actionControlForegroundColor)
}
let iconLayout = self.iconNode.asyncLayout()
let iconApply = iconLayout(TransformImageArguments(corners: ImageCorners(), imageSize: self.iconNode.bounds.size, boundingSize: self.iconNode.bounds.size, intrinsicInsets: UIEdgeInsets(), custom: arguments))
iconApply()
if let avatarNode = self.avatarNode {
avatarNode.position = self.isSelected ? CGPoint(x: UIScreenPixel, y: -41.0) : CGPoint()
avatarNode.transform = self.isSelected ? CATransform3DIdentity : CATransform3DMakeScale(0.64, 0.64, 1.0)
avatarNode.view.superview?.bringSubviewToFront(avatarNode.view)
}
if !self.appeared {
self.appeared = true
self.initialized = true
self.animateAppearance()
}
}
}
@@ -0,0 +1,130 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import AppBundle
enum LocationAttribution: Equatable {
case foursquare
case google
}
class LocationAttributionItem: ListViewItem {
let presentationData: ItemListPresentationData
let attribution: LocationAttribution
public init(presentationData: ItemListPresentationData, attribution: LocationAttribution) {
self.presentationData = presentationData
self.attribution = attribution
}
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 = LocationAttributionItemNode()
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? LocationAttributionItemNode {
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
}
}
private class LocationAttributionItemNode: ListViewItemNode {
private var imageNode: ASImageNode
private var item: LocationAttributionItem?
private var layoutParams: ListViewItemLayoutParams?
required init() {
self.imageNode = ASImageNode()
self.imageNode.displaysAsynchronously = false
self.imageNode.displayWithoutProcessing = true
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
self.addSubnode(self.imageNode)
}
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: LocationAttributionItem, _ params: ListViewItemLayoutParams) -> (ListViewItemNodeLayout, () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) {
let currentItem = self.item
return { [weak self] item, params in
let contentSize = CGSize(width: params.width, height: 55.0)
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
}
return (nil, { _ in
if let strongSelf = self {
strongSelf.item = item
strongSelf.layoutParams = params
if let _ = updatedTheme {
switch item.attribution {
case .foursquare:
strongSelf.imageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Location/FoursquareAttribution"), color: item.presentationData.theme.list.itemSecondaryTextColor)
case .google:
if item.presentationData.theme.overallDarkAppearance {
strongSelf.imageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Location/GoogleAttribution"), color: item.presentationData.theme.list.itemSecondaryTextColor)
} else {
strongSelf.imageNode.image = UIImage(bundleImageName: "Location/GoogleAttribution")
}
}
}
if let image = strongSelf.imageNode.image {
strongSelf.imageNode.frame = CGRect(x: floor((params.width - image.size.width) / 2.0), y: floor((contentSize.height - image.size.height) / 2.0), width: image.size.width, height: image.size.height)
}
}
})
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
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)
}
}
@@ -0,0 +1,664 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import AccountContext
import SolidRoundedButtonNode
import TelegramPresentationData
import TelegramStringFormatting
import PresentationDataUtils
import CoreLocation
enum LocationDistancePickerScreenStyle {
case `default`
case media
}
final class LocationDistancePickerScreen: ViewController {
private var controllerNode: LocationDistancePickerScreenNode {
return self.displayNode as! LocationDistancePickerScreenNode
}
private var animatedIn = false
private let context: AccountContext
private let style: LocationDistancePickerScreenStyle
private let distances: Signal<[Double], NoError>
private let compactDisplayTitle: String?
private let updated: (Int32?) -> Void
private let completion: (Int32, @escaping () -> Void) -> Void
private let willDismiss: () -> Void
private var presentationDataDisposable: Disposable?
init(context: AccountContext, style: LocationDistancePickerScreenStyle, compactDisplayTitle: String?, distances: Signal<[Double], NoError>, updated: @escaping (Int32?) -> Void, completion: @escaping (Int32, @escaping () -> Void) -> Void, willDismiss: @escaping () -> Void) {
self.context = context
self.style = style
self.distances = distances
self.compactDisplayTitle = compactDisplayTitle
self.updated = updated
self.completion = completion
self.willDismiss = willDismiss
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
self.blocksBackgroundWhenInOverlay = true
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
strongSelf.controllerNode.updatePresentationData(presentationData)
}
})
self.statusBar.statusBarStyle = .Ignore
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = LocationDistancePickerScreenNode(context: self.context, style: self.style, compactDisplayTitle: self.compactDisplayTitle, distances: self.distances)
self.controllerNode.updated = { [weak self] distance in
guard let strongSelf = self else {
return
}
strongSelf.updated(distance)
}
self.controllerNode.completion = { [weak self] distance in
guard let strongSelf = self else {
return
}
strongSelf.completion(distance, {
strongSelf.dismiss()
})
}
self.controllerNode.dismiss = { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.controllerNode.cancel = { [weak self] in
self?.dismiss()
}
let _ = self.controllerNode.update()
}
override public func loadView() {
super.loadView()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.animatedIn {
self.animatedIn = true
self.controllerNode.animateIn()
}
}
override public func dismiss(completion: (() -> Void)? = nil) {
self.willDismiss()
self.controllerNode.animateOut(completion: completion)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
}
private class TimerPickerView: UIPickerView {
var selectorColor: UIColor? = nil {
didSet {
for subview in self.subviews {
if subview.bounds.height <= 1.0 {
subview.backgroundColor = self.selectorColor
}
}
}
}
override func didAddSubview(_ subview: UIView) {
super.didAddSubview(subview)
if let selectorColor = self.selectorColor {
if subview.bounds.height <= 1.0 {
subview.backgroundColor = selectorColor
}
}
}
override func didMoveToWindow() {
super.didMoveToWindow()
if let selectorColor = self.selectorColor {
for subview in self.subviews {
if subview.bounds.height <= 1.0 {
subview.backgroundColor = selectorColor
}
}
}
}
}
private var unitValues: [Int32] = {
var values: [Int32] = []
for i in 0 ..< 99 {
values.append(Int32(i))
}
return values
}()
private var smallUnitValues: [Int32] = {
var values: [Int32] = []
values.append(0)
values.append(5)
for i in 1 ..< 10 {
values.append(Int32(i * 10))
}
return values
}()
class LocationDistancePickerScreenNode: ViewControllerTracingNode, ASScrollViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
private let context: AccountContext
private let controllerStyle: LocationDistancePickerScreenStyle
private var presentationData: PresentationData
private var compactDisplayTitle: String?
private var distances: [Double] = []
private let dimNode: ASDisplayNode
private let wrappingScrollNode: ASScrollNode
private let contentContainerNode: ASDisplayNode
private let effectNode: ASDisplayNode
private let backgroundNode: ASDisplayNode
private let contentBackgroundNode: ASDisplayNode
private let titleNode: ASTextNode
private let textNode: ImmediateTextNode
private let cancelButton: HighlightableButtonNode
private let doneButton: SolidRoundedButtonNode
private let measureButtonTitleNode: ImmediateTextNode
private var pickerView: TimerPickerView?
private let unitLabelNode: ImmediateTextNode
private let smallUnitLabelNode: ImmediateTextNode
private var pickerTimer: SwiftSignalKit.Timer?
private var containerLayout: (ContainerViewLayout, CGFloat)?
private var distancesDisposable: Disposable?
var updated: ((Int32) -> Void)?
var completion: ((Int32) -> Void)?
var dismiss: (() -> Void)?
var cancel: (() -> Void)?
init(context: AccountContext, style: LocationDistancePickerScreenStyle, compactDisplayTitle: String?, distances: Signal<[Double], NoError>) {
self.context = context
self.controllerStyle = style
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.compactDisplayTitle = compactDisplayTitle
self.wrappingScrollNode = ASScrollNode()
self.wrappingScrollNode.view.alwaysBounceVertical = true
self.wrappingScrollNode.view.delaysContentTouches = false
self.wrappingScrollNode.view.canCancelContentTouches = true
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.contentContainerNode = ASDisplayNode()
self.contentContainerNode.isOpaque = false
self.backgroundNode = ASDisplayNode()
self.backgroundNode.clipsToBounds = true
self.backgroundNode.cornerRadius = 16.0
let backgroundColor: UIColor
let textColor: UIColor
let accentColor: UIColor
let blurStyle: UIBlurEffect.Style
switch style {
case .default:
backgroundColor = self.presentationData.theme.actionSheet.itemBackgroundColor
textColor = self.presentationData.theme.actionSheet.primaryTextColor
accentColor = self.presentationData.theme.actionSheet.controlAccentColor
blurStyle = self.presentationData.theme.actionSheet.backgroundType == .light ? .light : .dark
case .media:
backgroundColor = UIColor(rgb: 0x1c1c1e)
textColor = .white
accentColor = self.presentationData.theme.actionSheet.controlAccentColor
blurStyle = .dark
}
self.effectNode = ASDisplayNode(viewBlock: {
return UIVisualEffectView(effect: UIBlurEffect(style: blurStyle))
})
self.contentBackgroundNode = ASDisplayNode()
self.contentBackgroundNode.backgroundColor = backgroundColor
self.titleNode = ASTextNode()
self.titleNode.attributedText = NSAttributedString(string: self.presentationData.strings.Location_ProximityNotification_Title, font: Font.bold(17.0), textColor: textColor)
self.textNode = ImmediateTextNode()
self.textNode.alpha = 0.0
self.cancelButton = HighlightableButtonNode()
self.cancelButton.setTitle(self.presentationData.strings.Common_Cancel, with: Font.regular(17.0), with: accentColor, for: .normal)
self.doneButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(theme: self.presentationData.theme), height: 52.0, cornerRadius: 11.0)
self.doneButton.title = self.presentationData.strings.Conversation_Timer_Send
self.unitLabelNode = ImmediateTextNode()
self.smallUnitLabelNode = ImmediateTextNode()
self.measureButtonTitleNode = ImmediateTextNode()
super.init()
self.backgroundColor = nil
self.isOpaque = false
self.unitLabelNode.attributedText = NSAttributedString(string: self.usesMetricSystem ? self.presentationData.strings.Location_ProximityNotification_DistanceKM : self.presentationData.strings.Location_ProximityNotification_DistanceMI, font: Font.regular(15.0), textColor: textColor)
self.smallUnitLabelNode.attributedText = NSAttributedString(string: self.usesMetricSystem ? self.presentationData.strings.Location_ProximityNotification_DistanceM : "", font: Font.regular(15.0), textColor: textColor)
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
self.addSubnode(self.dimNode)
self.wrappingScrollNode.view.delegate = self.wrappedScrollViewDelegate
self.addSubnode(self.wrappingScrollNode)
self.wrappingScrollNode.addSubnode(self.backgroundNode)
self.wrappingScrollNode.addSubnode(self.contentContainerNode)
self.backgroundNode.addSubnode(self.effectNode)
self.backgroundNode.addSubnode(self.contentBackgroundNode)
self.contentContainerNode.addSubnode(self.titleNode)
self.contentContainerNode.addSubnode(self.textNode)
self.contentContainerNode.addSubnode(self.cancelButton)
self.contentContainerNode.addSubnode(self.doneButton)
self.contentContainerNode.addSubnode(self.unitLabelNode)
self.contentContainerNode.addSubnode(self.smallUnitLabelNode)
self.cancelButton.addTarget(self, action: #selector(self.cancelButtonPressed), forControlEvents: .touchUpInside)
self.doneButton.pressed = { [weak self] in
if let strongSelf = self, let pickerView = strongSelf.pickerView {
strongSelf.doneButton.isUserInteractionEnabled = false
let largeValue = unitValues[pickerView.selectedRow(inComponent: 0)]
let smallValue = smallUnitValues[pickerView.selectedRow(inComponent: 1)]
var value = largeValue * 1000 + smallValue * 10
if !strongSelf.usesMetricSystem {
value = Int32(Double(value) * 1.60934)
}
strongSelf.completion?(value)
}
}
self.setupPickerView()
self.distancesDisposable = (distances
|> deliverOnMainQueue).start(next: { [weak self] distances in
if let strongSelf = self {
strongSelf.distances = distances
strongSelf.updateDoneButtonTitle()
}
})
}
deinit {
self.distancesDisposable?.dispose()
self.pickerTimer?.invalidate()
}
func setupPickerView() {
if let pickerView = self.pickerView {
pickerView.removeFromSuperview()
}
let pickerView = TimerPickerView()
pickerView.selectorColor = UIColor(rgb: 0xffffff, alpha: 0.18)
pickerView.dataSource = self
pickerView.delegate = self
pickerView.selectRow(0, inComponent: 0, animated: false)
if self.usesMetricSystem {
pickerView.selectRow(6, inComponent: 1, animated: false)
} else {
pickerView.selectRow(4, inComponent: 1, animated: false)
}
self.contentContainerNode.view.addSubview(pickerView)
self.pickerView = pickerView
self.contentContainerNode.addSubnode(self.unitLabelNode)
self.contentContainerNode.addSubnode(self.smallUnitLabelNode)
self.pickerTimer?.invalidate()
let pickerTimer = SwiftSignalKit.Timer(timeout: 0.4, repeat: true, completion: { [weak self] in
if let strongSelf = self {
if strongSelf.update() {
strongSelf.updateDoneButtonTitle()
}
}
}, queue: Queue.mainQueue())
self.pickerTimer = pickerTimer
pickerTimer.start()
self.updateDoneButtonTitle()
}
private var usesMetricSystem: Bool {
let locale = localeWithStrings(self.presentationData.strings)
if locale.identifier.hasSuffix("GB") {
return false
}
return locale.usesMetricSystem
}
func numberOfComponents(in pickerView: UIPickerView) -> Int {
return 2
}
private func updateDoneButtonTitle() {
if let pickerView = self.pickerView {
let selectedLargeRow = pickerView.selectedRow(inComponent: 0)
var selectedSmallRow = pickerView.selectedRow(inComponent: 1)
if selectedLargeRow == 0 && selectedSmallRow == 0 {
selectedSmallRow = 1
}
let largeValue = unitValues[selectedLargeRow]
let smallValue = smallUnitValues[selectedSmallRow]
let value = largeValue * 1000 + smallValue * 10
var formattedValue = String(format: "%0.1f", CGFloat(value) / 1000.0)
if smallValue == 5 {
formattedValue = formattedValue.replacingOccurrences(of: ".1", with: ".05").replacingOccurrences(of: ",1", with: ",05")
}
let distance = self.usesMetricSystem ? "\(formattedValue) \(self.presentationData.strings.Location_ProximityNotification_DistanceKM)" : "\(formattedValue) \(self.presentationData.strings.Location_ProximityNotification_DistanceMI)"
let shortTitle = self.presentationData.strings.Location_ProximityNotification_Notify(distance).string
var longTitle: String?
if let displayTitle = self.compactDisplayTitle, let (layout, _) = self.containerLayout {
let title = self.presentationData.strings.Location_ProximityNotification_NotifyLong(displayTitle, distance).string
let width = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: 0.0)
self.measureButtonTitleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(17.0), textColor: .black)
let titleSize = self.measureButtonTitleNode.updateLayout(CGSize(width: width * 2.0, height: 50.0))
if titleSize.width < width - 70.0 {
longTitle = title
}
}
self.doneButton.title = longTitle ?? shortTitle
self.textNode.attributedText = NSAttributedString(string: self.presentationData.strings.Location_ProximityNotification_AlreadyClose(distance).string, font: Font.regular(14.0), textColor: self.presentationData.theme.actionSheet.secondaryTextColor)
if let (layout, navigationBarHeight) = self.containerLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
var convertedValue = Double(value)
if !self.usesMetricSystem {
convertedValue = Double(convertedValue) * 1.60934
}
if let distance = self.distances.last, convertedValue > distance {
self.doneButton.alpha = 0.0
self.doneButton.isUserInteractionEnabled = false
self.textNode.alpha = 1.0
} else {
self.doneButton.alpha = 1.0
self.doneButton.isUserInteractionEnabled = true
self.textNode.alpha = 0.0
}
}
}
var previousReportedValue: Int32?
fileprivate func update() -> Bool {
if let pickerView = self.pickerView {
let selectedLargeRow = pickerView.selectedRow(inComponent: 0)
var selectedSmallRow = pickerView.selectedRow(inComponent: 1)
if selectedLargeRow == 0 && selectedSmallRow == 0 {
selectedSmallRow = 1
}
let largeValue = unitValues[selectedLargeRow]
let smallValue = smallUnitValues[selectedSmallRow]
var value = largeValue * 1000 + smallValue * 10
if !self.usesMetricSystem {
value = Int32(Double(value) * 1.60934)
}
if let previousReportedValue = self.previousReportedValue, value == previousReportedValue {
return false
} else {
self.updated?(value)
self.previousReportedValue = value
return true
}
} else {
return false
}
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
if pickerView.selectedRow(inComponent: 0) == 0 && pickerView.selectedRow(inComponent: 1) == 0 {
pickerView.selectRow(1, inComponent: 1, animated: true)
}
self.updateDoneButtonTitle()
let _ = self.update()
}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
if component == 0 {
return unitValues.count
} else if component == 1 {
return smallUnitValues.count
} else {
return 1
}
}
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
let font = Font.regular(17.0)
let string: String
if component == 0 {
let value = unitValues[row]
string = "\(value)"
} else {
if self.usesMetricSystem {
let value = String(format: "%d", smallUnitValues[row] * 10)
string = "\(value)"
} else {
let value = smallUnitValues[row]
if value == 0 {
string = ".0"
} else if value == 5 {
string = ".05"
} else {
string = ".\(value / 10)"
}
}
}
return NSAttributedString(string: string, font: font, textColor: self.presentationData.theme.actionSheet.primaryTextColor)
}
func updatePresentationData(_ presentationData: PresentationData) {
let previousTheme = self.presentationData.theme
self.presentationData = presentationData
guard case .default = self.controllerStyle else {
return
}
if let effectView = self.effectNode.view as? UIVisualEffectView {
effectView.effect = UIBlurEffect(style: presentationData.theme.actionSheet.backgroundType == .light ? .light : .dark)
}
self.contentBackgroundNode.backgroundColor = self.presentationData.theme.actionSheet.itemBackgroundColor
self.titleNode.attributedText = NSAttributedString(string: self.titleNode.attributedText?.string ?? "", font: Font.bold(17.0), textColor: self.presentationData.theme.actionSheet.primaryTextColor)
if previousTheme !== presentationData.theme, let (layout, navigationBarHeight) = self.containerLayout {
self.setupPickerView()
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
self.cancelButton.setTitle(self.presentationData.strings.Common_Cancel, with: Font.regular(17.0), with: self.presentationData.theme.actionSheet.controlAccentColor, for: .normal)
self.doneButton.updateTheme(SolidRoundedButtonTheme(theme: self.presentationData.theme))
self.updateDoneButtonTitle()
self.unitLabelNode.attributedText = NSAttributedString(string: self.usesMetricSystem ? self.presentationData.strings.Location_ProximityNotification_DistanceKM : self.presentationData.strings.Location_ProximityNotification_DistanceMI, font: Font.regular(15.0), textColor: self.presentationData.theme.actionSheet.primaryTextColor)
self.smallUnitLabelNode.attributedText = NSAttributedString(string: self.usesMetricSystem ? self.presentationData.strings.Location_ProximityNotification_DistanceM : "", font: Font.regular(15.0), textColor: self.presentationData.theme.actionSheet.primaryTextColor)
}
override func didLoad() {
super.didLoad()
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never
}
}
@objc func cancelButtonPressed() {
self.cancel?()
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.cancelButtonPressed()
}
}
func animateIn() {
self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
let offset = self.contentContainerNode.frame.height
let position = self.wrappingScrollNode.position
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
self.wrappingScrollNode.position = CGPoint(x: position.x, y: position.y + offset)
transition.animateView({
self.wrappingScrollNode.position = position
})
}
func animateOut(completion: (() -> Void)? = nil) {
var dimCompleted = false
var offsetCompleted = false
let internalCompletion: () -> Void = { [weak self] in
if let strongSelf = self, dimCompleted && offsetCompleted {
strongSelf.dismiss?()
}
completion?()
}
self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in
dimCompleted = true
internalCompletion()
})
let offset = self.contentContainerNode.frame.height
self.wrappingScrollNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true, completion: { _ in
offsetCompleted = true
internalCompletion()
})
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.contains(point) {
if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) {
return self.dimNode.view
}
}
return super.hitTest(point, with: event)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset
let additionalTopHeight = max(0.0, -contentOffset.y)
if additionalTopHeight >= 30.0 {
self.cancelButtonPressed()
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
let hadValidLayout = self.containerLayout != nil
self.containerLayout = (layout, navigationBarHeight)
var insets = layout.insets(options: [.statusBar, .input])
let cleanInsets = layout.insets(options: [.statusBar])
insets.top = max(10.0, insets.top)
let buttonOffset: CGFloat = 0.0
let bottomInset: CGFloat = 10.0 + cleanInsets.bottom
let titleHeight: CGFloat = 54.0
var contentHeight = titleHeight + bottomInset + 52.0 + 17.0
let pickerHeight: CGFloat = min(216.0, layout.size.height - contentHeight)
contentHeight = titleHeight + bottomInset + 52.0 + 17.0 + pickerHeight + buttonOffset
let width = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: 0.0)
let sideInset = floor((layout.size.width - width) / 2.0)
let contentContainerFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - contentHeight), size: CGSize(width: width, height: contentHeight))
let contentFrame = contentContainerFrame
var backgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: contentFrame.width, height: contentFrame.height + 2000.0))
if backgroundFrame.minY < contentFrame.minY {
backgroundFrame.origin.y = contentFrame.minY
}
transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame)
transition.updateFrame(node: self.effectNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size))
transition.updateFrame(node: self.contentBackgroundNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size))
transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: insets.top + 66.0 + UIScreenPixel)))
let titleSize = self.titleNode.measure(CGSize(width: width, height: titleHeight))
let titleFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - titleSize.width) / 2.0), y: 16.0), size: titleSize)
transition.updateFrame(node: self.titleNode, frame: titleFrame)
let cancelSize = self.cancelButton.measure(CGSize(width: width, height: titleHeight))
let cancelFrame = CGRect(origin: CGPoint(x: 16.0, y: 16.0), size: cancelSize)
transition.updateFrame(node: self.cancelButton, frame: cancelFrame)
let buttonInset: CGFloat = 16.0
let doneButtonHeight = self.doneButton.updateLayout(width: contentFrame.width - buttonInset * 2.0, transition: transition)
let doneButtonFrame = CGRect(x: buttonInset, y: contentHeight - doneButtonHeight - insets.bottom - 16.0 - buttonOffset, width: contentFrame.width, height: doneButtonHeight)
transition.updateFrame(node: self.doneButton, frame: doneButtonFrame)
let textSize = self.textNode.updateLayout(CGSize(width: width, height: titleHeight))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floor((width - textSize.width) / 2.0), y: floor(doneButtonFrame.center.y - textSize.height / 2.0)), size: textSize))
let pickerFrame = CGRect(origin: CGPoint(x: 0.0, y: 54.0), size: CGSize(width: contentFrame.width, height: pickerHeight))
self.pickerView?.frame = pickerFrame
let unitLabelSize = self.unitLabelNode.updateLayout(CGSize(width: width, height: titleHeight))
transition.updateFrame(node: self.unitLabelNode, frame: CGRect(origin: CGPoint(x: floor(pickerFrame.width / 4.0) + 50.0, y: floor(pickerFrame.center.y - unitLabelSize.height / 2.0)), size: unitLabelSize))
let smallUnitLabelSize = self.smallUnitLabelNode.updateLayout(CGSize(width: width, height: titleHeight))
transition.updateFrame(node: self.smallUnitLabelNode, frame: CGRect(origin: CGPoint(x: floor(pickerFrame.width / 4.0 * 3.0) + 50.0, y: floor(pickerFrame.center.y - smallUnitLabelSize.height / 2.0)), size: smallUnitLabelSize))
transition.updateFrame(node: self.contentContainerNode, frame: contentContainerFrame)
if !hadValidLayout {
self.updateDoneButtonTitle()
}
}
}
@@ -0,0 +1,409 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import ItemListUI
import LocationResources
import AppBundle
import SolidRoundedButtonNode
import ShimmerEffect
public final class LocationInfoListItem: ListViewItem {
let presentationData: ItemListPresentationData
let engine: TelegramEngine
let location: TelegramMediaMap
let address: String?
let distance: String?
let drivingTime: ExpectedTravelTime
let transitTime: ExpectedTravelTime
let walkingTime: ExpectedTravelTime
let hasEta: Bool
let action: () -> Void
let drivingAction: () -> Void
let transitAction: () -> Void
let walkingAction: () -> Void
public init(presentationData: ItemListPresentationData, engine: TelegramEngine, location: TelegramMediaMap, address: String?, distance: String?, drivingTime: ExpectedTravelTime, transitTime: ExpectedTravelTime, walkingTime: ExpectedTravelTime, hasEta: Bool, action: @escaping () -> Void, drivingAction: @escaping () -> Void, transitAction: @escaping () -> Void, walkingAction: @escaping () -> Void) {
self.presentationData = presentationData
self.engine = engine
self.location = location
self.address = address
self.distance = distance
self.drivingTime = drivingTime
self.transitTime = transitTime
self.walkingTime = walkingTime
self.hasEta = hasEta
self.action = action
self.drivingAction = drivingAction
self.transitAction = transitAction
self.walkingAction = walkingAction
}
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
}
}
public 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 placeholderNode: ShimmerEffectNode?
private var drivingButtonNode: SolidRoundedButtonNode?
private var transitButtonNode: SolidRoundedButtonNode?
private var walkingButtonNode: SolidRoundedButtonNode?
private var item: LocationInfoListItem?
private var layoutParams: ListViewItemLayoutParams?
private var absoluteLocation: (CGRect, CGSize)?
required public 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 public 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()
}
}
public 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 textContentSize = verticalInset * 2.0 + titleLayout.size.height + titleSpacing + subtitleLayout.size.height + bottomInset
let contentSize = CGSize(width: params.width, height: item.hasEta ? max(100.0, textContentSize) : textContentSize)
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.backgroundNode.isHidden = params.isStandalone
let arguments = VenueIconArguments(defaultBackgroundColor: item.presentationData.theme.chat.inputPanel.actionControlFillColor, defaultForegroundColor: item.presentationData.theme.chat.inputPanel.actionControlForegroundColor)
if let updatedLocation = updatedLocation {
strongSelf.venueIconNode.setSignal(venueIcon(engine: item.engine, 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(), custom: arguments))
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 buttonTheme = SolidRoundedButtonTheme(theme: item.presentationData.theme)
if strongSelf.drivingButtonNode == nil {
strongSelf.drivingButtonNode = SolidRoundedButtonNode(icon: generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsDriving"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor), theme: buttonTheme, fontSize: 15.0, height: 32.0, cornerRadius: 16.0)
strongSelf.drivingButtonNode?.iconSpacing = 5.0
strongSelf.drivingButtonNode?.alpha = 0.0
strongSelf.drivingButtonNode?.allowsGroupOpacity = true
strongSelf.drivingButtonNode?.pressed = { [weak self] in
if let item = self?.item {
item.drivingAction()
}
}
strongSelf.drivingButtonNode.flatMap { strongSelf.addSubnode($0) }
strongSelf.transitButtonNode = SolidRoundedButtonNode(icon: generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsTransit"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor), theme: buttonTheme, fontSize: 15.0, height: 32.0, cornerRadius: 16.0)
strongSelf.transitButtonNode?.iconSpacing = 2.0
strongSelf.transitButtonNode?.alpha = 0.0
strongSelf.transitButtonNode?.allowsGroupOpacity = true
strongSelf.transitButtonNode?.pressed = { [weak self] in
if let item = self?.item {
item.transitAction()
}
}
strongSelf.transitButtonNode.flatMap { strongSelf.addSubnode($0) }
strongSelf.walkingButtonNode = SolidRoundedButtonNode(icon: generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsWalking"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor), theme: buttonTheme, fontSize: 15.0, height: 32.0, cornerRadius: 16.0)
strongSelf.walkingButtonNode?.iconSpacing = 2.0
strongSelf.walkingButtonNode?.alpha = 0.0
strongSelf.walkingButtonNode?.allowsGroupOpacity = true
strongSelf.walkingButtonNode?.pressed = { [weak self] in
if let item = self?.item {
item.walkingAction()
}
}
strongSelf.walkingButtonNode.flatMap { strongSelf.addSubnode($0) }
} else if let _ = updatedTheme {
strongSelf.drivingButtonNode?.updateTheme(buttonTheme)
strongSelf.drivingButtonNode?.icon = generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsDriving"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor)
strongSelf.transitButtonNode?.updateTheme(buttonTheme)
strongSelf.transitButtonNode?.icon = generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsTransit"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor)
strongSelf.walkingButtonNode?.updateTheme(buttonTheme)
strongSelf.walkingButtonNode?.icon = generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsWalking"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor)
}
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 iconNodeFrame = CGRect(origin: CGPoint(x: params.leftInset + inset, y: 10.0), size: CGSize(width: iconSize, height: iconSize))
strongSelf.venueIconNode.frame = iconNodeFrame
var directionsWidth: CGFloat = 93.0
if item.hasEta {
if item.drivingTime == .unknown && item.transitTime == .unknown && item.walkingTime == .unknown {
strongSelf.drivingButtonNode?.icon = nil
strongSelf.drivingButtonNode?.title = item.presentationData.strings.Map_GetDirections
if let drivingButtonNode = strongSelf.drivingButtonNode {
let buttonSize = drivingButtonNode.sizeThatFits(contentSize)
directionsWidth = buttonSize.width
}
if let previousDrivingTime = currentItem?.drivingTime, case .calculating = previousDrivingTime {
strongSelf.drivingButtonNode?.alpha = 1.0
strongSelf.drivingButtonNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
} else {
if case let .ready(drivingTime) = item.drivingTime {
strongSelf.drivingButtonNode?.title = stringForEstimatedDuration(strings: item.presentationData.strings, time: drivingTime, format: { $0 })
if let previousDrivingTime = currentItem?.drivingTime, case .calculating = previousDrivingTime {
strongSelf.drivingButtonNode?.alpha = 1.0
strongSelf.drivingButtonNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
if case let .ready(transitTime) = item.transitTime {
strongSelf.transitButtonNode?.title = stringForEstimatedDuration(strings: item.presentationData.strings, time: transitTime, format: { $0 })
if let previousTransitTime = currentItem?.transitTime, case .calculating = previousTransitTime {
strongSelf.transitButtonNode?.alpha = 1.0
strongSelf.transitButtonNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
if case let .ready(walkingTime) = item.walkingTime {
strongSelf.walkingButtonNode?.title = stringForEstimatedDuration(strings: item.presentationData.strings, time: walkingTime, format: { $0 })
if let previousWalkingTime = currentItem?.walkingTime, case .calculating = previousWalkingTime {
strongSelf.walkingButtonNode?.alpha = 1.0
strongSelf.walkingButtonNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
}
let directionsSpacing: CGFloat = 8.0
if case .calculating = item.drivingTime, case .calculating = item.transitTime, case .calculating = item.walkingTime {
let shimmerNode: ShimmerEffectNode
if let current = strongSelf.placeholderNode {
shimmerNode = current
} else {
shimmerNode = ShimmerEffectNode()
strongSelf.placeholderNode = shimmerNode
strongSelf.addSubnode(shimmerNode)
}
shimmerNode.frame = CGRect(origin: CGPoint(x: leftInset, y: subtitleFrame.maxY + 12.0), size: CGSize(width: contentSize.width - leftInset, height: 32.0))
if let (rect, size) = strongSelf.absoluteLocation {
shimmerNode.updateAbsoluteRect(rect, within: size)
}
var shapes: [ShimmerEffectNode.Shape] = []
shapes.append(.roundedRectLine(startPoint: CGPoint(x: 0.0, y: 0.0), width: directionsWidth, diameter: 32.0))
shapes.append(.roundedRectLine(startPoint: CGPoint(x: directionsWidth + directionsSpacing, y: 0.0), width: directionsWidth, diameter: 32.0))
shapes.append(.roundedRectLine(startPoint: CGPoint(x: directionsWidth + directionsSpacing + directionsWidth + directionsSpacing, y: 0.0), width: directionsWidth, diameter: 32.0))
shimmerNode.update(backgroundColor: item.presentationData.theme.list.plainBackgroundColor, foregroundColor: item.presentationData.theme.list.mediaPlaceholderColor, shimmeringColor: item.presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: shimmerNode.frame.size)
} else if let shimmerNode = strongSelf.placeholderNode {
strongSelf.placeholderNode = nil
shimmerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak shimmerNode] _ in
shimmerNode?.removeFromSupernode()
})
}
let drivingHeight = strongSelf.drivingButtonNode?.updateLayout(width: directionsWidth, transition: .immediate) ?? 0.0
let transitHeight = strongSelf.transitButtonNode?.updateLayout(width: directionsWidth, transition: .immediate) ?? 0.0
let walkingHeight = strongSelf.walkingButtonNode?.updateLayout(width: directionsWidth, transition: .immediate) ?? 0.0
var buttonOrigin = leftInset
strongSelf.drivingButtonNode?.frame = CGRect(origin: CGPoint(x: buttonOrigin, y: subtitleFrame.maxY + 12.0), size: CGSize(width: directionsWidth, height: drivingHeight))
if case .ready = item.drivingTime {
buttonOrigin += directionsWidth + directionsSpacing
}
strongSelf.transitButtonNode?.frame = CGRect(origin: CGPoint(x: buttonOrigin, y: subtitleFrame.maxY + 12.0), size: CGSize(width: directionsWidth, height: transitHeight))
if case .ready = item.transitTime {
buttonOrigin += directionsWidth + directionsSpacing
}
strongSelf.walkingButtonNode?.frame = CGRect(origin: CGPoint(x: buttonOrigin, y: subtitleFrame.maxY + 12.0), size: CGSize(width: directionsWidth, height: walkingHeight))
} else {
}
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 public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.5)
}
override public 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()
}
override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
var rect = rect
rect.origin.y += self.insets.top
self.absoluteLocation = (rect, containerSize)
if let shimmerNode = self.placeholderNode {
shimmerNode.updateAbsoluteRect(rect, within: containerSize)
}
}
}
@@ -0,0 +1,409 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import AccountContext
import TelegramPresentationData
import TelegramUIPreferences
import TelegramStringFormatting
import ItemListUI
import LocationResources
import AppBundle
import AvatarNode
import LiveLocationTimerNode
import SolidRoundedButtonNode
final class LocationLiveListItem: ListViewItem {
let presentationData: ItemListPresentationData
let dateTimeFormat: PresentationDateTimeFormat
let nameDisplayOrder: PresentationPersonNameOrder
let context: AccountContext
let message: EngineMessage
let distance: Double?
let drivingTime: ExpectedTravelTime
let transitTime: ExpectedTravelTime
let walkingTime: ExpectedTravelTime
let action: () -> Void
let longTapAction: () -> Void
let drivingAction: () -> Void
let transitAction: () -> Void
let walkingAction: () -> Void
public init(presentationData: ItemListPresentationData, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, context: AccountContext, message: EngineMessage, distance: Double?, drivingTime: ExpectedTravelTime, transitTime: ExpectedTravelTime, walkingTime: ExpectedTravelTime, action: @escaping () -> Void, longTapAction: @escaping () -> Void = { }, drivingAction: @escaping () -> Void, transitAction: @escaping () -> Void, walkingAction: @escaping () -> Void) {
self.presentationData = presentationData
self.dateTimeFormat = dateTimeFormat
self.nameDisplayOrder = nameDisplayOrder
self.context = context
self.message = message
self.distance = distance
self.drivingTime = drivingTime
self.transitTime = transitTime
self.walkingTime = walkingTime
self.action = action
self.longTapAction = longTapAction
self.drivingAction = drivingAction
self.transitAction = transitAction
self.walkingAction = walkingAction
}
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 = LocationLiveListItemNode()
let makeLayout = node.asyncLayout()
let (nodeLayout, nodeApply) = makeLayout(self, params, nextItem is LocationLiveListItem)
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? LocationLiveListItemNode {
let layout = nodeValue.asyncLayout()
async {
let (nodeLayout, apply) = layout(self, params, nextItem is LocationLiveListItem)
Queue.mainQueue().async {
completion(nodeLayout, { info in
apply().1(info)
})
}
}
}
}
}
public var selectable: Bool {
return true
}
public func selected(listView: ListView) {
listView.clearHighlightAnimated(false)
self.action()
}
}
private let avatarFont = avatarPlaceholderFont(size: floor(40.0 * 16.0 / 37.0))
final class LocationLiveListItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let separatorNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private var titleNode: TextNode?
private var subtitleNode: TextNode?
private let avatarNode: AvatarNode
private var timerNode: ChatMessageLiveLocationTimerNode?
private var drivingButtonNode: SolidRoundedButtonNode?
private var transitButtonNode: SolidRoundedButtonNode?
private var walkingButtonNode: SolidRoundedButtonNode?
private var item: LocationLiveListItem?
private var layoutParams: ListViewItemLayoutParams?
required init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.avatarNode = AvatarNode(font: avatarFont)
self.avatarNode.isLayerBacked = !smartInvertColorsEnabled()
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.separatorNode)
self.addSubnode(self.avatarNode)
}
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, nextItem is LocationLiveListItem)
self.contentSize = nodeLayout.contentSize
self.insets = nodeLayout.insets
let _ = nodeApply()
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode)
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
func asyncLayout() -> (_ item: LocationLiveListItem, _ params: ListViewItemLayoutParams, _ hasSeparator: Bool) -> (ListViewItemNodeLayout, () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) {
let currentItem = self.item
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode)
return { [weak self] item, params, hasSeparator in
let leftInset: CGFloat = 65.0 + params.leftInset
let rightInset: CGFloat = params.rightInset
let verticalInset: CGFloat = 8.0
let titleFont = Font.semibold(item.presentationData.fontSize.itemListBaseFontSize)
let subtitleFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0))
var title: String = ""
if let author = item.message.author {
title = author.displayTitle(strings: item.presentationData.strings, displayOrder: item.nameDisplayOrder)
}
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 - 54.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
var updateTimestamp = item.message.timestamp
for attribute in item.message.attributes {
if let attribute = attribute as? EditedMessageAttribute {
updateTimestamp = attribute.date
break
}
}
let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970
let timeString = stringForRelativeLiveLocationTimestamp(strings: item.presentationData.strings, relativeTimestamp: Int32(updateTimestamp), relativeTo: Int32(timestamp), dateTimeFormat: item.dateTimeFormat)
var subtitle = timeString
if let distance = item.distance {
let distanceString = item.presentationData.strings.Map_DistanceAway(shortStringForDistance(strings: item.presentationData.strings, distance: Int32(distance))).string
subtitle = "\(timeString)\(distanceString)"
}
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 - 54.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let titleSpacing: CGFloat = 1.0
var contentSize = CGSize(width: params.width, height: verticalInset * 2.0 + titleLayout.size.height + titleSpacing + subtitleLayout.size.height)
let hasEta: Bool
if case .ready = item.drivingTime {
hasEta = true
} else if case .ready = item.transitTime {
hasEta = true
} else if case .ready = item.walkingTime {
hasEta = true
} else {
hasEta = false
}
if hasEta {
contentSize.height += 46.0
}
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
}
return (self?.avatarNode.ready, { _ in
if let strongSelf = self {
strongSelf.item = item
strongSelf.layoutParams = params
if let _ = updatedTheme {
strongSelf.separatorNode.backgroundColor = item.presentationData.theme.list.itemPlainSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.presentationData.theme.list.plainBackgroundColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor
}
let titleNode = titleApply()
if strongSelf.titleNode == nil {
strongSelf.titleNode = titleNode
strongSelf.addSubnode(titleNode)
}
let subtitleNode = subtitleApply()
if strongSelf.subtitleNode == nil {
strongSelf.subtitleNode = subtitleNode
strongSelf.addSubnode(subtitleNode)
}
let buttonTheme = SolidRoundedButtonTheme(theme: item.presentationData.theme)
if strongSelf.drivingButtonNode == nil {
strongSelf.drivingButtonNode = SolidRoundedButtonNode(icon: generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsDriving"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor), theme: buttonTheme, fontSize: 15.0, height: 32.0, cornerRadius: 16.0)
strongSelf.drivingButtonNode?.alpha = 0.0
strongSelf.drivingButtonNode?.iconSpacing = 5.0
strongSelf.drivingButtonNode?.allowsGroupOpacity = true
strongSelf.drivingButtonNode?.pressed = { [weak self] in
if let item = self?.item {
item.drivingAction()
}
}
strongSelf.drivingButtonNode.flatMap { strongSelf.addSubnode($0) }
strongSelf.transitButtonNode = SolidRoundedButtonNode(icon: generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsTransit"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor), theme: buttonTheme, fontSize: 15.0, height: 32.0, cornerRadius: 16.0)
strongSelf.transitButtonNode?.alpha = 0.0
strongSelf.transitButtonNode?.iconSpacing = 2.0
strongSelf.transitButtonNode?.allowsGroupOpacity = true
strongSelf.transitButtonNode?.pressed = { [weak self] in
if let item = self?.item {
item.transitAction()
}
}
strongSelf.transitButtonNode.flatMap { strongSelf.addSubnode($0) }
strongSelf.walkingButtonNode = SolidRoundedButtonNode(icon: generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsWalking"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor), theme: buttonTheme, fontSize: 15.0, height: 32.0, cornerRadius: 16.0)
strongSelf.walkingButtonNode?.alpha = 0.0
strongSelf.walkingButtonNode?.iconSpacing = 2.0
strongSelf.walkingButtonNode?.allowsGroupOpacity = true
strongSelf.walkingButtonNode?.pressed = { [weak self] in
if let item = self?.item {
item.walkingAction()
}
}
strongSelf.walkingButtonNode.flatMap { strongSelf.addSubnode($0) }
} else if let _ = updatedTheme {
strongSelf.drivingButtonNode?.updateTheme(buttonTheme)
strongSelf.drivingButtonNode?.icon = generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsDriving"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor)
strongSelf.transitButtonNode?.updateTheme(buttonTheme)
strongSelf.transitButtonNode?.icon = generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsTransit"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor)
strongSelf.walkingButtonNode?.updateTheme(buttonTheme)
strongSelf.walkingButtonNode?.icon = generateTintedImage(image: UIImage(bundleImageName: "Location/DirectionsWalking"), color: item.presentationData.theme.list.itemCheckColors.foregroundColor)
}
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 separatorRightInset: CGFloat = 16.0
let avatarSize: CGFloat = 40.0
if let peer = item.message.author {
strongSelf.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: nil, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, synchronousLoad: false)
}
strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: params.leftInset + 15.0, y: 8.0), size: CGSize(width: avatarSize, height: avatarSize))
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: contentSize.width, height: contentSize.height))
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top - topHighlightInset), size: CGSize(width: contentSize.width, height: contentSize.height + topHighlightInset))
strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - separatorHeight), size: CGSize(width: nodeLayout.size.width - leftInset - params.rightInset - separatorRightInset, height: separatorHeight))
strongSelf.separatorNode.isHidden = !hasSeparator
var liveBroadcastingTimeout: Int32 = 0
if let location = getLocation(from: item.message), let timeout = location.liveBroadcastingTimeout {
liveBroadcastingTimeout = timeout
}
let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
let remainingTime: Int32
if liveBroadcastingTimeout == liveLocationIndefinitePeriod {
remainingTime = liveLocationIndefinitePeriod
} else {
remainingTime = max(0, item.message.timestamp + liveBroadcastingTimeout - currentTimestamp)
}
if remainingTime > 0 {
let timerNode: ChatMessageLiveLocationTimerNode
if let current = strongSelf.timerNode {
timerNode = current
} else {
timerNode = ChatMessageLiveLocationTimerNode()
strongSelf.addSubnode(timerNode)
strongSelf.timerNode = timerNode
}
let timerSize = CGSize(width: 28.0, height: 28.0)
timerNode.update(backgroundColor: item.presentationData.theme.list.itemAccentColor.withAlphaComponent(0.4), foregroundColor: item.presentationData.theme.list.itemAccentColor, textColor: item.presentationData.theme.list.itemAccentColor, beginTimestamp: Double(item.message.timestamp), timeout: Int32(liveBroadcastingTimeout) == liveLocationIndefinitePeriod ? -1.0 : Double(liveBroadcastingTimeout), strings: item.presentationData.strings)
timerNode.frame = CGRect(origin: CGPoint(x: contentSize.width - 16.0 - timerSize.width, y: 14.0), size: timerSize)
} else if let timerNode = strongSelf.timerNode {
strongSelf.timerNode = nil
timerNode.removeFromSupernode()
}
if case let .ready(drivingTime) = item.drivingTime {
strongSelf.drivingButtonNode?.title = stringForEstimatedDuration(strings: item.presentationData.strings, time: drivingTime, format: { $0 })
if let previousDrivingTime = currentItem?.drivingTime, case .calculating = previousDrivingTime {
strongSelf.drivingButtonNode?.alpha = 1.0
strongSelf.drivingButtonNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
if case let .ready(transitTime) = item.transitTime {
strongSelf.transitButtonNode?.title = stringForEstimatedDuration(strings: item.presentationData.strings, time: transitTime, format: { $0 })
if let previousTransitTime = currentItem?.transitTime, case .calculating = previousTransitTime {
strongSelf.transitButtonNode?.alpha = 1.0
strongSelf.transitButtonNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
if case let .ready(walkingTime) = item.walkingTime {
strongSelf.walkingButtonNode?.title = stringForEstimatedDuration(strings: item.presentationData.strings, time: walkingTime, format: { $0 })
if let previousWalkingTime = currentItem?.walkingTime, case .calculating = previousWalkingTime {
strongSelf.walkingButtonNode?.alpha = 1.0
strongSelf.walkingButtonNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
let directionsWidth: CGFloat = 93.0
let directionsSpacing: CGFloat = 8.0
let drivingHeight = strongSelf.drivingButtonNode?.updateLayout(width: directionsWidth, transition: .immediate) ?? 0.0
let transitHeight = strongSelf.transitButtonNode?.updateLayout(width: directionsWidth, transition: .immediate) ?? 0.0
let walkingHeight = strongSelf.walkingButtonNode?.updateLayout(width: directionsWidth, transition: .immediate) ?? 0.0
var buttonOrigin = leftInset
strongSelf.drivingButtonNode?.frame = CGRect(origin: CGPoint(x: buttonOrigin, y: subtitleFrame.maxY + 12.0), size: CGSize(width: directionsWidth, height: drivingHeight))
if case .ready = item.drivingTime {
buttonOrigin += directionsWidth + directionsSpacing
}
strongSelf.transitButtonNode?.frame = CGRect(origin: CGPoint(x: buttonOrigin, y: subtitleFrame.maxY + 12.0), size: CGSize(width: directionsWidth, height: transitHeight))
if case .ready = item.transitTime {
buttonOrigin += directionsWidth + directionsSpacing
}
strongSelf.walkingButtonNode?.frame = CGRect(origin: CGPoint(x: buttonOrigin, y: subtitleFrame.maxY + 12.0), size: CGSize(width: directionsWidth, height: walkingHeight))
}
})
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
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)
}
}
@@ -0,0 +1,659 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import AppBundle
import CoreLocation
import ComponentFlow
import GlassBackgroundComponent
import PlainButtonComponent
import BundleIconComponent
import MultilineTextComponent
private let panelInset: CGFloat = 4.0
private let panelButtonSize = CGSize(width: 46.0, height: 46.0)
private let glassPanelButtonSize = CGSize(width: 40.0, height: 40.0)
private func generateBackgroundImage(theme: PresentationTheme) -> UIImage? {
let cornerRadius: CGFloat = 9.0
return generateImage(CGSize(width: (cornerRadius + panelInset) * 2.0, height: (cornerRadius + panelInset) * 2.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setShadow(offset: CGSize(), blur: 10.0, color: UIColor(rgb: 0x000000, alpha: 0.2).cgColor)
context.setFillColor(theme.rootController.navigationBar.opaqueBackgroundColor.cgColor)
let path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: panelInset, y: panelInset), size: CGSize(width: cornerRadius * 2.0, height: cornerRadius * 2.0)), cornerRadius: cornerRadius)
context.addPath(path.cgPath)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: Int(cornerRadius + panelInset), topCapHeight: Int(cornerRadius + panelInset))
}
private func generateShadowImage(theme: PresentationTheme, highlighted: Bool) -> UIImage? {
return generateImage(CGSize(width: 26.0, height: 14.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setShadow(offset: CGSize(), blur: 10.0, color: UIColor(rgb: 0x000000, alpha: 0.2).cgColor)
context.setFillColor(highlighted ? theme.list.itemHighlightedBackgroundColor.cgColor : theme.list.plainBackgroundColor.cgColor)
let path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: 0.0, y: 4.0), size: CGSize(width: 26.0, height: 20.0)), cornerRadius: 9.0)
context.addPath(path.cgPath)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 13, topCapHeight: 0)
}
public final class LocationMapHeaderNode: ASDisplayNode {
private var presentationData: PresentationData
private let glass: Bool
private let toggleMapModeSelection: () -> Void
private let updateMapMode: (LocationMapMode) -> Void
private let goToUserLocation: () -> Void
private let showPlacesInThisArea: () -> Void
private let setupProximityNotification: (Bool) -> Void
private var displayingPlacesButton = false
private var proximityNotification: Bool?
public let mapNode: LocationMapNode
public var trackingMode: LocationTrackingMode = .none
private let options: ComponentView<Empty>?
private let optionsBackgroundView: GlassBackgroundView?
private let optionsBackgroundNode: ASImageNode
private let optionsSeparatorNode: ASDisplayNode
private let optionsSecondSeparatorNode: ASDisplayNode
private let infoButtonNode: HighlightableButtonNode
private let locationButtonNode: HighlightableButtonNode
private let notificationButtonNode: HighlightableButtonNode
private let placesBackgroundView: GlassBackgroundView?
private let placesBackgroundNode: ASImageNode
private let placesButtonNode: HighlightableButtonNode
private let shadowNode: ASImageNode
private var validLayout: (ContainerViewLayout, CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, CGSize)?
public init(
presentationData: PresentationData,
glass: Bool,
toggleMapModeSelection: @escaping () -> Void,
updateMapMode: @escaping (LocationMapMode) -> Void,
goToUserLocation: @escaping () -> Void,
setupProximityNotification: @escaping (Bool) -> Void = { _ in },
showPlacesInThisArea: @escaping () -> Void = {}
) {
self.presentationData = presentationData
self.glass = glass
self.toggleMapModeSelection = toggleMapModeSelection
self.updateMapMode = updateMapMode
self.goToUserLocation = goToUserLocation
self.setupProximityNotification = setupProximityNotification
self.showPlacesInThisArea = showPlacesInThisArea
self.mapNode = LocationMapNode()
if glass {
self.options = ComponentView()
} else {
self.options = nil
}
self.optionsBackgroundNode = ASImageNode()
self.optionsBackgroundNode.contentMode = .scaleToFill
self.optionsBackgroundNode.displaysAsynchronously = false
self.optionsBackgroundNode.displayWithoutProcessing = true
self.optionsBackgroundNode.image = generateBackgroundImage(theme: presentationData.theme)
self.optionsBackgroundNode.isUserInteractionEnabled = true
self.optionsSeparatorNode = ASDisplayNode()
self.optionsSeparatorNode.backgroundColor = presentationData.theme.rootController.navigationBar.separatorColor
self.optionsSecondSeparatorNode = ASDisplayNode()
self.optionsSecondSeparatorNode.backgroundColor = presentationData.theme.rootController.navigationBar.separatorColor
let buttonColor = self.glass ? presentationData.theme.rootController.navigationBar.primaryTextColor.withAlphaComponent(0.62) : presentationData.theme.rootController.navigationBar.buttonColor
self.infoButtonNode = HighlightableButtonNode()
self.infoButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: self.glass ? "Location/OptionMap" : "Location/InfoIcon"), color: buttonColor), for: .normal)
self.infoButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Location/InfoActiveIcon"), color: buttonColor), for: .selected)
self.infoButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Location/InfoActiveIcon"), color: buttonColor), for: [.selected, .highlighted])
self.locationButtonNode = HighlightableButtonNode()
self.locationButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Location/TrackIcon"), color: buttonColor), for: .normal)
self.notificationButtonNode = HighlightableButtonNode()
self.notificationButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Location/NotificationIcon"), color: buttonColor), for: .normal)
self.notificationButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/MuteIcon"), color: buttonColor), for: .selected)
self.notificationButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/MuteIcon"), color: buttonColor), for: [.selected, .highlighted])
self.placesBackgroundNode = ASImageNode()
self.placesBackgroundNode.contentMode = .scaleToFill
self.placesBackgroundNode.displaysAsynchronously = false
self.placesBackgroundNode.displayWithoutProcessing = true
self.placesBackgroundNode.image = generateBackgroundImage(theme: presentationData.theme)
self.placesBackgroundNode.isUserInteractionEnabled = true
self.placesButtonNode = HighlightableButtonNode()
self.placesButtonNode.setTitle(presentationData.strings.Map_PlacesInThisArea, with: Font.medium(17.0), with: self.glass ? presentationData.theme.rootController.navigationBar.primaryTextColor : buttonColor, for: .normal)
self.shadowNode = ASImageNode()
self.shadowNode.contentMode = .scaleToFill
self.shadowNode.displaysAsynchronously = false
self.shadowNode.displayWithoutProcessing = true
self.shadowNode.image = generateShadowImage(theme: presentationData.theme, highlighted: false)
if glass {
self.optionsBackgroundView = GlassBackgroundView()
self.optionsBackgroundNode.image = nil
self.placesBackgroundView = GlassBackgroundView()
self.placesBackgroundNode.image = nil
} else {
self.optionsBackgroundView = nil
self.placesBackgroundView = nil
}
super.init()
self.clipsToBounds = true
self.addSubnode(self.mapNode)
if glass {
if let placesBackgroundView = self.placesBackgroundView {
self.placesBackgroundNode.view.addSubview(placesBackgroundView)
}
} else {
if let optionsBackgroundView = self.optionsBackgroundView {
self.optionsSeparatorNode.isHidden = true
self.view.addSubview(optionsBackgroundView)
}
self.addSubnode(self.optionsBackgroundNode)
self.optionsBackgroundNode.addSubnode(self.optionsSeparatorNode)
self.optionsBackgroundNode.addSubnode(self.optionsSecondSeparatorNode)
self.optionsBackgroundNode.addSubnode(self.infoButtonNode)
self.optionsBackgroundNode.addSubnode(self.locationButtonNode)
self.optionsBackgroundNode.addSubnode(self.notificationButtonNode)
}
self.addSubnode(self.placesBackgroundNode)
self.placesBackgroundNode.addSubnode(self.placesButtonNode)
//self.addSubnode(self.shadowNode)
self.infoButtonNode.addTarget(self, action: #selector(self.infoPressed), forControlEvents: .touchUpInside)
self.locationButtonNode.addTarget(self, action: #selector(self.locationPressed), forControlEvents: .touchUpInside)
self.notificationButtonNode.addTarget(self, action: #selector(self.notificationPressed), forControlEvents: .touchUpInside)
self.placesButtonNode.addTarget(self, action: #selector(self.placesPressed), forControlEvents: .touchUpInside)
}
public func updateState(mapMode: LocationMapMode, trackingMode: LocationTrackingMode, displayingMapModeOptions: Bool, displayingPlacesButton: Bool, proximityNotification: Bool?, animated: Bool) {
let mapModeUpdated = self.mapNode.mapMode != mapMode
let displayingMapModesUpdated = self.infoButtonNode.isSelected != displayingMapModeOptions
self.mapNode.mapMode = mapMode
self.trackingMode = trackingMode
self.infoButtonNode.isSelected = displayingMapModeOptions
self.notificationButtonNode.isSelected = proximityNotification ?? false
let buttonColor = self.glass ? presentationData.theme.rootController.navigationBar.primaryTextColor.withAlphaComponent(0.62) : presentationData.theme.rootController.navigationBar.buttonColor
self.locationButtonNode.setImage(generateTintedImage(image: self.iconForTracking(), color: buttonColor), for: .normal)
let updateLayout = self.displayingPlacesButton != displayingPlacesButton || self.proximityNotification != proximityNotification || mapModeUpdated || displayingMapModesUpdated
self.displayingPlacesButton = displayingPlacesButton
self.proximityNotification = proximityNotification
if updateLayout, let (layout, navigationBarHeight, topPadding, controlsTopPadding, controlsBottomPadding, offset, size) = self.validLayout {
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.3, curve: .spring) : .immediate
self.updateLayout(layout: layout, navigationBarHeight: navigationBarHeight, topPadding: topPadding, controlsTopPadding: controlsTopPadding, controlsBottomPadding: controlsBottomPadding, offset: offset, size: size, transition: transition)
}
}
public func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
let buttonColor = self.glass ? presentationData.theme.rootController.navigationBar.primaryTextColor.withAlphaComponent(0.62) : presentationData.theme.rootController.navigationBar.buttonColor
self.optionsBackgroundNode.image = generateBackgroundImage(theme: presentationData.theme)
self.optionsSeparatorNode.backgroundColor = presentationData.theme.rootController.navigationBar.separatorColor
self.optionsSecondSeparatorNode.backgroundColor = presentationData.theme.rootController.navigationBar.separatorColor
self.infoButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: self.glass ? "Location/OptionMap" : "Location/InfoIcon"), color: buttonColor), for: .normal)
self.infoButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Location/InfoActiveIcon"), color: buttonColor), for: .selected)
self.infoButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Location/InfoActiveIcon"), color: buttonColor), for: [.selected, .highlighted])
self.locationButtonNode.setImage(generateTintedImage(image: self.iconForTracking(), color: buttonColor), for: .normal)
self.notificationButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Location/NotificationIcon"), color: buttonColor), for: .normal)
self.notificationButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/MuteIcon"), color: buttonColor), for: .selected)
self.notificationButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/MuteIcon"), color: buttonColor), for: [.selected, .highlighted])
if !self.glass {
self.placesBackgroundNode.image = generateBackgroundImage(theme: presentationData.theme)
}
self.shadowNode.image = generateShadowImage(theme: presentationData.theme, highlighted: false)
}
private func iconForTracking() -> UIImage? {
switch self.trackingMode {
case .none:
return UIImage(bundleImageName: self.glass ? "Location/OptionLocate" : "Location/TrackIcon")
case .follow:
return UIImage(bundleImageName: "Location/TrackActiveIcon")
case .followWithHeading:
return UIImage(bundleImageName: "Location/TrackHeadingIcon")
}
}
public func updateLayout(layout: ContainerViewLayout, navigationBarHeight: CGFloat, topPadding: CGFloat, controlsTopPadding: CGFloat, controlsBottomPadding: CGFloat, offset: CGFloat, size: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayout = (layout, navigationBarHeight, topPadding, controlsTopPadding, controlsBottomPadding, offset, size)
let mapHeight: CGFloat = floor(layout.size.height * 1.3) + layout.intrinsicInsets.top * 2.0
let mapFrame = CGRect(x: 0.0, y: floorToScreenPixels((size.height - mapHeight + navigationBarHeight) / 2.0) + offset + floor(layout.intrinsicInsets.top * 0.5), width: size.width, height: mapHeight)
transition.updateFrame(node: self.mapNode, frame: mapFrame)
self.mapNode.updateLayout(size: mapFrame.size, topPadding: topPadding, inset: mapFrame.origin.y * -1.0 + navigationBarHeight, transition: transition)
let inset: CGFloat = 6.0
let placesButtonSize = CGSize(width: 180.0 + panelInset * 2.0, height: 45.0 + panelInset * 2.0)
let placesButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - placesButtonSize.width) / 2.0), y: navigationBarHeight + topPadding - 6.0), size: placesButtonSize)
transition.updateFrame(node: self.placesBackgroundNode, frame: placesButtonFrame)
if let placesBackgroundView = self.placesBackgroundView {
let backgroundViewFrame = CGRect(origin: .zero, size: placesButtonFrame.size).insetBy(dx: 5.0, dy: 6.0)
transition.updateFrame(view: placesBackgroundView, frame: backgroundViewFrame)
placesBackgroundView.update(size: backgroundViewFrame.size, cornerRadius: backgroundViewFrame.height * 0.5, isDark: self.presentationData.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: self.presentationData.theme.rootController.navigationBar.glassBarButtonBackgroundColor), transition: .immediate)
}
transition.updateFrame(node: self.placesButtonNode, frame: CGRect(origin: CGPoint(), size: placesButtonSize))
transition.updateAlpha(node: self.placesBackgroundNode, alpha: self.displayingPlacesButton ? 1.0 : 0.0)
transition.updateAlpha(node: self.placesButtonNode, alpha: self.displayingPlacesButton ? 1.0 : 0.0)
transition.updateFrame(node: self.shadowNode, frame: CGRect(x: 0.0, y: size.height - 14.0, width: size.width, height: 14.0))
if let options = self.options {
let optionsSize = options.update(
transition: ComponentTransition(transition),
component: AnyComponent(
LocationOptionsComponent(
theme: self.presentationData.theme,
strings: self.presentationData.strings,
mapMode: self.mapNode.mapMode,
showMapModes: self.infoButtonNode.isSelected,
updateMapMode: { [weak self] mode in
guard let self else {
return
}
self.updateMapMode(mode)
},
goToUserLocation: { [weak self] in
guard let self else {
return
}
self.goToUserLocation()
},
requestedMapModes: { [weak self] in
guard let self else {
return
}
self.toggleMapModeSelection()
}
)
),
environment: {},
containerSize: layout.size
)
if let optionsView = options.view {
if optionsView.superview == nil {
self.view.addSubview(optionsView)
}
transition.updateFrame(view: optionsView, frame: CGRect(origin: CGPoint(x: size.width - optionsSize.width - inset - 10.0, y: size.height - optionsSize.height - inset - controlsBottomPadding - 10.0), size: optionsSize))
ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut)).setAlpha(view: optionsView, alpha: size.height < 180.0 ? 0.0 : 1.0)
}
} else {
let buttonSize = self.glass ? glassPanelButtonSize : panelButtonSize
transition.updateFrame(node: self.infoButtonNode, frame: CGRect(x: panelInset, y: panelInset, width: buttonSize.width, height: buttonSize.height))
transition.updateFrame(node: self.locationButtonNode, frame: CGRect(x: panelInset, y: panelInset + buttonSize.height, width: buttonSize.width, height: buttonSize.height))
transition.updateFrame(node: self.notificationButtonNode, frame: CGRect(x: panelInset, y: panelInset + buttonSize.height * 2.0, width: buttonSize.width, height: buttonSize.height))
transition.updateFrame(node: self.optionsSeparatorNode, frame: CGRect(x: panelInset, y: panelInset + buttonSize.height, width: buttonSize.width, height: UIScreenPixel))
transition.updateFrame(node: self.optionsSecondSeparatorNode, frame: CGRect(x: panelInset, y: panelInset + buttonSize.height * 2.0, width: buttonSize.width, height: UIScreenPixel))
var panelHeight: CGFloat = buttonSize.height * 2.0
if self.proximityNotification != nil {
panelHeight += buttonSize.height
}
transition.updateAlpha(node: self.notificationButtonNode, alpha: self.proximityNotification != nil ? 1.0 : 0.0)
transition.updateAlpha(node: self.optionsSecondSeparatorNode, alpha: self.proximityNotification != nil ? 1.0 : 0.0)
let backgroundFrame = CGRect(x: size.width - inset - buttonSize.width - panelInset * 2.0 - layout.safeInsets.right - 6.0, y: size.height - panelHeight - inset - 14.0 - controlsBottomPadding, width: buttonSize.width + panelInset * 2.0, height: panelHeight + panelInset * 2.0)
transition.updateFrame(node: self.optionsBackgroundNode, frame: backgroundFrame)
if let optionsBackgroundView = self.optionsBackgroundView {
let backgroundViewFrame = backgroundFrame.insetBy(dx: 4.0, dy: 4.0)
transition.updateFrame(view: optionsBackgroundView, frame: backgroundViewFrame)
optionsBackgroundView.update(size: backgroundViewFrame.size, cornerRadius: backgroundViewFrame.width * 0.5, isDark: self.presentationData.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: self.presentationData.theme.rootController.navigationBar.blurredBackgroundColor), transition: .immediate)
}
let alphaTransition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut)
let optionsAlpha: CGFloat = size.height > 160.0 + navigationBarHeight && !self.forceIsHidden ? 1.0 : 0.0
alphaTransition.updateAlpha(node: self.optionsBackgroundNode, alpha: optionsAlpha)
}
}
public var forceIsHidden: Bool = false {
didSet {
if let (layout, navigationBarHeight, topPadding, controlsTopPadding, controlsBottomPadding, offset, size) = self.validLayout {
self.updateLayout(layout: layout, navigationBarHeight: navigationBarHeight, topPadding: topPadding, controlsTopPadding: controlsTopPadding, controlsBottomPadding: controlsBottomPadding, offset: offset, size: size, transition: .immediate)
}
}
}
public func updateHighlight(_ highlighted: Bool) {
self.shadowNode.image = generateShadowImage(theme: self.presentationData.theme, highlighted: highlighted)
}
public func proximityButtonFrame() -> CGRect? {
if self.notificationButtonNode.alpha > 0.0 {
return self.optionsBackgroundNode.view.convert(self.notificationButtonNode.frame, to: self.view)
} else {
return nil
}
}
@objc private func infoPressed() {
self.toggleMapModeSelection()
}
@objc private func locationPressed() {
self.goToUserLocation()
}
@objc private func notificationPressed() {
if let proximityNotification = self.proximityNotification {
self.setupProximityNotification(proximityNotification)
}
}
@objc private func placesPressed() {
self.showPlacesInThisArea()
}
}
public final class LocationOptionsComponent: Component {
public let theme: PresentationTheme
public let strings: PresentationStrings
public let mapMode: LocationMapMode
public let showMapModes: Bool
public let updateMapMode: (LocationMapMode) -> Void
public let goToUserLocation: () -> Void
public let requestedMapModes: () -> Void
public init(
theme: PresentationTheme,
strings: PresentationStrings,
mapMode: LocationMapMode,
showMapModes: Bool,
updateMapMode: @escaping (LocationMapMode) -> Void,
goToUserLocation: @escaping () -> Void,
requestedMapModes: @escaping () -> Void
) {
self.theme = theme
self.strings = strings
self.mapMode = mapMode
self.showMapModes = showMapModes
self.updateMapMode = updateMapMode
self.goToUserLocation = goToUserLocation
self.requestedMapModes = requestedMapModes
}
public static func ==(lhs: LocationOptionsComponent, rhs: LocationOptionsComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.mapMode != rhs.mapMode {
return false
}
if lhs.showMapModes != rhs.showMapModes {
return false
}
return true
}
public final class View: HighlightTrackingButton {
private let containerView: GlassBackgroundContainerView
private let backgroundView: GlassBackgroundView
private let clippingView: UIView
private let collapsedContainerView = UIView()
private var mapModeButton = ComponentView<Empty>()
private var trackingButton = ComponentView<Empty>()
private let expandedContainerView = UIView()
private let checkIcon = UIImageView()
private var mapButton = ComponentView<Empty>()
private var satelliteButton = ComponentView<Empty>()
private var hybridButton = ComponentView<Empty>()
private var component: LocationOptionsComponent?
public override init(frame: CGRect) {
self.containerView = GlassBackgroundContainerView()
self.backgroundView = GlassBackgroundView()
self.clippingView = UIView()
self.clippingView.clipsToBounds = true
self.checkIcon.image = UIImage(bundleImageName: "Media Gallery/Check")?.withRenderingMode(.alwaysTemplate)
super.init(frame: frame)
self.addSubview(self.containerView)
self.containerView.contentView.addSubview(self.backgroundView)
self.addSubview(self.clippingView)
self.clippingView.addSubview(self.collapsedContainerView)
self.clippingView.addSubview(self.expandedContainerView)
}
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: LocationOptionsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let mapButtonSize = self.mapButton.update(
transition: transition,
component: AnyComponent(
PlainButtonComponent(
content: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: component.strings.Map_Map, font: Font.regular(17.0), textColor: component.theme.rootController.navigationBar.primaryTextColor)))
),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.updateMapMode(.map)
},
animateScale: false
)
),
environment: {},
containerSize: availableSize
)
let satelliteButtonSize = self.satelliteButton.update(
transition: transition,
component: AnyComponent(
PlainButtonComponent(
content: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: component.strings.Map_Satellite, font: Font.regular(17.0), textColor: component.theme.rootController.navigationBar.primaryTextColor)))
),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.updateMapMode(.satellite)
},
animateScale: false
)
),
environment: {},
containerSize: availableSize
)
let hybridButtonSize = self.hybridButton.update(
transition: transition,
component: AnyComponent(
PlainButtonComponent(
content: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: component.strings.Map_Hybrid, font: Font.regular(17.0), textColor: component.theme.rootController.navigationBar.primaryTextColor)))
),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.updateMapMode(.hybrid)
},
animateScale: false
)
),
environment: {},
containerSize: availableSize
)
self.checkIcon.tintColor = component.theme.rootController.navigationBar.primaryTextColor
if let image = self.checkIcon.image {
self.checkIcon.frame = CGRect(origin: CGPoint(x: -34.0, y: floorToScreenPixels((mapButtonSize.height - image.size.height) / 2.0)), size: image.size)
}
let leftInset: CGFloat = 60.0
let rightInset: CGFloat = 44.0
let verticalInset: CGFloat = 23.0
let maxWidth = max(mapButtonSize.width, max(satelliteButtonSize.width, hybridButtonSize.width))
let cornerRadius: CGFloat = component.showMapModes ? 27.0 : 20.0
let expandedSize = CGSize(width: leftInset + maxWidth + rightInset, height: 150.0)
let mapButtonFrame = CGRect(origin: CGPoint(x: leftInset, y: verticalInset), size: mapButtonSize)
if let mapButtonView = self.mapButton.view {
if mapButtonView.superview == nil {
self.expandedContainerView.addSubview(mapButtonView)
}
if component.mapMode == .map && component.showMapModes {
mapButtonView.addSubview(self.checkIcon)
}
transition.setFrame(view: mapButtonView, frame: mapButtonFrame)
}
let satelliteButtonFrame = CGRect(origin: CGPoint(x: leftInset, y: floorToScreenPixels((expandedSize.height - satelliteButtonSize.height) / 2.0)), size: satelliteButtonSize)
if let satelliteButtonView = self.satelliteButton.view {
if satelliteButtonView.superview == nil {
self.expandedContainerView.addSubview(satelliteButtonView)
}
if component.mapMode == .satellite && component.showMapModes {
satelliteButtonView.addSubview(self.checkIcon)
}
transition.setFrame(view: satelliteButtonView, frame: satelliteButtonFrame)
}
let hybridButtonFrame = CGRect(origin: CGPoint(x: leftInset, y: expandedSize.height - hybridButtonSize.height - verticalInset), size: hybridButtonSize)
if let hybridButtonView = self.hybridButton.view {
if hybridButtonView.superview == nil {
self.expandedContainerView.addSubview(hybridButtonView)
}
if component.mapMode == .hybrid && component.showMapModes {
hybridButtonView.addSubview(self.checkIcon)
}
transition.setFrame(view: hybridButtonView, frame: hybridButtonFrame)
}
let normalSize = CGSize(width: 40.0, height: 80.0)
let expandedFrame = CGRect(origin: .zero, size: expandedSize)
let collapsedFrame = CGRect(origin: CGPoint(x: expandedSize.width - normalSize.width, y: expandedSize.height - normalSize.height), size: normalSize)
let effectiveBackgroundFrame = component.showMapModes ? expandedFrame : collapsedFrame
self.backgroundView.update(size: effectiveBackgroundFrame.size, cornerRadius: cornerRadius, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: component.theme.rootController.navigationBar.glassBarButtonBackgroundColor), isInteractive: true, transition: transition)
transition.setFrame(view: self.backgroundView, frame: effectiveBackgroundFrame)
transition.setFrame(view: self.clippingView, frame: effectiveBackgroundFrame)
transition.setFrame(view: self.expandedContainerView, frame: expandedFrame.offsetBy(dx: effectiveBackgroundFrame.width - expandedFrame.width, dy: effectiveBackgroundFrame.height - expandedFrame.height))
transition.setFrame(view: self.collapsedContainerView, frame: collapsedFrame.offsetBy(dx: -effectiveBackgroundFrame.minX, dy: -effectiveBackgroundFrame.minY))
let mapModeButtonSize = self.mapModeButton.update(
transition: transition,
component: AnyComponent(
PlainButtonComponent(
content: AnyComponent(
BundleIconComponent(name: "Location/OptionMap", tintColor: component.theme.rootController.navigationBar.primaryTextColor.withAlphaComponent(0.62))
),
minSize: CGSize(width: 40.0, height: 40.0),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.requestedMapModes()
},
animateAlpha: true,
animateScale: false
)
),
environment: {},
containerSize: CGSize(width: 40.0, height: 40.0)
)
let mapModeButtonFrame = CGRect(origin: .zero, size: mapModeButtonSize)
if let mapModeButtonView = self.mapModeButton.view {
if mapModeButtonView.superview == nil {
self.collapsedContainerView.addSubview(mapModeButtonView)
}
transition.setFrame(view: mapModeButtonView, frame: mapModeButtonFrame)
}
let trackingButtonSize = self.trackingButton.update(
transition: transition,
component: AnyComponent(
PlainButtonComponent(
content: AnyComponent(
BundleIconComponent(name: "Location/OptionLocate", tintColor: component.theme.rootController.navigationBar.primaryTextColor.withAlphaComponent(0.62))
),
minSize: CGSize(width: 40.0, height: 40.0),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.goToUserLocation()
},
animateAlpha: true,
animateScale: false
)
),
environment: {},
containerSize: CGSize(width: 40.0, height: 40.0)
)
let trackingButtonFrame = CGRect(origin: CGPoint(x: 0.0, y: 40.0), size: trackingButtonSize)
if let trackingButtonView = self.trackingButton.view {
if trackingButtonView.superview == nil {
self.collapsedContainerView.addSubview(trackingButtonView)
}
transition.setFrame(view: trackingButtonView, frame: trackingButtonFrame)
}
transition.setAlpha(view: self.collapsedContainerView, alpha: component.showMapModes ? 0.0 : 1.0)
transition.setAlpha(view: self.expandedContainerView, alpha: component.showMapModes ? 1.0 : 0.0)
self.containerView.update(size: expandedSize, isDark: component.theme.overallDarkAppearance, transition: transition)
transition.setFrame(view: self.containerView, frame: CGRect(origin: .zero, size: expandedSize))
return expandedSize
}
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return self.backgroundView.frame.contains(point)
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,877 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import MapKit
private let pinOffset = CGPoint(x: 0.0, y: 33.0)
public enum LocationMapMode {
case map
case satellite
case hybrid
var mapType: MKMapType {
switch self {
case .satellite:
return .satellite
case .hybrid:
return .hybrid
default:
return .standard
}
}
}
public enum LocationTrackingMode {
case none
case follow
case followWithHeading
var userTrackingMode: MKUserTrackingMode {
switch self {
case .follow:
return .follow
case .followWithHeading:
return .followWithHeading
default:
return .none
}
}
}
private class PickerAnnotationContainerView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
if result == self {
return nil
}
return result
}
}
private class LocationMapView: MKMapView, UIGestureRecognizerDelegate {
var customHitTest: ((CGPoint) -> Bool)?
private var allowSelectionChanges = true
var onTouch: (() -> Void)?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
self.onTouch?()
}
@objc override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let customHitTest = self.customHitTest, customHitTest(gestureRecognizer.location(in: self)) {
return false
}
return self.allowSelectionChanges
}
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let pointInside = super.point(inside: point, with: event)
if !pointInside {
return pointInside
}
for annotation in self.annotations(in: self.visibleMapRect) where annotation is LocationPinAnnotation {
guard let view = self.view(for: annotation as! MKAnnotation) else {
continue
}
if view.frame.insetBy(dx: -16.0, dy: -16.0).contains(point) {
self.allowSelectionChanges = true
return true
}
}
self.allowSelectionChanges = false
return pointInside
}
public override func layoutSubviews() {
super.layoutSubviews()
}
}
private let arrowImageSize = CGSize(width: 90.0, height: 90.0)
func generateHeadingArrowImage() -> UIImage? {
return generateImage(arrowImageSize, contextGenerator: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.saveGState()
let center = CGPoint(x: arrowImageSize.width / 2.0, y: arrowImageSize.height / 2.0)
context.move(to: center)
context.addArc(center: center, radius: arrowImageSize.width / 2.0, startAngle: CGFloat.pi / 2.0 + CGFloat.pi / 8.0, endAngle: CGFloat.pi / 2.0 - CGFloat.pi / 8.0, clockwise: true)
context.clip()
var locations: [CGFloat] = [0.0, 0.4, 1.0]
let colors: [CGColor] = [UIColor(rgb: 0x0088ff, alpha: 0.5).cgColor, UIColor(rgb: 0x0088ff, alpha: 0.3).cgColor, UIColor(rgb: 0x0088ff, alpha: 0.0).cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawRadialGradient(gradient, startCenter: center, startRadius: 11.0, endCenter: center, endRadius: arrowImageSize.width / 2.0, options: .drawsAfterEndLocation)
context.restoreGState()
context.setBlendMode(.clear)
context.fillEllipse(in: CGRect(x: (arrowImageSize.width - 22.0) / 2.0, y: (arrowImageSize.height - 22.0) / 2.0, width: 22.0, height: 22.0))
})
}
private func generateProximityDim(size: CGSize) -> UIImage {
return generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor(rgb: 0x000000, alpha: 0.4).cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
context.setBlendMode(.clear)
let ellipseSize = CGSize(width: 260.0, height: 260.0)
context.fillEllipse(in: CGRect(origin: CGPoint(x: (size.width - ellipseSize.width) / 2.0, y: (size.height - ellipseSize.height) / 2.0), size: ellipseSize))
})!
}
protocol MKMapViewDelegateTarget: AnyObject {
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool)
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool)
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation)
func mapView(_ mapView: MKMapView, didFailToLocateUserWithError error: Error)
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView?
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView])
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView)
func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView)
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer
}
private final class MKMapViewDelegateImpl: NSObject, MKMapViewDelegate {
private weak var target: MKMapViewDelegateTarget?
init(target: MKMapViewDelegateTarget) {
self.target = target
super.init()
}
func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
self.target?.mapView(mapView, regionWillChangeAnimated: animated)
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
self.target?.mapView(mapView, regionDidChangeAnimated: animated)
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
self.target?.mapView(mapView, didUpdate: userLocation)
}
func mapView(_ mapView: MKMapView, didFailToLocateUserWithError error: Error) {
self.target?.mapView(mapView, didFailToLocateUserWithError: error)
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
return self.target?.mapView(mapView, viewFor: annotation)
}
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
self.target?.mapView(mapView, didAdd: views)
}
func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
self.target?.mapView(mapView, didSelect: view)
}
func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
self.target?.mapView(mapView, didDeselect: view)
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
return self.target?.mapView(mapView, rendererFor: overlay) ?? MKOverlayRenderer()
}
}
public final class LocationMapNode: ASDisplayNode, MKMapViewDelegateTarget {
private var delegateImpl: MKMapViewDelegateImpl?
public static let defaultMapSpan = MKCoordinateSpan(latitudeDelta: 0.016, longitudeDelta: 0.016)
public static let viewMapSpan = MKCoordinateSpan(latitudeDelta: 0.008, longitudeDelta: 0.008)
public static let globalMapSpan = MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
class ProximityCircleRenderer: MKCircleRenderer {
override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {
super.draw(mapRect, zoomScale: zoomScale, in: context)
context.saveGState()
let mapBoundingRect = self.circle.boundingMapRect
let mapPoint = MKMapPoint(x: mapBoundingRect.midX, y: mapBoundingRect.maxY)
let drawingPoint = point(for: mapPoint)
if let image = generateTintedImage(image: UIImage(bundleImageName: "Location/ProximityIcon"), color: self.strokeColor ?? .black) {
let imageSize = CGSize(width: floor(image.size.width / zoomScale * self.contentScaleFactor * 0.75), height: floor(image.size.height / zoomScale * self.contentScaleFactor * 0.75))
context.translateBy(x: drawingPoint.x, y: drawingPoint.y)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -drawingPoint.x, y: -drawingPoint.y)
let imageRect = CGRect(x: floor((drawingPoint.x - imageSize.width / 2.0)), y: floor((drawingPoint.y - imageSize.height / 2.0)), width: imageSize.width, height: imageSize.height)
context.clear(imageRect)
context.draw(image.cgImage!, in: imageRect)
}
context.restoreGState()
}
}
private let locationPromise = Promise<CLLocation?>(nil)
private let pickerAnnotationContainerView: PickerAnnotationContainerView
private weak var userLocationAnnotationView: MKAnnotationView?
private var headingArrowView: UIImageView?
private var compassView: MKCompassButton?
private weak var defaultUserLocationAnnotation: MKAnnotation?
private let pinDisposable = MetaDisposable()
private var mapView: LocationMapView? {
return self.view as? LocationMapView
}
var returnedToUserLocation = true
var ignoreRegionChanges = false
var isDragging = false
var onTouch: (() -> Void)?
var beganInteractiveDragging: (() -> Void)?
var endedInteractiveDragging: ((CLLocationCoordinate2D) -> Void)?
var disableHorizontalTransitionGesture = false
var annotationSelected: ((LocationPinAnnotation?) -> Void)?
var userLocationAnnotationSelected: (() -> Void)?
var proximityDimView = UIImageView()
var proximityIndicatorRadius: Double? {
didSet {
if let _ = self.proximityIndicatorRadius, let mapView = self.mapView {
if self.proximityDimView.image == nil {
proximityDimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
if oldValue == 0 {
UIView.transition(with: proximityDimView, duration: 0.3, options: .transitionCrossDissolve) {
self.proximityDimView.image = generateProximityDim(size: mapView.bounds.size)
} completion: { _ in
}
}
} else {
if self.proximityDimView.image != nil {
UIView.transition(with: proximityDimView, duration: 0.3, options: .transitionCrossDissolve) {
self.proximityDimView.image = nil
} completion: { _ in
}
}
}
}
}
private var circleOverlay: MKCircle?
public var activeProximityRadius: Double? {
didSet {
if let activeProximityRadius = self.activeProximityRadius {
if let circleOverlay = self.circleOverlay {
self.circleOverlay = nil
self.mapView?.removeOverlay(circleOverlay)
}
if let location = self.currentUserLocation {
let overlay = MKCircle(center: location.coordinate, radius: activeProximityRadius)
self.circleOverlay = overlay
self.mapView?.addOverlay(overlay)
}
} else {
if let circleOverlay = self.circleOverlay {
self.circleOverlay = nil
self.mapView?.removeOverlay(circleOverlay)
}
}
}
}
override public init() {
self.pickerAnnotationContainerView = PickerAnnotationContainerView()
self.pickerAnnotationContainerView.isHidden = true
super.init()
self.setViewBlock({
return LocationMapView()
})
}
override public func didLoad() {
super.didLoad()
guard let mapView = self.mapView else {
return
}
self.headingArrowView = UIImageView()
self.headingArrowView?.frame = CGRect(origin: CGPoint(), size: CGSize(width: 88.0, height: 88.0))
self.headingArrowView?.image = generateHeadingArrowImage()
mapView.interactiveTransitionGestureRecognizerTest = { p in
if p.x > 44.0 {
return true
} else {
return false
}
}
mapView.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in
return self?.disableHorizontalTransitionGesture == true
}
let delegateImpl = MKMapViewDelegateImpl(target: self)
self.delegateImpl = delegateImpl
let compassView = MKCompassButton(mapView: mapView)
compassView.isUserInteractionEnabled = true
self.compassView = compassView
mapView.addSubview(compassView)
mapView.delegate = delegateImpl
mapView.mapType = self.mapMode.mapType
mapView.isRotateEnabled = self.isRotateEnabled
mapView.showsUserLocation = true
mapView.pointOfInterestFilter = .excludingAll
mapView.showsCompass = false
mapView.customHitTest = { [weak self] point in
guard let strongSelf = self else {
return false
}
if let annotationView = strongSelf.customUserLocationAnnotationView, let annotationRect = annotationView.superview?.convert(annotationView.frame.insetBy(dx: -16.0, dy: -16.0), to: strongSelf.mapView), annotationRect.contains(point) {
strongSelf.userLocationAnnotationSelected?()
return true
}
if let userAnnotation = strongSelf.defaultUserLocationAnnotation, let annotationView = strongSelf.mapView?.view(for: userAnnotation), let annotationRect = annotationView.superview?.convert(annotationView.frame.insetBy(dx: -16.0, dy: -16.0), to: strongSelf.mapView), annotationRect.contains(point) {
strongSelf.userLocationAnnotationSelected?()
return true
}
return false
}
mapView.onTouch = { [weak self] in
guard let self else {
return
}
self.onTouch?()
}
self.view.addSubview(self.pickerAnnotationContainerView)
}
var isRotateEnabled: Bool = true {
didSet {
self.mapView?.isRotateEnabled = self.isRotateEnabled
}
}
var mapMode: LocationMapMode = .map {
didSet {
self.mapView?.mapType = self.mapMode.mapType
}
}
public var trackingMode: LocationTrackingMode = .none {
didSet {
self.mapView?.userTrackingMode = self.trackingMode.userTrackingMode
if self.trackingMode == .followWithHeading && self.headingArrowView?.image != nil {
self.headingArrowView?.image = nil
} else if self.trackingMode != .followWithHeading && self.headingArrowView?.image == nil {
self.headingArrowView?.image = generateHeadingArrowImage()
}
}
}
var topPadding: CGFloat = 0.0
var mapOffset: CGFloat = 0.0
var hasValidLayout: Bool = false
var pendingSetMapCenter: (coordinate: CLLocationCoordinate2D, span: MKCoordinateSpan, offset: CGPoint, isUserLocation: Bool, hidePicker: Bool, animated: Bool)?
public func setMapCenter(coordinate: CLLocationCoordinate2D, radius: Double, insets: UIEdgeInsets, offset: CGFloat, animated: Bool = false) {
self.mapOffset = offset
self.ignoreRegionChanges = true
var insets = insets
insets.top += self.topPadding
let mapRect = MKMapRect(region: MKCoordinateRegion(center: coordinate, latitudinalMeters: radius * 2.0, longitudinalMeters: radius * 2.0))
self.mapView?.setVisibleMapRect(mapRect, edgePadding: insets, animated: animated)
self.ignoreRegionChanges = false
self.proximityDimView.center = CGPoint(x: self.bounds.midX, y: self.bounds.midY + offset)
}
public func setMapCenter(coordinate: CLLocationCoordinate2D, span: MKCoordinateSpan = defaultMapSpan, offset: CGPoint = CGPoint(), isUserLocation: Bool = false, hidePicker: Bool = false, animated: Bool = false) {
self.pendingSetMapCenter = (
coordinate, span, offset, isUserLocation, hidePicker, animated
)
if self.hasValidLayout {
self.applyPendingSetMapCenter()
}
}
private func applyPendingSetMapCenter() {
if !self.hasValidLayout {
return
}
guard let (coordinate, span, offset, isUserLocation, hidePicker, animated) = self.pendingSetMapCenter else {
return
}
self.pendingSetMapCenter = nil
let region = MKCoordinateRegion(center: coordinate, span: span)
self.ignoreRegionChanges = true
if offset == CGPoint() && self.topPadding == 0.0 {
self.mapView?.setRegion(region, animated: animated)
} else {
let mapRect = MKMapRect(region: region)
var effectiveTopOffset: CGFloat = offset.y
if #available(iOS 18.0, *) {
} else {
effectiveTopOffset += self.topPadding
}
self.mapView?.setVisibleMapRect(mapRect, edgePadding: UIEdgeInsets(top: effectiveTopOffset, left: offset.x, bottom: 0.0, right: 0.0), animated: animated)
}
self.ignoreRegionChanges = false
if isUserLocation {
if !self.returnedToUserLocation {
self.returnedToUserLocation = true
self.pickerAnnotationView?.setRaised(true, animated: true)
}
} else if self.hasPickerAnnotation, let customUserLocationAnnotationView = self.customUserLocationAnnotationView, customUserLocationAnnotationView.isHidden, hidePicker {
self.pickerAnnotationContainerView.isHidden = true
customUserLocationAnnotationView.setSelected(false, animated: false)
customUserLocationAnnotationView.isHidden = false
customUserLocationAnnotationView.animateAppearance()
}
}
public func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
guard !self.ignoreRegionChanges, let scrollView = mapView.subviews.first, let gestureRecognizers = scrollView.gestureRecognizers else {
return
}
for gestureRecognizer in gestureRecognizers {
if gestureRecognizer.state == .began || gestureRecognizer.state == .ended {
self.isDragging = true
self.returnedToUserLocation = false
self.beganInteractiveDragging?()
self.switchToPicking(raise: true, animated: true)
break
}
}
}
public func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
let wasDragging = self.isDragging
if self.isDragging {
self.isDragging = false
if let coordinate = self.mapCenterCoordinate {
self.endedInteractiveDragging?(coordinate)
}
}
if let pickerAnnotationView = self.pickerAnnotationView {
if pickerAnnotationView.isRaised && (wasDragging || self.returnedToUserLocation) {
self.schedulePin(wasDragging: wasDragging)
}
}
}
public func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard let location = userLocation.location else {
return
}
userLocation.title = ""
self.locationPromise.set(.single(location))
}
public func mapView(_ mapView: MKMapView, didFailToLocateUserWithError error: Error) {
self.locationPromise.set(.single(nil))
}
public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
if annotation === mapView.userLocation {
return nil
}
if let annotation = annotation as? LocationPinAnnotation {
var view = mapView.dequeueReusableAnnotationView(withIdentifier: locationPinReuseIdentifier)
if view == nil {
view = LocationPinAnnotationView(annotation: annotation)
}
return view
}
return nil
}
public func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
for view in views {
if view.annotation is MKUserLocation {
self.defaultUserLocationAnnotation = view.annotation
view.canShowCallout = false
self.userLocationAnnotationView = view
if let headingArrowView = self.headingArrowView {
view.addSubview(headingArrowView)
headingArrowView.center = CGPoint(x: view.frame.width / 2.0, y: view.frame.height / 2.0)
}
if let annotationView = self.customUserLocationAnnotationView {
view.addSubview(annotationView)
}
} else if let view = view as? LocationPinAnnotationView {
view.setZPosition(view.defaultZPosition)
}
if let container = view.superview {
container.insertSubview(self.proximityDimView, at: 0)
}
}
}
public func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) {
guard let annotation = view.annotation as? LocationPinAnnotation else {
return
}
if let view = view as? LocationPinAnnotationView {
view.setZPosition(nil)
}
self.annotationSelected?(annotation)
if let annotationView = self.customUserLocationAnnotationView, annotationView.isSelected {
annotationView.setSelected(false, animated: true)
}
}
public func mapView(_ mapView: MKMapView, didDeselect view: MKAnnotationView) {
if let view = view as? LocationPinAnnotationView {
Queue.mainQueue().after(0.2) {
view.setZPosition(view.defaultZPosition)
}
}
Queue.mainQueue().after(0.05) {
if mapView.selectedAnnotations.isEmpty {
if !self.isDragging {
self.annotationSelected?(nil)
}
if let annotationView = self.customUserLocationAnnotationView, !annotationView.isSelected {
annotationView.setSelected(true, animated: true)
}
}
}
}
public func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let circle = overlay as? MKCircle {
let renderer = ProximityCircleRenderer(circle: circle)
renderer.fillColor = .clear
renderer.strokeColor = UIColor(rgb: 0xc3baaf)
renderer.lineWidth = 0.75
renderer.lineDashPattern = [5, 4]
return renderer
} else {
return MKOverlayRenderer()
}
}
public var distancesToAllAnnotations: Signal<[Double], NoError> {
let poll = Signal<[LocationPinAnnotation], NoError> { [weak self] subscriber in
if let strongSelf = self {
subscriber.putNext(strongSelf.annotations)
}
subscriber.putCompletion()
return EmptyDisposable
}
let annotationsPoll = (poll |> then(.complete() |> delay(1.0, queue: Queue.concurrentDefaultQueue()))) |> restart
return combineLatest(self.userLocation, annotationsPoll)
|> map { userLocation, annotations -> [Double] in
var distances: [Double] = []
if let userLocation = userLocation {
for annotation in annotations {
if annotation.isSelf {
continue
}
distances.append(userLocation.distance(from: CLLocation(latitude: annotation.coordinate.latitude, longitude: annotation.coordinate.longitude)))
}
}
return distances.sorted()
}
}
public var currentUserLocation: CLLocation? {
return self.mapView?.userLocation.location
}
public var userLocation: Signal<CLLocation?, NoError> {
return .single(self.currentUserLocation)
|> then (self.locationPromise.get())
}
public var mapCenterCoordinate: CLLocationCoordinate2D? {
guard let mapView = self.mapView else {
return nil
}
return mapView.convert(CGPoint(x: (mapView.frame.width + pinOffset.x) / 2.0, y: (mapView.frame.height + pinOffset.y) / 2.0), toCoordinateFrom: mapView)
}
public var mapSpan: MKCoordinateSpan? {
guard let mapView = self.mapView else {
return nil
}
return mapView.region.span
}
public func resetAnnotationSelection() {
guard let mapView = self.mapView else {
return
}
for annotation in mapView.selectedAnnotations {
mapView.deselectAnnotation(annotation, animated: true)
}
}
public var pickerAnnotationView: LocationPinAnnotationView? = nil
public var hasPickerAnnotation: Bool = false {
didSet {
if self.hasPickerAnnotation, let annotation = self.userLocationAnnotation {
let pickerAnnotationView = LocationPinAnnotationView(annotation: annotation)
pickerAnnotationView.center = CGPoint(x: self.pickerAnnotationContainerView.frame.width / 2.0, y: self.pickerAnnotationContainerView.frame.height / 2.0 + 16.0)
self.pickerAnnotationContainerView.addSubview(pickerAnnotationView)
self.pickerAnnotationView = pickerAnnotationView
} else {
self.pickerAnnotationView?.removeFromSuperview()
self.pickerAnnotationView = nil
}
}
}
public func switchToPicking(raise: Bool = false, animated: Bool) {
guard self.hasPickerAnnotation else {
return
}
self.customUserLocationAnnotationView?.isHidden = true
self.pickerAnnotationContainerView.isHidden = false
if let pickerAnnotationView = self.pickerAnnotationView, !pickerAnnotationView.isRaised {
pickerAnnotationView.setCustom(true, animated: animated)
if raise {
pickerAnnotationView.setRaised(true, animated: animated)
}
}
self.resetAnnotationSelection()
self.resetScheduledPin()
}
public var customUserLocationAnnotationView: LocationPinAnnotationView? = nil
public var userLocationAnnotation: LocationPinAnnotation? = nil {
didSet {
if let annotation = self.userLocationAnnotation {
self.customUserLocationAnnotationView?.removeFromSuperview()
let annotationView = LocationPinAnnotationView(annotation: annotation)
annotationView.frame = annotationView.frame.offsetBy(dx: 21.0, dy: 22.0)
if let parentView = self.userLocationAnnotationView {
parentView.addSubview(annotationView)
}
self.customUserLocationAnnotationView = annotationView
self.pickerAnnotationView?.annotation = annotation
} else {
self.customUserLocationAnnotationView?.removeFromSuperview()
self.customUserLocationAnnotationView = nil
}
}
}
public var userHeading: CGFloat? = nil {
didSet {
if let heading = self.userHeading {
self.headingArrowView?.isHidden = false
self.headingArrowView?.transform = CGAffineTransform(rotationAngle: CGFloat(heading / 180.0 * CGFloat.pi))
} else {
self.headingArrowView?.isHidden = true
self.headingArrowView?.transform = CGAffineTransform.identity
}
}
}
public var annotations: [LocationPinAnnotation] = [] {
didSet {
guard let mapView = self.mapView else {
return
}
var dict: [String: LocationPinAnnotation] = [:]
for annotation in self.annotations {
dict[annotation.id] = annotation
}
var annotationsToRemove = Set<LocationPinAnnotation>()
for annotation in mapView.annotations {
guard let annotation = annotation as? LocationPinAnnotation else {
continue
}
if let updatedAnnotation = dict[annotation.id] {
func degToRad(_ degrees: Double) -> Double {
return degrees * Double.pi / 180.0
}
func radToDeg(_ radians: Double) -> Double {
return radians / Double.pi * 180.0
}
let currentCoordinate = annotation.coordinate
let coordinate = updatedAnnotation.coordinate
var heading = updatedAnnotation.heading
if heading == nil {
let previous = CLLocation(latitude: currentCoordinate.latitude, longitude: currentCoordinate.longitude)
let new = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
if new.distance(from: previous) > 10 {
let lat1 = degToRad(currentCoordinate.latitude)
let lon1 = degToRad(currentCoordinate.longitude)
let lat2 = degToRad(coordinate.latitude)
let lon2 = degToRad(coordinate.longitude)
let dLat = lat2 - lat1
let dLon = lon2 - lon1
if dLat != 0 && dLon != 0 {
let y = sin(dLon) * cos(lat2)
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
heading = NSNumber(value: radToDeg(atan2(y, x)))
}
} else {
heading = annotation.heading
}
}
UIView.animate(withDuration: 0.2) {
annotation.coordinate = updatedAnnotation.coordinate
}
annotation.heading = heading
dict[annotation.id] = nil
} else {
annotationsToRemove.insert(annotation)
}
}
let selectedAnnotation = mapView.selectedAnnotations.first
var updated = false
if !annotationsToRemove.isEmpty {
mapView.removeAnnotations(Array(annotationsToRemove))
updated = true
}
if !dict.isEmpty {
mapView.addAnnotations(Array(dict.values))
updated = true
}
if let selectedAnnotation = selectedAnnotation as? LocationPinAnnotation, updated {
for annotation in self.annotations {
if annotation.id == selectedAnnotation.id {
mapView.selectAnnotation(annotation, animated: false)
break
}
}
}
}
}
private func schedulePin(wasDragging: Bool) {
let timeout: Double = wasDragging ? 0.38 : 0.05
let signal: Signal<Never, NoError> = .complete()
|> delay(timeout, queue: Queue.mainQueue())
self.pinDisposable.set(signal.start(completed: { [weak self] in
guard let strongSelf = self, let pickerAnnotationView = strongSelf.pickerAnnotationView else {
return
}
pickerAnnotationView.setRaised(false, animated: true) { [weak self] in
guard let strongSelf = self else {
return
}
if strongSelf.returnedToUserLocation {
strongSelf.pickerAnnotationContainerView.isHidden = true
strongSelf.customUserLocationAnnotationView?.isHidden = false
}
}
if strongSelf.returnedToUserLocation {
pickerAnnotationView.setCustom(false, animated: true)
}
}))
}
private func resetScheduledPin() {
self.pinDisposable.set(nil)
}
public func showAll(animated: Bool = true) {
guard let mapView = self.mapView else {
return
}
var coordinates: [CLLocationCoordinate2D] = []
if let location = self.currentUserLocation {
coordinates.append(location.coordinate)
}
coordinates.append(contentsOf: self.annotations.map { $0.coordinate })
var zoomRect: MKMapRect?
for coordinate in coordinates {
let pointRegionRect = MKMapRect(region: MKCoordinateRegion(center: coordinate, latitudinalMeters: 100, longitudinalMeters: 100))
if let currentZoomRect = zoomRect {
zoomRect = currentZoomRect.union(pointRegionRect)
} else {
zoomRect = pointRegionRect
}
}
if let zoomRect = zoomRect {
let insets = UIEdgeInsets(top: 88.0, left: 80.0, bottom: 160.0, right: 80.0)
let fittedZoomRect = mapView.mapRectThatFits(zoomRect, edgePadding: insets)
mapView.setVisibleMapRect(fittedZoomRect, animated: animated)
}
}
public func updateLayout(size: CGSize, topPadding: CGFloat, inset: CGFloat, transition: ContainedViewLayoutTransition) {
self.hasValidLayout = true
self.topPadding = topPadding
self.proximityDimView.frame = CGRect(origin: CGPoint(x: 0.0, y: self.topPadding + self.mapOffset), size: size)
self.pickerAnnotationContainerView.frame = CGRect(x: 0.0, y: floorToScreenPixels((size.height - size.width) / 2.0), width: size.width, height: size.width)
if let pickerAnnotationView = self.pickerAnnotationView {
pickerAnnotationView.center = CGPoint(x: self.pickerAnnotationContainerView.frame.width / 2.0, y: self.pickerAnnotationContainerView.frame.height / 2.0)
}
if let compassView = self.compassView {
transition.updateFrame(view: compassView, frame: CGRect(origin: CGPoint(x: size.width - compassView.frame.width - 11.0, y: inset + 110.0 + topPadding), size: compassView.frame.size))
}
self.applyPendingSetMapCenter()
}
}
@@ -0,0 +1,64 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import SegmentedControlNode
public final class LocationOptionsNode: ASDisplayNode {
private var presentationData: PresentationData
private let backgroundNode: NavigationBackgroundNode
private let separatorNode: ASDisplayNode
private let segmentedControlNode: SegmentedControlNode
public init(presentationData: PresentationData, hasBackground: Bool = true, updateMapMode: @escaping (LocationMapMode) -> Void) {
self.presentationData = presentationData
self.backgroundNode = NavigationBackgroundNode(color: self.presentationData.theme.rootController.navigationBar.blurredBackgroundColor)
self.separatorNode = ASDisplayNode()
self.separatorNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.separatorColor
self.segmentedControlNode = SegmentedControlNode(theme: SegmentedControlTheme(theme: self.presentationData.theme), items: [SegmentedControlItem(title: self.presentationData.strings.Map_Map), SegmentedControlItem(title: self.presentationData.strings.Map_Satellite), SegmentedControlItem(title: self.presentationData.strings.Map_Hybrid)], selectedIndex: 0)
super.init()
if hasBackground {
self.addSubnode(self.backgroundNode)
self.addSubnode(self.separatorNode)
}
self.addSubnode(self.segmentedControlNode)
self.segmentedControlNode.selectedIndexChanged = { index in
switch index {
case 0:
updateMapMode(.map)
case 1:
updateMapMode(.satellite)
case 2:
updateMapMode(.hybrid)
default:
break
}
}
}
public func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
self.backgroundNode.updateColor(color: self.presentationData.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate)
self.separatorNode.backgroundColor = self.presentationData.theme.rootController.navigationBar.separatorColor
self.segmentedControlNode.updateTheme(SegmentedControlTheme(theme: self.presentationData.theme))
}
public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size))
self.backgroundNode.update(size: size, transition: transition)
transition.updateFrame(node: self.separatorNode, frame: CGRect(x: 0.0, y: size.height, width: size.width, height: UIScreenPixel))
let controlSize = self.segmentedControlNode.updateLayout(.stretchToFill(width: size.width - 16.0 - leftInset - rightInset), transition: .immediate)
self.segmentedControlNode.frame = CGRect(origin: CGPoint(x: floor((size.width - controlSize.width) / 2.0), y: 0.0), size: controlSize)
}
}
@@ -0,0 +1,462 @@
import Foundation
import UIKit
import Display
import LegacyComponents
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import AppBundle
import CoreLocation
import PresentationDataUtils
import DeviceAccess
import AttachmentUI
public enum LocationPickerMode {
case share(peer: EnginePeer?, selfPeer: EnginePeer?, hasLiveLocation: Bool)
case pick
}
class LocationPickerInteraction {
let sendLocation: (CLLocationCoordinate2D, String?, MapGeoAddress?) -> Void
let sendLiveLocation: (CLLocationCoordinate2D) -> Void
let sendVenue: (TelegramMediaMap, Int64?, String?) -> Void
let toggleMapModeSelection: () -> Void
let updateMapMode: (LocationMapMode) -> Void
let goToUserLocation: () -> Void
let goToCoordinate: (CLLocationCoordinate2D, Bool) -> Void
let openSearch: () -> Void
let updateSearchQuery: (String) -> Void
let dismissSearch: () -> Void
let dismissInput: () -> Void
let updateSendActionHighlight: (Bool) -> Void
let openHomeWorkInfo: () -> Void
let showPlacesInThisArea: () -> Void
init(sendLocation: @escaping (CLLocationCoordinate2D, String?, MapGeoAddress?) -> Void, sendLiveLocation: @escaping (CLLocationCoordinate2D) -> Void, sendVenue: @escaping (TelegramMediaMap, Int64?, String?) -> Void, toggleMapModeSelection: @escaping () -> Void, updateMapMode: @escaping (LocationMapMode) -> Void, goToUserLocation: @escaping () -> Void, goToCoordinate: @escaping (CLLocationCoordinate2D, Bool) -> Void, openSearch: @escaping () -> Void, updateSearchQuery: @escaping (String) -> Void, dismissSearch: @escaping () -> Void, dismissInput: @escaping () -> Void, updateSendActionHighlight: @escaping (Bool) -> Void, openHomeWorkInfo: @escaping () -> Void, showPlacesInThisArea: @escaping ()-> Void) {
self.sendLocation = sendLocation
self.sendLiveLocation = sendLiveLocation
self.sendVenue = sendVenue
self.toggleMapModeSelection = toggleMapModeSelection
self.updateMapMode = updateMapMode
self.goToUserLocation = goToUserLocation
self.goToCoordinate = goToCoordinate
self.openSearch = openSearch
self.updateSearchQuery = updateSearchQuery
self.dismissSearch = dismissSearch
self.dismissInput = dismissInput
self.updateSendActionHighlight = updateSendActionHighlight
self.openHomeWorkInfo = openHomeWorkInfo
self.showPlacesInThisArea = showPlacesInThisArea
}
}
public final class LocationPickerController: ViewController, AttachmentContainable {
public enum Source {
case generic
case story
}
private var controllerNode: LocationPickerControllerNode {
return self.displayNode as! LocationPickerControllerNode
}
private let context: AccountContext
private let mode: LocationPickerMode
private let source: Source
let initialLocation: CLLocationCoordinate2D?
private let completion: (TelegramMediaMap, Int64?, String?, String?, String?) -> Void
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
let updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?
private var searchNavigationContentNode: LocationSearchNavigationContentNode?
private var isSearchingDisposable = MetaDisposable()
private let locationManager = LocationManager()
private var permissionDisposable: Disposable?
private var interaction: LocationPickerInteraction?
public var requestAttachmentMenuExpansion: () -> Void = {}
public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in }
public var parentController: () -> ViewController? = {
return nil
}
public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in }
public var updateTabBarVisibility: (Bool, ContainedViewLayoutTransition) -> Void = { _, _ in }
public var cancelPanGesture: () -> Void = { }
public var isContainerPanning: () -> Bool = { return false }
public var isContainerExpanded: () -> Bool = { return false }
public var isMinimized: Bool = false
private let style: Style
public enum Style {
case glass
case legacy
}
public init(context: AccountContext, style: Style = .legacy, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, mode: LocationPickerMode, source: Source = .generic, initialLocation: CLLocationCoordinate2D? = nil, completion: @escaping (TelegramMediaMap, Int64?, String?, String?, String?) -> Void) {
self.context = context
self.style = style
self.mode = mode
self.source = source
self.initialLocation = initialLocation
self.completion = completion
self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
self.updatedPresentationData = updatedPresentationData
let navigationBarPresentationData = NavigationBarPresentationData(theme: NavigationBarTheme(rootControllerTheme: self.presentationData.theme, hideBackground: style == .glass, hideSeparator: true), strings: NavigationBarStrings(presentationStrings: self.presentationData.strings))
super.init(navigationBarPresentationData: navigationBarPresentationData)
self.navigationPresentation = .modal
if case .glass = style {
self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: UIView())
} else {
self.title = self.presentationData.strings.Map_ChooseLocationTitle
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed))
}
self.updateBarButtons()
self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData)
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
guard let strongSelf = self, strongSelf.presentationData.theme !== presentationData.theme else {
return
}
strongSelf.presentationData = presentationData
let navigationBarPresentationData = NavigationBarPresentationData(theme: NavigationBarTheme(rootControllerTheme: strongSelf.presentationData.theme, hideBackground: style == .glass, hideSeparator: true), strings: NavigationBarStrings(presentationStrings: strongSelf.presentationData.strings))
strongSelf.navigationBar?.updatePresentationData(navigationBarPresentationData)
strongSelf.searchNavigationContentNode?.updatePresentationData(strongSelf.presentationData)
strongSelf.updateBarButtons()
if strongSelf.isNodeLoaded {
strongSelf.controllerNode.updatePresentationData(presentationData)
}
})
self.interaction = LocationPickerInteraction(sendLocation: { [weak self] coordinate, name, geoAddress in
guard let strongSelf = self else {
return
}
strongSelf.completion(
TelegramMediaMap(
latitude: coordinate.latitude,
longitude: coordinate.longitude,
heading: nil,
accuracyRadius: nil,
venue: nil,
address: geoAddress,
liveBroadcastingTimeout: nil,
liveProximityNotificationRadius: nil
),
nil,
nil,
name,
geoAddress?.country
)
strongSelf.dismiss()
}, sendLiveLocation: { [weak self] coordinate in
guard let strongSelf = self else {
return
}
DeviceAccess.authorizeAccess(to: .location(.live), locationManager: strongSelf.locationManager, presentationData: strongSelf.presentationData, present: { c, a in
strongSelf.present(c, in: .window(.root), with: a)
}, openSettings: {
strongSelf.context.sharedContext.applicationBindings.openSettings()
}, { [weak self] authorized in
guard let strongSelf = self, authorized else {
return
}
let controller = ActionSheetController(presentationData: strongSelf.presentationData)
var title = strongSelf.presentationData.strings.Map_LiveLocationGroupNewDescription
if case let .share(peer, _, _) = strongSelf.mode, let peer = peer, case .user = peer {
title = strongSelf.presentationData.strings.Map_LiveLocationPrivateNewDescription(peer.compactDisplayTitle).string
}
let sendLiveLocationImpl: (Int32) -> Void = { [weak self, weak controller] period in
controller?.dismissAnimated()
guard let self, let controller else {
return
}
controller.dismissAnimated()
self.completion(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: period), nil, nil, nil, nil)
self.dismiss()
}
controller.setItemGroups([
ActionSheetItemGroup(items: [
ActionSheetTextItem(title: title, font: .large, parseMarkdown: true),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationForMinutes(15), color: .accent, action: { sendLiveLocationImpl(15 * 60)
}),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationForHours(1), color: .accent, action: {
sendLiveLocationImpl(60 * 60 - 1)
}),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationForHours(8), color: .accent, action: {
sendLiveLocationImpl(8 * 60 * 60)
}),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationIndefinite, color: .accent, action: {
sendLiveLocationImpl(liveLocationIndefinitePeriod)
})
]),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak controller] in
controller?.dismissAnimated()
})
])
])
strongSelf.present(controller, in: .window(.root))
})
}, sendVenue: { [weak self] venue, queryId, resultId in
guard let strongSelf = self else {
return
}
let venueType = venue.venue?.type ?? ""
if ["home", "work"].contains(venueType) {
completion(TelegramMediaMap(latitude: venue.latitude, longitude: venue.longitude, heading: nil, accuracyRadius: nil, venue: nil, liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil), nil, nil, nil, nil)
} else {
completion(venue, queryId, resultId, venue.venue?.address, nil)
}
strongSelf.dismiss()
}, 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.goToUserLocation()
}, goToCoordinate: { [weak self] coordinate, zoomOut in
guard let strongSelf = self else {
return
}
strongSelf.controllerNode.updateState { state in
var state = state
state.displayingMapModeOptions = false
state.selectedLocation = .location(coordinate, nil, zoomOut)
state.searchingVenuesAround = false
return state
}
}, openSearch: { [weak self] in
guard let self, let interaction = self.interaction, let navigationBar = self.navigationBar else {
return
}
self.controllerNode.updateState { state in
var state = state
state.displayingMapModeOptions = false
return state
}
switch style {
case .glass:
let _ = self.controllerNode.activateSearch(navigationBar: navigationBar)
self.updateTabBarVisibility(false, .animated(duration: 0.4, curve: .spring))
case .legacy:
let contentNode = LocationSearchNavigationContentNode(presentationData: self.presentationData, interaction: interaction)
self.searchNavigationContentNode = contentNode
navigationBar.setContentNode(contentNode, animated: true)
let isSearching = self.controllerNode.activateSearch(navigationBar: navigationBar)
contentNode.activate()
self.isSearchingDisposable.set((isSearching
|> deliverOnMainQueue).start(next: { [weak self] value in
if let strongSelf = self, let searchNavigationContentNode = strongSelf.searchNavigationContentNode {
searchNavigationContentNode.updateActivity(value)
}
}))
}
}, updateSearchQuery: { [weak self] query in
guard let strongSelf = self else {
return
}
strongSelf.controllerNode.searchContainerNode?.searchTextUpdated(text: query)
}, dismissSearch: { [weak self] in
guard let self, let navigationBar = self.navigationBar else {
return
}
self.isSearchingDisposable.set(nil)
self.searchNavigationContentNode?.deactivate()
self.searchNavigationContentNode = nil
navigationBar.setContentNode(nil, animated: true)
self.controllerNode.deactivateSearch()
if !self.controllerNode.isPickingLocation {
self.updateTabBarVisibility(true, .animated(duration: 0.4, curve: .spring))
}
}, dismissInput: { [weak self] in
guard let self else {
return
}
self.searchNavigationContentNode?.deactivate()
self.controllerNode.deactivateInput()
}, updateSendActionHighlight: { [weak self] highlighted in
guard let strongSelf = self else {
return
}
strongSelf.controllerNode.updateSendActionHighlight(highlighted)
}, openHomeWorkInfo: { [weak self] in
guard let strongSelf = self else {
return
}
let controller = textAlertController(context: strongSelf.context, updatedPresentationData: updatedPresentationData, title: strongSelf.presentationData.strings.Map_HomeAndWorkTitle, text: strongSelf.presentationData.strings.Map_HomeAndWorkInfo, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})])
strongSelf.present(controller, in: .window(.root))
}, showPlacesInThisArea: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.controllerNode.requestPlacesAtSelectedLocation()
})
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()
self.permissionDisposable?.dispose()
self.isSearchingDisposable.dispose()
}
private var locationAccessDenied = false
private func updateBarButtons() {
if self.locationAccessDenied {
self.navigationItem.rightBarButtonItem = nil
} else {
if case .glass = self.style {
} else {
self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationCompactSearchIcon(self.presentationData.theme), style: .plain, target: self, action: #selector(self.searchPressed))
self.navigationItem.rightBarButtonItem?.accessibilityLabel = self.presentationData.strings.Common_Search
}
}
}
override public func loadDisplayNode() {
super.loadDisplayNode()
guard let interaction = self.interaction else {
return
}
self.displayNode = LocationPickerControllerNode(controller: self, context: self.context, presentationData: self.presentationData, mode: self.mode, source: self.source, interaction: interaction, locationManager: self.locationManager)
self.displayNodeDidLoad()
self.controllerNode.beganInteractiveDragging = { [weak self] in
self?.requestAttachmentMenuExpansion()
}
self.controllerNode.locationAccessDeniedUpdated = { [weak self] denied in
self?.locationAccessDenied = denied
self?.updateBarButtons()
}
self.permissionDisposable = (DeviceAccess.authorizationStatus(subject: .location(.send))
|> deliverOnMainQueue).start(next: { [weak self] next in
guard let strongSelf = self else {
return
}
switch next {
case .notDetermined:
DeviceAccess.authorizeAccess(to: .location(.send), locationManager: strongSelf.locationManager, presentationData: strongSelf.presentationData, present: { c, a in
strongSelf.present(c, in: .window(.root), with: a)
}, openSettings: {
strongSelf.context.sharedContext.applicationBindings.openSettings()
})
case .denied:
strongSelf.controllerNode.updateState { state in
var state = state
state.forceSelection = true
return state
}
default:
break
}
})
self.navigationBar?.passthroughTouches = false
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
@objc private func cancelPressed() {
self.dismiss()
}
@objc func searchPressed() {
self.requestAttachmentMenuExpansion()
self.interaction?.openSearch()
}
func dismissSearchPressed() {
self.interaction?.dismissSearch()
}
func updateSearchQuery(_ query: String) {
self.interaction?.updateSearchQuery(query)
}
public func resetForReuse() {
self.interaction?.updateMapMode(.map)
self.interaction?.dismissSearch()
self.scrollToTop?()
}
public var mediaPickerContext: AttachmentMediaPickerContext? {
return LocationPickerContext()
}
}
private final class LocationPickerContext: AttachmentMediaPickerContext {
}
public func storyLocationPickerController(
context: AccountContext,
location: CLLocationCoordinate2D?,
dismissed: @escaping () -> Void,
completion: @escaping (TelegramMediaMap, Int64?, String?, String?, String?) -> Void
) -> ViewController {
let presentationData = context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkColorPresentationTheme)
let updatedPresentationData: (PresentationData, Signal<PresentationData, NoError>) = (presentationData, .single(presentationData))
let controller = AttachmentController(context: context, updatedPresentationData: updatedPresentationData, style: .glass, chatLocation: nil, buttons: [.standalone], initialButton: .standalone, fromMenu: false, hasTextInput: false)
controller.requestController = { _, present in
let locationPickerController = LocationPickerController(context: context, style: .glass, updatedPresentationData: updatedPresentationData, mode: .share(peer: nil, selfPeer: nil, hasLiveLocation: false), source: .story, initialLocation: location, completion: { location, queryId, resultId, address, countryCode in
completion(location, queryId, resultId, address, countryCode)
})
present(locationPickerController, locationPickerController.mediaPickerContext)
}
controller.navigationPresentation = .flatModal
controller.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
controller.didDismiss = {
dismissed()
}
return controller
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import AnimatedStickerNode
import TelegramAnimatedStickerNode
import SolidRoundedButtonNode
import PresentationDataUtils
final class LocationPlaceholderNode: ASDisplayNode {
enum Content {
case intro
}
private let content: Content
private var animationNode: AnimatedStickerNode
private let titleNode: ImmediateTextNode
private let textNode: ImmediateTextNode
private let buttonNode: SolidRoundedButtonNode
private var validLayout: ContainerViewLayout?
private var cameraTextNode: ImmediateTextNode
var settingsPressed: () -> Void = {}
var cameraPressed: () -> Void = {}
init(content: Content) {
self.content = content
let name: String
let playbackMode: AnimatedStickerPlaybackMode
switch content {
case .intro:
name = "Location"
playbackMode = .loop
}
self.animationNode = DefaultAnimatedStickerNodeImpl()
self.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: name), width: 320, height: 320, playbackMode: playbackMode, mode: .direct(cachePathPrefix: nil))
self.animationNode.visibility = true
self.titleNode = ImmediateTextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.textAlignment = .center
self.titleNode.maximumNumberOfLines = 1
self.textNode = ImmediateTextNode()
self.textNode.isUserInteractionEnabled = false
self.textNode.lineSpacing = 0.1
self.textNode.textAlignment = .center
self.textNode.maximumNumberOfLines = 0
self.buttonNode = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: .black, foregroundColor: .white), height: 50.0, cornerRadius: 11.0, isShimmering: true)
self.cameraTextNode = ImmediateTextNode()
self.cameraTextNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.animationNode)
self.addSubnode(self.textNode)
if case .intro = self.content {
self.addSubnode(self.titleNode)
self.addSubnode(self.buttonNode)
self.buttonNode.pressed = { [weak self] in
self?.settingsPressed()
}
}
}
private var theme: PresentationTheme?
func update(layout: ContainerViewLayout, theme: PresentationTheme, strings: PresentationStrings, transition: ContainedViewLayoutTransition) {
self.validLayout = layout
let themeUpdated = self.theme != theme
self.theme = theme
var imageSize = CGSize(width: 144.0, height: 144.0)
var insets = layout.insets(options: [])
if layout.size.width == 320.0 {
insets.top += -60.0
imageSize = CGSize(width: 112.0, height: 112.0)
} else {
insets.top += -160.0
}
let imageSpacing: CGFloat = 12.0
let textSpacing: CGFloat = 12.0
let buttonSpacing: CGFloat = 15.0
let cameraSpacing: CGFloat = 13.0
let imageHeight = layout.size.width < layout.size.height ? imageSize.height + imageSpacing : 0.0
if themeUpdated {
self.buttonNode.updateTheme(SolidRoundedButtonTheme(theme: theme))
}
self.buttonNode.title = strings.Attachment_OpenSettings
let buttonWidth: CGFloat = 248.0
let buttonHeight = self.buttonNode.updateLayout(width: buttonWidth, transition: transition)
let title: String
let text: String
switch self.content {
case .intro:
title = strings.Attachment_LocationAccessTitle
text = strings.Attachment_LocationAccessText
}
self.titleNode.attributedText = NSAttributedString(string: title, font: Font.medium(17.0), textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: .center)
self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(15.0), textColor: theme.list.freeTextColor, paragraphAlignment: .center)
self.cameraTextNode.attributedText = NSAttributedString(string: strings.Attachment_OpenCamera, font: Font.regular(17.0), textColor: theme.list.itemAccentColor, paragraphAlignment: .center)
let titleSize = self.titleNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 40.0, height: max(1.0, layout.size.height - insets.top - insets.bottom)))
let textSize = self.textNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 40.0, height: max(1.0, layout.size.height - insets.top - insets.bottom)))
let cameraSize = self.cameraTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 70.0, height: max(1.0, layout.size.height - insets.top - insets.bottom)))
let totalHeight = imageHeight + titleSize.height + textSpacing + textSize.height + buttonSpacing + buttonHeight + cameraSpacing + cameraSize.height
let topOffset = insets.top + floor((layout.size.height - insets.top - insets.bottom - totalHeight) / 2.0)
transition.updateAlpha(node: self.animationNode, alpha: imageHeight > 0.0 ? 1.0 : 0.0)
transition.updateFrame(node: self.animationNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - imageSize.width) / 2.0), y: topOffset), size: imageSize))
self.animationNode.updateLayout(size: imageSize)
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - titleSize.width - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: topOffset + imageHeight), size: titleSize))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - textSize.width - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: self.titleNode.frame.maxY + textSpacing), size: textSize))
transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - buttonWidth - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: self.textNode.frame.maxY + buttonSpacing), size: CGSize(width: buttonWidth, height: buttonHeight)))
}
}
@@ -0,0 +1,379 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramStringFormatting
import TelegramUIPreferences
import MergeLists
import AccountContext
import ItemListVenueItem
import ItemListUI
import MapKit
import Geocoding
import ChatListSearchItemHeader
private struct LocationSearchEntry: Identifiable, Comparable {
let index: Int
let theme: PresentationTheme
let location: TelegramMediaMap
let queryId: Int64?
let resultId: String?
let title: String?
let distance: Double
let story: Bool
var stableId: String {
return self.location.venue?.id ?? ""
}
static func ==(lhs: LocationSearchEntry, rhs: LocationSearchEntry) -> Bool {
if lhs.index != rhs.index {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.location.venue?.id != rhs.location.venue?.id {
return false
}
if lhs.queryId != rhs.queryId {
return false
}
if lhs.resultId != rhs.resultId {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.distance != rhs.distance {
return false
}
if lhs.story != rhs.story {
return false
}
return true
}
static func <(lhs: LocationSearchEntry, rhs: LocationSearchEntry) -> Bool {
return lhs.index < rhs.index
}
func item(engine: TelegramEngine, presentationData: PresentationData, sendVenue: @escaping (TelegramMediaMap, Int64?, String?) -> Void, goToVenue: @escaping (TelegramMediaMap) -> Void) -> ListViewItem {
let venue = self.location
let queryId = self.queryId
let resultId = self.resultId
let header: ChatListSearchItemHeader
let subtitle: String?
if let _ = venue.venue {
header = ChatListSearchItemHeader(type: .nearbyVenues, theme: presentationData.theme, strings: presentationData.strings)
subtitle = nil
} else {
header = ChatListSearchItemHeader(type: .mapAddress, theme: presentationData.theme, strings: presentationData.strings)
subtitle = presentationData.strings.Map_DistanceAway(stringForDistance(strings: presentationData.strings, distance: self.distance)).string
}
return ItemListVenueItem(presentationData: ItemListPresentationData(presentationData), engine: engine, venue: self.location, title: self.title, subtitle: subtitle, icon: .goTo, style: .plain, action: {
sendVenue(venue, queryId, resultId)
}, infoAction: self.story && venue.venue == nil ? {
goToVenue(venue)
} : nil, header: header)
}
}
struct LocationSearchContainerTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let query: String
let isSearching: Bool
let isEmpty: Bool
}
private func locationSearchContainerPreparedTransition(from fromEntries: [LocationSearchEntry], to toEntries: [LocationSearchEntry], query: String, isSearching: Bool, isEmpty: Bool, engine: TelegramEngine, presentationData: PresentationData, sendVenue: @escaping (TelegramMediaMap, Int64?, String?) -> Void, goToVenue: @escaping (TelegramMediaMap) -> Void) -> LocationSearchContainerTransition {
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(engine: engine, presentationData: presentationData, sendVenue: sendVenue, goToVenue: goToVenue), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(engine: engine, presentationData: presentationData, sendVenue: sendVenue, goToVenue: goToVenue), directionHint: nil) }
return LocationSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, query: query, isSearching: isSearching, isEmpty: isEmpty)
}
final class LocationSearchContainerNode: ASDisplayNode {
private let context: AccountContext
private let interaction: LocationPickerInteraction
private let story: Bool
private let dimNode: ASDisplayNode
public let listNode: ListView
private let emptyResultsTitleNode: ImmediateTextNode
private let emptyResultsTextNode: ImmediateTextNode
private let searchQuery = Promise<String?>()
private let searchDisposable = MetaDisposable()
private var presentationData: PresentationData
private let themeAndStringsPromise: Promise<(PresentationTheme, PresentationStrings)>
private var validLayout: (ContainerViewLayout, CGFloat)?
private var enqueuedTransitions: [LocationSearchContainerTransition] = []
private let _isSearching = ValuePromise<Bool>(false, ignoreRepeated: true)
var isSearching: Signal<Bool, NoError> {
return self._isSearching.get()
}
public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, coordinate: CLLocationCoordinate2D, interaction: LocationPickerInteraction, story: Bool) {
self.context = context
self.interaction = interaction
self.story = story
let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
self.presentationData = presentationData
self.themeAndStringsPromise = Promise((self.presentationData.theme, self.presentationData.strings))
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = .clear // UIColor.black.withAlphaComponent(0.5)
self.listNode = ListView()
self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.listNode.alpha = 0.0
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
self.emptyResultsTitleNode = ImmediateTextNode()
self.emptyResultsTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.SharedMedia_SearchNoResults, font: Font.semibold(17.0), textColor: self.presentationData.theme.list.freeTextColor)
self.emptyResultsTitleNode.textAlignment = .center
self.emptyResultsTitleNode.alpha = 0.0
self.emptyResultsTextNode = ImmediateTextNode()
self.emptyResultsTextNode.maximumNumberOfLines = 0
self.emptyResultsTextNode.textAlignment = .center
self.emptyResultsTextNode.alpha = 0.0
super.init()
self.backgroundColor = nil
self.isOpaque = false
self.addSubnode(self.dimNode)
self.addSubnode(self.listNode)
self.addSubnode(self.emptyResultsTitleNode)
self.addSubnode(self.emptyResultsTextNode)
let currentLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let themeAndStringsPromise = self.themeAndStringsPromise
let locale = localeWithStrings(presentationData.strings)
let isSearching = self._isSearching
let searchItems = self.searchQuery.get()
|> mapToSignal { query -> Signal<String?, NoError> in
if let query = query, !query.isEmpty {
return (.complete() |> delay(1.0, queue: Queue.mainQueue()))
|> then(.single(query))
} else {
return .single(query)
}
}
|> mapToSignal { query -> Signal<([LocationSearchEntry], String)?, NoError> in
if let query, !query.isEmpty {
let foundVenues = nearbyVenues(context: context, story: story, latitude: coordinate.latitude, longitude: coordinate.longitude, query: query)
|> afterCompleted {
isSearching.set(false)
}
let foundPlacemarks = geocodeLocation(address: query, locale: locale)
return combineLatest(foundVenues, foundPlacemarks, themeAndStringsPromise.get())
|> delay(0.1, queue: Queue.concurrentDefaultQueue())
|> beforeStarted {
isSearching.set(true)
}
|> map { contextResult, placemarks, themeAndStrings -> ([LocationSearchEntry], String) in
var entries: [LocationSearchEntry] = []
var index: Int = 0
if let placemarks = placemarks {
for placemark in placemarks {
guard let placemarkLocation = placemark.location else {
continue
}
var address: MapGeoAddress?
if let countryCode = placemark.isoCountryCode, placemark.thoroughfare == nil {
address = MapGeoAddress(country: countryCode, state: placemark.administrativeArea, city: placemark.locality, street: nil)
}
let location = TelegramMediaMap(latitude: placemarkLocation.coordinate.latitude, longitude: placemarkLocation.coordinate.longitude, heading: nil, accuracyRadius: nil, venue: nil, address: address, liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil)
entries.append(LocationSearchEntry(index: index, theme: themeAndStrings.0, location: location, queryId: nil, resultId: nil, title: placemark.name ?? "Name", distance: placemarkLocation.distance(from: currentLocation), story: story))
index += 1
}
}
if let contextResult {
for result in contextResult.results {
switch result.message {
case let .mapLocation(mapMedia, _):
if let _ = mapMedia.venue {
entries.append(LocationSearchEntry(index: index, theme: themeAndStrings.0, location: mapMedia, queryId: contextResult.queryId, resultId: result.id, title: nil, distance: 0.0, story: story))
index += 1
}
default:
break
}
}
}
return (entries, query)
}
} else {
return .single(nil)
|> afterCompleted {
isSearching.set(false)
}
}
}
let previousSearchItems = Atomic<[LocationSearchEntry]>(value: [])
self.searchDisposable.set((searchItems
|> deliverOnMainQueue).start(next: { [weak self] itemsAndQuery in
if let strongSelf = self {
let (items, query) = itemsAndQuery ?? (nil, "")
let previousItems = previousSearchItems.swap(items ?? [])
let transition = locationSearchContainerPreparedTransition(from: previousItems, to: items ?? [], query: query, isSearching: items != nil, isEmpty: items?.isEmpty ?? false, engine: context.engine, presentationData: strongSelf.presentationData, sendVenue: { venue, queryId, resultId in
self?.listNode.clearHighlightAnimated(true)
if let _ = venue.venue {
self?.interaction.sendVenue(venue, queryId, resultId)
} else if story, let address = venue.address {
let name: String
if let city = address.city {
name = city
} else {
name = displayCountryName(address.country, locale: locale)
}
self?.interaction.sendLocation(venue.coordinate, name, address)
} else {
self?.interaction.goToCoordinate(venue.coordinate, false)
self?.interaction.dismissSearch()
}
}, goToVenue: { venue in
self?.interaction.goToCoordinate(venue.coordinate, true)
self?.interaction.dismissSearch()
})
strongSelf.enqueueTransition(transition)
}
}))
self.listNode.beganInteractiveDragging = { [weak self] _ in
self?.interaction.dismissInput()
}
}
deinit {
self.searchDisposable.dispose()
}
func scrollToTop() {
if self.listNode.alpha > 0.0 {
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 })
}
}
override func didLoad() {
super.didLoad()
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
self.themeAndStringsPromise.set(.single((presentationData.theme, presentationData.strings)))
self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
}
func searchTextUpdated(text: String) {
if text.isEmpty {
self.searchQuery.set(.single(nil))
} else {
self.searchQuery.set(.single(text))
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
let hadValidLayout = self.validLayout != nil
self.validLayout = (layout, navigationBarHeight)
let topInset = navigationBarHeight
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: layout.size.width, height: layout.size.height - topInset)))
self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size)
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: topInset, left: layout.intrinsicInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.intrinsicInsets.right), duration: 0.0, curve: .Default(duration: nil)), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
let padding: CGFloat = 16.0
let emptyTitleSize = self.emptyResultsTitleNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let insets = layout.insets(options: [.input])
let emptyTextSpacing: CGFloat = 8.0
let emptyTotalHeight = emptyTitleSize.height + emptyTextSize.height + emptyTextSpacing
let emptyTitleY = navigationBarHeight + floorToScreenPixels((layout.size.height - navigationBarHeight - max(insets.bottom, layout.intrinsicInsets.bottom) - emptyTotalHeight) / 2.0)
transition.updateFrame(node: self.emptyResultsTitleNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTitleSize.width) / 2.0, y: emptyTitleY), size: emptyTitleSize))
transition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTextSize.width) / 2.0, y: emptyTitleY + emptyTitleSize.height + emptyTextSpacing), size: emptyTextSize))
if !hadValidLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func enqueueTransition(_ transition: LocationSearchContainerTransition) {
self.enqueuedTransitions.append(transition)
if self.validLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let transition = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
options.insert(.PreferSynchronousDrawing)
options.insert(.PreferSynchronousResourceLoading)
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
let containerTransition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut)
containerTransition.updateAlpha(node: strongSelf.listNode, alpha: transition.isSearching ? 1.0 : 0.0)
strongSelf.dimNode.isHidden = transition.isSearching
strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.Map_SearchNoResultsDescription(transition.query).string, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor)
let emptyResults = transition.isSearching && transition.isEmpty
containerTransition.updateAlpha(node: strongSelf.emptyResultsTitleNode, alpha: emptyResults ? 1.0 : 0.0)
containerTransition.updateAlpha(node: strongSelf.emptyResultsTextNode, alpha: emptyResults ? 1.0 : 0.0)
if let (layout, navigationBarHeight) = strongSelf.validLayout {
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
})
}
}
@objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.interaction.dismissSearch()
}
}
}
@@ -0,0 +1,63 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import SearchBarNode
private let searchBarFont = Font.regular(17.0)
final class LocationSearchNavigationContentNode: NavigationBarContentNode {
private var presentationData: PresentationData
private let searchBar: SearchBarNode
private let interaction: LocationPickerInteraction
init(presentationData: PresentationData, interaction: LocationPickerInteraction) {
self.presentationData = presentationData
self.interaction = interaction
self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: presentationData.theme, hasSeparator: false), strings: presentationData.strings, fieldStyle: .modern)
self.searchBar.placeholderString = NSAttributedString(string: presentationData.strings.Map_Search, font: searchBarFont, textColor: presentationData.theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
super.init()
self.addSubnode(self.searchBar)
self.searchBar.cancel = { [weak self] in
self?.searchBar.deactivate(clear: false)
self?.interaction.dismissSearch()
}
self.searchBar.textUpdated = { [weak self] query, _ in
self?.interaction.updateSearchQuery(query)
}
}
override var nominalHeight: CGFloat {
return 56.0
}
override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - self.nominalHeight), size: CGSize(width: size.width, height: 56.0))
self.searchBar.frame = searchBarFrame
self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset, rightInset: rightInset, transition: transition)
}
func activate() {
self.searchBar.activate()
}
func deactivate() {
self.searchBar.deactivate(clear: false)
}
func updateActivity(_ activity: Bool) {
self.searchBar.activity = activity
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
self.searchBar.updateThemeAndStrings(theme: SearchBarNodeTheme(theme: presentationData.theme, hasSeparator: false), strings: presentationData.strings)
}
}
@@ -0,0 +1,121 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ListSectionHeaderNode
import ItemListUI
class LocationSectionHeaderItem: ListViewItem {
let presentationData: ItemListPresentationData
let systemStyle: ItemListSystemStyle
let title: String
public init(presentationData: ItemListPresentationData, systemStyle: ItemListSystemStyle = .legacy, title: String) {
self.presentationData = presentationData
self.systemStyle = systemStyle
self.title = title
}
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 = LocationSectionHeaderItemNode()
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? LocationSectionHeaderItemNode {
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
}
}
private class LocationSectionHeaderItemNode: ListViewItemNode {
private var headerNode: ListSectionHeaderNode?
private var item: LocationSectionHeaderItem?
private var layoutParams: ListViewItemLayoutParams?
required init() {
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
}
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: LocationSectionHeaderItem, _ params: ListViewItemLayoutParams) -> (ListViewItemNodeLayout, () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) {
let currentItem = self.item
return { [weak self] item, params in
let contentSize = CGSize(width: params.width, height: 28.0)
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
}
return (nil, { _ in
if let strongSelf = self {
strongSelf.item = item
strongSelf.layoutParams = params
let headerNode: ListSectionHeaderNode
if let currentHeaderNode = strongSelf.headerNode {
headerNode = currentHeaderNode
if let _ = updatedTheme {
headerNode.updateTheme(theme: item.presentationData.theme)
}
} else {
headerNode = ListSectionHeaderNode(theme: item.presentationData.theme)
headerNode.title = item.title
strongSelf.addSubnode(headerNode)
strongSelf.headerNode = headerNode
}
headerNode.frame = CGRect(origin: CGPoint(), size: contentSize)
headerNode.updateLayout(size: contentSize, leftInset: params.leftInset, rightInset: params.rightInset, showBackground: item.systemStyle == .legacy)
}
})
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
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)
}
}
@@ -0,0 +1,169 @@
import Foundation
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramStringFormatting
import MapKit
import AccountContext
extension TelegramMediaMap {
convenience init(coordinate: CLLocationCoordinate2D, liveBroadcastingTimeout: Int32? = nil, proximityNotificationRadius: Int32? = nil) {
self.init(latitude: coordinate.latitude, longitude: coordinate.longitude, heading: nil, accuracyRadius: nil, venue: nil, liveBroadcastingTimeout: liveBroadcastingTimeout, liveProximityNotificationRadius: proximityNotificationRadius)
}
var coordinate: CLLocationCoordinate2D {
return CLLocationCoordinate2D(latitude: self.latitude, longitude: self.longitude)
}
}
extension MKMapRect {
init(region: MKCoordinateRegion) {
let point1 = MKMapPoint(CLLocationCoordinate2D(latitude: region.center.latitude + region.span.latitudeDelta / 2.0, longitude: region.center.longitude - region.span.longitudeDelta / 2.0))
let point2 = MKMapPoint(CLLocationCoordinate2D(latitude: region.center.latitude - region.span.latitudeDelta / 2.0, longitude: region.center.longitude + region.span.longitudeDelta / 2.0))
self = MKMapRect(x: min(point1.x, point2.x), y: min(point1.y, point2.y), width: abs(point1.x - point2.x), height: abs(point1.y - point2.y))
}
}
public func locationCoordinatesAreEqual(_ lhs: CLLocationCoordinate2D?, _ rhs: CLLocationCoordinate2D?) -> Bool {
if let lhs, let rhs {
return lhs.isEqual(to: rhs)
} else if (lhs == nil) != (rhs == nil) {
return false
} else {
return true
}
}
extension CLLocationCoordinate2D {
func isEqual(to other: CLLocationCoordinate2D) -> Bool {
return self.latitude == other.latitude && self.longitude == other.longitude
}
}
public func nearbyVenues(context: AccountContext, story: Bool = false, latitude: Double, longitude: Double, query: String? = nil) -> Signal<ChatContextResultCollection?, NoError> {
let botUsername: Signal<String, NoError>
if story {
botUsername = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.App())
|> map { appConfiguration in
let storiesConfiguration = StoriesConfiguration.with(appConfiguration: appConfiguration)
return storiesConfiguration.venueSearchBot
}
} else {
botUsername = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots())
|> map { searchBotsConfiguration -> String in
return searchBotsConfiguration.venueBotUsername ?? "foursquare"
}
}
return botUsername
|> mapToSignal { botUsername in
return context.engine.peers.resolvePeerByName(name: botUsername, referrer: nil)
|> mapToSignal { result -> Signal<EnginePeer?, NoError> in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}
|> mapToSignal { peer -> Signal<ChatContextResultCollection?, NoError> in
guard let peer = peer else {
return .single(nil)
}
return context.engine.messages.requestChatContextResults(botId: peer.id, peerId: context.account.peerId, query: query ?? "", location: .single((latitude, longitude)), offset: "")
|> map { results -> ChatContextResultCollection? in
return results?.results
}
|> `catch` { error -> Signal<ChatContextResultCollection?, NoError> in
return .single(nil)
}
}
|> map { contextResult -> ChatContextResultCollection? in
guard let contextResult else {
return nil
}
return contextResult
}
}
}
func stringForEstimatedDuration(strings: PresentationStrings, time: Double, format: (String) -> String) -> String? {
if time > 0.0 {
let time = max(time, 60.0)
let minutes = Int32(time / 60.0) % 60
let hours = Int32(time / 3600.0)
let days = Int32(time / (3600.0 * 24.0))
let string: String
if hours >= 24 {
string = strings.Map_ETADays(days)
} else if hours > 0 {
if hours == 1 && minutes == 0 {
string = strings.Map_ETAHours(1)
} else {
string = strings.Map_ETAHours(10).replacingOccurrences(of: "10", with: String(format: "%d:%02d", arguments: [hours, minutes]))
}
} else {
string = strings.Map_ETAMinutes(minutes)
}
return format(string)
} else {
return nil
}
}
public 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
}
}
}
}
public enum ExpectedTravelTime: Equatable {
case unknown
case calculating
case ready(Double)
}
public func getExpectedTravelTime(coordinate: CLLocationCoordinate2D, transportType: MKDirectionsTransportType) -> Signal<ExpectedTravelTime, NoError> {
return Signal { subscriber in
subscriber.putNext(.calculating)
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 = transportType
request.requestsAlternateRoutes = false
let directions = MKDirections(request: request)
directions.calculateETA { response, error in
if let travelTime = response?.expectedTravelTime {
subscriber.putNext(.ready(travelTime))
} else {
subscriber.putNext(.unknown)
}
subscriber.putCompletion()
}
return ActionDisposable {
directions.cancel()
}
}
}
@@ -0,0 +1,563 @@
import Foundation
import UIKit
import Display
import LegacyComponents
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramStringFormatting
import AccountContext
import AppBundle
import CoreLocation
import PresentationDataUtils
import OpenInExternalAppUI
import ShareController
import DeviceAccess
import UndoUI
import MapKit
public class LocationViewParams {
let sendLiveLocation: (TelegramMediaMap) -> Void
let stopLiveLocation: (EngineMessage.Id?) -> Void
let openUrl: (String) -> Void
let openPeer: (EnginePeer) -> Void
let showAll: Bool
public init(sendLiveLocation: @escaping (TelegramMediaMap) -> Void, stopLiveLocation: @escaping (EngineMessage.Id?) -> Void, openUrl: @escaping (String) -> Void, openPeer: @escaping (EnginePeer) -> Void, showAll: Bool = false) {
self.sendLiveLocation = sendLiveLocation
self.stopLiveLocation = stopLiveLocation
self.openUrl = openUrl
self.openPeer = openPeer
self.showAll = showAll
}
}
enum LocationViewRightBarButton {
case none
case share
case showAll
}
class LocationViewInteraction {
let toggleMapModeSelection: () -> Void
let updateMapMode: (LocationMapMode) -> Void
let toggleTrackingMode: () -> Void
let goToCoordinate: (CLLocationCoordinate2D) -> Void
let requestDirections: (TelegramMediaMap, String?, OpenInLocationDirections) -> Void
let share: () -> Void
let setupProximityNotification: (Bool, EngineMessage.Id?) -> Void
let updateSendActionHighlight: (Bool) -> Void
let sendLiveLocation: (Int32?, Bool, EngineMessage.Id?) -> Void
let stopLiveLocation: () -> Void
let updateRightBarButton: (LocationViewRightBarButton) -> Void
let present: (ViewController) -> Void
init(toggleMapModeSelection: @escaping () -> Void, updateMapMode: @escaping (LocationMapMode) -> Void, toggleTrackingMode: @escaping () -> Void, goToCoordinate: @escaping (CLLocationCoordinate2D) -> Void, requestDirections: @escaping (TelegramMediaMap, String?, OpenInLocationDirections) -> Void, share: @escaping () -> Void, setupProximityNotification: @escaping (Bool, EngineMessage.Id?) -> Void, updateSendActionHighlight: @escaping (Bool) -> Void, sendLiveLocation: @escaping (Int32?, Bool, EngineMessage.Id?) -> Void, stopLiveLocation: @escaping () -> Void, updateRightBarButton: @escaping (LocationViewRightBarButton) -> Void, present: @escaping (ViewController) -> Void) {
self.toggleMapModeSelection = toggleMapModeSelection
self.updateMapMode = updateMapMode
self.toggleTrackingMode = toggleTrackingMode
self.goToCoordinate = goToCoordinate
self.requestDirections = requestDirections
self.share = share
self.setupProximityNotification = setupProximityNotification
self.updateSendActionHighlight = updateSendActionHighlight
self.sendLiveLocation = sendLiveLocation
self.stopLiveLocation = stopLiveLocation
self.updateRightBarButton = updateRightBarButton
self.present = present
}
}
public final class LocationViewController: ViewController {
private var controllerNode: LocationViewControllerNode {
return self.displayNode as! LocationViewControllerNode
}
private let context: AccountContext
public var subject: EngineMessage
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private var showAll: Bool
private let isStoryLocation: Bool
private let locationManager = LocationManager()
private var interaction: LocationViewInteraction?
private var rightBarButtonAction: LocationViewRightBarButton = .none
public var dismissed: () -> Void = {}
public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, subject: EngineMessage, isStoryLocation: Bool = false, params: LocationViewParams) {
self.context = context
self.subject = subject
self.showAll = params.showAll
self.isStoryLocation = isStoryLocation
self.presentationData = updatedPresentationData?.initial ?? 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.presentationDataDisposable = ((updatedPresentationData?.signal ?? 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.updateRightBarButton()
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
}
}, toggleTrackingMode: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.controllerNode.updateState { state in
var state = state
state.displayingMapModeOptions = false
switch state.trackingMode {
case .none:
state.trackingMode = .follow
case .follow:
state.trackingMode = .followWithHeading
case .followWithHeading:
state.trackingMode = .none
}
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, false)
return state
}
}, requestDirections: { [weak self] location, peerName, directions in
guard let strongSelf = self else {
return
}
let item: OpenInItem = .location(location: location, directions: directions)
let openInOptions = availableOpenInOptions(context: context, item: item)
if openInOptions.count == 1, let action = openInOptions.first?.action() {
if case let .openLocation(latitude, longitude, directions) = action {
let placemark = MKPlacemark(coordinate: CLLocationCoordinate2DMake(latitude, longitude), addressDictionary: [:])
let mapItem = MKMapItem(placemark: placemark)
if let title = location.venue?.title {
mapItem.name = title
} else if let peerName = peerName {
mapItem.name = peerName
}
if let directions = directions {
let options = [ MKLaunchOptionsDirectionsModeKey: directions.launchOptions ]
MKMapItem.openMaps(with: [MKMapItem.forCurrentLocation(), mapItem], launchOptions: options)
} else {
mapItem.openInMaps(launchOptions: nil)
}
}
} else {
strongSelf.present(OpenInActionSheetController(context: context, updatedPresentationData: updatedPresentationData, item: .location(location: location, directions: directions), additionalAction: nil, openUrl: params.openUrl), in: .window(.root), with: nil)
}
}, share: { [weak self] in
guard let strongSelf = self else {
return
}
if let location = getLocation(from: strongSelf.subject) {
let shareAction = OpenInControllerAction(title: strongSelf.presentationData.strings.Conversation_ContextMenuShare, action: {
strongSelf.present(ShareController(context: context, subject: .mapMedia(location), externalShare: true), in: .window(.root), with: nil)
})
strongSelf.present(OpenInActionSheetController(context: context, updatedPresentationData: updatedPresentationData, item: .location(location: location, directions: nil), additionalAction: shareAction, openUrl: params.openUrl), in: .window(.root), with: nil)
}
}, setupProximityNotification: { [weak self] reset, messageId in
guard let strongSelf = self else {
return
}
if reset {
if let messageId = messageId {
strongSelf.controllerNode.updateState { state in
var state = state
state.cancellingProximityRadius = true
return state
}
let _ = context.engine.messages.requestEditLiveLocation(messageId: messageId, stop: false, coordinate: nil, heading: nil, proximityNotificationRadius: 0, extendPeriod: nil).start(completed: { [weak self] in
guard let strongSelf = self else {
return
}
Queue.mainQueue().after(0.5) {
strongSelf.controllerNode.updateState { state in
var state = state
state.cancellingProximityRadius = false
return state
}
}
})
strongSelf.dismissAllTooltips()
strongSelf.present(
UndoOverlayController(
presentationData: strongSelf.presentationData,
content: .setProximityAlert(
title: strongSelf.presentationData.strings.Location_ProximityAlertCancelled,
text: "",
cancelled: true
),
elevatedLayout: false,
action: { action in
return true
}
),
in: .current
)
}
} else {
DeviceAccess.authorizeAccess(to: .location(.live), locationManager: strongSelf.locationManager, presentationData: strongSelf.presentationData, present: { c, a in
strongSelf.present(c, in: .window(.root), with: a)
}, openSettings: {
context.sharedContext.applicationBindings.openSettings()
}, { [weak self] authorized in
guard let strongSelf = self, authorized else {
return
}
strongSelf.controllerNode.setProximityIndicator(radius: 0)
let _ = (strongSelf.context.account.postbox.loadedPeerWithId(strongSelf.subject.id.peerId)
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let strongSelf = self else {
return
}
var compactDisplayTitle: String?
if let peer = peer as? TelegramUser {
compactDisplayTitle = EnginePeer(peer).compactDisplayTitle
}
let controller = LocationDistancePickerScreen(context: context, style: .default, compactDisplayTitle: compactDisplayTitle, distances: strongSelf.controllerNode.headerNode.mapNode.distancesToAllAnnotations, updated: { [weak self] distance in
guard let strongSelf = self else {
return
}
strongSelf.controllerNode.setProximityIndicator(radius: distance)
}, completion: { [weak self] distance, completion in
guard let strongSelf = self else {
return
}
if let messageId = messageId {
strongSelf.controllerNode.updateState { state in
var state = state
state.updatingProximityRadius = distance
return state
}
let _ = context.engine.messages.requestEditLiveLocation(messageId: messageId, stop: false, coordinate: nil, heading: nil, proximityNotificationRadius: distance, extendPeriod: nil).start(completed: { [weak self] in
guard let strongSelf = self else {
return
}
Queue.mainQueue().after(0.5) {
strongSelf.controllerNode.updateState { state in
var state = state
state.updatingProximityRadius = nil
return state
}
}
})
var text: String
let distanceString = shortStringForDistance(strings: strongSelf.presentationData.strings, distance: distance)
if let compactDisplayTitle = compactDisplayTitle {
text = strongSelf.presentationData.strings.Location_ProximityAlertSetText(compactDisplayTitle, distanceString).string
} else {
text = strongSelf.presentationData.strings.Location_ProximityAlertSetTextGroup(distanceString).string
}
strongSelf.dismissAllTooltips()
strongSelf.present(
UndoOverlayController(
presentationData: strongSelf.presentationData,
content: .setProximityAlert(
title: strongSelf.presentationData.strings.Location_ProximityAlertSetTitle,
text: text,
cancelled: false
),
elevatedLayout: false,
action: { action in
return true
}
),
in: .current
)
} else {
strongSelf.present(textAlertController(context: strongSelf.context, updatedPresentationData: updatedPresentationData, title: strongSelf.presentationData.strings.Location_LiveLocationRequired_Title, text: strongSelf.presentationData.strings.Location_LiveLocationRequired_Description, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Location_LiveLocationRequired_ShareLocation, action: {
completion()
strongSelf.interaction?.sendLiveLocation(distance, false, nil)
}), TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {})], actionLayout: .vertical), in: .window(.root))
}
completion()
}, willDismiss: { [weak self] in
if let strongSelf = self {
strongSelf.controllerNode.setProximityIndicator(radius: nil)
}
})
strongSelf.present(controller, in: .window(.root))
})
})
}
}, updateSendActionHighlight: { [weak self] highlighted in
guard let strongSelf = self else {
return
}
strongSelf.controllerNode.updateSendActionHighlight(highlighted)
}, sendLiveLocation: { [weak self] distance, extend, messageId in
guard let strongSelf = self else {
return
}
DeviceAccess.authorizeAccess(to: .location(.live), locationManager: strongSelf.locationManager, presentationData: strongSelf.presentationData, present: { c, a in
strongSelf.present(c, in: .window(.root), with: a)
}, openSettings: {
context.sharedContext.applicationBindings.openSettings()
}, { [weak self] authorized in
guard let strongSelf = self, authorized else {
return
}
if let distance = distance {
let _ = (strongSelf.controllerNode.coordinate
|> deliverOnMainQueue).start(next: { coordinate in
params.sendLiveLocation(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: 30 * 60, proximityNotificationRadius: distance))
})
let _ = (strongSelf.context.account.postbox.loadedPeerWithId(strongSelf.subject.id.peerId)
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let strongSelf = self else {
return
}
var compactDisplayTitle: String?
if let peer = peer as? TelegramUser {
compactDisplayTitle = EnginePeer(peer).compactDisplayTitle
}
var text: String
let distanceString = shortStringForDistance(strings: strongSelf.presentationData.strings, distance: distance)
if let compactDisplayTitle = compactDisplayTitle {
text = strongSelf.presentationData.strings.Location_ProximityAlertSetText(compactDisplayTitle, distanceString).string
} else {
text = strongSelf.presentationData.strings.Location_ProximityAlertSetTextGroup(distanceString).string
}
strongSelf.dismissAllTooltips()
strongSelf.present(
UndoOverlayController(
presentationData: strongSelf.presentationData,
content: .setProximityAlert(
title: strongSelf.presentationData.strings.Location_ProximityAlertSetTitle,
text: text,
cancelled: false
),
elevatedLayout: false,
action: { action in
return true
}
),
in: .current
)
})
} else {
let _ = (context.account.postbox.loadedPeerWithId(subject.id.peerId)
|> deliverOnMainQueue).start(next: { peer in
let controller = ActionSheetController(presentationData: strongSelf.presentationData)
var title: String
if extend {
title = strongSelf.presentationData.strings.Map_LiveLocationExtendDescription
} else {
title = strongSelf.presentationData.strings.Map_LiveLocationGroupNewDescription
if let user = peer as? TelegramUser {
title = strongSelf.presentationData.strings.Map_LiveLocationPrivateNewDescription(EnginePeer(user).compactDisplayTitle).string
}
}
let sendLiveLocationImpl: (Int32) -> Void = { [weak controller] period in
controller?.dismissAnimated()
if extend {
if let messageId {
let _ = context.engine.messages.requestEditLiveLocation(messageId: messageId, stop: false, coordinate: nil, heading: nil, proximityNotificationRadius: nil, extendPeriod: period).start()
}
} else {
let _ = (strongSelf.controllerNode.coordinate
|> deliverOnMainQueue).start(next: { coordinate in
params.sendLiveLocation(TelegramMediaMap(coordinate: coordinate, liveBroadcastingTimeout: period))
})
strongSelf.controllerNode.showAll()
}
}
controller.setItemGroups([
ActionSheetItemGroup(items: [
ActionSheetTextItem(title: title, font: .large, parseMarkdown: true),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationForMinutes(15), color: .accent, action: {
sendLiveLocationImpl(15 * 60)
}),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationForHours(1), color: .accent, action: {
sendLiveLocationImpl(60 * 60 - 1)
}),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationForHours(8), color: .accent, action: {
sendLiveLocationImpl(8 * 60 * 60)
}),
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Map_LiveLocationIndefinite, color: .accent, action: {
sendLiveLocationImpl(liveLocationIndefinitePeriod)
})
]),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak controller] in
controller?.dismissAnimated()
})
])
])
strongSelf.present(controller, in: .window(.root))
})
}
})
}, stopLiveLocation: { [weak self] in
params.stopLiveLocation(nil)
self?.dismiss()
}, updateRightBarButton: { [weak self] action in
guard let strongSelf = self else {
return
}
if action != strongSelf.rightBarButtonAction {
strongSelf.rightBarButtonAction = action
strongSelf.updateRightBarButton()
}
}, present: { [weak self] c in
if let strongSelf = self {
strongSelf.present(c, in: .window(.root))
}
})
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()
}
public func goToUserLocation(visibleRadius: Double? = nil) {
}
private func dismissAllTooltips() {
self.forEachController({ controller in
if let controller = controller as? UndoOverlayController {
controller.dismissWithCommitAction()
}
return true
})
}
override public func loadDisplayNode() {
super.loadDisplayNode()
guard let interaction = self.interaction else {
return
}
self.displayNode = LocationViewControllerNode(context: self.context, presentationData: self.presentationData, subject: self.subject, interaction: interaction, locationManager: self.locationManager, isStoryLocation: self.isStoryLocation)
self.displayNodeDidLoad()
self.controllerNode.onAnnotationsReady = { [weak self] in
guard let strongSelf = self, strongSelf.showAll else {
return
}
strongSelf.controllerNode.showAll()
}
self.controllerNode.headerNode.mapNode.disableHorizontalTransitionGesture = self.isStoryLocation
}
private func updateRightBarButton() {
switch self.rightBarButtonAction {
case .none:
self.navigationItem.rightBarButtonItem = nil
case .share:
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
case .showAll:
self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Map_LiveLocationShowAll, style: .plain, target: self, action: #selector(self.showAllPressed))
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
@objc private func cancelPressed() {
self.dismiss()
}
@objc private func sharePressed() {
self.interaction?.share()
}
@objc private func showAllPressed() {
self.controllerNode.showAll()
}
private var didDismiss = false
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if !self.didDismiss {
self.didDismiss = true
self.dismissed()
}
}
public override func dismiss(completion: (() -> Void)? = nil) {
super.dismiss(completion: completion)
}
}
@@ -0,0 +1,994 @@
import Foundation
import UIKit
import Display
import LegacyComponents
import TelegramCore
import SwiftSignalKit
import MergeLists
import ItemListUI
import ItemListVenueItem
import TelegramPresentationData
import TelegramStringFormatting
import TelegramUIPreferences
import TelegramNotices
import AccountContext
import AppBundle
import CoreLocation
import Geocoding
import DeviceAccess
import TooltipUI
func getLocation(from message: EngineMessage) -> TelegramMediaMap? {
return message.media.first(where: { $0 is TelegramMediaMap } ) as? TelegramMediaMap
}
private func areMessagesEqual(_ lhsMessage: EngineMessage, _ rhsMessage: EngineMessage) -> 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]
let gotTravelTimes: Bool
let count: Int
let animated: Bool
}
public enum LocationViewEntryId: Hashable {
case info
case toggleLiveLocation(Bool)
case liveLocation(UInt32)
}
public enum LocationViewEntry: Comparable, Identifiable {
case info(PresentationTheme, TelegramMediaMap, String?, Double?, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime, Bool)
case toggleLiveLocation(PresentationTheme, String, String, Double?, Double?, Bool, EngineMessage.Id?)
case liveLocation(PresentationTheme, PresentationDateTimeFormat, PresentationPersonNameOrder, EngineMessage, Double?, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime, Int)
public var stableId: LocationViewEntryId {
switch self {
case .info:
return .info
case let .toggleLiveLocation(_, _, _, _, _, additional, _):
return .toggleLiveLocation(additional)
case let .liveLocation(_, _, _, message, _, _, _, _, _):
return .liveLocation(message.stableId)
}
}
public static func ==(lhs: LocationViewEntry, rhs: LocationViewEntry) -> Bool {
switch lhs {
case let .info(lhsTheme, lhsLocation, lhsAddress, lhsDistance, lhsDrivingTime, lhsTransitTime, lhsWalkingTime, lhsHasEta):
if case let .info(rhsTheme, rhsLocation, rhsAddress, rhsDistance, rhsDrivingTime, rhsTransitTime, rhsWalkingTime, rhsHasEta) = rhs, lhsTheme === rhsTheme, lhsLocation.venue?.id == rhsLocation.venue?.id, lhsAddress == rhsAddress, lhsDistance == rhsDistance, lhsDrivingTime == rhsDrivingTime, lhsTransitTime == rhsTransitTime, lhsWalkingTime == rhsWalkingTime, lhsHasEta == rhsHasEta {
return true
} else {
return false
}
case let .toggleLiveLocation(lhsTheme, lhsTitle, lhsSubtitle, lhsBeginTimestamp, lhsTimeout, lhsAdditional, lhsMessageId):
if case let .toggleLiveLocation(rhsTheme, rhsTitle, rhsSubtitle, rhsBeginTimestamp, rhsTimeout, rhsAdditional, rhsMessageId) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsBeginTimestamp == rhsBeginTimestamp, lhsTimeout == rhsTimeout, lhsAdditional == rhsAdditional, lhsMessageId == rhsMessageId {
return true
} else {
return false
}
case let .liveLocation(lhsTheme, lhsDateTimeFormat, lhsNameDisplayOrder, lhsMessage, lhsDistance, lhsDrivingTime, lhsTransitTime, lhsWalkingTime, lhsIndex):
if case let .liveLocation(rhsTheme, rhsDateTimeFormat, rhsNameDisplayOrder, rhsMessage, rhsDistance, rhsDrivingTime, rhsTransitTime, rhsWalkingTime, rhsIndex) = rhs, lhsTheme === rhsTheme, lhsDateTimeFormat == rhsDateTimeFormat, lhsNameDisplayOrder == rhsNameDisplayOrder, areMessagesEqual(lhsMessage, rhsMessage), lhsDistance == rhsDistance, lhsDrivingTime == rhsDrivingTime, lhsTransitTime == rhsTransitTime, lhsWalkingTime == rhsWalkingTime, lhsIndex == rhsIndex {
return true
} else {
return false
}
}
}
public static func <(lhs: LocationViewEntry, rhs: LocationViewEntry) -> Bool {
switch lhs {
case .info:
switch rhs {
case .info:
return false
case .toggleLiveLocation, .liveLocation:
return true
}
case let .toggleLiveLocation(_, _, _, _, _, lhsAdditional, _):
switch rhs {
case .info:
return false
case let .toggleLiveLocation(_, _, _, _, _, rhsAdditional, _):
return !lhsAdditional && rhsAdditional
case .liveLocation:
return true
}
case let .liveLocation(_, _, _, _, _, _, _, _, lhsIndex):
switch rhs {
case .info, .toggleLiveLocation:
return false
case let .liveLocation(_, _, _, _, _, _, _, _, rhsIndex):
return lhsIndex < rhsIndex
}
}
}
func item(context: AccountContext, presentationData: PresentationData, interaction: LocationViewInteraction?) -> ListViewItem {
switch self {
case let .info(_, location, address, distance, drivingTime, transitTime, walkingTime, hasEta):
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)).string
} else {
distanceString = nil
}
return LocationInfoListItem(presentationData: ItemListPresentationData(presentationData), engine: context.engine, location: location, address: addressString, distance: distanceString, drivingTime: drivingTime, transitTime: transitTime, walkingTime: walkingTime, hasEta: hasEta, action: {
interaction?.goToCoordinate(location.coordinate)
}, drivingAction: {
interaction?.requestDirections(location, nil, .driving)
}, transitAction: {
interaction?.requestDirections(location, nil, .transit)
}, walkingAction: {
interaction?.requestDirections(location, nil, .walking)
})
case let .toggleLiveLocation(_, title, subtitle, beginTimstamp, timeout, additional, messageId):
var beginTimeAndTimeout: (Double, Double)?
if let beginTimstamp = beginTimstamp, let timeout = timeout {
beginTimeAndTimeout = (beginTimstamp, timeout)
} else {
beginTimeAndTimeout = nil
}
let icon: LocationActionListItemIcon
if let timeout, Int32(timeout) != liveLocationIndefinitePeriod, !additional {
icon = .extendLiveLocation
} else if beginTimeAndTimeout != nil {
icon = .stopLiveLocation
} else {
icon = .liveLocation
}
return LocationActionListItem(presentationData: ItemListPresentationData(presentationData), engine: context.engine, title: title, subtitle: subtitle, icon: icon, beginTimeAndTimeout: !additional ? beginTimeAndTimeout : nil, action: {
if beginTimeAndTimeout != nil {
if let timeout, Int32(timeout) != liveLocationIndefinitePeriod {
if additional {
interaction?.stopLiveLocation()
} else {
interaction?.sendLiveLocation(nil, true, messageId)
}
} else {
interaction?.stopLiveLocation()
}
} else {
interaction?.sendLiveLocation(nil, false, nil)
}
}, highlighted: { highlight in
interaction?.updateSendActionHighlight(highlight)
})
case let .liveLocation(_, dateTimeFormat, nameDisplayOrder, message, distance, drivingTime, transitTime, walkingTime, _):
var title: String?
if let author = message.author {
title = author.displayTitle(strings: presentationData.strings, displayOrder: nameDisplayOrder)
}
return LocationLiveListItem(presentationData: ItemListPresentationData(presentationData), dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, context: context, message: message, distance: distance, drivingTime: drivingTime, transitTime: transitTime, walkingTime: walkingTime, action: {
if let location = getLocation(from: message) {
interaction?.goToCoordinate(location.coordinate)
}
}, longTapAction: {}, drivingAction: {
if let location = getLocation(from: message) {
interaction?.requestDirections(location, title, .driving)
}
}, transitAction: {
if let location = getLocation(from: message) {
interaction?.requestDirections(location, title, .transit)
}
}, walkingAction: {
if let location = getLocation(from: message) {
interaction?.requestDirections(location, title, .walking)
}
})
}
}
}
private func preparedTransition(from fromEntries: [LocationViewEntry], to toEntries: [LocationViewEntry], context: AccountContext, presentationData: PresentationData, interaction: LocationViewInteraction?, gotTravelTimes: Bool, animated: Bool) -> 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(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) }
return LocationViewTransaction(deletions: deletions, insertions: insertions, updates: updates, gotTravelTimes: gotTravelTimes, count: toEntries.count, animated: animated)
}
public enum LocationViewLocation: Equatable {
case initial
case user
case coordinate(CLLocationCoordinate2D, Bool)
case custom
public static func ==(lhs: LocationViewLocation, rhs: LocationViewLocation) -> Bool {
switch lhs {
case .initial:
if case .initial = rhs {
return true
} else {
return false
}
case .user:
if case .user = rhs {
return true
} else {
return false
}
case let .coordinate(lhsCoordinate, lhsValue):
if case let .coordinate(rhsCoordinate, rhsValue) = rhs, locationCoordinatesAreEqual(lhsCoordinate, rhsCoordinate), lhsValue == rhsValue {
return true
} else {
return false
}
case .custom:
if case .custom = rhs {
return true
} else {
return false
}
}
}
}
public struct LocationViewState {
public var mapMode: LocationMapMode
public var displayingMapModeOptions: Bool
public var selectedLocation: LocationViewLocation
public var trackingMode: LocationTrackingMode
public var updatingProximityRadius: Int32?
public var cancellingProximityRadius: Bool
public init() {
self.mapMode = .map
self.displayingMapModeOptions = false
self.selectedLocation = .initial
self.trackingMode = .none
self.updatingProximityRadius = nil
self.cancellingProximityRadius = false
}
}
final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationManagerDelegate {
private let context: AccountContext
private var presentationData: PresentationData
private let presentationDataPromise: Promise<PresentationData>
private var subject: EngineMessage
private let interaction: LocationViewInteraction
private let locationManager: LocationManager
private let isStoryLocation: Bool
private let listNode: ListView
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 validLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)?
private var listOffset: CGFloat?
private var displayedProximityAlertTooltip = false
var reportedAnnotationsReady = false
var onAnnotationsReady: (() -> Void)?
private let travelDisposables = DisposableSet()
private var travelTimes: [EngineMessage.Id: (Double, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime)] = [:] {
didSet {
self.travelTimesPromise.set(.single(self.travelTimes))
}
}
private let travelTimesPromise = Promise<[EngineMessage.Id: (Double, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime)]>([:])
init(context: AccountContext, presentationData: PresentationData, subject: EngineMessage, interaction: LocationViewInteraction, locationManager: LocationManager, isStoryLocation: Bool) {
self.context = context
self.presentationData = presentationData
self.presentationDataPromise = Promise(presentationData)
self.subject = subject
self.interaction = interaction
self.locationManager = locationManager
self.isStoryLocation = isStoryLocation
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.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
var setupProximityNotificationImpl: ((Bool) -> Void)?
self.headerNode = LocationMapHeaderNode(
presentationData: presentationData,
glass: false,
toggleMapModeSelection: interaction.toggleMapModeSelection,
updateMapMode: interaction.updateMapMode,
goToUserLocation: interaction.toggleTrackingMode,
setupProximityNotification: { reset in
setupProximityNotificationImpl?(reset)
})
//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 userLocation: Signal<CLLocation?, NoError> = .single(nil)
|> then(
throttledUserLocation(self.headerNode.mapNode.userLocation)
)
var eta: Signal<(ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime), NoError> = .single((.calculating, .calculating, .calculating))
var address: Signal<String?, NoError> = .single(nil)
let locale = localeWithStrings(presentationData.strings)
if let location = getLocation(from: subject), location.liveBroadcastingTimeout == nil {
eta = .single((.calculating, .calculating, .calculating))
|> then(combineLatest(queue: Queue.mainQueue(), getExpectedTravelTime(coordinate: location.coordinate, transportType: .automobile), getExpectedTravelTime(coordinate: location.coordinate, transportType: .transit), getExpectedTravelTime(coordinate: location.coordinate, transportType: .walking))
|> mapToSignal { drivingTime, transitTime, walkingTime -> Signal<(ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime), NoError> in
if case .calculating = drivingTime {
return .complete()
}
if case .calculating = transitTime {
return .complete()
}
if case .calculating = walkingTime {
return .complete()
}
return .single((drivingTime, transitTime, walkingTime))
})
if let venue = location.venue, let venueAddress = venue.address, !venueAddress.isEmpty {
address = .single(venueAddress)
} else {
address = .single(nil)
|> then(
reverseGeocodeLocation(latitude: location.latitude, longitude: location.longitude, locale: locale)
|> map { placemark -> String? in
return placemark?.compactDisplayAddress ?? ""
}
)
}
}
let liveLocations = context.engine.messages.topPeerActiveLiveLocationMessages(peerId: subject.id.peerId)
|> map { _, messages -> [EngineMessage] in
return messages.map(EngineMessage.init)
}
setupProximityNotificationImpl = { reset in
let _ = (liveLocations
|> take(1)
|> deliverOnMainQueue).start(next: { messages in
var ownMessageId: EngineMessage.Id?
for message in messages {
if message.localTags.contains(.OutgoingLiveLocation) {
ownMessageId = message.id
break
}
}
interaction.setupProximityNotification(reset, ownMessageId)
let _ = ApplicationSpecificNotice.incrementLocationProximityAlertTip(accountManager: context.sharedContext.accountManager, count: 4).start()
})
}
let previousState = Atomic<LocationViewState?>(value: nil)
let previousUserAnnotation = Atomic<LocationPinAnnotation?>(value: nil)
let previousAnnotations = Atomic<[LocationPinAnnotation]>(value: [])
let previousEntries = Atomic<[LocationViewEntry]?>(value: nil)
let previousHadTravelTimes = Atomic<Bool>(value: false)
let selfPeer = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
self.disposable = (combineLatest(self.presentationDataPromise.get(), self.statePromise.get(), selfPeer, liveLocations, self.headerNode.mapNode.userLocation, userLocation, address, eta, self.travelTimesPromise.get())
|> deliverOnMainQueue).start(next: { [weak self] presentationData, state, selfPeer, liveLocations, userLocation, distance, address, eta, travelTimes in
if let strongSelf = self, let location = getLocation(from: subject) {
var entries: [LocationViewEntry] = []
var annotations: [LocationPinAnnotation] = []
var userAnnotation: LocationPinAnnotation? = nil
var effectiveLiveLocations: [EngineMessage] = liveLocations
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
var proximityNotification: Bool? = nil
var proximityNotificationRadius: Int32?
var index: Int = 0
var isLocationView = false
if location.liveBroadcastingTimeout == nil {
isLocationView = true
let subjectLocation = CLLocation(latitude: location.latitude, longitude: location.longitude)
let distance = userLocation.flatMap { subjectLocation.distance(from: $0) }
entries.append(.info(presentationData.theme, location, address, distance, eta.0, eta.1, eta.2, !isStoryLocation))
annotations.append(LocationPinAnnotation(context: context, theme: presentationData.theme, location: location, queryId: nil, resultId: nil, forcedSelection: true))
} else {
var activeOwnLiveLocation: EngineMessage?
for message in effectiveLiveLocations {
if message.localTags.contains(.OutgoingLiveLocation) {
activeOwnLiveLocation = message
if let location = getLocation(from: message), let radius = location.liveProximityNotificationRadius {
proximityNotificationRadius = radius
proximityNotification = true
}
break
}
}
let title: String
let subtitle: String
let beginTime: Double?
let timeout: Double?
if let message = activeOwnLiveLocation {
var liveBroadcastingTimeout: Int32 = 0
if let location = getLocation(from: message), let timeout = location.liveBroadcastingTimeout {
liveBroadcastingTimeout = timeout
}
title = presentationData.strings.Map_StopLiveLocation
var updateTimestamp = message.timestamp
for attribute in message.attributes {
if let attribute = attribute as? EditedMessageAttribute {
updateTimestamp = attribute.date
break
}
}
subtitle = stringForRelativeLiveLocationTimestamp(strings: presentationData.strings, relativeTimestamp: updateTimestamp, relativeTo: currentTime, dateTimeFormat: presentationData.dateTimeFormat)
beginTime = Double(message.timestamp)
timeout = Double(liveBroadcastingTimeout)
} else {
title = presentationData.strings.Map_ShareLiveLocation
subtitle = presentationData.strings.Map_ShareLiveLocationHelp
beginTime = nil
timeout = nil
}
if case let .channel(channel) = subject.author, case .broadcast = channel.info, activeOwnLiveLocation == nil {
} else {
if let timeout, Int32(timeout) != liveLocationIndefinitePeriod {
entries.append(.toggleLiveLocation(presentationData.theme, presentationData.strings.Map_SharingLocation, presentationData.strings.Map_TapToAddTime, beginTime, timeout, false, activeOwnLiveLocation?.id))
entries.append(.toggleLiveLocation(presentationData.theme, title, subtitle, beginTime, timeout, true, nil))
} else {
entries.append(.toggleLiveLocation(presentationData.theme, title, subtitle, beginTime, timeout, false, nil))
}
}
var sortedLiveLocations: [EngineMessage] = []
var effectiveSubject: EngineMessage?
for message in effectiveLiveLocations {
if message.id == subject.id {
effectiveSubject = message
} else {
sortedLiveLocations.append(message)
}
}
if let effectiveSubject = effectiveSubject {
sortedLiveLocations.insert(effectiveSubject, at: 0)
} else {
sortedLiveLocations.insert(subject, at: 0)
}
effectiveLiveLocations = sortedLiveLocations
}
for message in effectiveLiveLocations {
if let location = getLocation(from: message) {
if let channel = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = channel.info, message.threadId != nil {
continue
}
var liveBroadcastingTimeout: Int32 = 0
if let timeout = location.liveBroadcastingTimeout {
liveBroadcastingTimeout = timeout
}
let remainingTime: Int32
if liveBroadcastingTimeout == liveLocationIndefinitePeriod {
remainingTime = liveLocationIndefinitePeriod
} else {
remainingTime = max(0, message.timestamp + liveBroadcastingTimeout - currentTime)
}
if message.flags.contains(.Incoming) && remainingTime != 0 && proximityNotification == nil {
proximityNotification = false
}
let subjectLocation = CLLocation(latitude: location.latitude, longitude: location.longitude)
let distance = userLocation.flatMap { subjectLocation.distance(from: $0) }
let timestamp = CACurrentMediaTime()
if message.localTags.contains(.OutgoingLiveLocation), let selfPeer = selfPeer {
userAnnotation = LocationPinAnnotation(context: context, theme: presentationData.theme, message: message, selfPeer: selfPeer, isSelf: true, heading: location.heading)
} else {
var drivingTime: ExpectedTravelTime = .unknown
var transitTime: ExpectedTravelTime = .unknown
var walkingTime: ExpectedTravelTime = .unknown
if !isLocationView && message.author?.id != context.account.peerId {
let signal = combineLatest(queue: Queue.mainQueue(), getExpectedTravelTime(coordinate: location.coordinate, transportType: .automobile), getExpectedTravelTime(coordinate: location.coordinate, transportType: .transit), getExpectedTravelTime(coordinate: location.coordinate, transportType: .walking))
|> mapToSignal { drivingTime, transitTime, walkingTime -> Signal<(ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime), NoError> in
if case .calculating = drivingTime {
return .complete()
}
if case .calculating = transitTime {
return .complete()
}
if case .calculating = walkingTime {
return .complete()
}
return .single((drivingTime, transitTime, walkingTime))
}
if let (previousTimestamp, maybeDrivingTime, maybeTransitTime, maybeWalkingTime) = travelTimes[message.id] {
drivingTime = maybeDrivingTime
transitTime = maybeTransitTime
walkingTime = maybeWalkingTime
if timestamp > previousTimestamp + 60.0 {
strongSelf.travelDisposables.add(signal.start(next: { [weak self] drivingTime, transitTime, walkingTime in
guard let strongSelf = self else {
return
}
let timestamp = CACurrentMediaTime()
var travelTimes = strongSelf.travelTimes
travelTimes[message.id] = (timestamp, drivingTime, transitTime, walkingTime)
strongSelf.travelTimes = travelTimes
}))
}
} else {
drivingTime = .calculating
transitTime = .calculating
walkingTime = .calculating
strongSelf.travelDisposables.add(signal.start(next: { [weak self] drivingTime, transitTime, walkingTime in
guard let strongSelf = self else {
return
}
let timestamp = CACurrentMediaTime()
var travelTimes = strongSelf.travelTimes
travelTimes[message.id] = (timestamp, drivingTime, transitTime, walkingTime)
strongSelf.travelTimes = travelTimes
}))
}
}
annotations.append(LocationPinAnnotation(context: context, theme: presentationData.theme, message: message, selfPeer: selfPeer, isSelf: message.author?.id == context.account.peerId, heading: location.heading))
entries.append(.liveLocation(presentationData.theme, presentationData.dateTimeFormat, presentationData.nameDisplayOrder, message, distance, drivingTime, transitTime, walkingTime, index))
}
index += 1
}
}
if let currentProximityNotification = proximityNotification, currentProximityNotification && state.cancellingProximityRadius {
proximityNotification = false
proximityNotificationRadius = nil
} else if let radius = state.updatingProximityRadius {
proximityNotification = true
proximityNotificationRadius = radius
}
if subject.id.peerId.namespace != Namespaces.Peer.CloudUser, proximityNotification == nil {
proximityNotification = false
}
if case let .channel(channel) = subject.author, case .broadcast = channel.info {
proximityNotification = nil
}
let previousEntries = previousEntries.swap(entries)
let previousState = previousState.swap(state)
let previousHadTravelTimes = previousHadTravelTimes.swap(!travelTimes.isEmpty)
var animated = false
var previousActionsCount = 0
var actionsCount = 0
if let previousEntries {
for entry in previousEntries {
if case .toggleLiveLocation = entry {
previousActionsCount += 1
}
}
}
for entry in entries {
if case .toggleLiveLocation = entry {
actionsCount += 1
}
}
if actionsCount < previousActionsCount {
animated = true
}
let transition = preparedTransition(from: previousEntries ?? [], to: entries, context: context, presentationData: presentationData, interaction: strongSelf.interaction, gotTravelTimes: !travelTimes.isEmpty && !previousHadTravelTimes, animated: animated)
strongSelf.enqueueTransition(transition)
strongSelf.headerNode.updateState(mapMode: state.mapMode, trackingMode: state.trackingMode, displayingMapModeOptions: state.displayingMapModeOptions, displayingPlacesButton: false, proximityNotification: proximityNotification, animated: false)
if let proximityNotification = proximityNotification, !proximityNotification && !strongSelf.displayedProximityAlertTooltip {
strongSelf.displayedProximityAlertTooltip = true
let _ = (ApplicationSpecificNotice.getLocationProximityAlertTip(accountManager: context.sharedContext.accountManager)
|> deliverOnMainQueue).start(next: { [weak self] counter in
if let strongSelf = self, counter < 3 {
let _ = ApplicationSpecificNotice.incrementLocationProximityAlertTip(accountManager: context.sharedContext.accountManager).start()
strongSelf.displayProximityAlertTooltip()
}
})
}
switch state.selectedLocation {
case .initial:
if previousState?.selectedLocation != .initial {
strongSelf.headerNode.mapNode.setMapCenter(coordinate: location.coordinate, span: LocationMapNode.viewMapSpan, animated: previousState != nil)
}
case let .coordinate(coordinate, defaultSpan):
if let previousState = previousState, case let .coordinate(previousCoordinate, _) = previousState.selectedLocation, locationCoordinatesAreEqual(previousCoordinate, coordinate) {
} else {
strongSelf.headerNode.mapNode.setMapCenter(coordinate: coordinate, span: defaultSpan ? LocationMapNode.defaultMapSpan : LocationMapNode.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
}
strongSelf.headerNode.mapNode.trackingMode = state.trackingMode
let previousAnnotations = previousAnnotations.swap(annotations)
let previousUserAnnotation = previousUserAnnotation.swap(userAnnotation)
if (userAnnotation == nil) != (previousUserAnnotation == nil) {
strongSelf.headerNode.mapNode.userLocationAnnotation = userAnnotation
}
if annotations != previousAnnotations {
strongSelf.headerNode.mapNode.annotations = annotations
if !strongSelf.reportedAnnotationsReady {
strongSelf.reportedAnnotationsReady = true
if annotations.count > 0 {
strongSelf.onAnnotationsReady?()
}
}
}
if let _ = proximityNotification {
strongSelf.headerNode.mapNode.activeProximityRadius = proximityNotificationRadius.flatMap { Double($0) }
} else {
strongSelf.headerNode.mapNode.activeProximityRadius = nil
}
let rightBarButtonAction: LocationViewRightBarButton
if location.liveBroadcastingTimeout != nil {
if annotations.count > 0 {
rightBarButtonAction = .showAll
} else {
rightBarButtonAction = .none
}
} else {
rightBarButtonAction = .share
}
strongSelf.interaction.updateRightBarButton(rightBarButtonAction)
if let (layout, navigationBarHeight) = strongSelf.validLayout {
var updateLayout = false
let 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, controlsTopPadding: strongSelf.state.displayingMapModeOptions ? 38.0 : 0.0, controlsBottomPadding: 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
state.trackingMode = .none
return state
}
}
self.headerNode.mapNode.annotationSelected = { [weak self] annotation in
guard let strongSelf = self else {
return
}
if let annotation = annotation {
strongSelf.interaction.goToCoordinate(annotation.coordinate)
}
}
self.headerNode.mapNode.userLocationAnnotationSelected = { [weak self] in
if let strongSelf = self, let location = strongSelf.headerNode.mapNode.currentUserLocation {
strongSelf.interaction.goToCoordinate(location.coordinate)
}
}
self.locationManager.manager.startUpdatingHeading()
self.locationManager.manager.delegate = self
}
deinit {
self.disposable?.dispose()
self.travelDisposables.dispose()
self.locationManager.manager.stopUpdatingHeading()
}
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
if newHeading.headingAccuracy < 0.0 {
self.headerNode.mapNode.userHeading = nil
}
if newHeading.trueHeading > 0.0 {
self.headerNode.mapNode.userHeading = CGFloat(newHeading.trueHeading)
} else {
self.headerNode.mapNode.userHeading = CGFloat(newHeading.magneticHeading)
}
}
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))
}
func updateSendActionHighlight(_ highlighted: Bool) {
self.headerNode.updateHighlight(highlighted)
}
private func enqueueTransition(_ transition: LocationViewTransaction) {
self.enqueuedTransitions.append(transition)
if let _ = self.validLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
var initialized = false
private func dequeueTransition() {
guard let _ = self.validLayout, let transition = self.enqueuedTransitions.first else {
return
}
self.enqueuedTransitions.remove(at: 0)
let scrollToItem: ListViewScrollToItem?
if (!self.initialized && transition.insertions.count > 0) || transition.gotTravelTimes {
var index: Int = 0
var offset: CGFloat = 0.0
if transition.gotTravelTimes {
if transition.count > 1 {
index = 1
} else {
index = 0
}
offset = 0.0
} else if transition.insertions.count > 2 {
index = 2
offset = 40.0
} else if transition.insertions.count == 2 {
index = 1
}
scrollToItem = ListViewScrollToItem(index: index, position: .bottom(offset), animated: transition.gotTravelTimes, curve: .Default(duration: 0.3), directionHint: .Up)
self.initialized = true
} else {
scrollToItem = nil
}
var options = ListViewDeleteAndInsertOptions()
if transition.animated {
options.insert(.AnimateInsertion)
}
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: scrollToItem, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ 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 setProximityIndicator(radius: Int32?) {
guard let (layout, navigationBarHeight) = self.validLayout else {
return
}
if let radius = radius {
self.headerNode.forceIsHidden = true
if let coordinate = self.headerNode.mapNode.currentUserLocation?.coordinate {
self.updateState { state in
var state = state
state.selectedLocation = .custom
state.trackingMode = .none
return state
}
var contentOffset: CGFloat = 0.0
if case let .known(offset) = self.listNode.visibleContentOffset() {
contentOffset = offset
}
let panelHeight: CGFloat = 349.0 + layout.intrinsicInsets.bottom
let inset = (layout.size.width - 260.0) / 2.0
let offset = panelHeight / 2.0 + 60.0 + inset + navigationBarHeight / 2.0
let point = CGPoint(x: layout.size.width / 2.0, y: navigationBarHeight + (layout.size.height - navigationBarHeight - panelHeight) / 2.0)
let convertedPoint = self.view.convert(point, to: self.headerNode.mapNode.view)
self.headerNode.mapNode.setMapCenter(coordinate: coordinate, radius: Double(radius), insets: UIEdgeInsets(top: navigationBarHeight, left: inset, bottom: offset - contentOffset, right: inset), offset: convertedPoint.y - self.headerNode.mapNode.frame.height / 2.0, animated: true)
}
self.headerNode.mapNode.proximityIndicatorRadius = Double(radius)
} else {
self.headerNode.forceIsHidden = false
self.headerNode.mapNode.proximityIndicatorRadius = nil
self.updateState { state in
var state = state
state.selectedLocation = .user
state.trackingMode = .none
return state
}
}
}
func showAll() {
self.headerNode.mapNode.showAll()
}
private func displayProximityAlertTooltip() {
guard let location = self.headerNode.proximityButtonFrame().flatMap({ frame -> CGRect in
return self.headerNode.view.convert(frame, to: nil)
}) else {
return
}
let _ = (self.context.account.postbox.loadedPeerWithId(self.subject.id.peerId)
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let strongSelf = self else {
return
}
var text: String = strongSelf.presentationData.strings.Location_ProximityGroupTip
if peer.id.namespace == Namespaces.Peer.CloudUser {
text = strongSelf.presentationData.strings.Location_ProximityTip(EnginePeer(peer).compactDisplayTitle).string
}
strongSelf.interaction.present(TooltipScreen(account: strongSelf.context.account, sharedContext: strongSelf.context.sharedContext, text: .plain(text: text), icon: nil, location: .point(location.offsetBy(dx: -9.0, dy: 0.0), .right), displayDuration: .custom(3.0), shouldDismissOnTouch: { _, _ in
return .dismiss(consume: false)
}))
})
}
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
var topInset: CGFloat = layout.size.height - layout.intrinsicInsets.bottom - overlap
if !self.isStoryLocation {
topInset -= 100.0
}
if let location = getLocation(from: self.subject), location.liveBroadcastingTimeout != nil {
topInset += 66.0
}
let headerHeight: CGFloat
if let listOffset = self.listOffset {
headerHeight = max(0.0, listOffset + overlap)
} else {
headerHeight = topInset + overlap
}
let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: headerHeight))
transition.updateFrame(node: self.headerNode, frame: headerFrame)
self.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationHeight, topPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, controlsTopPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, controlsBottomPadding: 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)
self.optionsNode.isUserInteractionEnabled = self.state.displayingMapModeOptions
}
var coordinate: Signal<CLLocationCoordinate2D, NoError> {
return self.headerNode.mapNode.userLocation
|> filter { location in
return location != nil
}
|> take(1)
|> map { location -> CLLocationCoordinate2D in
return location!.coordinate
}
}
}