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