import Foundation import UIKit import Display import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import TelegramPresentationData public final class SparseDiscreteScrollingArea: ASDisplayNode { private final class DragGesture: UIGestureRecognizer { private let shouldBegin: (CGPoint) -> Bool private let began: () -> Void private let ended: () -> Void private let moved: (CGFloat) -> Void private var initialLocation: CGPoint? public init( shouldBegin: @escaping (CGPoint) -> Bool, began: @escaping () -> Void, ended: @escaping () -> Void, moved: @escaping (CGFloat) -> Void ) { self.shouldBegin = shouldBegin self.began = began self.ended = ended self.moved = moved super.init(target: nil, action: nil) } deinit { } override public func reset() { super.reset() self.initialLocation = nil self.initialLocation = nil } override public func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) if self.numberOfTouches > 1 { self.state = .failed self.ended() return } if self.state == .possible { if let location = touches.first?.location(in: self.view) { if self.shouldBegin(location) { self.initialLocation = location self.state = .began self.began() } else { self.state = .failed } } else { self.state = .failed } } } override public func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) self.initialLocation = nil if self.state == .began || self.state == .changed { self.ended() self.state = .failed } } override public func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) self.initialLocation = nil if self.state == .began || self.state == .changed { self.ended() self.state = .failed } } override public func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { self.state = .changed let offset = location.y - initialLocation.y self.moved(offset) } } } private let dateIndicator: ComponentHostView private let lineIndicator: UIImageView private var containerSize: CGSize? private var indicatorPosition: CGFloat? private var scrollIndicatorHeight: CGFloat? private var scrollIndicatorRange: (CGFloat, CGFloat)? private var initialDraggingOffset: CGFloat? private var draggingOffset: CGFloat? private var dragGesture: DragGesture? public private(set) var isDragging: Bool = false private var activityTimer: SwiftSignalKit.Timer? public var openCurrentDate: (() -> Void)? public var navigateToPosition: ((Float) -> Void)? private var navigatingToPositionOffset: CGFloat? private var offsetBarTimer: SwiftSignalKit.Timer? private let hapticFeedback = HapticFeedback() private var theme: PresentationTheme? private struct State { var containerSize: CGSize var containerInsets: UIEdgeInsets var scrollingState: ListView.ScrollingIndicatorState? var isScrolling: Bool var isDragging: Bool var theme: PresentationTheme } private var state: State? override public init() { self.dateIndicator = ComponentHostView() self.lineIndicator = UIImageView() self.dateIndicator.alpha = 0.0 self.lineIndicator.alpha = 0.0 super.init() self.dateIndicator.isUserInteractionEnabled = false self.lineIndicator.isUserInteractionEnabled = false self.view.addSubview(self.dateIndicator) self.view.addSubview(self.lineIndicator) let dragGesture = DragGesture( shouldBegin: { [weak self] point in guard let _ = self else { return false } return true }, began: { [weak self] in guard let strongSelf = self else { return } let offsetBarTimer = SwiftSignalKit.Timer(timeout: 0.2, repeat: false, completion: { guard let strongSelf = self else { return } strongSelf.performOffsetBarTimerEvent() }, queue: .mainQueue()) strongSelf.offsetBarTimer?.invalidate() strongSelf.offsetBarTimer = offsetBarTimer offsetBarTimer.start() strongSelf.isDragging = true strongSelf.initialDraggingOffset = strongSelf.lineIndicator.frame.minY strongSelf.draggingOffset = 0.0 if let state = strongSelf.state { strongSelf.update( containerSize: state.containerSize, containerInsets: state.containerInsets, scrollingState: state.scrollingState, isScrolling: state.isScrolling, theme: state.theme, transition: .animated(duration: 0.2, curve: .easeInOut) ) } strongSelf.updateActivityTimer(isScrolling: false) }, ended: { [weak self] in guard let strongSelf = self else { return } if strongSelf.offsetBarTimer != nil { strongSelf.offsetBarTimer?.invalidate() strongSelf.offsetBarTimer = nil strongSelf.openCurrentDate?() } let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) transition.updateSublayerTransformOffset(layer: strongSelf.dateIndicator.layer, offset: CGPoint(x: 0.0, y: 0.0)) strongSelf.isDragging = false if let _ = strongSelf.initialDraggingOffset, let _ = strongSelf.draggingOffset, let scrollIndicatorRange = strongSelf.scrollIndicatorRange { strongSelf.navigatingToPositionOffset = strongSelf.lineIndicator.frame.minY var absoluteOffset = strongSelf.lineIndicator.frame.minY - scrollIndicatorRange.0 absoluteOffset /= (scrollIndicatorRange.1 - scrollIndicatorRange.0) absoluteOffset = abs(absoluteOffset) absoluteOffset = 1.0 - absoluteOffset strongSelf.navigateToPosition?(Float(absoluteOffset)) } else { strongSelf.navigatingToPositionOffset = nil } strongSelf.initialDraggingOffset = nil strongSelf.draggingOffset = nil if let state = strongSelf.state { strongSelf.update( containerSize: state.containerSize, containerInsets: state.containerInsets, scrollingState: state.scrollingState, isScrolling: state.isScrolling, theme: state.theme, transition: transition ) } strongSelf.updateActivityTimer(isScrolling: false) }, moved: { [weak self] relativeOffset in guard let strongSelf = self else { return } if strongSelf.offsetBarTimer != nil { strongSelf.offsetBarTimer?.invalidate() strongSelf.offsetBarTimer = nil strongSelf.performOffsetBarTimerEvent() } strongSelf.draggingOffset = relativeOffset if let state = strongSelf.state { strongSelf.update( containerSize: state.containerSize, containerInsets: state.containerInsets, scrollingState: state.scrollingState, isScrolling: state.isScrolling, theme: state.theme, transition: .immediate ) } } ) self.dragGesture = dragGesture self.view.addGestureRecognizer(dragGesture) } private func performOffsetBarTimerEvent() { self.hapticFeedback.impact() self.offsetBarTimer = nil /*let transition: ContainedViewLayoutTransition = .animated(duration: 0.1, curve: .easeInOut) transition.updateSublayerTransformOffset(layer: self.dateIndicator.layer, offset: CGPoint(x: -80.0, y: 0.0)) self.updateLineIndicator(transition: transition)*/ } func feedbackTap() { self.hapticFeedback.tap() } public func resetNavigatingToPosition() { self.navigatingToPositionOffset = nil if let state = self.state { self.update( containerSize: state.containerSize, containerInsets: state.containerInsets, scrollingState: state.scrollingState, isScrolling: state.isScrolling, theme: state.theme, transition: .animated(duration: 0.2, curve: .easeInOut) ) } } public func update( containerSize: CGSize, containerInsets: UIEdgeInsets, scrollingState: ListView.ScrollingIndicatorState?, isScrolling: Bool, theme: PresentationTheme, transition: ContainedViewLayoutTransition ) { let updateLineImage = self.state?.isDragging != self.isDragging || self.state?.theme !== theme self.state = State( containerSize: containerSize, containerInsets: containerInsets, scrollingState: scrollingState, isScrolling: isScrolling, isDragging: self.isDragging, theme: theme ) self.containerSize = containerSize if self.theme !== theme { self.theme = theme /*var backgroundColors: [UInt32] = [] switch chatPresentationInterfaceState.chatWallpaper { case let .file(file): if file.isPattern { backgroundColors = file.settings.colors } case let .gradient(gradient): backgroundColors = gradient.colors case let .color(color): backgroundColors = [color] default: break }*/ } if updateLineImage { let lineColor: UIColor if theme.overallDarkAppearance { lineColor = UIColor(white: 0.0, alpha: 0.3) } else { lineColor = UIColor(white: 0.0, alpha: 0.3) } if let image = generateStretchableFilledCircleImage(diameter: self.isDragging ? 6.0 : 3.0, color: lineColor, strokeColor: nil, strokeWidth: nil, backgroundColor: nil) { if transition.isAnimated, let previousImage = self.lineIndicator.image { self.lineIndicator.image = image self.lineIndicator.layer.animate(from: previousImage.cgImage!, to: image.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) } else { self.lineIndicator.image = image } } } if self.dateIndicator.alpha.isZero { let transition: ContainedViewLayoutTransition = .immediate transition.updateSublayerTransformOffset(layer: self.dateIndicator.layer, offset: CGPoint()) } self.updateActivityTimer(isScrolling: isScrolling) let indicatorSize = self.dateIndicator.update( transition: .immediate, component: AnyComponent(SparseItemGridScrollingIndicatorComponent( backgroundColor: theme.list.itemBlocksBackgroundColor, shadowColor: .black, foregroundColor: theme.list.itemPrimaryTextColor, date: ("Date", 0), previousDate: nil )), environment: {}, containerSize: containerSize ) let _ = indicatorSize self.dateIndicator.isHidden = true if let scrollingIndicatorState = scrollingState { let averageRangeItemHeight: CGFloat = 44.0 let upperItemsHeight = floor(averageRangeItemHeight * CGFloat(scrollingIndicatorState.topItem.index)) let approximateContentHeight = CGFloat(scrollingIndicatorState.itemCount) * averageRangeItemHeight var convertedTopBoundary: CGFloat if scrollingIndicatorState.topItem.offset < scrollingIndicatorState.insets.top { convertedTopBoundary = (scrollingIndicatorState.topItem.offset - scrollingIndicatorState.insets.top) * averageRangeItemHeight / scrollingIndicatorState.topItem.height } else { convertedTopBoundary = scrollingIndicatorState.topItem.offset - scrollingIndicatorState.insets.top } convertedTopBoundary -= upperItemsHeight let approximateOffset = -convertedTopBoundary var convertedBottomBoundary: CGFloat = 0.0 if scrollingIndicatorState.bottomItem.offset > containerSize.height - scrollingIndicatorState.insets.bottom { convertedBottomBoundary = ((containerSize.height - scrollingIndicatorState.insets.bottom) - scrollingIndicatorState.bottomItem.offset) * averageRangeItemHeight / scrollingIndicatorState.bottomItem.height } else { convertedBottomBoundary = (containerSize.height - scrollingIndicatorState.insets.bottom) - scrollingIndicatorState.bottomItem.offset } convertedBottomBoundary += CGFloat(scrollingIndicatorState.bottomItem.index + 1) * averageRangeItemHeight let approximateVisibleHeight = max(0.0, convertedBottomBoundary - approximateOffset) let approximateScrollingProgress = approximateOffset / (approximateContentHeight - approximateVisibleHeight) let indicatorSideInset: CGFloat = 3.0 let indicatorTopInset: CGFloat = 3.0 /*if self.verticalScrollIndicatorFollowsOverscroll { if scrollingIndicatorState.topItem.index == 0 { indicatorTopInset = max(scrollingIndicatorState.topItem.offset + 3.0 - self.insets.top, 3.0) } }*/ let indicatorBottomInset: CGFloat = 3.0 let minIndicatorContentHeight: CGFloat = 12.0 let minIndicatorHeight: CGFloat = 6.0 let visibleHeightWithoutIndicatorInsets = containerSize.height - containerInsets.top - containerInsets.bottom - indicatorTopInset - indicatorBottomInset let indicatorHeight: CGFloat if approximateContentHeight <= 0 { indicatorHeight = 0.0 } else { indicatorHeight = max(minIndicatorContentHeight, 44.0)//max(minIndicatorContentHeight, floor(visibleHeightWithoutIndicatorInsets * (containerSize.height - scrollingIndicatorState.insets.top - scrollingIndicatorState.insets.bottom) / approximateContentHeight)) } let upperBound = containerInsets.top + indicatorTopInset let lowerBound = containerSize.height - containerInsets.bottom - indicatorTopInset - indicatorBottomInset - indicatorHeight let indicatorOffset = ceilToScreenPixels(upperBound * (1.0 - approximateScrollingProgress) + lowerBound * approximateScrollingProgress) var indicatorFrame = CGRect(origin: CGPoint(x: containerSize.width - 3.0 - indicatorSideInset, y: indicatorOffset), size: CGSize(width: 3.0, height: indicatorHeight)) if indicatorFrame.minY < containerInsets.top + indicatorTopInset { indicatorFrame.size.height -= containerInsets.top + indicatorTopInset - indicatorFrame.minY indicatorFrame.origin.y = containerInsets.top + indicatorTopInset indicatorFrame.size.height = max(minIndicatorHeight, indicatorFrame.height) } if indicatorFrame.maxY > containerSize.height - (containerInsets.bottom + indicatorTopInset + indicatorBottomInset) { indicatorFrame.size.height -= indicatorFrame.maxY - (containerSize.height - (containerInsets.bottom + indicatorTopInset)) indicatorFrame.size.height = max(minIndicatorHeight, indicatorFrame.height) indicatorFrame.origin.y = containerSize.height - (containerInsets.bottom + indicatorBottomInset) - indicatorFrame.height } if indicatorFrame.origin.y.isNaN { indicatorFrame.origin.y = indicatorTopInset } indicatorFrame.origin.y = containerSize.height - indicatorFrame.origin.y - indicatorFrame.height if self.isDragging { indicatorFrame.origin.x -= 3.0 indicatorFrame.size.width += 3.0 } var alternativeOffset: CGFloat? if let navigatingToPositionOffset = self.navigatingToPositionOffset { alternativeOffset = navigatingToPositionOffset } else if let initialDraggingOffset = self.initialDraggingOffset, let draggingOffset = self.draggingOffset { alternativeOffset = initialDraggingOffset + draggingOffset } if let alternativeOffset = alternativeOffset { indicatorFrame.origin.y = alternativeOffset if indicatorFrame.origin.y > containerSize.height - (containerInsets.top + indicatorBottomInset) - indicatorFrame.height { indicatorFrame.origin.y = containerSize.height - (containerInsets.top + indicatorBottomInset) - indicatorFrame.height } if indicatorFrame.origin.y < containerInsets.bottom + indicatorTopInset { indicatorFrame.origin.y = containerInsets.bottom + indicatorTopInset } } transition.updateFrame(view: self.lineIndicator, frame: indicatorFrame) if indicatorHeight >= visibleHeightWithoutIndicatorInsets { self.lineIndicator.isHidden = true } else { self.lineIndicator.isHidden = false } self.scrollIndicatorRange = ( containerInsets.bottom + indicatorTopInset, containerSize.height - (containerInsets.top + indicatorBottomInset) - self.lineIndicator.bounds.height ) } else { self.lineIndicator.isHidden = true self.scrollIndicatorRange = nil } } private func updateActivityTimer(isScrolling: Bool) { if self.isDragging || isScrolling { self.activityTimer?.invalidate() self.activityTimer = nil let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) transition.updateAlpha(layer: self.dateIndicator.layer, alpha: 1.0) transition.updateAlpha(layer: self.lineIndicator.layer, alpha: 1.0) } else { if self.activityTimer == nil { self.activityTimer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self] in guard let strongSelf = self else { return } strongSelf.activityTimer = nil let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) transition.updateAlpha(layer: strongSelf.dateIndicator.layer, alpha: 0.0) transition.updateAlpha(layer: strongSelf.lineIndicator.layer, alpha: 0.0) }, queue: .mainQueue()) self.activityTimer?.start() } } } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.lineIndicator.alpha <= 0.01 { return nil } if self.lineIndicator.frame.insetBy(dx: -8.0, dy: -4.0).contains(point) { return super.hitTest(point, with: event) } return nil } }