import Foundation import UIKit import Display import AsyncDisplayKit import TelegramCore import SwiftSignalKit import AccountContext import TelegramPresentationData import PresentationDataUtils import ComponentFlow import ViewControllerComponent import MultilineTextComponent import BundleIconComponent import UndoUI import Markdown import TextFormat import ButtonComponent import PeerListItemComponent import TelegramStringFormatting import AvatarNode private final class ReplaceBoostScreenComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let peerId: EnginePeer.Id let myBoostStatus: MyBoostStatus let selectedSlotsUpdated: ([Int32]) -> Void let presentController: (ViewController) -> Void init(context: AccountContext, peerId: EnginePeer.Id, myBoostStatus: MyBoostStatus, selectedSlotsUpdated: @escaping ([Int32]) -> Void, presentController: @escaping (ViewController) -> Void) { self.context = context self.peerId = peerId self.myBoostStatus = myBoostStatus self.selectedSlotsUpdated = selectedSlotsUpdated self.presentController = presentController } static func ==(lhs: ReplaceBoostScreenComponent, rhs: ReplaceBoostScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.peerId != rhs.peerId { return false } if lhs.myBoostStatus != rhs.myBoostStatus { return false } return true } final class State: ComponentState { private let context: AccountContext private let disposable = MetaDisposable() private var timer: SwiftSignalKit.Timer? var peer: EnginePeer? var selectedSlots: [Int32] = [] var currentTime: Int32 var cachedCloseImage: (UIImage, PresentationTheme)? init(context: AccountContext, peerId: EnginePeer.Id) { self.context = context self.currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) super.init() self.disposable.set((context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> deliverOnMainQueue).startStrict(next: { [weak self] peer in guard let self else { return } self.peer = peer self.updated() })) self.timer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in if let self { self.currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) self.updated() } }, queue: Queue.mainQueue()) self.timer?.start() } deinit { self.disposable.dispose() self.timer?.invalidate() } } func makeState() -> State { return State(context: self.context, peerId: self.peerId) } static var body: Body { // let closeButton = Child(Button.self) let header = Child(ReplaceBoostHeaderComponent.self) let description = Child(MultilineTextComponent.self) let boostsBackground = Child(RoundedRectangle.self) let boosts = Child(List.self) return { context in let environment = context.environment[ViewControllerComponentContainer.Environment.self].value let state = context.state let availableSize = context.availableSize let theme = environment.theme let strings = environment.strings // let topInset: CGFloat = environment.navigationHeight + 22.0 let textSideInset: CGFloat = 32.0 let sideInset: CGFloat = 16.0 + environment.safeInsets.left var boostItems: [AnyComponentWithIdentity] = [] let myBoosts = context.component.myBoostStatus.boosts let occupiedBoosts = myBoosts.filter { $0.peer?.id != context.component.peerId && $0.peer != nil }.sorted { lhs, rhs in return lhs.date < rhs.date } var otherPeers: [EnginePeer] = [] for slot in state.selectedSlots { if let peer = occupiedBoosts.first(where: { $0.slot == slot })?.peer { if !otherPeers.contains(where: { $0.id == peer.id }) { otherPeers.append(peer) } } } if let mainPeer = state.peer { let header = header.update( component: ReplaceBoostHeaderComponent( context: context.component.context, theme: environment.theme, mainPeer: mainPeer, otherPeers: otherPeers.reversed() ), availableSize: availableSize, transition: context.transition ) context.add(header .position(CGPoint(x: availableSize.width / 2.0, y: 93.0)) ) } let closeImage: UIImage if let (image, theme) = state.cachedCloseImage, theme === environment.theme { closeImage = image } else { closeImage = generateCloseButtonImage(backgroundColor: UIColor(rgb: 0x808084, alpha: 0.1), foregroundColor: theme.actionSheet.inputClearButtonColor)! state.cachedCloseImage = (closeImage, theme) } let textFont = Font.regular(15.0) let boldTextFont = Font.semibold(15.0) let textColor = theme.actionSheet.primaryTextColor let linkColor = theme.actionSheet.controlAccentColor let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) let channelName = state.peer?.compactDisplayTitle ?? "" let descriptionString = "To boost **\(channelName)**, reassign a previous boost or gift **Telegram Premium** to a friend to get **3** additional boosts." let description = description.update( component: MultilineTextComponent( text: .markdown(text: descriptionString, attributes: markdownAttributes), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.1 ), environment: {}, availableSize: CGSize(width: availableSize.width - sideInset * 2.0 - textSideInset, height: availableSize.height), transition: .immediate ) context.add(description .position(CGPoint(x: availableSize.width / 2.0, y: 172.0)) ) let selectedSlotsUpdated = context.component.selectedSlotsUpdated let presentController = context.component.presentController for i in 0 ..< occupiedBoosts.count { let boost = occupiedBoosts[i] guard let peer = boost.peer else { continue } var isEnabled = true let subtitle: String if let cooldownUntil = boost.cooldownUntil, cooldownUntil > state.currentTime { let duration = cooldownUntil - state.currentTime let durationValue = stringForDuration(duration, position: nil) subtitle = "available in \(durationValue)" isEnabled = false } else { let expiresValue = stringForDate(timestamp: boost.expires, strings: strings) subtitle = "boost expires on \(expiresValue)" } let accountContext = context.component.context boostItems.append( AnyComponentWithIdentity( id: AnyHashable(boost.slot), component: AnyComponent( PeerListItemComponent( context: context.component.context, theme: theme, strings: strings, style: .generic, sideInset: 0.0, title: peer.compactDisplayTitle, peer: peer, subtitle: subtitle, subtitleAccessory: .none, presence: nil, selectionState: .editing(isSelected: state.selectedSlots.contains(boost.slot), isTinted: false), selectionPosition: .right, isEnabled: isEnabled, hasNext: i != occupiedBoosts.count - 1, action: { [weak state] _ in guard let state else { return } if isEnabled { if state.selectedSlots.contains(boost.slot) { state.selectedSlots.removeAll(where: { $0 == boost.slot }) } else { state.selectedSlots.append(boost.slot) } state.updated(transition: .easeInOut(duration: 0.2)) selectedSlotsUpdated(state.selectedSlots) } else { let presentationData = accountContext.sharedContext.currentPresentationData.with { $0 } let undoController = UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: "Wait until the boost is available or get **3** more boosts by gifting a **Telegram Premium** subscription.", timeout: nil, customUndoText: nil), elevatedLayout: false, position: .top, action: { _ in return true }) presentController(undoController) } }) ) ) ) } let boosts = boosts.update( component: List(boostItems), environment: {}, availableSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0), transition: context.transition ) let boostsBackground = boostsBackground.update( component: RoundedRectangle(color: theme.list.itemBlocksBackgroundColor, cornerRadius: 10.0), environment: {}, availableSize: CGSize(width: availableSize.width - sideInset * 2.0, height: boosts.size.height), transition: context.transition ) context.add(boostsBackground .position(CGPoint(x: availableSize.width / 2.0, y: 226 + boosts.size.height / 2.0)) ) context.add(boosts .position(CGPoint(x: availableSize.width / 2.0, y: 226 + boosts.size.height / 2.0)) ) let contentSize = CGSize(width: availableSize.width, height: 226.0 + boosts.size.height) return contentSize } } } public class ReplaceBoostScreen: ViewController { final class Node: ViewControllerTracingNode, UIScrollViewDelegate, UIGestureRecognizerDelegate { private var presentationData: PresentationData private weak var controller: ReplaceBoostScreen? private let component: AnyComponent let dim: ASDisplayNode let wrappingView: UIView let containerView: UIView let scrollView: UIScrollView let hostView: ComponentHostView private let footerView: FooterView private(set) var isExpanded = false private var panGestureRecognizer: UIPanGestureRecognizer? private var panGestureArguments: (topInset: CGFloat, offset: CGFloat, scrollView: UIScrollView?, listNode: ListView?)? var selectedSlots: [Int32] = [] { didSet { self.controller?.requestLayout(transition: .immediate) } } private var currentIsVisible: Bool = false private var currentLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)? init(context: AccountContext, controller: ReplaceBoostScreen, component: AnyComponent) { self.presentationData = context.sharedContext.currentPresentationData.with { $0 } self.controller = controller self.component = component let effectiveTheme = self.presentationData.theme self.dim = ASDisplayNode() self.dim.alpha = 0.0 self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25) self.wrappingView = UIView() self.containerView = UIView() self.scrollView = UIScrollView() self.hostView = ComponentHostView() self.footerView = FooterView() super.init() self.scrollView.delegate = self self.scrollView.showsVerticalScrollIndicator = false self.containerView.clipsToBounds = true self.containerView.backgroundColor = effectiveTheme.list.blocksBackgroundColor self.addSubnode(self.dim) self.view.addSubview(self.wrappingView) self.wrappingView.addSubview(self.containerView) self.containerView.addSubview(self.scrollView) self.scrollView.addSubview(self.hostView) self.wrappingView.addSubview(self.footerView) self.footerView.action = { [weak self] in guard let self else { return } self.controller?.replaceBoosts?(self.selectedSlots) self.controller?.dismiss(animated: true) } } override func didLoad() { super.didLoad() let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) panRecognizer.delegate = self panRecognizer.delaysTouchesBegan = false panRecognizer.cancelsTouchesInView = true self.panGestureRecognizer = panRecognizer self.wrappingView.addGestureRecognizer(panRecognizer) self.dim.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) self.controller?.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate) } @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.controller?.dismiss(animated: true) } } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if let (layout, _) = self.currentLayout { if case .regular = layout.metrics.widthClass { return false } } return true } func scrollViewDidScroll(_ scrollView: UIScrollView) { let contentOffset = self.scrollView.contentOffset.y self.controller?.navigationBar?.updateBackgroundAlpha(min(30.0, contentOffset) / 30.0, transition: .immediate) } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { return true } return false } private var isDismissing = false func animateIn() { ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear).updateAlpha(node: self.dim, alpha: 1.0) let targetPosition = self.containerView.center let startPosition = targetPosition.offsetBy(dx: 0.0, dy: self.bounds.height) let footerTargetPosition = self.footerView.center let footerStartPosition = targetPosition.offsetBy(dx: 0.0, dy: self.bounds.height) self.containerView.center = startPosition self.footerView.center = footerStartPosition let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) transition.animateView(allowUserInteraction: true, { self.containerView.center = targetPosition self.footerView.center = footerTargetPosition }, completion: { _ in }) } func animateOut(completion: @escaping () -> Void = {}) { self.isDismissing = true let positionTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) positionTransition.updatePosition(layer: self.containerView.layer, position: CGPoint(x: self.containerView.center.x, y: self.bounds.height + self.containerView.bounds.height / 2.0), completion: { [weak self] _ in self?.controller?.dismiss(animated: false, completion: completion) }) positionTransition.updatePosition(layer: self.footerView.layer, position: CGPoint(x: self.footerView.center.x, y: self.bounds.height + self.footerView.bounds.height / 2.0)) let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) alphaTransition.updateAlpha(node: self.dim, alpha: 0.0) } func containerLayoutUpdated(layout: ContainerViewLayout, navigationHeight: CGFloat, transition: Transition) { self.currentLayout = (layout, navigationHeight) if let controller = self.controller, let navigationBar = controller.navigationBar, navigationBar.view.superview !== self.wrappingView { self.containerView.addSubview(navigationBar.view) } self.dim.frame = CGRect(origin: CGPoint(x: 0.0, y: -layout.size.height), size: CGSize(width: layout.size.width, height: layout.size.height * 3.0)) var effectiveExpanded = self.isExpanded if case .regular = layout.metrics.widthClass { effectiveExpanded = true } let isLandscape = layout.orientation == .landscape let edgeTopInset = isLandscape ? 0.0 : self.defaultTopInset let topInset: CGFloat if let (panInitialTopInset, panOffset, _, _) = self.panGestureArguments { if effectiveExpanded { topInset = min(edgeTopInset, panInitialTopInset + max(0.0, panOffset)) } else { topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) } } else { topInset = effectiveExpanded ? 0.0 : edgeTopInset } transition.setFrame(view: self.wrappingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: layout.size), completion: nil) let modalProgress = isLandscape ? 0.0 : (1.0 - topInset / self.defaultTopInset) self.controller?.updateModalStyleOverlayTransitionFactor(modalProgress, transition: transition.containedViewLayoutTransition) let clipFrame: CGRect if layout.metrics.widthClass == .compact { self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.25) if isLandscape { self.containerView.layer.cornerRadius = 0.0 } else { self.containerView.layer.cornerRadius = 10.0 } if #available(iOS 11.0, *) { if layout.safeInsets.bottom.isZero { self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] } else { self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] } } if isLandscape { clipFrame = CGRect(origin: CGPoint(), size: layout.size) } else { let coveredByModalTransition: CGFloat = 0.0 var containerTopInset: CGFloat = 10.0 if let statusBarHeight = layout.statusBarHeight { containerTopInset += statusBarHeight } let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: containerTopInset - coveredByModalTransition * 10.0), size: CGSize(width: layout.size.width, height: layout.size.height - containerTopInset)) let maxScale: CGFloat = (layout.size.width - 16.0 * 2.0) / layout.size.width let containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition let maxScaledTopInset: CGFloat = containerTopInset - 10.0 let scaledTopInset: CGFloat = containerTopInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition let containerFrame = unscaledFrame.offsetBy(dx: 0.0, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0)) clipFrame = CGRect(x: containerFrame.minX, y: containerFrame.minY, width: containerFrame.width, height: containerFrame.height) } } else { self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.4) self.containerView.layer.cornerRadius = 10.0 let verticalInset: CGFloat = 44.0 let maxSide = max(layout.size.width, layout.size.height) let minSide = min(layout.size.width, layout.size.height) let containerSize = CGSize(width: min(layout.size.width - 20.0, floor(maxSide / 2.0)), height: min(layout.size.height, minSide) - verticalInset * 2.0) clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize) } transition.setFrame(view: self.containerView, frame: clipFrame) transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: clipFrame.size), completion: nil) let environment = ViewControllerComponentContainer.Environment( statusBarHeight: 0.0, navigationHeight: navigationHeight, safeInsets: UIEdgeInsets(top: layout.intrinsicInsets.top + layout.safeInsets.top, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom + layout.safeInsets.bottom, right: layout.safeInsets.right), inputHeight: layout.inputHeight ?? 0.0, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, isVisible: self.currentIsVisible, theme: self.presentationData.theme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, controller: { [weak self] in return self?.controller } ) var contentSize = self.hostView.update( transition: transition, component: self.component, environment: { environment }, forceUpdate: true, containerSize: CGSize(width: clipFrame.size.width, height: 10000.0) ) contentSize.height = max(layout.size.height - navigationHeight, contentSize.height) transition.setFrame(view: self.hostView, frame: CGRect(origin: CGPoint(), size: contentSize), completion: nil) self.scrollView.contentSize = contentSize let footerInsets = UIEdgeInsets(top: 0.0, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right) transition.setFrame(view: self.footerView, frame: CGRect(origin: CGPoint(x: 0.0, y: -topInset), size: layout.size)) let _ = self.footerView.update(size: layout.size, insets: footerInsets, theme: self.presentationData.theme, count: Int32(self.selectedSlots.count)) self.footerView.updateBackgroundAlpha(0.0, transition: .immediate) } private var didPlayAppearAnimation = false func updateIsVisible(isVisible: Bool) { if self.currentIsVisible == isVisible { return } self.currentIsVisible = isVisible guard let currentLayout = self.currentLayout else { return } self.containerLayoutUpdated(layout: currentLayout.layout, navigationHeight: currentLayout.navigationHeight, transition: .immediate) if !self.didPlayAppearAnimation { self.didPlayAppearAnimation = true self.animateIn() } } private var defaultTopInset: CGFloat { guard let (layout, _) = self.currentLayout else{ return 210.0 } if case .compact = layout.metrics.widthClass { var factor: CGFloat = 0.2488 if layout.size.width <= 320.0 { factor = 0.15 } return floor(max(layout.size.width, layout.size.height) * factor) } else { return 210.0 } } private func findScrollView(view: UIView?) -> (UIScrollView, ListView?)? { if let view = view { if let view = view as? UIScrollView { return (view, nil) } if let node = view.asyncdisplaykit_node as? ListView { return (node.scroller, node) } return findScrollView(view: view.superview) } else { return nil } } @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { guard let (layout, navigationHeight) = self.currentLayout else { return } let isLandscape = layout.orientation == .landscape let edgeTopInset = isLandscape ? 0.0 : defaultTopInset switch recognizer.state { case .began: let point = recognizer.location(in: self.view) let currentHitView = self.hitTest(point, with: nil) var scrollViewAndListNode = self.findScrollView(view: currentHitView) if scrollViewAndListNode?.0.frame.height == self.frame.width { scrollViewAndListNode = nil } let scrollView = scrollViewAndListNode?.0 let listNode = scrollViewAndListNode?.1 let topInset: CGFloat if self.isExpanded { topInset = 0.0 } else { topInset = edgeTopInset } self.panGestureArguments = (topInset, 0.0, scrollView, listNode) case .changed: guard let (topInset, panOffset, scrollView, listNode) = self.panGestureArguments else { return } let visibleContentOffset = listNode?.visibleContentOffset() let contentOffset = scrollView?.contentOffset.y ?? 0.0 var translation = recognizer.translation(in: self.view).y var currentOffset = topInset + translation let epsilon = 1.0 if case let .known(value) = visibleContentOffset, value <= epsilon { if let scrollView = scrollView { scrollView.bounces = false scrollView.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: false) } } else if let scrollView = scrollView, contentOffset <= -scrollView.contentInset.top + epsilon { scrollView.bounces = false scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } else if let scrollView = scrollView { translation = panOffset currentOffset = topInset + translation if self.isExpanded { recognizer.setTranslation(CGPoint(), in: self.view) } else if currentOffset > 0.0 { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } } self.panGestureArguments = (topInset, translation, scrollView, listNode) if !self.isExpanded { if currentOffset > 0.0, let scrollView = scrollView { scrollView.panGestureRecognizer.setTranslation(CGPoint(), in: scrollView) } } var bounds = self.bounds if self.isExpanded { bounds.origin.y = -max(0.0, translation - edgeTopInset) } else { bounds.origin.y = -translation } bounds.origin.y = min(0.0, bounds.origin.y) self.bounds = bounds self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate) case .ended: guard let (currentTopInset, panOffset, scrollView, listNode) = self.panGestureArguments else { return } self.panGestureArguments = nil let visibleContentOffset = listNode?.visibleContentOffset() let contentOffset = scrollView?.contentOffset.y ?? 0.0 let translation = recognizer.translation(in: self.view).y var velocity = recognizer.velocity(in: self.view) if self.isExpanded { if case let .known(value) = visibleContentOffset, value > 0.1 { velocity = CGPoint() } else if case .unknown = visibleContentOffset { velocity = CGPoint() } else if contentOffset > 0.1 { velocity = CGPoint() } } var bounds = self.bounds if self.isExpanded { bounds.origin.y = -max(0.0, translation - edgeTopInset) } else { bounds.origin.y = -translation } bounds.origin.y = min(0.0, bounds.origin.y) scrollView?.bounces = true let offset = currentTopInset + panOffset let topInset: CGFloat = edgeTopInset var dismissing = false if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) || (self.isExpanded && bounds.minY.isZero && velocity.y > 1800.0) { self.controller?.dismiss(animated: true, completion: nil) dismissing = true } else if self.isExpanded { if velocity.y > 300.0 || offset > topInset / 2.0 { self.isExpanded = false if let listNode = listNode { listNode.scroller.setContentOffset(CGPoint(), animated: false) } else if let scrollView = scrollView { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } let distance = topInset - offset let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance) let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } else { self.isExpanded = true self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) } } else if (velocity.y < -300.0 || offset < topInset / 2.0) { if velocity.y > -2200.0 && velocity.y < -300.0, let listNode = listNode { DispatchQueue.main.async { 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 }) } } let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset) let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) self.isExpanded = true self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } else { if let listNode = listNode { listNode.scroller.setContentOffset(CGPoint(), animated: false) } else if let scrollView = scrollView { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) } if !dismissing { var bounds = self.bounds let previousBounds = bounds bounds.origin.y = 0.0 self.bounds = bounds self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) } case .cancelled: self.panGestureArguments = nil self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) default: break } } func update(isExpanded: Bool, transition: ContainedViewLayoutTransition) { guard isExpanded != self.isExpanded else { return } self.isExpanded = isExpanded guard let (layout, navigationHeight) = self.currentLayout else { return } self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } } var node: Node { return self.displayNode as! Node } private let context: AccountContext private let component: AnyComponent private var replaceBoosts: (([Int32]) -> Void)? private var currentLayout: ContainerViewLayout? public convenience init(context: AccountContext, peerId: EnginePeer.Id, myBoostStatus: MyBoostStatus, replaceBoosts: @escaping ([Int32]) -> Void) { let presentationData = context.sharedContext.currentPresentationData.with { $0 } var selectedSlotsUpdatedImpl: (([Int32]) -> Void)? var presentControllerImpl: ((ViewController) -> Void)? self.init(context: context, component: ReplaceBoostScreenComponent(context: context, peerId: peerId, myBoostStatus: myBoostStatus, selectedSlotsUpdated: { slots in selectedSlotsUpdatedImpl?(slots) }, presentController: { c in presentControllerImpl?(c) })) self.title = "Reassign Boost" self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(self.cancelPressed)) self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) selectedSlotsUpdatedImpl = { [weak self] selectedSlots in self?.node.selectedSlots = selectedSlots } presentControllerImpl = { [weak self] c in self?.present(c, in: .window(.root)) } self.replaceBoosts = replaceBoosts } private init(context: AccountContext, component: C, theme: PresentationTheme? = nil) where C.EnvironmentType == ViewControllerComponentContainer.Environment { self.context = context self.component = AnyComponent(component) let presentationData = context.sharedContext.currentPresentationData.with { $0 } super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: presentationData)) self.navigationPresentation = .flatModal } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func cancelPressed() { self.dismiss(animated: true, completion: nil) } override open func loadDisplayNode() { self.displayNode = Node(context: self.context, controller: self, component: self.component) self.displayNodeDidLoad() } public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { self.view.endEditing(true) if flag { self.node.animateOut(completion: { super.dismiss(animated: false, completion: {}) completion?() }) } else { super.dismiss(animated: false, completion: {}) completion?() } } override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.node.updateIsVisible(isVisible: true) } override open func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.node.updateIsVisible(isVisible: false) } override public func updateNavigationBarLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { var navigationLayout = self.navigationLayout(layout: layout) var navigationFrame = navigationLayout.navigationFrame var layout = layout if case .regular = layout.metrics.widthClass { let verticalInset: CGFloat = 44.0 let maxSide = max(layout.size.width, layout.size.height) let minSide = min(layout.size.width, layout.size.height) let containerSize = CGSize(width: min(layout.size.width - 20.0, floor(maxSide / 2.0)), height: min(layout.size.height, minSide) - verticalInset * 2.0) let clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize) navigationFrame.size.width = clipFrame.width layout.size = clipFrame.size } navigationFrame.size.height = 56.0 navigationLayout.navigationFrame = navigationFrame navigationLayout.defaultContentHeight = 56.0 layout.statusBarHeight = nil self.applyNavigationBarLayout(layout, navigationLayout: navigationLayout, additionalBackgroundHeight: 0.0, transition: transition) } override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.currentLayout = layout super.containerLayoutUpdated(layout, transition: transition) let navigationHeight: CGFloat = 56.0 self.node.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } } private final class FooterView: UIView { private let backgroundNode: NavigationBackgroundNode private let separatorView: UIView private let button = ComponentView() var action: () -> Void = {} init() { self.backgroundNode = NavigationBackgroundNode(color: .clear) self.separatorView = UIView() super.init(frame: .zero) self.addSubnode(self.backgroundNode) self.addSubview(self.separatorView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private var currentLayout: (CGSize, UIEdgeInsets)? func update(size: CGSize, insets: UIEdgeInsets, theme: PresentationTheme, count: Int32) -> CGFloat { let hadLayout = self.currentLayout != nil self.currentLayout = (size, insets) self.backgroundNode.updateColor(color: theme.rootController.tabBar.backgroundColor, transition: .immediate) self.separatorView.backgroundColor = theme.rootController.tabBar.separatorColor let buttonInset: CGFloat = 16.0 let buttonWidth = size.width - insets.left - insets.right - buttonInset * 2.0 let inset: CGFloat = 9.0 var panelHeight: CGFloat = 50.0 + inset * 2.0 panelHeight += insets.bottom let totalPanelHeight = panelHeight let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - totalPanelHeight), size: CGSize(width: size.width, height: panelHeight)) var buttonTransition: Transition = .easeInOut(duration: 0.2) if !hadLayout { buttonTransition = .immediate } let buttonSize = self.button.update( transition: buttonTransition, component: AnyComponent( ButtonComponent( background: ButtonComponent.Background( color: theme.list.itemCheckColors.fillColor, foreground: theme.list.itemCheckColors.foregroundColor, pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) ), content: AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(ButtonTextContentComponent( text: "Reassign Boosts", badge: Int(count), textColor: theme.list.itemCheckColors.foregroundColor, badgeBackground: theme.list.itemCheckColors.foregroundColor, badgeForeground: theme.list.itemCheckColors.fillColor, badgeStyle: .roundedRectangle, badgeIconName: "Premium/BoostButtonIcon", combinedAlignment: true )) ), isEnabled: true, displaysProgress: false, action: { [weak self] in guard let self else { return } self.action() } ) ), environment: {}, containerSize: CGSize(width: buttonWidth, height: 50.0) ) if let view = self.button.view { if view.superview == nil { self.addSubview(view) } view.frame = CGRect(origin: CGPoint(x: insets.left + buttonInset, y: panelFrame.minY + inset), size: buttonSize) } self.backgroundNode.frame = panelFrame self.backgroundNode.update(size: panelFrame.size, transition: .immediate) self.separatorView.frame = CGRect(origin: panelFrame.origin, size: CGSize(width: panelFrame.width, height: UIScreenPixel)) return panelHeight } func updateBackgroundAlpha(_ alpha: CGFloat, transition: Transition) { transition.setAlpha(view: self.backgroundNode.view, alpha: alpha) transition.setAlpha(view: self.separatorView, alpha: alpha) } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { if self.backgroundNode.frame.contains(point) { return true } else { return false } } } private func generateBoostIcon(theme: PresentationTheme) -> UIImage? { if let image = UIImage(bundleImageName: "Premium/AvatarBoost") { let size = CGSize(width: image.size.width + 4.0, height: image.size.height + 4.0) return generateImage(size, contextGenerator: { size, context in let bounds = CGRect(origin: .zero, size: size) context.clear(bounds) if let cgImage = image.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(x: 2.0, y: 2.0), size: image.size)) } let lineWidth = 2.0 - UIScreenPixel context.setLineWidth(lineWidth) context.setStrokeColor(theme.list.blocksBackgroundColor.cgColor) context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0 + UIScreenPixel, dy: lineWidth / 2.0 + UIScreenPixel)) }, opaque: false) } return nil } private final class ReplaceBoostHeaderComponent: Component { let context: AccountContext let theme: PresentationTheme let mainPeer: EnginePeer let otherPeers: [EnginePeer] init(context: AccountContext, theme: PresentationTheme, mainPeer: EnginePeer, otherPeers: [EnginePeer]) { self.context = context self.theme = theme self.mainPeer = mainPeer self.otherPeers = otherPeers } static func ==(lhs: ReplaceBoostHeaderComponent, rhs: ReplaceBoostHeaderComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.mainPeer != rhs.mainPeer { return false } if lhs.otherPeers != rhs.otherPeers { return false } return true } final class WrapperAvatarView: UIView { let backgroundView = UIView() let avatarNode: AvatarNode let badgeImageView: UIImageView override init(frame: CGRect) { self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0)) self.avatarNode.frame = CGRect(origin: .zero, size: CGSize(width: 60.0, height: 60.0)) self.backgroundView.frame = self.avatarNode.frame.insetBy(dx: -3.0 + UIScreenPixel, dy: -3.0 + UIScreenPixel) self.backgroundView.layer.cornerRadius = self.backgroundView.frame.height / 2.0 self.badgeImageView = UIImageView(frame: CGRect(x: 60.0 - 24.0, y: 60.0 - 24.0, width: 28.0, height: 28.0)) self.badgeImageView.alpha = 0.0 super.init(frame: frame) self.backgroundView.clipsToBounds = true self.addSubview(self.backgroundView) self.addSubnode(self.avatarNode) self.addSubview(self.badgeImageView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } final class View: UIView { private let containerView = UIView() private let avatarNode: AvatarNode private let arrowView: UIImageView private var otherAvatarNodes: [EnginePeer.Id: WrapperAvatarView] = [:] private var component: ReplaceBoostHeaderComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 26.0)) self.avatarNode.frame = CGRect(origin: .zero, size: CGSize(width: 60.0, height: 60.0)) self.arrowView = UIImageView(image: UIImage(bundleImageName: "Peer Info/AlertArrow")?.withRenderingMode(.alwaysTemplate)) super.init(frame: frame) self.addSubview(self.containerView) self.containerView.addSubview(self.arrowView) self.containerView.addSubnode(self.avatarNode) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private var badgeImage: UIImage? func update(component: ReplaceBoostHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state let avatarSize = CGSize(width: 60.0, height: 60.0) let spacing: CGFloat = 27.0 var totalWidth: CGFloat = avatarSize.width if !component.otherPeers.isEmpty { totalWidth += spacing totalWidth += avatarSize.width if component.otherPeers.count > 1 { totalWidth += (avatarSize.width / 2.0) * CGFloat(component.otherPeers.count - 1) } } transition.setFrame(view: self.containerView, frame: CGRect(origin: CGPoint(x: (availableSize.width - totalWidth) / 2.0, y: 0.0), size: CGSize(width: totalWidth, height: avatarSize.height))) var originX: CGFloat = 0.0 var validIds: [EnginePeer.Id] = [] for i in 0 ..< component.otherPeers.count { let peer = component.otherPeers[i] validIds.append(peer.id) let avatarView: WrapperAvatarView var avatarTransition = transition if let current = self.otherAvatarNodes[peer.id] { avatarView = current } else { avatarTransition = .immediate avatarView = WrapperAvatarView() avatarView.bounds = CGRect(origin: .zero, size: avatarSize) avatarView.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, synchronousLoad: true) avatarView.backgroundView.backgroundColor = component.theme.list.blocksBackgroundColor if self.badgeImage == nil { self.badgeImage = generateBoostIcon(theme: component.theme) } avatarView.badgeImageView.image = self.badgeImage self.otherAvatarNodes[peer.id] = avatarView self.containerView.insertSubview(avatarView, at: 0) avatarView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) avatarView.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) } let isLast = i == component.otherPeers.count - 1 avatarTransition.setAlpha(view: avatarView.badgeImageView, alpha: isLast ? 1.0 : 0.0) avatarTransition.setScale(view: avatarView.badgeImageView, scale: isLast ? 1.0 : 0.1) avatarTransition.setPosition(view: avatarView, position: CGPoint(x: originX + avatarSize.width / 2.0, y: avatarSize.height / 2.0)) if isLast { originX += avatarSize.width } else { originX += avatarSize.width / 2.0 } } if !component.otherPeers.isEmpty { originX += spacing } self.arrowView.tintColor = component.theme.list.itemSecondaryTextColor transition.setAlpha(view: self.arrowView, alpha: component.otherPeers.isEmpty ? 0.0 : 1.0) transition.setScale(view: self.arrowView, scale: component.otherPeers.isEmpty ? 0.1 : 1.0) transition.setPosition(view: self.arrowView, position: CGPoint(x: originX - 13.0, y: avatarSize.height / 2.0)) transition.setFrame(view: self.avatarNode.view, frame: CGRect(origin: CGPoint(x: originX, y: 0.0), size: avatarSize)) self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: component.mainPeer, synchronousLoad: true) var removeIds: [EnginePeer.Id] = [] for (id, avatarView) in self.otherAvatarNodes { if !validIds.contains(id) { removeIds.append(id) avatarView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in avatarView.removeFromSuperview() }) avatarView.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false) } } for id in removeIds { self.otherAvatarNodes.removeValue(forKey: id) } return CGSize(width: availableSize.width, height: avatarSize.height) } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }