Swiftgram/submodules/SparseItemGrid/Sources/SparseDiscreteScrollingArea.swift
2021-11-09 21:55:54 +04:00

514 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"
)),
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: -4.0, dy: -2.0).contains(point) {
return super.hitTest(point, with: event)
}
return nil
}
}