mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
515 lines
21 KiB
Swift
515 lines
21 KiB
Swift
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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UITouch>, 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<Empty>
|
|
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<Empty>()
|
|
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,
|
|
dateString: "Date",
|
|
previousDateString: 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
|
|
}
|
|
}
|