Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,458 @@
import Foundation
import UIKit
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import Postbox
import Display
import UniversalMediaPlayer
import TelegramPresentationData
import RangeSet
import ShimmerEffect
import TelegramUniversalVideoContent
private let textFont = Font.with(size: 13.0, design: .regular, weight: .regular, traits: [.monospacedNumbers])
private let scrubberBackgroundColor = UIColor(white: 1.0, alpha: 0.42)
private let scrubberForegroundColor = UIColor.white
private let scrubberBufferingColor = UIColor(rgb: 0xffffff, alpha: 0.5)
final class ChatVideoGalleryItemScrubberView: UIView {
private var containerLayout: (CGSize, CGFloat, CGFloat)?
private let leftTimestampNode: MediaPlayerTimeTextNode
private let rightTimestampNode: MediaPlayerTimeTextNode
private let infoNode: ASTextNode
private let scrubberNode: MediaPlayerScrubbingNode
private let shimmerEffectNode: ShimmerEffectForegroundNode
private let hapticFeedback = HapticFeedback()
private var playbackStatus: MediaPlayerStatus?
private var chapters: [MediaPlayerScrubbingChapter] = []
private var fetchStatusDisposable = MetaDisposable()
private var scrubbingDisposable = MetaDisposable()
private var chapterDisposable = MetaDisposable()
private var loadingDisposable = MetaDisposable()
private var leftTimestampNodePushed = false
private var rightTimestampNodePushed = false
private var infoNodePushed = false
private var currentChapter: MediaPlayerScrubbingChapter?
private var isAnimatedOut: Bool = false
var hideWhenDurationIsUnknown = false {
didSet {
if self.hideWhenDurationIsUnknown {
if let playbackStatus = self.playbackStatus, !playbackStatus.duration.isZero {
self.scrubberNode.isHidden = false
self.leftTimestampNode.isHidden = false
self.rightTimestampNode.isHidden = false
} else {
self.scrubberNode.isHidden = true
self.leftTimestampNode.isHidden = true
self.rightTimestampNode.isHidden = true
}
} else {
self.scrubberNode.isHidden = false
self.leftTimestampNode.isHidden = false
self.rightTimestampNode.isHidden = false
}
}
}
var updateScrubbing: (Double?) -> Void = { _ in }
var updateScrubbingVisual: (Double?) -> Void = { _ in }
var updateScrubbingHandlePosition: (CGFloat) -> Void = { _ in }
var seek: (Double) -> Void = { _ in }
init(chapters: [MediaPlayerScrubbingChapter]) {
self.chapters = chapters
self.scrubberNode = MediaPlayerScrubbingNode(content: .standard(lineHeight: 5.0, lineCap: .round, scrubberHandle: .circle, backgroundColor: scrubberBackgroundColor, foregroundColor: scrubberForegroundColor, bufferingColor: scrubberBufferingColor, chapters: chapters))
self.shimmerEffectNode = ShimmerEffectForegroundNode()
self.leftTimestampNode = MediaPlayerTimeTextNode(textColor: .white)
self.rightTimestampNode = MediaPlayerTimeTextNode(textColor: .white)
self.rightTimestampNode.alignment = .right
self.rightTimestampNode.mode = .reversed
self.infoNode = ASTextNode()
self.infoNode.maximumNumberOfLines = 1
self.infoNode.isUserInteractionEnabled = false
self.infoNode.displaysAsynchronously = false
super.init(frame: CGRect())
self.scrubberNode.seek = { [weak self] timestamp in
self?.seek(timestamp)
}
self.scrubberNode.update = { [weak self] timestamp, position in
self?.updateScrubbing(timestamp)
self?.updateScrubbingVisual(timestamp)
self?.updateScrubbingHandlePosition(position)
}
self.scrubberNode.playerStatusUpdated = { [weak self] status in
if let strongSelf = self {
strongSelf.playbackStatus = status
if strongSelf.hideWhenDurationIsUnknown {
if let playbackStatus = status, !playbackStatus.duration.isZero {
strongSelf.scrubberNode.isHidden = false
strongSelf.leftTimestampNode.isHidden = false
strongSelf.rightTimestampNode.isHidden = false
} else {
strongSelf.scrubberNode.isHidden = true
strongSelf.leftTimestampNode.isHidden = true
strongSelf.rightTimestampNode.isHidden = true
}
} else {
strongSelf.scrubberNode.isHidden = false
strongSelf.leftTimestampNode.isHidden = false
strongSelf.rightTimestampNode.isHidden = false
}
}
}
self.addSubnode(self.scrubberNode)
self.addSubnode(self.leftTimestampNode)
self.addSubnode(self.rightTimestampNode)
self.addSubnode(self.infoNode)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.scrubbingDisposable.dispose()
self.fetchStatusDisposable.dispose()
self.chapterDisposable.dispose()
self.loadingDisposable.dispose()
}
var isLoading = false
var isCollapsed: Bool?
func setCollapsed(_ collapsed: Bool, animated: Bool) {
guard self.isCollapsed != collapsed else {
return
}
self.isCollapsed = collapsed
self.updateTimestampsVisibility(animated: animated)
self.updateScrubberVisibility(animated: animated)
if let (size, _, _) = self.containerLayout {
self.infoNode.alpha = size.width < size.height && !collapsed ? 1.0 : 0.0
}
}
func updateTimestampsVisibility(animated: Bool) {
if self.isAnimatedOut {
return
}
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate
let alpha: CGFloat = self.isCollapsed == true || self.isLoading ? 0.0 : 1.0
transition.updateAlpha(node: self.leftTimestampNode, alpha: alpha)
transition.updateAlpha(node: self.rightTimestampNode, alpha: alpha)
}
private func updateScrubberVisibility(animated: Bool) {
var collapsed = self.isCollapsed
var alpha: CGFloat = 1.0
if let playbackStatus = self.playbackStatus, playbackStatus.duration <= 30.0 {
} else {
alpha = self.isCollapsed == true ? 0.0 : 1.0
collapsed = false
}
self.scrubberNode.setCollapsed(collapsed == true, animated: animated)
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.3, curve: .linear) : .immediate
transition.updateAlpha(node: self.scrubberNode, alpha: alpha)
}
func animateTo(_ timestamp: Double) {
self.scrubberNode.animateTo(timestamp)
}
func setStatusSignal(_ status: Signal<MediaPlayerStatus, NoError>?) {
let mappedStatus: Signal<MediaPlayerStatus, NoError>?
if let status = status {
mappedStatus = combineLatest(status, self.scrubberNode.scrubbingTimestamp) |> map { status, scrubbingTimestamp -> MediaPlayerStatus in
return MediaPlayerStatus(generationTimestamp: scrubbingTimestamp != nil ? 0 : status.generationTimestamp, duration: status.duration, dimensions: status.dimensions, timestamp: scrubbingTimestamp ?? status.timestamp, baseRate: status.baseRate, seekId: status.seekId, status: status.status, soundEnabled: status.soundEnabled)
}
} else {
mappedStatus = nil
}
self.scrubberNode.status = mappedStatus
self.leftTimestampNode.status = mappedStatus
self.rightTimestampNode.status = mappedStatus
if let mappedStatus = mappedStatus {
self.loadingDisposable.set((mappedStatus
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self {
if status.duration < 1.0 {
strongSelf.isLoading = true
strongSelf.updateTimestampsVisibility(animated: true)
if strongSelf.shimmerEffectNode.supernode == nil {
strongSelf.scrubberNode.containerNode.addSubnode(strongSelf.shimmerEffectNode)
}
} else {
strongSelf.isLoading = false
strongSelf.updateTimestampsVisibility(animated: true)
if strongSelf.shimmerEffectNode.supernode != nil {
strongSelf.shimmerEffectNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.shimmerEffectNode.removeFromSupernode()
}
})
}
}
}
}))
self.chapterDisposable.set((mappedStatus
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self, status.duration > 1.0, strongSelf.chapters.count > 0 {
let previousChapter = strongSelf.currentChapter
var currentChapter: MediaPlayerScrubbingChapter?
for chapter in strongSelf.chapters {
if chapter.start > status.timestamp {
break
} else {
currentChapter = chapter
}
}
if let chapter = currentChapter, chapter != previousChapter {
strongSelf.currentChapter = chapter
if strongSelf.scrubberNode.isScrubbing {
strongSelf.hapticFeedback.impact(.light)
}
if let previousChapter = previousChapter, !strongSelf.infoNode.alpha.isZero {
if let snapshotView = strongSelf.infoNode.view.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = strongSelf.infoNode.frame
strongSelf.infoNode.view.superview?.addSubview(snapshotView)
let offset: CGFloat = 30.0
let snapshotTargetPosition: CGPoint
let nodeStartPosition: CGPoint
if previousChapter.start < chapter.start {
snapshotTargetPosition = CGPoint(x: -offset, y: 0.0)
nodeStartPosition = CGPoint(x: offset, y: 0.0)
} else {
snapshotTargetPosition = CGPoint(x: offset, y: 0.0)
nodeStartPosition = CGPoint(x: -offset, y: 0.0)
}
snapshotView.layer.animatePosition(from: CGPoint(), to: snapshotTargetPosition, duration: 0.2, removeOnCompletion: false, additive: true)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
strongSelf.infoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
strongSelf.infoNode.layer.animatePosition(from: nodeStartPosition, to: CGPoint(), duration: 0.2, additive: true)
}
}
strongSelf.infoNode.attributedText = NSAttributedString(string: chapter.title, font: textFont, textColor: .white)
if let (size, leftInset, rightInset) = strongSelf.containerLayout {
strongSelf.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, transition: .immediate)
}
}
}
}))
}
self.scrubbingDisposable.set((self.scrubberNode.scrubbingPosition
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let strongSelf = self else {
return
}
let leftTimestampNodePushed: Bool
let rightTimestampNodePushed: Bool
let infoNodePushed: Bool
if let value = value {
leftTimestampNodePushed = value < 0.16
rightTimestampNodePushed = value > 0.84
infoNodePushed = value >= 0.16 && value <= 0.84
} else {
leftTimestampNodePushed = false
rightTimestampNodePushed = false
infoNodePushed = false
}
if leftTimestampNodePushed != strongSelf.leftTimestampNodePushed || rightTimestampNodePushed != strongSelf.rightTimestampNodePushed || infoNodePushed != strongSelf.infoNodePushed {
strongSelf.leftTimestampNodePushed = leftTimestampNodePushed
strongSelf.rightTimestampNodePushed = rightTimestampNodePushed
strongSelf.infoNodePushed = infoNodePushed
if let layout = strongSelf.containerLayout {
strongSelf.updateLayout(size: layout.0, leftInset: layout.1, rightInset: layout.2, transition: .animated(duration: 0.35, curve: .spring))
}
}
}))
}
func setBufferingStatusSignal(_ status: Signal<(RangeSet<Int64>, Int64)?, NoError>?) {
self.scrubberNode.bufferingStatus = status
}
func setFetchStatusSignal(_ fetchStatus: Signal<MediaResourceStatus, NoError>?, strings: PresentationStrings, decimalSeparator: String, fileSize: Int64?) {
let formatting = DataSizeStringFormatting(strings: strings, decimalSeparator: decimalSeparator)
if let fileSize = fileSize {
if let fetchStatus = fetchStatus {
self.fetchStatusDisposable.set((fetchStatus
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self, strongSelf.chapters.isEmpty {
var text: String
switch status {
case .Remote:
text = dataSizeString(fileSize, forceDecimal: true, formatting: formatting)
case let .Fetching(_, progress):
text = strings.DownloadingStatus(dataSizeString(Int64(Float(fileSize) * progress), forceDecimal: true, formatting: formatting), dataSizeString(fileSize, forceDecimal: true, formatting: formatting)).string
default:
text = ""
}
strongSelf.infoNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: .white)
if let (size, leftInset, rightInset) = strongSelf.containerLayout {
strongSelf.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, transition: .immediate)
}
}
}))
} else if self.chapters.isEmpty {
self.infoNode.attributedText = NSAttributedString(string: dataSizeString(fileSize, forceDecimal: true, formatting: formatting), font: textFont, textColor: .white)
}
} else if self.chapters.isEmpty {
self.infoNode.attributedText = nil
}
}
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (size, leftInset, rightInset)
let scrubberHeight: CGFloat = 14.0
var scrubberInset: CGFloat
let leftTimestampOffset: CGFloat
let rightTimestampOffset: CGFloat
let infoOffset: CGFloat
if size.width > size.height {
scrubberInset = 58.0
leftTimestampOffset = 4.0
rightTimestampOffset = 4.0
infoOffset = 0.0
} else {
scrubberInset = 13.0
leftTimestampOffset = 22.0 + (self.leftTimestampNodePushed ? 8.0 : 0.0)
rightTimestampOffset = 22.0 + (self.rightTimestampNodePushed ? 8.0 : 0.0)
infoOffset = 22.0 + (self.infoNodePushed ? 8.0 : 0.0)
}
transition.updateFrame(node: self.leftTimestampNode, frame: CGRect(origin: CGPoint(x: 12.0, y: leftTimestampOffset), size: CGSize(width: 60.0, height: 20.0)))
transition.updateFrame(node: self.rightTimestampNode, frame: CGRect(origin: CGPoint(x: size.width - leftInset - rightInset - 60.0 - 12.0, y: rightTimestampOffset), size: CGSize(width: 60.0, height: 20.0)))
var infoConstrainedSize = size
infoConstrainedSize.width = size.width - scrubberInset * 2.0 - 100.0
let infoSize = self.infoNode.measure(infoConstrainedSize)
self.infoNode.bounds = CGRect(origin: CGPoint(), size: infoSize)
transition.updatePosition(node: self.infoNode, position: CGPoint(x: size.width / 2.0, y: infoOffset + infoSize.height / 2.0))
self.infoNode.alpha = size.width < size.height && self.isCollapsed == false ? 1.0 : 0.0
let scrubberFrame = CGRect(origin: CGPoint(x: scrubberInset, y: 6.0), size: CGSize(width: size.width - leftInset - rightInset - scrubberInset * 2.0, height: scrubberHeight))
self.scrubberNode.frame = scrubberFrame
self.shimmerEffectNode.updateAbsoluteRect(CGRect(origin: .zero, size: scrubberFrame.size), within: scrubberFrame.size)
self.shimmerEffectNode.update(backgroundColor: .clear, foregroundColor: UIColor(rgb: 0xffffff, alpha: 0.75), horizontal: true, effectSize: nil, globalTimeOffset: false, duration: nil)
self.shimmerEffectNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 4.0), size: CGSize(width: scrubberFrame.size.width, height: 5.0))
self.shimmerEffectNode.cornerRadius = 2.5
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
var hitTestRect = self.bounds
let minHeightDiff = 44.0 - hitTestRect.height
if (minHeightDiff > 0) {
hitTestRect = bounds.insetBy(dx: 0, dy: -minHeightDiff / 2.0)
}
return hitTestRect.contains(point)
}
func animateIn(from scrubberTransition: GalleryItemScrubberTransition?, transition: ContainedViewLayoutTransition) {
if let scrubberTransition = scrubberTransition?.scrubber {
let fromRect = scrubberTransition.view.convert(scrubberTransition.view.bounds, to: self)
let targetCloneView = scrubberTransition.makeView()
self.addSubview(targetCloneView)
targetCloneView.frame = fromRect
scrubberTransition.updateView(targetCloneView, GalleryItemScrubberTransition.Scrubber.TransitionState(sourceSize: fromRect.size, destinationSize: CGSize(width: self.scrubberNode.bounds.width, height: fromRect.height), progress: 0.0, direction: .in), .immediate)
targetCloneView.alpha = 1.0
transition.updateFrame(view: targetCloneView, frame: CGRect(origin: CGPoint(x: self.scrubberNode.frame.minX, y: self.scrubberNode.frame.maxY - fromRect.height - 3.0), size: CGSize(width: self.scrubberNode.bounds.width, height: fromRect.height)))
scrubberTransition.updateView(targetCloneView, GalleryItemScrubberTransition.Scrubber.TransitionState(sourceSize: fromRect.size, destinationSize: CGSize(width: self.scrubberNode.bounds.width, height: fromRect.height), progress: 1.0, direction: .in), transition)
let scrubberTransitionView = scrubberTransition.view
scrubberTransitionView.isHidden = true
ContainedViewLayoutTransition.animated(duration: 0.08, curve: .easeInOut).updateAlpha(layer: targetCloneView.layer, alpha: 0.0, completion: { [weak targetCloneView] _ in
targetCloneView?.removeFromSuperview()
})
let scrubberSourceRect = CGRect(origin: CGPoint(x: fromRect.minX, y: fromRect.maxY - 3.0), size: CGSize(width: fromRect.width, height: 3.0))
let leftTimestampOffset = CGPoint(x: self.leftTimestampNode.position.x - self.scrubberNode.frame.minX, y: self.leftTimestampNode.position.y - self.scrubberNode.frame.maxY)
let rightTimestampOffset = CGPoint(x: self.rightTimestampNode.position.x - self.scrubberNode.frame.maxX, y: self.rightTimestampNode.position.y - self.scrubberNode.frame.maxY)
transition.animatePosition(node: self.scrubberNode, from: scrubberSourceRect.center)
self.scrubberNode.animateWidth(from: scrubberSourceRect.width, transition: transition)
transition.animatePosition(node: self.leftTimestampNode, from: CGPoint(x: leftTimestampOffset.x + scrubberSourceRect.minX, y: leftTimestampOffset.y + scrubberSourceRect.maxY))
transition.animatePosition(node: self.rightTimestampNode, from: CGPoint(x: rightTimestampOffset.x + scrubberSourceRect.maxX, y: rightTimestampOffset.y + scrubberSourceRect.maxY))
}
self.scrubberNode.layer.animateAlpha(from: 0.0, to: self.leftTimestampNode.alpha, duration: 0.25)
self.leftTimestampNode.layer.animateAlpha(from: 0.0, to: self.leftTimestampNode.alpha, duration: 0.25)
self.rightTimestampNode.layer.animateAlpha(from: 0.0, to: self.leftTimestampNode.alpha, duration: 0.25)
self.infoNode.layer.animateAlpha(from: 0.0, to: self.leftTimestampNode.alpha, duration: 0.25)
}
func animateOut(to scrubberTransition: GalleryItemScrubberTransition?, transition: ContainedViewLayoutTransition) {
self.isAnimatedOut = true
if let scrubberTransition = scrubberTransition?.scrubber {
let toRect = scrubberTransition.view.convert(scrubberTransition.view.bounds, to: self)
let scrubberDestinationRect = CGRect(origin: CGPoint(x: toRect.minX, y: toRect.maxY - 3.0), size: CGSize(width: toRect.width, height: 3.0))
let targetCloneView = scrubberTransition.makeView()
self.addSubview(targetCloneView)
targetCloneView.frame = CGRect(origin: CGPoint(x: self.scrubberNode.frame.minX, y: self.scrubberNode.frame.maxY - toRect.height), size: CGSize(width: self.scrubberNode.bounds.width, height: toRect.height))
scrubberTransition.updateView(targetCloneView, GalleryItemScrubberTransition.Scrubber.TransitionState(sourceSize: CGSize(width: self.scrubberNode.bounds.width, height: toRect.height), destinationSize: toRect.size, progress: 0.0, direction: .out), .immediate)
targetCloneView.alpha = 0.0
transition.updateFrame(view: targetCloneView, frame: toRect)
scrubberTransition.updateView(targetCloneView, GalleryItemScrubberTransition.Scrubber.TransitionState(sourceSize: CGSize(width: self.scrubberNode.bounds.width, height: toRect.height), destinationSize: toRect.size, progress: 1.0, direction: .out), transition)
let scrubberTransitionView = scrubberTransition.view
scrubberTransitionView.isHidden = true
transition.updateAlpha(layer: targetCloneView.layer, alpha: 1.0, completion: { [weak scrubberTransitionView] _ in
scrubberTransitionView?.isHidden = false
})
let leftTimestampOffset = CGPoint(x: self.leftTimestampNode.position.x - self.scrubberNode.frame.minX, y: self.leftTimestampNode.position.y - self.scrubberNode.frame.maxY)
let rightTimestampOffset = CGPoint(x: self.rightTimestampNode.position.x - self.scrubberNode.frame.maxX, y: self.rightTimestampNode.position.y - self.scrubberNode.frame.maxY)
transition.animatePositionAdditive(layer: self.scrubberNode.layer, offset: CGPoint(), to: CGPoint(x: scrubberDestinationRect.midX - self.scrubberNode.position.x, y: scrubberDestinationRect.midY - self.scrubberNode.position.y), removeOnCompletion: false)
self.scrubberNode.animateWidth(to: scrubberDestinationRect.width, transition: transition)
transition.animatePositionAdditive(layer: self.leftTimestampNode.layer, offset: CGPoint(), to: CGPoint(x: -self.leftTimestampNode.position.x + (leftTimestampOffset.x + scrubberDestinationRect.minX), y: -self.leftTimestampNode.position.y + (leftTimestampOffset.y + scrubberDestinationRect.maxY)), removeOnCompletion: false)
transition.animatePositionAdditive(layer: self.rightTimestampNode.layer, offset: CGPoint(), to: CGPoint(x: -self.rightTimestampNode.position.x + (rightTimestampOffset.x + scrubberDestinationRect.maxX), y: -self.rightTimestampNode.position.y + (rightTimestampOffset.y + scrubberDestinationRect.maxY)), removeOnCompletion: false)
}
transition.updateAlpha(layer: self.scrubberNode.layer, alpha: 0.0)
transition.updateAlpha(layer: self.leftTimestampNode.layer, alpha: 0.0)
transition.updateAlpha(layer: self.rightTimestampNode.layer, alpha: 0.0)
transition.updateAlpha(layer: self.infoNode.layer, alpha: 0.0)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,591 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import SwipeToDismissGesture
import AccountContext
import UndoUI
open class GalleryControllerNode: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDelegate {
public enum CustomDismissType {
case `default`
case simpleAnimation
case pip
}
private let context: AccountContext
public var statusBar: StatusBar?
public var navigationBar: NavigationBar? {
didSet {
}
}
public let footerNode: GalleryFooterNode
public var currentThumbnailContainerNode: GalleryThumbnailContainerNode?
public var overlayNode: ASDisplayNode?
public var transitionDataForCentralItem: (() -> ((ASDisplayNode, CGRect, () -> (UIView?, UIView?))?, (UIView) -> Void)?)?
public var dismiss: (() -> Void)?
public var containerLayout: (CGFloat, ContainerViewLayout)?
public var backgroundNode: ASDisplayNode
public var scrollView: UIScrollView
public var pager: GalleryPagerNode
public var beginCustomDismiss: (GalleryControllerNode.CustomDismissType) -> Void = { _ in }
public var completeCustomDismiss: (Bool) -> Void = { _ in }
public var baseNavigationController: () -> NavigationController? = { return nil }
public var galleryController: () -> ViewController? = { return nil }
private var presentationState = GalleryControllerPresentationState()
private var isDismissed = false
public var areControlsHidden = false
public var controlsVisibilityChanged: ((Bool) -> Void)?
public var animateAlpha = true
public var updateOrientation: ((UIInterfaceOrientation) -> Void)?
public var isBackgroundExtendedOverNavigationBar = true {
didSet {
if let (navigationBarHeight, layout) = self.containerLayout {
self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - (self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight)))
}
}
}
public init(context: AccountContext, controllerInteraction: GalleryControllerInteraction, pageGap: CGFloat = 20.0, disableTapNavigation: Bool = false) {
self.context = context
self.backgroundNode = ASDisplayNode()
self.backgroundNode.backgroundColor = UIColor.black
self.scrollView = UIScrollView()
self.scrollView.delaysContentTouches = false
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
self.pager = GalleryPagerNode(pageGap: pageGap, disableTapNavigation: disableTapNavigation)
self.footerNode = GalleryFooterNode(controllerInteraction: controllerInteraction)
super.init()
self.setViewBlock({
return UITracingLayerView()
})
self.pager.toggleControlsVisibility = { [weak self] in
if let strongSelf = self {
strongSelf.setControlsHidden(!strongSelf.areControlsHidden, animated: true)
}
}
self.pager.updateControlsVisibility = { [weak self] visible in
if let strongSelf = self {
strongSelf.setControlsHidden(!visible, animated: true)
}
}
self.pager.controlsVisibility = { [weak self] in
guard let self else {
return true
}
return !self.areControlsHidden && self.footerNode.alpha != 0.0
}
self.pager.updateOrientation = { [weak self] orientation in
if let strongSelf = self {
strongSelf.updateOrientation?(orientation)
}
}
self.pager.dismiss = { [weak self] in
if let strongSelf = self {
if let galleryController = strongSelf.galleryController(), galleryController.navigationController != nil {
galleryController.dismiss(animated: true)
return
}
var interfaceAnimationCompleted = false
var contentAnimationCompleted = true
strongSelf.scrollView.isScrollEnabled = false
let completion = { [weak self] in
if interfaceAnimationCompleted && contentAnimationCompleted {
if let dismiss = self?.dismiss {
dismiss()
}
}
}
if let centralItemNode = strongSelf.pager.centralItemNode(), let (transitionNodeForCentralItem, addToTransitionSurface) = strongSelf.transitionDataForCentralItem?(), let node = transitionNodeForCentralItem {
contentAnimationCompleted = false
centralItemNode.animateOut(to: node, addToTransitionSurface: addToTransitionSurface, completion: {
contentAnimationCompleted = true
completion()
})
}
strongSelf.animateOut(animateContent: false, completion: {
interfaceAnimationCompleted = true
completion()
})
}
}
self.pager.beginCustomDismiss = { [weak self] animationType in
if let strongSelf = self {
strongSelf.beginCustomDismiss(animationType)
}
}
self.pager.completeCustomDismiss = { [weak self] isPictureInPicture in
if let strongSelf = self {
strongSelf.completeCustomDismiss(isPictureInPicture)
}
}
self.pager.baseNavigationController = { [weak self] in
return self?.baseNavigationController()
}
self.pager.galleryController = { [weak self] in
return self?.galleryController()
}
self.addSubnode(self.backgroundNode)
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = false
self.scrollView.clipsToBounds = false
self.scrollView.delegate = self.wrappedScrollViewDelegate
self.scrollView.scrollsToTop = false
self.view.addSubview(self.scrollView)
self.scrollView.addSubview(self.pager.view)
var previousIndex: Int?
self.pager.centralItemIndexOffsetUpdated = { [weak self] itemsIndexAndProgress in
if let strongSelf = self {
if abs(strongSelf.scrollView.contentOffset.y - strongSelf.scrollView.contentSize.height / 3.0) > 0.1 {
strongSelf.scrollView.setContentOffset(CGPoint(x: 0.0, y: strongSelf.scrollView.contentSize.height / 3.0), animated: true)
}
var node: GalleryThumbnailContainerNode?
var thumbnailContainerVisible = false
if let layout = strongSelf.containerLayout?.1, layout.size.width < layout.size.height {
thumbnailContainerVisible = !strongSelf.areControlsHidden
}
if let (updatedItems, index, progress) = itemsIndexAndProgress {
if let (centralId, centralItem) = strongSelf.pager.items[index].thumbnailItem() {
var items: [GalleryThumbnailItem]
var indexes: [Int]
if updatedItems != nil || strongSelf.currentThumbnailContainerNode == nil {
items = [centralItem]
indexes = [index]
for i in (0 ..< index).reversed() {
if let (id, item) = strongSelf.pager.items[i].thumbnailItem(), id == centralId {
items.insert(item, at: 0)
indexes.insert(i, at: 0)
} else {
break
}
}
for i in (index + 1) ..< strongSelf.pager.items.count {
if let (id, item) = strongSelf.pager.items[i].thumbnailItem(), id == centralId {
items.append(item)
indexes.append(i)
} else {
break
}
}
} else if let currentThumbnailContainerNode = strongSelf.currentThumbnailContainerNode {
items = currentThumbnailContainerNode.items
indexes = currentThumbnailContainerNode.indexes
} else {
items = []
indexes = []
assertionFailure()
}
var convertedIndex: Int?
if let firstIndex = indexes.first {
convertedIndex = index - firstIndex
}
if let convertedIndex = convertedIndex {
if strongSelf.currentThumbnailContainerNode?.groupId != centralId {
if items.count > 1 {
node = GalleryThumbnailContainerNode(groupId: centralId)
}
} else {
node = strongSelf.currentThumbnailContainerNode
}
node?.alpha = thumbnailContainerVisible ? 1.0 : 0.0
node?.updateItems(items, indexes: indexes, centralIndex: convertedIndex, progress: progress)
node?.itemChanged = { [weak self] index in
if let strongSelf = self {
let pagerIndex = indexes[index]
strongSelf.pager.transaction(GalleryPagerTransaction(deleteItems: [], insertItems: [], updateItems: [], focusOnItem: pagerIndex, synchronous: false))
}
}
}
}
}
let previous = previousIndex
previousIndex = itemsIndexAndProgress?.1
if node !== strongSelf.currentThumbnailContainerNode {
let fromLeft: Bool
if let previous = previous, let index = itemsIndexAndProgress?.1 {
fromLeft = index > previous
} else {
fromLeft = true
}
if let current = strongSelf.currentThumbnailContainerNode {
strongSelf.currentThumbnailContainerNode = nil
if thumbnailContainerVisible {
current.animateOut(toRight: fromLeft)
current.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak current] _ in
current?.removeFromSupernode()
})
if let (navigationHeight, layout) = strongSelf.containerLayout, node == nil {
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .immediate)
}
}
}
if let node = node {
strongSelf.currentThumbnailContainerNode = node
strongSelf.insertSubnode(node, aboveSubnode: strongSelf.footerNode)
if let (navigationHeight, layout) = strongSelf.containerLayout, thumbnailContainerVisible {
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .immediate)
node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
node.animateIn(fromLeft: fromLeft)
}
}
}
}
}
}
override open func didLoad() {
super.didLoad()
if #available(iOSApplicationExtension 11.0, iOS 11.0, *), !self.isLayerBacked {
self.view.accessibilityIgnoresInvertColors = true
}
self.view.disablesInteractiveTransitionGestureRecognizer = true
}
open func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (navigationBarHeight, layout)
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - (self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight))))
transition.updateFrame(node: self.footerNode, frame: CGRect(origin: CGPoint(), size: layout.size))
if let navigationBar = self.navigationBar {
transition.updateFrame(node: navigationBar, frame: CGRect(origin: CGPoint(x: 0.0, y: self.areControlsHidden ? -navigationBarHeight : 0.0), size: CGSize(width: layout.size.width, height: navigationBarHeight)))
if self.footerNode.supernode == nil {
self.addSubnode(self.footerNode)
}
}
var thumbnailPanelHeight: CGFloat = 0.0
if let currentThumbnailContainerNode = self.currentThumbnailContainerNode {
let panelHeight: CGFloat = 52.0
thumbnailPanelHeight = panelHeight
let thumbnailsFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - 40.0 - panelHeight + 4.0 - layout.intrinsicInsets.bottom + (self.areControlsHidden ? 106.0 : 0.0)), size: CGSize(width: layout.size.width, height: panelHeight - 4.0))
transition.updateFrame(node: currentThumbnailContainerNode, frame: thumbnailsFrame)
currentThumbnailContainerNode.updateLayout(size: thumbnailsFrame.size, transition: transition)
self.updateThumbnailContainerNodeAlpha(transition)
}
self.footerNode.updateLayout(layout, navigationBarHeight: navigationBarHeight, footerContentNode: self.presentationState.footerContentNode, overlayContentNode: self.presentationState.overlayContentNode, thumbnailPanelHeight: thumbnailPanelHeight, isHidden: self.areControlsHidden, transition: transition)
let previousContentHeight = self.scrollView.contentSize.height
let previousVerticalOffset = self.scrollView.contentOffset.y
self.scrollView.frame = CGRect(origin: CGPoint(), size: layout.size)
self.scrollView.contentSize = CGSize(width: 0.0, height: layout.size.height * 3.0)
if previousContentHeight.isEqual(to: 0.0) {
self.scrollView.contentOffset = CGPoint(x: 0.0, y: self.scrollView.contentSize.height / 3.0)
} else {
self.scrollView.contentOffset = CGPoint(x: 0.0, y: previousVerticalOffset * self.scrollView.contentSize.height / previousContentHeight)
}
self.pager.frame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height), size: layout.size)
self.pager.containerLayoutUpdated(layout, navigationBarHeight: self.areControlsHidden ? 0.0 : navigationBarHeight, transition: transition)
}
open func setControlsHidden(_ hidden: Bool, animated: Bool) {
guard self.areControlsHidden != hidden && (!self.isDismissed || hidden) else {
return
}
self.areControlsHidden = hidden
self.controlsVisibilityChanged?(!hidden)
if animated {
UIView.animate(withDuration: 0.3, animations: {
let alpha: CGFloat = self.areControlsHidden ? 0.0 : 1.0
self.navigationBar?.alpha = alpha
self.statusBar?.updateAlpha(alpha, transition: .animated(duration: 0.3, curve: .easeInOut))
self.footerNode.setVisibilityAlpha(alpha, animated: animated)
self.updateThumbnailContainerNodeAlpha(.immediate)
})
if let (navigationBarHeight, layout) = self.containerLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.3, curve: .easeInOut))
}
} else {
let alpha: CGFloat = self.areControlsHidden ? 0.0 : 1.0
self.navigationBar?.alpha = alpha
self.statusBar?.updateAlpha(alpha, transition: .immediate)
self.footerNode.setVisibilityAlpha(alpha, animated: animated)
self.updateThumbnailContainerNodeAlpha(.immediate)
if let (navigationBarHeight, layout) = self.containerLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
}
}
open func updateThumbnailContainerNodeAlpha(_ transition: ContainedViewLayoutTransition) {
if let currentThumbnailContainerNode = self.currentThumbnailContainerNode, let layout = self.containerLayout?.1 {
let visible = layout.size.width < layout.size.height && !self.areControlsHidden
transition.updateAlpha(node: currentThumbnailContainerNode, alpha: visible ? 1.0 : 0.0)
}
}
open func animateIn(animateContent: Bool, useSimpleAnimation: Bool) {
let duration: Double = animateContent ? 0.2 : 0.3
let backgroundColor = self.backgroundNode.backgroundColor ?? .black
self.statusBar?.alpha = 0.0
self.navigationBar?.alpha = 0.0
self.footerNode.alpha = 0.0
self.currentThumbnailContainerNode?.alpha = 0.0
self.backgroundNode.layer.animate(from: backgroundColor.withAlphaComponent(0.0).cgColor, to: backgroundColor.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.15)
UIView.animate(withDuration: 0.15, delay: 0.0, options: [.curveLinear], animations: {
if !self.areControlsHidden {
self.statusBar?.alpha = 1.0
self.navigationBar?.alpha = 1.0
self.updateThumbnailContainerNodeAlpha(.immediate)
}
})
if !self.areControlsHidden {
self.footerNode.alpha = 1.0
self.footerNode.animateIn(transition: .animated(duration: 0.15, curve: .linear))
}
if animateContent {
self.scrollView.layer.animateBounds(from: self.scrollView.layer.bounds.offsetBy(dx: 0.0, dy: -self.scrollView.layer.bounds.size.height), to: self.scrollView.layer.bounds, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring)
} else if useSimpleAnimation {
self.scrollView.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration)
}
if let chatController = self.baseNavigationController()?.topViewController as? ChatController {
chatController.updatePushedTransition(1.0, transition: .animated(duration: 0.45, curve: .customSpring(damping: 180.0, initialVelocity: 0.0)))
}
}
open func animateOut(animateContent: Bool, completion: @escaping () -> Void) {
self.isDismissed = true
self.pager.isScrollEnabled = false
var contentAnimationCompleted = true
var interfaceAnimationCompleted = false
let intermediateCompletion = {
if contentAnimationCompleted && interfaceAnimationCompleted {
completion()
}
}
if let backgroundColor = self.backgroundNode.backgroundColor {
let updatedColor = backgroundColor.withAlphaComponent(0.0)
self.backgroundNode.backgroundColor = updatedColor
self.backgroundNode.layer.animate(from: backgroundColor.cgColor, to: updatedColor.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.1)
}
UIView.animate(withDuration: 0.1, animations: {
self.statusBar?.alpha = 0.0
self.navigationBar?.alpha = 0.0
self.currentThumbnailContainerNode?.alpha = 0.0
}, completion: { _ in
interfaceAnimationCompleted = true
intermediateCompletion()
})
self.footerNode.animateOut(transition: .animated(duration: 0.1, curve: .easeInOut))
if animateContent {
contentAnimationCompleted = false
self.scrollView.layer.animateBounds(from: self.scrollView.layer.bounds, to: self.scrollView.layer.bounds.offsetBy(dx: 0.0, dy: -self.scrollView.layer.bounds.size.height), duration: 0.25, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { _ in
contentAnimationCompleted = true
intermediateCompletion()
})
} else if self.animateAlpha {
self.scrollView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in
contentAnimationCompleted = true
intermediateCompletion()
})
}
}
open func updateDismissTransition(_ value: CGFloat) {
}
open func updateDistanceFromEquilibrium(_ value: CGFloat) {
}
open func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !self.isDismissed else {
return
}
let distanceFromEquilibrium = scrollView.contentOffset.y - scrollView.contentSize.height / 3.0
let transition = 1.0 - min(1.0, max(0.0, abs(distanceFromEquilibrium) / 50.0))
let backgroundTransition = 1.0 - min(1.0, max(0.0, abs(distanceFromEquilibrium) / 80.0))
self.backgroundNode.backgroundColor = self.backgroundNode.backgroundColor?.withAlphaComponent(backgroundTransition)
self.updateThumbnailContainerNodeAlpha(.immediate)
if !self.areControlsHidden {
if transition < 0.5 {
self.statusBar?.statusBarStyle = .Ignore
} else {
self.statusBar?.statusBarStyle = .White
}
self.navigationBar?.alpha = transition
self.footerNode.alpha = transition
if let currentThumbnailContainerNode = self.currentThumbnailContainerNode, let layout = self.containerLayout?.1, layout.size.width < layout.size.height {
currentThumbnailContainerNode.alpha = transition
}
}
self.updateDismissTransition(transition)
self.updateDistanceFromEquilibrium(distanceFromEquilibrium)
if scrollView.isDragging, let chatController = self.baseNavigationController()?.topViewController as? ChatController {
let transition = 1.0 - min(1.0, max(0.0, abs(distanceFromEquilibrium) / 150.0))
chatController.updatePushedTransition(transition, transition: .immediate)
}
if let overlayNode = self.overlayNode {
overlayNode.alpha = transition
}
}
open func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
targetContentOffset.pointee = scrollView.contentOffset
let distanceFromEquilibrium = scrollView.contentOffset.y - scrollView.contentSize.height / 3.0
let minimalDismissDistance = scrollView.contentSize.height / 12.0
if abs(velocity.y) > 1.0 || abs(distanceFromEquilibrium) > minimalDismissDistance {
if distanceFromEquilibrium > 1.0, let centralItemNode = self.pager.centralItemNode(), centralItemNode.maybePerformActionForSwipeDismiss() {
if let chatController = self.baseNavigationController()?.topViewController as? ChatController {
let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 })
chatController.present(UndoOverlayController(
presentationData: presentationData,
content: .hidArchive(title: presentationData.strings.MediaGallery_ToastVideoPip_Title, text: presentationData.strings.MediaGallery_ToastVideoPip_Text, undo: false),
elevatedLayout: false, action: { _ in true }
), in: .current)
}
return
}
if distanceFromEquilibrium < -1.0, let centralItemNode = self.pager.centralItemNode(), centralItemNode.maybePerformActionForSwipeDownDismiss() {
}
if let backgroundColor = self.backgroundNode.backgroundColor {
self.backgroundNode.layer.animate(from: backgroundColor, to: UIColor(white: 0.0, alpha: 0.0).cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.2, removeOnCompletion: false)
}
var interfaceAnimationCompleted = false
var contentAnimationCompleted = true
self.scrollView.isScrollEnabled = false
let completion = { [weak self] in
if interfaceAnimationCompleted && contentAnimationCompleted {
if let dismiss = self?.dismiss {
dismiss()
}
}
}
if let chatController = self.baseNavigationController()?.topViewController as? ChatController {
chatController.updatePushedTransition(0.0, transition: .animated(duration: 0.45, curve: .customSpring(damping: 180.0, initialVelocity: 0.0)))
}
if let centralItemNode = self.pager.centralItemNode(), let (transitionNodeForCentralItem, addToTransitionSurface) = self.transitionDataForCentralItem?(), let node = transitionNodeForCentralItem {
contentAnimationCompleted = false
centralItemNode.animateOut(to: node, addToTransitionSurface: addToTransitionSurface, completion: {
contentAnimationCompleted = true
completion()
})
}
self.animateOut(animateContent: false, completion: {
interfaceAnimationCompleted = true
completion()
})
if contentAnimationCompleted {
contentAnimationCompleted = false
self.scrollView.layer.animateBounds(from: self.scrollView.layer.bounds, to: self.scrollView.layer.bounds.offsetBy(dx: 0.0, dy: self.scrollView.layer.bounds.size.height * (velocity.y < 0.0 ? -1.0 : 1.0)), duration: 0.2, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { _ in
contentAnimationCompleted = true
completion()
})
}
} else {
self.scrollView.setContentOffset(CGPoint(x: 0.0, y: self.scrollView.contentSize.height / 3.0), animated: true)
if let chatController = self.baseNavigationController()?.topViewController as? ChatController {
chatController.updatePushedTransition(1.0, transition: .animated(duration: 0.45, curve: .customSpring(damping: 180.0, initialVelocity: 0.0)))
}
}
}
open func updatePresentationState(_ f: (GalleryControllerPresentationState) -> GalleryControllerPresentationState, transition: ContainedViewLayoutTransition) {
self.presentationState = f(self.presentationState)
if let (navigationBarHeight, layout) = self.containerLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
}
@objc private func panGesture(_ recognizer: SwipeToDismissGestureRecognizer) {
switch recognizer.state {
case .began:
break
case .changed:
break
case .ended:
break
case .cancelled:
break
default:
break
}
}
open override func accessibilityPerformEscape() -> Bool {
if let controller = self.galleryController() {
controller.dismiss(animated: true)
return true
}
return false
}
}
@@ -0,0 +1,24 @@
import Foundation
public final class GalleryControllerPresentationState {
public let footerContentNode: GalleryFooterContentNode?
public let overlayContentNode: GalleryOverlayContentNode?
public init() {
self.footerContentNode = nil
self.overlayContentNode = nil
}
public init(footerContentNode: GalleryFooterContentNode?, overlayContentNode: GalleryOverlayContentNode?) {
self.footerContentNode = footerContentNode
self.overlayContentNode = overlayContentNode
}
public func withUpdatedFooterContentNode(_ footerContentNode: GalleryFooterContentNode?) -> GalleryControllerPresentationState {
return GalleryControllerPresentationState(footerContentNode: footerContentNode, overlayContentNode: self.overlayContentNode)
}
public func withUpdatedOverlayContentNode(_ overlayContentNode: GalleryOverlayContentNode?) -> GalleryControllerPresentationState {
return GalleryControllerPresentationState(footerContentNode: self.footerContentNode, overlayContentNode: overlayContentNode)
}
}
@@ -0,0 +1,72 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
public final class GalleryControllerInteraction {
public let presentController: (ViewController, ViewControllerPresentationArguments?) -> Void
public let pushController: (ViewController) -> Void
public let dismissController: () -> Void
public let replaceRootController: (ViewController, Promise<Bool>?) -> Void
public let editMedia: (MessageId) -> Void
public let controller: () -> ViewController?
public init(presentController: @escaping (ViewController, ViewControllerPresentationArguments?) -> Void, pushController: @escaping (ViewController) -> Void, dismissController: @escaping () -> Void, replaceRootController: @escaping (ViewController, Promise<Bool>?) -> Void, editMedia: @escaping (MessageId) -> Void, controller: @escaping () -> ViewController?) {
self.presentController = presentController
self.pushController = pushController
self.dismissController = dismissController
self.replaceRootController = replaceRootController
self.editMedia = editMedia
self.controller = controller
}
}
open class GalleryFooterContentNode: ASDisplayNode {
public var requestLayout: ((ContainedViewLayoutTransition) -> Void)?
public var controllerInteraction: GalleryControllerInteraction?
var visibilityAlpha: CGFloat = 1.0
open func setVisibilityAlpha(_ alpha: CGFloat, animated: Bool) {
self.visibilityAlpha = alpha
self.alpha = alpha
}
open func updateLayout(size: CGSize, metrics: LayoutMetrics, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, contentInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
return 0.0
}
open func animateIn(transition: ContainedViewLayoutTransition) {
self.alpha = 0.0
transition.updateAlpha(node: self, alpha: 1.0)
}
open func animateIn(fromHeight: CGFloat, previousContentNode: GalleryFooterContentNode, transition: ContainedViewLayoutTransition) {
}
open func animateOut(transition: ContainedViewLayoutTransition) {
transition.updateAlpha(node: self, alpha: 0.0)
}
open func animateOut(toHeight: CGFloat, nextContentNode: GalleryFooterContentNode, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
completion()
}
}
open class GalleryOverlayContentNode: ASDisplayNode {
var visibilityAlpha: CGFloat = 1.0
open func setVisibilityAlpha(_ alpha: CGFloat) {
self.visibilityAlpha = alpha
}
open func updateLayout(size: CGSize, metrics: LayoutMetrics, insets: UIEdgeInsets, isHidden: Bool, transition: ContainedViewLayoutTransition) {
}
open func animateIn(previousContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition) {
}
open func animateOut(nextContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
completion()
}
}
@@ -0,0 +1,167 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
public final class GalleryFooterNode: ASDisplayNode {
private let backgroundNode: ASDisplayNode
private var currentThumbnailPanelHeight: CGFloat?
private var currentFooterContentNode: GalleryFooterContentNode?
private var currentOverlayContentNode: GalleryOverlayContentNode?
private var currentLayout: (ContainerViewLayout, CGFloat, CGFloat, Bool)?
private let controllerInteraction: GalleryControllerInteraction
public init(controllerInteraction: GalleryControllerInteraction) {
self.controllerInteraction = controllerInteraction
self.backgroundNode = ASDisplayNode()
self.backgroundNode.backgroundColor = UIColor(white: 0.0, alpha: 0.6)
super.init()
self.addSubnode(self.backgroundNode)
}
private var visibilityAlpha: CGFloat = 1.0
public func setVisibilityAlpha(_ alpha: CGFloat, animated: Bool) {
self.visibilityAlpha = alpha
self.backgroundNode.alpha = alpha
self.currentFooterContentNode?.setVisibilityAlpha(alpha, animated: true)
self.currentOverlayContentNode?.setVisibilityAlpha(alpha)
}
func animateIn(transition: ContainedViewLayoutTransition) {
self.backgroundNode.alpha = 0.0
transition.updateAlpha(node: self.backgroundNode, alpha: 1.0)
if let currentFooterContentNode = self.currentFooterContentNode {
currentFooterContentNode.animateIn(transition: transition)
}
if let currentOverlayContentNode = self.currentOverlayContentNode {
currentOverlayContentNode.alpha = 0.0
transition.updateAlpha(node: currentOverlayContentNode, alpha: 1.0)
}
}
func animateOut(transition: ContainedViewLayoutTransition) {
transition.updateAlpha(node: self.backgroundNode, alpha: 0.0)
if let currentFooterContentNode = self.currentFooterContentNode {
currentFooterContentNode.animateOut(transition: transition)
}
if let currentOverlayContentNode = self.currentOverlayContentNode {
transition.updateAlpha(node: currentOverlayContentNode, alpha: 0.0)
}
}
public func updateLayout(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, footerContentNode: GalleryFooterContentNode?, overlayContentNode: GalleryOverlayContentNode?, thumbnailPanelHeight: CGFloat, isHidden: Bool, transition: ContainedViewLayoutTransition) {
self.currentLayout = (layout, navigationBarHeight, thumbnailPanelHeight, isHidden)
let cleanInsets = layout.insets(options: [])
var dismissedCurrentFooterContentNode: GalleryFooterContentNode?
if self.currentFooterContentNode !== footerContentNode {
if let currentFooterContentNode = self.currentFooterContentNode {
currentFooterContentNode.requestLayout = nil
dismissedCurrentFooterContentNode = currentFooterContentNode
}
self.currentThumbnailPanelHeight = thumbnailPanelHeight
self.currentFooterContentNode = footerContentNode
if let footerContentNode = footerContentNode {
footerContentNode.setVisibilityAlpha(self.visibilityAlpha, animated: transition.isAnimated)
footerContentNode.controllerInteraction = self.controllerInteraction
footerContentNode.requestLayout = { [weak self] transition in
if let strongSelf = self, let (currentLayout, navigationBarHeight, currentThumbnailPanelHeight, isHidden) = strongSelf.currentLayout {
strongSelf.updateLayout(currentLayout, navigationBarHeight: navigationBarHeight, footerContentNode: strongSelf.currentFooterContentNode, overlayContentNode: strongSelf.currentOverlayContentNode, thumbnailPanelHeight: currentThumbnailPanelHeight, isHidden: isHidden, transition: transition)
}
}
self.addSubnode(footerContentNode)
}
} else if let _ = self.currentThumbnailPanelHeight {
self.currentThumbnailPanelHeight = thumbnailPanelHeight
}
var animateOverlayIn = false
var dismissedCurrentOverlayContentNode: GalleryOverlayContentNode?
if self.currentOverlayContentNode !== overlayContentNode {
if let currentOverlayContentNode = self.currentOverlayContentNode {
dismissedCurrentOverlayContentNode = currentOverlayContentNode
}
self.currentOverlayContentNode = overlayContentNode
animateOverlayIn = true
if let overlayContentNode = overlayContentNode {
overlayContentNode.setVisibilityAlpha(self.visibilityAlpha)
self.addSubnode(overlayContentNode)
}
}
var effectiveThumbnailPanelHeight = self.currentThumbnailPanelHeight ?? thumbnailPanelHeight
if layout.size.width > layout.size.height {
effectiveThumbnailPanelHeight = 0.0
}
var backgroundHeight: CGFloat = 0.0
let verticalOffset: CGFloat = isHidden ? (layout.size.width > layout.size.height ? 44.0 : (effectiveThumbnailPanelHeight > 0.0 ? 106.0 : 54.0)) : 0.0
if let footerContentNode = self.currentFooterContentNode {
backgroundHeight = footerContentNode.updateLayout(size: layout.size, metrics: layout.metrics, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, bottomInset: cleanInsets.bottom, contentInset: effectiveThumbnailPanelHeight, transition: transition)
transition.updateFrame(node: footerContentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight + verticalOffset), size: CGSize(width: layout.size.width, height: backgroundHeight)))
if let dismissedCurrentFooterContentNode = dismissedCurrentFooterContentNode {
let contentTransition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
footerContentNode.animateIn(fromHeight: dismissedCurrentFooterContentNode.bounds.height, previousContentNode: dismissedCurrentFooterContentNode, transition: contentTransition)
dismissedCurrentFooterContentNode.animateOut(toHeight: backgroundHeight, nextContentNode: footerContentNode, transition: contentTransition, completion: { [weak self, weak dismissedCurrentFooterContentNode] in
if let strongSelf = self, let dismissedCurrentFooterContentNode = dismissedCurrentFooterContentNode, dismissedCurrentFooterContentNode !== strongSelf.currentFooterContentNode {
dismissedCurrentFooterContentNode.removeFromSupernode()
}
})
contentTransition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight + verticalOffset), size: CGSize(width: layout.size.width, height: backgroundHeight)))
} else {
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight + verticalOffset), size: CGSize(width: layout.size.width, height: backgroundHeight)))
}
} else {
if let dismissedCurrentFooterContentNode = dismissedCurrentFooterContentNode {
dismissedCurrentFooterContentNode.removeFromSupernode()
}
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight + verticalOffset), size: CGSize(width: layout.size.width, height: backgroundHeight)))
}
let contentTransition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
if let overlayContentNode = self.currentOverlayContentNode {
let insets = UIEdgeInsets(top: navigationBarHeight, left: layout.safeInsets.left, bottom: isHidden ? layout.intrinsicInsets.bottom : backgroundHeight, right: layout.safeInsets.right)
overlayContentNode.updateLayout(size: layout.size, metrics: layout.metrics, insets: insets, isHidden: isHidden, transition: transition)
transition.updateFrame(node: overlayContentNode, frame: CGRect(origin: CGPoint(), size: layout.size))
if animateOverlayIn {
overlayContentNode.animateIn(previousContentNode: dismissedCurrentOverlayContentNode, transition: contentTransition)
}
if let dismissedCurrentOverlayContentNode = dismissedCurrentOverlayContentNode {
dismissedCurrentOverlayContentNode.animateOut(nextContentNode: overlayContentNode, transition: contentTransition, completion: { [weak self, weak dismissedCurrentOverlayContentNode] in
if let strongSelf = self, let dismissedCurrentOverlayContentNode = dismissedCurrentOverlayContentNode, dismissedCurrentOverlayContentNode !== strongSelf.currentOverlayContentNode {
dismissedCurrentOverlayContentNode.removeFromSupernode()
}
})
}
} else {
if let dismissedCurrentOverlayContentNode = dismissedCurrentOverlayContentNode {
dismissedCurrentOverlayContentNode.animateOut(nextContentNode: overlayContentNode, transition: contentTransition, completion: { [weak self, weak dismissedCurrentOverlayContentNode] in
if let strongSelf = self, let dismissedCurrentOverlayContentNode = dismissedCurrentOverlayContentNode, dismissedCurrentOverlayContentNode !== strongSelf.currentOverlayContentNode {
dismissedCurrentOverlayContentNode.removeFromSupernode()
}
})
}
}
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let overlayResult = self.currentOverlayContentNode?.hitTest(point, with: event) {
return overlayResult
}
if !self.backgroundNode.frame.contains(point) || self.visibilityAlpha < 1.0 {
return nil
}
let result = super.hitTest(point, with: event)
return result
}
}
@@ -0,0 +1,29 @@
import Foundation
public struct GalleryItemOriginData: Equatable {
public var title: String?
public var timestamp: Int32?
public init(title: String?, timestamp: Int32?) {
self.title = title
self.timestamp = timestamp
}
}
public struct GalleryItemIndexData: Equatable {
public var position: Int32
public var totalCount: Int32
public init(position: Int32, totalCount: Int32) {
self.position = position
self.totalCount = totalCount
}
}
public protocol GalleryItem {
var id: AnyHashable { get }
func node(synchronous: Bool) -> GalleryItemNode
func updateNode(node: GalleryItemNode, synchronous: Bool)
func thumbnailItem() -> (Int64, GalleryThumbnailItem)?
}
@@ -0,0 +1,134 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
public enum GalleryItemNodeNavigationStyle {
case light
case dark
}
open class GalleryItemNode: ASDisplayNode {
public enum ActiveEdge {
case left
case right
}
private var _index: Int?
public var index: Int {
get {
return self._index!
} set(value) {
self._index = value
}
}
public var toggleControlsVisibility: () -> Void = { }
public var updateControlsVisibility: (Bool) -> Void = { _ in }
public var controlsVisibility: () -> Bool = { return true }
public var updateOrientation: (UIInterfaceOrientation) -> Void = { _ in }
public var dismiss: () -> Void = { }
public var beginCustomDismiss: (GalleryControllerNode.CustomDismissType) -> Void = { _ in }
public var completeCustomDismiss: (Bool) -> Void = { _ in }
public var baseNavigationController: () -> NavigationController? = { return nil }
public var galleryController: () -> ViewController? = { return nil }
public var alternativeDismiss: () -> Bool = { return false }
override public init() {
super.init()
self.setViewBlock({
return UITracingLayerView()
})
}
open func ready() -> Signal<Void, NoError> {
return .single(Void())
}
open func title() -> Signal<String, NoError> {
return .single("")
}
open func titleView() -> Signal<UIView?, NoError> {
return .single(nil)
}
open func rightBarButtonItem() -> Signal<UIBarButtonItem?, NoError> {
return .single(nil)
}
open func rightBarButtonItems() -> Signal<[UIBarButtonItem]?, NoError> {
return .single(nil)
}
open func isPagingEnabled() -> Signal<Bool, NoError> {
return .single(true)
}
open func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((nil, nil))
}
open func navigationStyle() -> Signal<GalleryItemNodeNavigationStyle, NoError> {
return .single(.dark)
}
open func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
}
open func centralityUpdated(isCentral: Bool) {
}
open func screenFrameUpdated(_ frame: CGRect) {
}
open func activateAsInitial() {
}
open func processAction(_ action: GalleryControllerItemNodeAction) {
}
open func visibilityUpdated(isVisible: Bool) {
}
open func controlsVisibilityUpdated(isVisible: Bool) {
}
open func adjustForPreviewing() {
}
open func animateIn(from node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
}
open func animateOut(to node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
}
open func maybePerformActionForSwipeDismiss() -> Bool {
return false
}
open func maybePerformActionForSwipeDownDismiss() -> Bool {
return false
}
open func contentSize() -> CGSize? {
return nil
}
open var keyShortcuts: [KeyShortcut] {
return []
}
open func hasActiveEdgeAction(edge: ActiveEdge) -> Bool {
return false
}
open func setActiveEdgeAction(edge: ActiveEdge?) {
}
open func adjustActiveEdgeAction(distance: CGFloat) {
}
}
@@ -0,0 +1,91 @@
import Foundation
import UIKit
import AccountContext
import Display
public final class GalleryItemScrubberTransition {
public final class Scrubber {
public struct TransitionState: Equatable {
public enum Direction {
case `in`
case out
}
public var sourceSize: CGSize
public var destinationSize: CGSize
public var progress: CGFloat
public var direction: Direction
public init(
sourceSize: CGSize,
destinationSize: CGSize,
progress: CGFloat,
direction: Direction
) {
self.sourceSize = sourceSize
self.destinationSize = destinationSize
self.progress = progress
self.direction = direction
}
}
public let view: UIView
public let makeView: () -> UIView
public let updateView: (UIView, TransitionState, ContainedViewLayoutTransition) -> Void
public init(view: UIView, makeView: @escaping () -> UIView, updateView: @escaping (UIView, TransitionState, ContainedViewLayoutTransition) -> Void) {
self.view = view
self.makeView = makeView
self.updateView = updateView
}
}
public final class Content {
public struct TransitionState: Equatable {
public var sourceSize: CGSize
public var destinationSize: CGSize
public var destinationCornerRadius: CGFloat
public var progress: CGFloat
public init(
sourceSize: CGSize,
destinationSize: CGSize,
destinationCornerRadius: CGFloat,
progress: CGFloat
) {
self.sourceSize = sourceSize
self.destinationSize = destinationSize
self.destinationCornerRadius = destinationCornerRadius
self.progress = progress
}
}
public let sourceView: UIView
public let sourceRect: CGRect
public let makeView: () -> UIView
public let updateView: (UIView, TransitionState, ContainedViewLayoutTransition) -> Void
public init(sourceView: UIView, sourceRect: CGRect, makeView: @escaping () -> UIView, updateView: @escaping (UIView, TransitionState, ContainedViewLayoutTransition) -> Void) {
self.sourceView = sourceView
self.sourceRect = sourceRect
self.makeView = makeView
self.updateView = updateView
}
}
public let scrubber: Scrubber?
public let content: Content?
public init(scrubber: Scrubber?, content: Content?) {
self.scrubber = scrubber
self.content = content
}
}
public protocol GalleryItemTransitionNode: AnyObject {
func isAvailableForGalleryTransition() -> Bool
func isAvailableForInstantPageTransition() -> Bool
var decoration: UniversalVideoDecoration? { get }
func scrubberTransition() -> GalleryItemScrubberTransition?
}
@@ -0,0 +1,55 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import CheckNode
public final class GalleryNavigationCheckNode: ASDisplayNode, NavigationButtonCustomDisplayNode {
private var checkNode: InteractiveCheckNode
private weak var target: AnyObject?
private var action: Selector?
public init(theme: PresentationTheme) {
self.checkNode = InteractiveCheckNode(theme: CheckNodeTheme(theme: theme, style: .overlay))
super.init()
self.addSubnode(self.checkNode)
self.checkNode.valueChanged = { [weak self] value in
if let strongSelf = self, let target = strongSelf.target, let action = strongSelf.action {
let _ = target.perform(action)
}
}
}
public var isHighlightable: Bool {
return false
}
public var isChecked: Bool {
return self.checkNode.selected
}
public func setIsChecked(_ isChecked: Bool, animated: Bool) {
self.checkNode.setSelected(isChecked, animated: animated)
}
public func addTarget(target: AnyObject?, action: Selector) {
self.target = target
self.action = action
}
override public func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
return CGSize(width: 39.0, height: 39.0)
}
override public func layout() {
super.layout()
let size = self.bounds.size
let checkSize = CGSize(width: 36.0, height: 36.0)
self.checkNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - checkSize.width) / 2.0) + 11.0, y: floorToScreenPixels((size.height - checkSize.height) / 2.0) + 3.0), size: checkSize)
}
}
@@ -0,0 +1,49 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import LegacyComponents
public final class GalleryNavigationRecipientNode: ASDisplayNode, NavigationButtonCustomDisplayNode {
private var iconNode: ASImageNode
private var textNode: ImmediateTextNode
public init(color: UIColor, title: String) {
self.iconNode = ASImageNode()
self.iconNode.alpha = 0.45
self.iconNode.image = TGComponentsImageNamed("PhotoPickerArrow")
self.textNode = ImmediateTextNode()
self.textNode.attributedText = NSAttributedString(string: title, font: Font.bold(13.0), textColor: UIColor(rgb: 0xffffff, alpha: 0.45))
self.textNode.maximumNumberOfLines = 1
super.init()
self.addSubnode(self.iconNode)
self.addSubnode(self.textNode)
if title.isEmpty {
self.iconNode.isHidden = true
self.textNode.isHidden = true
}
}
public var isHighlightable: Bool {
return false
}
override public func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
let textSize = self.textNode.updateLayout(CGSize(width: constrainedSize.width - 50.0, height: constrainedSize.height))
return CGSize(width: textSize.width + 12.0, height: 30.0)
}
override public func layout() {
super.layout()
if let image = self.iconNode.image {
self.iconNode.frame = CGRect(origin: CGPoint(x: -2.0, y: 9.0), size: image.size)
}
self.textNode.frame = CGRect(x: self.iconNode.frame.maxX + 6.0, y: 7.0, width: self.frame.size.width - 12.0, height: 15.0)
}
}
@@ -0,0 +1,832 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
private func edgeWidth(width: CGFloat) -> CGFloat {
return min(44.0, floor(width / 6.0))
}
private func activeEdgeWidth(width: CGFloat) -> CGFloat {
return floor(width * 0.4)
}
let fadeWidth: CGFloat = 70.0
private let leftFadeImage = generateImage(CGSize(width: fadeWidth, height: 32.0), opaque: false, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let gradientColors = [UIColor.black.withAlphaComponent(0.35).cgColor, UIColor.black.withAlphaComponent(0.0).cgColor] as CFArray
var locations: [CGFloat] = [0.0, 1.0]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
})
private let rightFadeImage = generateImage(CGSize(width: fadeWidth, height: 32.0), opaque: false, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let gradientColors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.35).cgColor] as CFArray
var locations: [CGFloat] = [0.0, 1.0]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
})
public struct GalleryPagerInsertItem {
public let index: Int
public let item: GalleryItem
public let previousIndex: Int?
public init(index: Int, item: GalleryItem, previousIndex: Int?) {
self.index = index
self.item = item
self.previousIndex = previousIndex
}
}
public struct GalleryPagerUpdateItem {
public let index: Int
public let previousIndex: Int
public let item: GalleryItem
public init(index: Int, previousIndex: Int, item: GalleryItem) {
self.index = index
self.previousIndex = previousIndex
self.item = item
}
}
public struct GalleryPagerTransaction {
public let deleteItems: [Int]
public let insertItems: [GalleryPagerInsertItem]
public let updateItems: [GalleryPagerUpdateItem]
public let focusOnItem: Int?
public let synchronous: Bool
public init(deleteItems: [Int], insertItems: [GalleryPagerInsertItem], updateItems: [GalleryPagerUpdateItem], focusOnItem: Int?, synchronous: Bool) {
self.deleteItems = deleteItems
self.insertItems = insertItems
self.updateItems = updateItems
self.focusOnItem = focusOnItem
self.synchronous = synchronous
}
}
public final class GalleryPagerNode: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDelegate {
private let pageGap: CGFloat
private let disableTapNavigation: Bool
private let scrollView: UIScrollView
private let leftFadeNode: ASDisplayNode
private let rightFadeNode: ASDisplayNode
private var highlightedSide: Bool?
private var activeSide: Bool?
private var canPerformSideNavigationAction: Bool = false
private var sideActionInitialPosition: CGPoint?
private var tapRecognizer: TapLongTapOrDoubleTapGestureRecognizer?
public private(set) var items: [GalleryItem] = []
private var itemNodes: [GalleryItemNode] = []
private var ignoreDidScroll = false
private var ignoreCentralItemIndexUpdate = false
private var centralItemIndex: Int? {
didSet {
if oldValue != self.centralItemIndex && !self.ignoreCentralItemIndexUpdate {
self.centralItemIndexUpdated(self.centralItemIndex)
}
}
}
private var containerLayout: (ContainerViewLayout, CGFloat)?
public var centralItemIndexUpdated: (Int?) -> Void = { _ in }
private var invalidatedItems = false
public var centralItemIndexOffsetUpdated: (([GalleryItem]?, Int, CGFloat)?) -> Void = { _ in }
public var toggleControlsVisibility: () -> Void = { }
public var updateControlsVisibility: (Bool) -> Void = { _ in }
public var controlsVisibility: () -> Bool = { return true }
public var updateOrientation: (UIInterfaceOrientation) -> Void = { _ in }
public var dismiss: () -> Void = { }
public var beginCustomDismiss: (GalleryControllerNode.CustomDismissType) -> Void = { _ in }
public var completeCustomDismiss: (Bool) -> Void = { _ in }
public var baseNavigationController: () -> NavigationController? = { return nil }
public var galleryController: () -> ViewController? = { return nil }
private var pagingEnabled = true
public var pagingEnabledPromise = Promise<Bool>(true)
private var pagingEnabledDisposable: Disposable?
private var edgeLongTapTimer: Foundation.Timer?
public init(pageGap: CGFloat, disableTapNavigation: Bool) {
self.pageGap = pageGap
self.disableTapNavigation = disableTapNavigation
self.scrollView = UIScrollView()
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
self.leftFadeNode = ASDisplayNode()
self.leftFadeNode.alpha = 0.0
self.leftFadeNode.backgroundColor = leftFadeImage.flatMap { UIColor(patternImage: $0) }
self.rightFadeNode = ASDisplayNode()
self.rightFadeNode.alpha = 0.0
self.rightFadeNode.backgroundColor = rightFadeImage.flatMap { UIColor(patternImage: $0) }
super.init()
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = !pageGap.isZero
self.scrollView.bounces = !pageGap.isZero
self.scrollView.isPagingEnabled = true
self.scrollView.delegate = self.wrappedScrollViewDelegate
self.scrollView.clipsToBounds = false
self.scrollView.scrollsToTop = false
self.scrollView.delaysContentTouches = false
self.view.addSubview(self.scrollView)
self.addSubnode(self.leftFadeNode)
self.addSubnode(self.rightFadeNode)
self.pagingEnabledDisposable = (self.pagingEnabledPromise.get()
|> deliverOnMainQueue).start(next: { [weak self] pagingEnabled in
if let strongSelf = self {
strongSelf.pagingEnabled = pagingEnabled
}
}).strict()
}
deinit {
self.pagingEnabledDisposable?.dispose()
}
public override func didLoad() {
super.didLoad()
let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:)))
recognizer.delegate = self.wrappedGestureRecognizerDelegate
self.tapRecognizer = recognizer
recognizer.tapActionAtPoint = { [weak self] point in
guard let strongSelf = self else {
return .fail
}
let size = strongSelf.bounds
var highlightedSide: Bool?
var activeSide: Bool?
if point.x < edgeWidth(width: size.width) {
if strongSelf.canGoToPreviousItem() {
if strongSelf.items.count > 1 {
highlightedSide = false
}
}
} else if point.x > size.width - edgeWidth(width: size.width) {
if strongSelf.canGoToNextItem() {
if strongSelf.items.count > 1 {
highlightedSide = true
}
}
}
if point.x < activeEdgeWidth(width: size.width), let centralIndex = strongSelf.centralItemIndex, let itemNode = strongSelf.visibleItemNode(at: centralIndex), itemNode.hasActiveEdgeAction(edge: .left) {
activeSide = false
} else if point.x > 0.0, let centralIndex = strongSelf.centralItemIndex, let itemNode = strongSelf.visibleItemNode(at: centralIndex), itemNode.hasActiveEdgeAction(edge: .right) {
activeSide = true
}
if !strongSelf.pagingEnabled {
highlightedSide = nil
}
if highlightedSide == nil && activeSide == nil {
return .fail
}
if let result = strongSelf.hitTest(point, with: nil), let _ = result.asyncdisplaykit_node as? ASButtonNode {
return .fail
}
if activeSide != nil {
return .waitForHold(timeout: 0.3, acceptTap: true)
} else {
return .keepWithSingleTap
}
}
recognizer.highlight = { [weak self] point in
guard let strongSelf = self else {
return
}
let size = strongSelf.bounds
var highlightedSide: Bool?
var activeSide: Bool?
if let point {
if point.x < edgeWidth(width: size.width) {
if strongSelf.canGoToPreviousItem() {
if strongSelf.items.count > 1 {
highlightedSide = false
}
}
} else if point.x > size.width - edgeWidth(width: size.width) {
if strongSelf.canGoToNextItem() {
if strongSelf.items.count > 1 {
highlightedSide = true
}
}
}
if point.x < activeEdgeWidth(width: size.width), let centralIndex = strongSelf.centralItemIndex, let itemNode = strongSelf.visibleItemNode(at: centralIndex), itemNode.hasActiveEdgeAction(edge: .left) {
activeSide = false
} else if point.x > 0.0, let centralIndex = strongSelf.centralItemIndex, let itemNode = strongSelf.visibleItemNode(at: centralIndex), itemNode.hasActiveEdgeAction(edge: .right) {
activeSide = true
}
}
if !strongSelf.pagingEnabled {
highlightedSide = nil
}
if strongSelf.highlightedSide != highlightedSide {
strongSelf.highlightedSide = highlightedSide
if highlightedSide != nil {
strongSelf.canPerformSideNavigationAction = true
}
let leftAlpha: CGFloat
let rightAlpha: CGFloat
if let highlightedSide = highlightedSide {
leftAlpha = highlightedSide ? 0.0 : 1.0
rightAlpha = highlightedSide ? 1.0 : 0.0
} else {
leftAlpha = 0.0
rightAlpha = 0.0
}
if strongSelf.leftFadeNode.alpha != leftAlpha {
strongSelf.leftFadeNode.alpha = leftAlpha
if leftAlpha.isZero {
strongSelf.leftFadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.16, timingFunction: kCAMediaTimingFunctionSpring)
} else {
strongSelf.leftFadeNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.08)
}
}
if strongSelf.rightFadeNode.alpha != rightAlpha {
strongSelf.rightFadeNode.alpha = rightAlpha
if rightAlpha.isZero {
strongSelf.rightFadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.16, timingFunction: kCAMediaTimingFunctionSpring)
} else {
strongSelf.rightFadeNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.08)
}
}
}
if strongSelf.activeSide != activeSide {
strongSelf.activeSide = activeSide
if let activeSide, let centralIndex = strongSelf.centralItemIndex, let _ = strongSelf.visibleItemNode(at: centralIndex) {
if strongSelf.edgeLongTapTimer == nil {
strongSelf.edgeLongTapTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false, block: { _ in
guard let self else {
return
}
if let centralIndex = self.centralItemIndex, let itemNode = self.visibleItemNode(at: centralIndex) {
itemNode.setActiveEdgeAction(edge: activeSide ? .right : .left)
}
self.canPerformSideNavigationAction = false
let leftAlpha: CGFloat
let rightAlpha: CGFloat
leftAlpha = 0.0
rightAlpha = 0.0
if self.leftFadeNode.alpha != leftAlpha {
self.leftFadeNode.alpha = leftAlpha
self.leftFadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.16, timingFunction: kCAMediaTimingFunctionSpring)
}
if self.rightFadeNode.alpha != rightAlpha {
self.rightFadeNode.alpha = rightAlpha
self.rightFadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.16, timingFunction: kCAMediaTimingFunctionSpring)
}
})
}
} else if let edgeLongTapTimer = strongSelf.edgeLongTapTimer {
edgeLongTapTimer.invalidate()
strongSelf.edgeLongTapTimer = nil
if let centralIndex = strongSelf.centralItemIndex, let itemNode = strongSelf.visibleItemNode(at: centralIndex) {
itemNode.setActiveEdgeAction(edge: nil)
}
}
}
}
self.view.addGestureRecognizer(recognizer)
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
@objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
switch recognizer.state {
case .ended:
self.sideActionInitialPosition = nil
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation {
if case .tap = gesture, self.canPerformSideNavigationAction {
let size = self.bounds.size
if location.x < edgeWidth(width: size.width) && self.canGoToPreviousItem() {
self.goToPreviousItem()
} else if location.x > size.width - edgeWidth(width: size.width) && self.canGoToNextItem() {
self.goToNextItem()
}
}
}
case .cancelled:
self.sideActionInitialPosition = nil
case .began:
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, case .hold = gesture {
self.sideActionInitialPosition = location
}
case .changed:
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, case .hold = gesture {
if let sideActionInitialPosition = self.sideActionInitialPosition {
let distance = location.x - sideActionInitialPosition.x
if let centralIndex = self.centralItemIndex, let itemNode = self.visibleItemNode(at: centralIndex) {
itemNode.adjustActiveEdgeAction(distance: distance)
}
}
}
default:
break
}
}
public var isScrollEnabled: Bool {
get {
return self.scrollView.isScrollEnabled
}
set {
self.scrollView.isScrollEnabled = newValue
}
}
public func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (layout, navigationBarHeight)
var centralPoint: CGPoint?
if transition.isAnimated, let centralItemIndex = self.centralItemIndex, let centralItemNode = self.visibleItemNode(at: centralItemIndex) {
centralPoint = self.view.convert(CGPoint(x: centralItemNode.frame.size.width / 2.0, y: centralItemNode.frame.size.height / 2.0), from: centralItemNode.view)
}
var previousCentralNodeHorizontalOffset: CGFloat?
if let centralItemIndex = self.centralItemIndex, let centralNode = self.visibleItemNode(at: centralItemIndex) {
previousCentralNodeHorizontalOffset = self.scrollView.contentOffset.x - centralNode.frame.minX
}
self.ignoreDidScroll = true
self.scrollView.frame = CGRect(origin: CGPoint(x: -self.pageGap, y: 0.0), size: CGSize(width: layout.size.width + self.pageGap * 2.0, height: layout.size.height))
self.ignoreDidScroll = false
for i in 0 ..< self.itemNodes.count {
transition.updateFrame(node: self.itemNodes[i], frame: CGRect(origin: CGPoint(x: CGFloat(i) * self.scrollView.bounds.size.width + self.pageGap, y: 0.0), size: CGSize(width: self.scrollView.bounds.size.width - self.pageGap * 2.0, height: self.scrollView.bounds.size.height)))
self.itemNodes[i].containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
if let previousCentralNodeHorizontalOffset = previousCentralNodeHorizontalOffset, let centralItemIndex = self.centralItemIndex, let centralNode = self.visibleItemNode(at: centralItemIndex) {
self.scrollView.contentOffset = CGPoint(x: centralNode.frame.minX + previousCentralNodeHorizontalOffset, y: 0.0)
}
self.updateItemNodes(transition: transition)
if let centralPoint = centralPoint, let centralItemIndex = self.centralItemIndex, let centralItemNode = self.visibleItemNode(at: centralItemIndex) {
let updatedCentralPoint = self.view.convert(CGPoint(x: centralItemNode.frame.size.width / 2.0, y: centralItemNode.frame.size.height / 2.0), from: centralItemNode.view)
transition.animatePosition(node: centralItemNode, from: centralItemNode.position.offsetBy(dx: -updatedCentralPoint.x + centralPoint.x, dy: -updatedCentralPoint.y + centralPoint.y))
}
self.leftFadeNode.frame = CGRect(x: 0.0, y: 0.0, width: fadeWidth, height: layout.size.height)
self.rightFadeNode.frame = CGRect(x: layout.size.width - fadeWidth, y: 0.0, width: fadeWidth, height: layout.size.height)
}
public func ready() -> Signal<Void, NoError> {
if let itemNode = self.centralItemNode() {
return itemNode.ready()
}
return .single(Void())
}
public func centralItemNode() -> GalleryItemNode? {
if let centralItemIndex = self.centralItemIndex, let centralItemNode = self.visibleItemNode(at: centralItemIndex) {
return centralItemNode
} else {
return nil
}
}
public var updateOnReplacement = false
public func replaceItems(_ items: [GalleryItem], centralItemIndex: Int?, synchronous: Bool = false) {
var updateItems: [GalleryPagerUpdateItem] = []
var deleteItems: [Int] = []
var insertItems: [GalleryPagerInsertItem] = []
var previousIndexById: [AnyHashable: Int] = [:]
let validIds = Set(items.map { $0.id })
for i in 0 ..< self.items.count {
previousIndexById[self.items[i].id] = i
if !validIds.contains(self.items[i].id) {
deleteItems.append(i)
}
}
if self.updateOnReplacement {
for i in 0 ..< items.count {
if (previousIndexById[items[i].id] == nil) {
insertItems.append(GalleryPagerInsertItem(index: i, item: items[i], previousIndex: previousIndexById[items[i].id]))
} else {
updateItems.append(GalleryPagerUpdateItem(index: i, previousIndex: i, item: items[i]))
}
}
} else {
for i in 0 ..< items.count {
insertItems.append(GalleryPagerInsertItem(index: i, item: items[i], previousIndex: previousIndexById[items[i].id]))
}
}
self.transaction(GalleryPagerTransaction(deleteItems: deleteItems, insertItems: insertItems, updateItems: updateItems, focusOnItem: centralItemIndex, synchronous: synchronous))
if self.updateOnReplacement {
self.items = items
for i in 0 ..< self.items.count {
if let itemNode = self.visibleItemNode(at: i) {
self.items[i].updateNode(node: itemNode, synchronous: synchronous)
}
}
for i in (0 ..< self.itemNodes.count).reversed() {
let node = self.itemNodes[i]
if node.index > self.items.count - 1 {
node.removeFromSupernode()
self.itemNodes.remove(at: i)
}
}
self.updateCentralIndexOffset(transition: .immediate)
}
}
public func transaction(_ transaction: GalleryPagerTransaction) {
for updatedItem in transaction.updateItems {
self.items[updatedItem.previousIndex] = updatedItem.item
if let itemNode = self.visibleItemNode(at: updatedItem.previousIndex) {
//print("update visible node at \(updatedItem.previousIndex)")
updatedItem.item.updateNode(node: itemNode, synchronous: transaction.synchronous)
}
}
if !transaction.deleteItems.isEmpty || !transaction.insertItems.isEmpty {
let deleteItems = transaction.deleteItems.sorted()
for deleteItemIndex in deleteItems.reversed() {
self.items.remove(at: deleteItemIndex)
for i in 0 ..< self.itemNodes.count {
if self.itemNodes[i].index == deleteItemIndex {
//print("delete visible node at \(deleteItemIndex)")
self.removeVisibleItemNode(internalIndex: i)
break
}
}
}
let insertItems = transaction.insertItems.sorted(by: { $0.index < $1.index })
if transaction.updateItems.isEmpty && !insertItems.isEmpty {
self.items.removeAll()
}
for insertedItem in insertItems {
self.items.append(insertedItem.item)
//self.items.insert(insertedItem.item, at: insertedItem.index)
}
let visibleIndices: [Int] = self.itemNodes.map { $0.index }
var remapIndices: [Int: Int] = [:]
for i in 0 ..< insertItems.count {
if let previousIndex = insertItems[i].previousIndex, visibleIndices.contains(previousIndex) {
remapIndices[previousIndex] = i
}
}
for itemNode in self.itemNodes {
if let remappedIndex = remapIndices[itemNode.index] {
//print("remap visible node \(itemNode.index) -> \(remappedIndex)")
itemNode.index = remappedIndex
}
}
self.itemNodes.sort(by: { $0.index < $1.index })
//print("visible indices before update \(self.itemNodes.map { $0.index })")
self.invalidatedItems = true
if let focusOnItem = transaction.focusOnItem {
self.centralItemIndex = focusOnItem
}
self.updateItemNodes(transition: .immediate, notify: transaction.focusOnItem != nil, synchronous: transaction.synchronous)
//print("visible indices after update \(self.itemNodes.map { $0.index })")
}
else if let focusOnItem = transaction.focusOnItem {
self.ignoreCentralItemIndexUpdate = true
self.centralItemIndex = focusOnItem
self.ignoreCentralItemIndexUpdate = false
self.updateItemNodes(transition: .immediate, forceOffsetReset: true, synchronous: transaction.synchronous)
}
}
func canGoToPreviousItem() -> Bool {
if self.disableTapNavigation {
return false
}
if let index = self.centralItemIndex, index > 0 {
return true
} else {
return false
}
}
func canGoToNextItem() -> Bool {
if self.disableTapNavigation {
return false
}
if let index = self.centralItemIndex, index < self.items.count - 1 {
return true
} else {
return false
}
}
func goToPreviousItem() {
if let index = self.centralItemIndex, index > 0 {
self.transaction(GalleryPagerTransaction(deleteItems: [], insertItems: [], updateItems: [], focusOnItem: index - 1, synchronous: false))
}
}
func goToNextItem() {
if let index = self.centralItemIndex, index < self.items.count - 1 {
self.transaction(GalleryPagerTransaction(deleteItems: [], insertItems: [], updateItems: [], focusOnItem: index + 1, synchronous: false))
}
}
private func makeNodeForItem(at index: Int, synchronous: Bool) -> GalleryItemNode {
let node = self.items[index].node(synchronous: synchronous)
node.toggleControlsVisibility = self.toggleControlsVisibility
node.updateControlsVisibility = self.updateControlsVisibility
node.controlsVisibility = self.controlsVisibility
node.updateOrientation = self.updateOrientation
node.dismiss = self.dismiss
node.beginCustomDismiss = self.beginCustomDismiss
node.completeCustomDismiss = self.completeCustomDismiss
node.baseNavigationController = self.baseNavigationController
node.galleryController = self.galleryController
node.index = index
return node
}
private func visibleItemNode(at index: Int) -> GalleryItemNode? {
for itemNode in self.itemNodes {
if itemNode.index == index {
return itemNode
}
}
return nil
}
private func addVisibleItemNode(_ node: GalleryItemNode) {
var added = false
for i in 0 ..< self.itemNodes.count {
if node.index < self.itemNodes[i].index {
self.itemNodes.insert(node, at: i)
added = true
break
}
}
if !added {
self.itemNodes.append(node)
}
self.scrollView.addSubview(node.view)
}
private func removeVisibleItemNode(internalIndex: Int) {
self.itemNodes[internalIndex].view.removeFromSuperview()
self.itemNodes.remove(at: internalIndex)
}
private func updateItemNodes(transition: ContainedViewLayoutTransition, forceOffsetReset: Bool = false, notify: Bool = false, forceLoad: Bool = false, synchronous: Bool = false) {
if self.items.isEmpty || self.containerLayout == nil {
return
}
var resetOffsetToCentralItem = forceOffsetReset
if let centralItemIndex = self.centralItemIndex, self.visibleItemNode(at: centralItemIndex) == nil, !self.itemNodes.isEmpty {
repeat {
self.removeVisibleItemNode(internalIndex: self.itemNodes.count - 1)
} while self.itemNodes.count > 0
}
if self.itemNodes.isEmpty {
let node = self.makeNodeForItem(at: self.centralItemIndex ?? 0, synchronous: synchronous)
node.frame = CGRect(origin: CGPoint(), size: self.scrollView.bounds.size)
if let containerLayout = self.containerLayout {
node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
self.centralItemIndex = node.index
resetOffsetToCentralItem = true
}
var notifyCentralItemUpdated = forceOffsetReset || notify
if let centralItemIndex = self.centralItemIndex, let centralItemNode = self.visibleItemNode(at: centralItemIndex) {
if centralItemIndex != 0 {
if self.shouldLoadItems(force: forceLoad) && self.visibleItemNode(at: centralItemIndex - 1) == nil {
let node = self.makeNodeForItem(at: centralItemIndex - 1, synchronous: synchronous)
node.frame = centralItemNode.frame.offsetBy(dx: -centralItemNode.frame.size.width - self.pageGap, dy: 0.0)
if let containerLayout = self.containerLayout {
node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
}
}
if centralItemIndex != self.items.count - 1 {
if self.shouldLoadItems(force: forceLoad) && self.visibleItemNode(at: centralItemIndex + 1) == nil {
let node = self.makeNodeForItem(at: centralItemIndex + 1, synchronous: synchronous)
node.frame = centralItemNode.frame.offsetBy(dx: centralItemNode.frame.size.width + self.pageGap, dy: 0.0)
if let containerLayout = self.containerLayout {
node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
}
}
for i in 0 ..< self.itemNodes.count {
let node = self.itemNodes[i]
transition.updateFrame(node: node, frame: CGRect(origin: CGPoint(x: CGFloat(i) * self.scrollView.bounds.size.width + self.pageGap, y: 0.0), size: CGSize(width: self.scrollView.bounds.size.width - self.pageGap * 2.0, height: self.scrollView.bounds.size.height)))
let screenFrame = node.view.convert(node.view.bounds, to: self.view.superview)
node.screenFrameUpdated(screenFrame)
}
if resetOffsetToCentralItem {
self.scrollView.contentOffset = CGPoint(x: centralItemNode.frame.minX - self.pageGap, y: 0.0)
}
if self.shouldLoadItems(force: forceLoad), let centralItemCandidateNode = self.centralItemCandidate(), centralItemCandidateNode.index != centralItemIndex {
for i in (0 ..< self.itemNodes.count).reversed() {
let node = self.itemNodes[i]
if node.index < centralItemCandidateNode.index - 1 || node.index > centralItemCandidateNode.index + 1 {
self.removeVisibleItemNode(internalIndex: i)
}
}
self.ignoreCentralItemIndexUpdate = true
self.centralItemIndex = centralItemCandidateNode.index
self.ignoreCentralItemIndexUpdate = false
notifyCentralItemUpdated = true
if centralItemCandidateNode.index != 0 {
if self.shouldLoadItems(force: forceLoad) && self.visibleItemNode(at: centralItemCandidateNode.index - 1) == nil {
let node = self.makeNodeForItem(at: centralItemCandidateNode.index - 1, synchronous: synchronous)
node.frame = centralItemCandidateNode.frame.offsetBy(dx: -centralItemCandidateNode.frame.size.width - self.pageGap, dy: 0.0)
if let containerLayout = self.containerLayout {
node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
}
}
if centralItemCandidateNode.index != items.count - 1 {
if self.shouldLoadItems(force: forceLoad) && self.visibleItemNode(at: centralItemCandidateNode.index + 1) == nil {
let node = self.makeNodeForItem(at: centralItemCandidateNode.index + 1, synchronous: synchronous)
node.frame = centralItemCandidateNode.frame.offsetBy(dx: centralItemCandidateNode.frame.size.width + self.pageGap, dy: 0.0)
if let containerLayout = self.containerLayout {
node.containerLayoutUpdated(containerLayout.0, navigationBarHeight: containerLayout.1, transition: .immediate)
}
self.addVisibleItemNode(node)
}
}
let previousCentralCandidateHorizontalOffset = self.scrollView.contentOffset.x - centralItemCandidateNode.frame.minX
for i in 0 ..< self.itemNodes.count {
let node = self.itemNodes[i]
transition.updateFrame(node: node, frame: CGRect(origin: CGPoint(x: CGFloat(i) * self.scrollView.bounds.size.width + self.pageGap, y: 0.0), size: CGSize(width: self.scrollView.bounds.size.width - self.pageGap * 2.0, height: self.scrollView.bounds.size.height)))
let screenFrame = node.convert(node.bounds, to: self.supernode)
node.screenFrameUpdated(screenFrame)
}
self.scrollView.contentOffset = CGPoint(x: centralItemCandidateNode.frame.minX + previousCentralCandidateHorizontalOffset, y: 0.0)
}
self.scrollView.contentSize = CGSize(width: CGFloat(self.itemNodes.count) * self.scrollView.bounds.size.width, height: self.scrollView.bounds.size.height)
} else {
assertionFailure()
}
for itemNode in self.itemNodes {
let isVisible = self.scrollView.bounds.intersects(itemNode.frame)
itemNode.centralityUpdated(isCentral: itemNode.index == self.centralItemIndex)
itemNode.visibilityUpdated(isVisible: isVisible)
itemNode.isHidden = !isVisible
}
if notifyCentralItemUpdated {
self.centralItemIndexUpdated(self.centralItemIndex)
}
self.updateCentralIndexOffset(transition: .immediate)
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreDidScroll {
self.updateItemNodes(transition: .immediate)
}
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
self.ensureItemsLoaded(force: false)
}
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.ensureItemsLoaded(force: true)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.ensureItemsLoaded(force: true)
}
private func shouldLoadItems(force: Bool) -> Bool {
return force || (!self.scrollView.isDecelerating && !self.scrollView.isDragging)
}
private func ensureItemsLoaded(force: Bool) {
self.updateItemNodes(transition: .immediate, forceLoad: force)
}
private func centralItemCandidate() -> GalleryItemNode? {
let hotizontlOffset = self.scrollView.contentOffset.x + self.pageGap
var closestNodeAndDistance: (Int, CGFloat)?
for i in 0 ..< self.itemNodes.count {
let node = self.itemNodes[i]
let distance = abs(node.frame.minX - hotizontlOffset)
if let currentClosestNodeAndDistance = closestNodeAndDistance {
if distance < currentClosestNodeAndDistance.1 {
closestNodeAndDistance = (node.index, distance)
}
} else {
closestNodeAndDistance = (node.index, distance)
}
}
if let closestNodeAndDistance = closestNodeAndDistance {
return self.visibleItemNode(at: closestNodeAndDistance.0)
} else {
return nil
}
}
private func updateCentralIndexOffset(transition: ContainedViewLayoutTransition) {
if let centralIndex = self.centralItemIndex, let itemNode = self.visibleItemNode(at: centralIndex) {
let offset: CGFloat = self.scrollView.contentOffset.x + self.pageGap - itemNode.frame.minX
var progress = offset / self.scrollView.bounds.size.width
progress = min(1.0, progress)
progress = max(-1.0, progress)
self.centralItemIndexOffsetUpdated((self.invalidatedItems ? self.items : nil, centralIndex, progress))
} else {
self.invalidatedItems = false
self.centralItemIndexOffsetUpdated(nil)
}
}
public func forEachItemNode(_ f: (GalleryItemNode) -> Void) {
for itemNode in self.itemNodes {
f(itemNode)
}
}
}
@@ -0,0 +1,118 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AppBundle
final class GalleryRateToastAnimationComponent: Component {
let speedFraction: CGFloat
init(speedFraction: CGFloat) {
self.speedFraction = speedFraction
}
static func ==(lhs: GalleryRateToastAnimationComponent, rhs: GalleryRateToastAnimationComponent) -> Bool {
if lhs.speedFraction != rhs.speedFraction {
return false
}
return true
}
final class View: UIView {
private let itemViewContainer: UIView
private var itemViews: [UIImageView] = []
private var link: SharedDisplayLinkDriver.Link?
private var timeValue: CGFloat = 0.0
private var speedFraction: CGFloat = 1.0
override init(frame: CGRect) {
self.itemViewContainer = UIView()
super.init(frame: frame)
self.addSubview(self.itemViewContainer)
let image = UIImage(bundleImageName: "Media Gallery/VideoRateToast")?.withRenderingMode(.alwaysTemplate)
for _ in 0 ..< 2 {
let itemView = UIImageView(image: image)
itemView.tintColor = .white
self.itemViews.append(itemView)
self.itemViewContainer.addSubview(itemView)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.link?.invalidate()
}
private func setupAnimations() {
if self.link == nil {
var previousTimestamp = CACurrentMediaTime()
self.link = SharedDisplayLinkDriver.shared.add { [weak self] _ in
guard let self else {
return
}
let timestamp = CACurrentMediaTime()
let deltaMultiplier = 1.0 * (1.0 - self.speedFraction) + 3.0 * self.speedFraction
let deltaTime = (timestamp - previousTimestamp) * deltaMultiplier
previousTimestamp = timestamp
self.timeValue += deltaTime
let duration: CGFloat = 1.2
for i in 0 ..< self.itemViews.count {
var itemFraction = (self.timeValue + CGFloat(i) * 0.1).truncatingRemainder(dividingBy: duration) / duration
if itemFraction >= 0.5 {
itemFraction = (1.0 - itemFraction) / 0.5
} else {
itemFraction = itemFraction / 0.5
}
let itemAlpha = 0.6 * (1.0 - itemFraction) + 1.0 * itemFraction
let itemScale = 0.9 * (1.0 - itemFraction) + 1.1 * itemFraction
self.itemViews[i].alpha = itemAlpha
self.itemViews[i].transform = CGAffineTransformMakeScale(itemScale, itemScale)
}
}
}
}
func update(component: GalleryRateToastAnimationComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.speedFraction = component.speedFraction
let itemSize = self.itemViews[0].image?.size ?? CGSize(width: 10.0, height: 10.0)
let itemSpacing: CGFloat = 1.0
let size = CGSize(width: itemSize.width * 2.0 + itemSpacing, height: 12.0)
for i in 0 ..< self.itemViews.count {
let itemFrame = CGRect(origin: CGPoint(x: CGFloat(i) * (itemSize.width + itemSpacing), y: UIScreenPixel), size: itemSize)
self.itemViews[i].center = itemFrame.center
self.itemViews[i].bounds = CGRect(origin: CGPoint(), size: itemFrame.size)
self.itemViews[i].layer.speed = Float(1.0 * (1.0 - component.speedFraction) + 2.0 * component.speedFraction)
}
self.setupAnimations()
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,234 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import AppBundle
import BalancedTextComponent
import AnimatedTextComponent
import LottieComponent
final class GalleryRateToastComponent: Component {
let rate: Double
let displayTooltip: String?
init(rate: Double, displayTooltip: String?) {
self.rate = rate
self.displayTooltip = displayTooltip
}
static func ==(lhs: GalleryRateToastComponent, rhs: GalleryRateToastComponent) -> Bool {
if lhs.rate != rhs.rate {
return false
}
if lhs.displayTooltip != rhs.displayTooltip {
return false
}
return true
}
final class View: UIView {
private let background = ComponentView<Empty>()
private let text = ComponentView<Empty>()
private let arrows = ComponentView<Empty>()
private var tooltipText: ComponentView<Empty>?
private var tooltipAnimation: ComponentView<Empty>?
private var tooltipIsHidden: Bool = false
private var tooltipTimer: Foundation.Timer?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.tooltipTimer?.invalidate()
}
func update(component: GalleryRateToastComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.state = state
let insets = UIEdgeInsets(top: 5.0, left: 11.0, bottom: 5.0, right: 16.0)
let spacing: CGFloat = 5.0
var rateString = String(format: "%.1f", component.rate)
if rateString.hasSuffix(".0") {
rateString = rateString.replacingOccurrences(of: ".0", with: "")
}
var textItems: [AnimatedTextComponent.Item] = []
if let dotRange = rateString.range(of: ".") {
textItems.append(AnimatedTextComponent.Item(id: AnyHashable("pre"), content: .text(String(rateString[rateString.startIndex ..< dotRange.lowerBound]))))
textItems.append(AnimatedTextComponent.Item(id: AnyHashable("dot"), content: .text(".")))
textItems.append(AnimatedTextComponent.Item(id: AnyHashable("post"), content: .text(String(rateString[dotRange.upperBound...]))))
} else {
textItems.append(AnimatedTextComponent.Item(id: AnyHashable("pre"), content: .text(rateString)))
}
textItems.append(AnimatedTextComponent.Item(id: AnyHashable("x"), content: .text("x")))
let textSize = self.text.update(
transition: transition,
component: AnyComponent(AnimatedTextComponent(
font: Font.with(size: 17.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]),
color: .white,
items: textItems
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
var speedFraction = (component.rate - 1.0) / (2.5 - 1.0)
speedFraction = max(0.0, min(1.0, speedFraction))
let arrowsSize = self.arrows.update(
transition: transition,
component: AnyComponent(GalleryRateToastAnimationComponent(speedFraction: speedFraction)),
environment: {},
containerSize: CGSize(width: 200.0, height: 100.0)
)
let size = CGSize(width: insets.left + insets.right + textSize.width + arrowsSize.width, height: insets.top + insets.bottom + max(textSize.height, arrowsSize.height))
let _ = self.background.update(
transition: transition,
component: AnyComponent(FilledRoundedRectangleComponent(
color: UIColor(white: 0.0, alpha: 0.5),
cornerRadius: .minEdge,
smoothCorners: false
)),
environment: {},
containerSize: size
)
let backgroundFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - size.width) * 0.5), y: 0.0), size: size)
if let backgroundView = self.background.view {
if backgroundView.superview == nil {
self.addSubview(backgroundView)
}
transition.setFrame(view: backgroundView, frame: backgroundFrame)
}
let textFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX + insets.left, y: backgroundFrame.minY + floorToScreenPixels((size.height - textSize.height) * 0.5)), size: textSize)
if let textView = self.text.view {
if textView.superview == nil {
textView.layer.anchorPoint = CGPoint()
self.addSubview(textView)
}
transition.setPosition(view: textView, position: textFrame.origin)
textView.bounds = CGRect(origin: CGPoint(), size: textFrame.size)
}
let arrowsFrame = CGRect(origin: CGPoint(x: textFrame.maxX + spacing, y: backgroundFrame.minY + floorToScreenPixels((size.height - arrowsSize.height) * 0.5)), size: arrowsSize)
if let arrowsView = self.arrows.view {
if arrowsView.superview == nil {
self.addSubview(arrowsView)
}
transition.setFrame(view: arrowsView, frame: arrowsFrame)
}
if let displayTooltip = component.displayTooltip {
var tooltipTransition = transition
let tooltipText: ComponentView<Empty>
if let current = self.tooltipText {
tooltipText = current
} else {
tooltipText = ComponentView()
self.tooltipText = tooltipText
tooltipTransition = tooltipTransition.withAnimation(.none)
}
let tooltipAnimation: ComponentView<Empty>
if let current = self.tooltipAnimation {
tooltipAnimation = current
} else {
tooltipAnimation = ComponentView()
self.tooltipAnimation = tooltipAnimation
}
let tooltipTextSize = tooltipText.update(
transition: .immediate,
component: AnyComponent(BalancedTextComponent(
text: .plain(NSAttributedString(string: displayTooltip, font: Font.regular(15.0), textColor: UIColor(white: 1.0, alpha: 0.8))),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 8.0 * 2.0, height: 1000.0)
)
let tooltipTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - tooltipTextSize.width) * 0.5), y: backgroundFrame.maxY + 10.0), size: tooltipTextSize)
if let tooltipTextView = tooltipText.view {
if tooltipTextView.superview == nil {
self.addSubview(tooltipTextView)
}
tooltipTransition.setPosition(view: tooltipTextView, position: tooltipTextFrame.center)
tooltipTextView.bounds = CGRect(origin: CGPoint(), size: tooltipTextFrame.size)
transition.setAlpha(view: tooltipTextView, alpha: self.tooltipIsHidden ? 0.0 : 1.0)
}
let tooltipAnimationSize = tooltipAnimation.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "video_toast_speedup"),
color: .white,
startingPosition: .begin,
loop: false
)),
environment: {},
containerSize: CGSize(width: 60.0, height: 60.0)
)
let tooltipAnimationFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - tooltipAnimationSize.width) * 0.5), y: tooltipTextFrame.maxY + 10.0), size: tooltipAnimationSize)
if let tooltipAnimationView = tooltipAnimation.view {
if tooltipAnimationView.superview == nil {
self.addSubview(tooltipAnimationView)
}
tooltipTransition.setFrame(view: tooltipAnimationView, frame: tooltipAnimationFrame)
transition.setAlpha(view: tooltipAnimationView, alpha: self.tooltipIsHidden ? 0.0 : 0.8)
}
} else {
if let tooltipText = self.tooltipText {
self.tooltipText = nil
if let tooltipTextView = tooltipText.view {
transition.setAlpha(view: tooltipTextView, alpha: 0.0, completion: { [weak tooltipTextView] _ in
tooltipTextView?.removeFromSuperview()
})
}
}
if let tooltipAnimation = self.tooltipAnimation {
self.tooltipAnimation = nil
if let tooltipAnimationView = tooltipAnimation.view {
transition.setAlpha(view: tooltipAnimationView, alpha: 0.0, completion: { [weak tooltipAnimationView] _ in
tooltipAnimationView?.removeFromSuperview()
})
}
}
}
if self.tooltipTimer == nil {
self.tooltipTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false, block: { [weak self] _ in
guard let self else {
return
}
self.tooltipIsHidden = true
self.state?.updated(transition: .easeInOut(duration: 0.25), isLocal: true)
})
}
return CGSize(width: availableSize.width, height: size.height)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,317 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
private let itemBaseSize = CGSize(width: 23.0, height: 42.0)
private let spacing: CGFloat = 2.0
private let maxWidth: CGFloat = 75.0
public protocol GalleryThumbnailItem {
func isEqual(to: GalleryThumbnailItem) -> Bool
func image(synchronous: Bool) -> (Signal<(TransformImageArguments) -> DrawingContext?, NoError>, CGSize)
}
private final class GalleryThumbnailItemNode: ASDisplayNode {
private let imageNode: TransformImageNode
private let imageContainerNode: ASDisplayNode
private let imageSize: CGSize
init(item: GalleryThumbnailItem, synchronous: Bool) {
self.imageNode = TransformImageNode()
self.imageContainerNode = ASDisplayNode()
self.imageContainerNode.clipsToBounds = true
self.imageContainerNode.cornerRadius = 2.0
let (signal, imageSize) = item.image(synchronous: synchronous)
self.imageSize = imageSize
super.init()
self.imageContainerNode.addSubnode(self.imageNode)
self.addSubnode(self.imageContainerNode)
self.imageNode.setSignal(signal, attemptSynchronously: synchronous)
}
func updateLayout(height: CGFloat, progress: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
let boundingSize = self.imageSize.aspectFilled(CGSize(width: 1.0, height: height))
let width = itemBaseSize.width * (1.0 - progress) + min(maxWidth, boundingSize.width) * progress
let arguments = TransformImageArguments(corners: ImageCorners(radius: 0), imageSize: boundingSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())
let makeLayout = self.imageNode.asyncLayout()
let apply = makeLayout(arguments)
apply()
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(x: (width - boundingSize.width) / 2.0, y: 0.0), size: boundingSize))
transition.updateFrame(node: self.imageContainerNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: width, height: height)))
return width
}
}
public final class GalleryThumbnailContainerNode: ASDisplayNode, ASScrollViewDelegate {
public let groupId: Int64
private let scrollNode: ASScrollNode
public private(set) var items: [GalleryThumbnailItem] = []
public private(set) var indexes: [Int] = []
private var itemNodes: [GalleryThumbnailItemNode] = []
private var centralIndexAndProgress: (Int, CGFloat?)?
private var currentLayout: CGSize?
public var updateSynchronously: Bool = false
private var isPanning: Bool = false
public var itemChanged: ((Int) -> Void)?
public init(groupId: Int64) {
self.groupId = groupId
self.scrollNode = ASScrollNode()
super.init()
self.scrollNode.view.delegate = self.wrappedScrollViewDelegate
self.scrollNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
self.scrollNode.view.showsHorizontalScrollIndicator = false
self.scrollNode.view.showsVerticalScrollIndicator = false
self.addSubnode(self.scrollNode)
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: recognizer.view)
for i in 0 ..< self.itemNodes.count {
let view = self.itemNodes[i]
if view.frame.contains(location) {
self.updateCentralIndexAndProgress(centralIndex: i, progress: 0.0, transition: .animated(duration: 0.4, curve: .spring))
self.itemChanged?(i)
break
}
}
}
public func updateItems(_ items: [GalleryThumbnailItem], indexes: [Int], centralIndex: Int, progress: CGFloat) {
self.indexes = indexes
let items: [GalleryThumbnailItem] = items.count <= 1 ? [] : items
var updated = false
if self.items.count == items.count {
for i in 0 ..< self.items.count {
if !self.items[i].isEqual(to: items[i]) {
updated = true
}
}
} else {
updated = true
}
if updated {
var itemNodes: [GalleryThumbnailItemNode] = []
for item in items {
if let index = self.items.firstIndex(where: { $0.isEqual(to: item) }) {
itemNodes.append(self.itemNodes[index])
} else {
itemNodes.append(GalleryThumbnailItemNode(item: item, synchronous: self.updateSynchronously))
}
}
for itemNode in itemNodes {
if itemNode.supernode == nil {
self.scrollNode.addSubnode(itemNode)
}
}
for itemNode in self.itemNodes {
if !itemNodes.contains(where: { $0 === itemNode }) {
itemNode.removeFromSupernode()
}
}
self.items = items
self.itemNodes = itemNodes
}
var updatedIndexOnly = false
if let centralIndexAndProgress = self.centralIndexAndProgress, centralIndexAndProgress.0 != centralIndex, centralIndexAndProgress.1 == progress {
updatedIndexOnly = true
}
self.centralIndexAndProgress = (centralIndex, progress)
if let size = self.currentLayout {
self.updateLayout(size: size, transition: updatedIndexOnly ? .animated(duration: 0.2, curve: .spring) : .immediate)
}
}
public func updateCentralIndexAndProgress(centralIndex: Int, progress: CGFloat, transition: ContainedViewLayoutTransition = .immediate) {
self.centralIndexAndProgress = (centralIndex, progress)
if let size = self.currentLayout {
self.updateLayout(size: size, transition: transition)
}
}
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
self.currentLayout = size
if let (centralIndex, progress) = self.centralIndexAndProgress {
self.updateLayout(size: size, centralIndex: centralIndex, progress: progress, transition: transition)
}
}
private func contentOffsetToCenterItem(index: Int, progress: CGFloat?, contentInset: UIEdgeInsets) -> CGPoint {
let progress = progress ?? 0.0
return CGPoint(x: -contentInset.left + (CGFloat(index) + progress) * (itemBaseSize.width + spacing), y: 0.0)
}
public func updateLayout(size: CGSize, centralIndex: Int, progress: CGFloat?, transition: ContainedViewLayoutTransition) {
self.currentLayout = size
self.scrollNode.frame = CGRect(origin: CGPoint(), size: size)
let centralSpacing: CGFloat = 8.0
let contentInset = UIEdgeInsets(top: 0.0, left: size.width / 2.0, bottom: 0.0, right: 0.0)
let contentSize = CGSize(width: size.width - contentInset.left + (itemBaseSize.width + spacing) * CGFloat(self.itemNodes.count - 1), height : size.height)
var updated = false
if contentInset != self.scrollNode.view.contentInset {
self.scrollNode.view.contentInset = contentInset
updated = true
}
if contentSize != self.scrollNode.view.contentSize {
self.scrollNode.view.contentSize = contentSize
updated = true
}
var progress = progress ?? 0.0
if centralIndex == 0 && progress < 0.0 {
progress = 0.0
} else if centralIndex == self.itemNodes.count - 1 && progress > 0.0 {
progress = 0.0
}
if updated || !self.isPanning {
transition.animateView {
self.scrollNode.view.contentOffset = self.contentOffsetToCenterItem(index: centralIndex, progress: progress, contentInset: contentInset)
}
}
var itemFrames: [CGRect] = []
var lastTrailingSpacing: CGFloat = 0.0
var xOffset: CGFloat = -itemBaseSize.width / 2.0
for i in 0 ..< self.itemNodes.count {
let itemProgress: CGFloat
if i == centralIndex && !self.isPanning {
itemProgress = 1.0 - abs(progress)
} else if i == centralIndex - 1 {
itemProgress = max(0.0, -progress)
} else if i == centralIndex + 1 {
itemProgress = max(0.0, progress)
} else {
itemProgress = 0.0
}
let itemSpacing = itemProgress * centralSpacing + (1.0 - itemProgress) * spacing
let itemWidth = self.itemNodes[i].updateLayout(height: itemBaseSize.height, progress: itemProgress, transition: transition)
if itemWidth > itemBaseSize.width {
xOffset -= (itemWidth - itemBaseSize.width) / 2.0
if itemSpacing > spacing && i > 0 {
xOffset -= (itemSpacing - spacing) / 2.0
}
}
let itemX: CGFloat
if i == 0 {
itemX = 0.0
} else {
itemX = itemFrames[itemFrames.count - 1].maxX + lastTrailingSpacing + itemSpacing * 0.5
}
if i == self.itemNodes.count - 1 {
lastTrailingSpacing = 0.0
} else {
lastTrailingSpacing = itemSpacing * 0.5
}
itemFrames.append(CGRect(origin: CGPoint(x: itemX, y: 0.0), size: CGSize(width: itemWidth, height: itemBaseSize.height)))
}
for i in 0 ..< itemFrames.count {
itemFrames[i].origin.x += xOffset
}
for i in 0 ..< self.itemNodes.count {
transition.updateFrame(node: self.itemNodes[i], frame: itemFrames[i])
}
}
func animateIn(fromLeft: Bool) {
let collection = fromLeft ? self.itemNodes : self.itemNodes.reversed()
let offset: CGFloat = fromLeft ? 15.0 : -15.0
var delay: Double = 0.0
for itemNode in collection {
itemNode.layer.animateScale(from: 0.9, to: 1.0, duration: 0.15 + delay)
itemNode.layer.animatePosition(from: CGPoint(x: offset, y: 0.0), to: CGPoint(), duration: 0.15 + delay, additive: true)
delay += 0.01
}
}
public func animateOut(toRight: Bool) {
let collection = toRight ? self.itemNodes : self.itemNodes.reversed()
let offset: CGFloat = toRight ? -15.0 : 15.0
var delay: Double = 0.0
for itemNode in collection {
itemNode.layer.animateScale(from: 1.0, to: 0.9, duration: 0.15 + delay, removeOnCompletion: false)
itemNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: offset, y: 0.0), duration: 0.15 + delay, removeOnCompletion: false, additive: true)
delay += 0.01
}
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let currentLayout = self.currentLayout else {
return
}
if scrollView.isDragging && !self.isPanning {
if let (currentCentralIndex, _) = self.centralIndexAndProgress {
self.centralIndexAndProgress = (currentCentralIndex, nil)
}
self.isPanning = true
self.updateLayout(size: currentLayout, transition: .animated(duration: 0.4, curve: .spring))
}
if scrollView.isDragging || scrollView.isDecelerating {
let position = scrollView.contentInset.left + scrollView.contentOffset.x
let index = max(0, min(self.items.count - 1, Int(round(position / (itemBaseSize.width + spacing)))))
if let (currentCentralIndex, _) = self.centralIndexAndProgress, currentCentralIndex != index {
self.centralIndexAndProgress = (index, nil)
self.itemChanged?(index)
}
}
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
guard let currentLayout = self.currentLayout else {
return
}
if let _ = self.centralIndexAndProgress {
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
if !decelerate {
self.isPanning = false
self.updateLayout(size: currentLayout, transition: transition)
}
}
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
guard let currentLayout = self.currentLayout, !scrollView.isTracking else {
return
}
if let (centralIndex, progress) = self.centralIndexAndProgress {
let contentOffset = contentOffsetToCenterItem(index: centralIndex, progress: progress, contentInset: self.scrollNode.view.contentInset)
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
if self.isPanning {
self.isPanning = false
self.updateLayout(size: currentLayout, transition: transition)
}
transition.animateView {
self.scrollNode.view.contentOffset = contentOffset
}
}
}
}
@@ -0,0 +1,65 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramStringFormatting
private let titleFont = Font.medium(15.0)
private let dateFont = Font.regular(14.0)
final class GalleryTitleView: UIView, NavigationBarTitleView {
private let authorNameNode: ASTextNode
private let dateNode: ASTextNode
override init(frame: CGRect) {
self.authorNameNode = ASTextNode()
self.authorNameNode.displaysAsynchronously = false
self.authorNameNode.maximumNumberOfLines = 1
self.dateNode = ASTextNode()
self.dateNode.displaysAsynchronously = false
self.dateNode.maximumNumberOfLines = 1
super.init(frame: frame)
self.addSubnode(self.authorNameNode)
self.addSubnode(self.dateNode)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setMessage(_ message: Message, presentationData: PresentationData, accountPeerId: PeerId) {
let authorNameText = stringForFullAuthorName(message: EngineMessage(message), strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, accountPeerId: accountPeerId).joined(separator: "")
let dateText = humanReadableStringForTimestamp(strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, timestamp: message.timestamp).string
self.authorNameNode.attributedText = NSAttributedString(string: authorNameText, font: titleFont, textColor: .white)
self.dateNode.attributedText = NSAttributedString(string: dateText, font: dateFont, textColor: .white)
}
func updateLayout(size: CGSize, clearBounds: CGRect, transition: ContainedViewLayoutTransition) -> CGRect {
let leftInset: CGFloat = 0.0
let rightInset: CGFloat = 0.0
let authorNameSize = self.authorNameNode.measure(CGSize(width: max(1.0, size.width - 8.0 * 2.0 - leftInset - rightInset), height: CGFloat.greatestFiniteMagnitude))
let dateSize = self.dateNode.measure(CGSize(width: max(1.0, size.width - 8.0 * 2.0), height: CGFloat.greatestFiniteMagnitude))
if authorNameSize.height.isZero {
self.dateNode.frame = CGRect(origin: CGPoint(x: floor((size.width - dateSize.width) / 2.0), y: floor((size.height - dateSize.height) / 2.0)), size: dateSize)
} else {
let labelsSpacing: CGFloat = 0.0
self.authorNameNode.frame = CGRect(origin: CGPoint(x: floor((size.width - authorNameSize.width) / 2.0), y: floor((size.height - dateSize.height - authorNameSize.height - labelsSpacing) / 2.0)), size: authorNameSize)
self.dateNode.frame = CGRect(origin: CGPoint(x: floor((size.width - dateSize.width) / 2.0), y: floor((size.height - dateSize.height - authorNameSize.height - labelsSpacing) / 2.0) + authorNameSize.height + labelsSpacing), size: dateSize)
}
return CGRect()
}
func animateLayoutTransition() {
}
}
@@ -0,0 +1,122 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import UniversalMediaPlayer
import AccountContext
import PhotoResources
public final class GalleryVideoDecoration: UniversalVideoDecoration {
public let backgroundNode: ASDisplayNode? = nil
public let contentContainerNode: ASDisplayNode
public let foregroundNode: ASDisplayNode? = nil
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
private var validLayout: (size: CGSize, actualSize: CGSize)?
public init() {
self.contentContainerNode = ASDisplayNode()
}
public func updateContentNode(_ contentNode: (UniversalVideoContentNode & ASDisplayNode)?) {
if self.contentNode !== contentNode {
let previous = self.contentNode
self.contentNode = contentNode
if let previous = previous {
if previous.supernode === self.contentContainerNode {
previous.removeFromSupernode()
}
}
if let contentNode = contentNode {
if contentNode.supernode !== self.contentContainerNode {
self.contentContainerNode.addSubnode(contentNode)
if let validLayout = self.validLayout {
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
}
}
}
}
}
public func updateCorners(_ corners: ImageCorners) {
self.contentContainerNode.clipsToBounds = true
if isRoundEqualCorners(corners) {
self.contentContainerNode.cornerRadius = corners.topLeft.radius
} else {
let boundingSize: CGSize = CGSize(width: max(corners.topLeft.radius, corners.bottomLeft.radius) + max(corners.topRight.radius, corners.bottomRight.radius), height: max(corners.topLeft.radius, corners.topRight.radius) + max(corners.bottomLeft.radius, corners.bottomRight.radius))
let size: CGSize = CGSize(width: boundingSize.width + corners.extendedEdges.left + corners.extendedEdges.right, height: boundingSize.height + corners.extendedEdges.top + corners.extendedEdges.bottom)
let arguments = TransformImageArguments(corners: corners, imageSize: size, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())
guard let context = DrawingContext(size: size, clear: true) else {
return
}
context.withContext { ctx in
ctx.setFillColor(UIColor.black.cgColor)
ctx.fill(arguments.drawingRect)
}
addCorners(context, arguments: arguments)
if let maskImage = context.generateImage() {
let mask = CALayer()
mask.contents = maskImage.cgImage
mask.contentsScale = maskImage.scale
mask.contentsCenter = CGRect(x: max(corners.topLeft.radius, corners.bottomLeft.radius) / maskImage.size.width, y: max(corners.topLeft.radius, corners.topRight.radius) / maskImage.size.height, width: (maskImage.size.width - max(corners.topLeft.radius, corners.bottomLeft.radius) - max(corners.topRight.radius, corners.bottomRight.radius)) / maskImage.size.width, height: (maskImage.size.height - max(corners.topLeft.radius, corners.topRight.radius) - max(corners.bottomLeft.radius, corners.bottomRight.radius)) / maskImage.size.height)
self.contentContainerNode.layer.mask = mask
self.contentContainerNode.layer.mask?.frame = self.contentContainerNode.bounds
}
}
}
public func updateClippingFrame(_ frame: CGRect, completion: (() -> Void)?) {
self.contentContainerNode.layer.animate(from: NSValue(cgRect: self.contentContainerNode.bounds), to: NSValue(cgRect: frame), keyPath: "bounds", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
if let maskLayer = self.contentContainerNode.layer.mask {
maskLayer.animate(from: NSValue(cgRect: self.contentContainerNode.bounds), to: NSValue(cgRect: frame), keyPath: "bounds", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
maskLayer.animate(from: NSValue(cgPoint: maskLayer.position), to: NSValue(cgPoint: frame.center), keyPath: "position", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
}
if let contentNode = self.contentNode {
contentNode.layer.animate(from: NSValue(cgPoint: contentNode.layer.position), to: NSValue(cgPoint: frame.center), keyPath: "position", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
completion?()
})
}
}
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
}
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, actualSize)
let bounds = CGRect(origin: CGPoint(), size: size)
if let backgroundNode = self.backgroundNode {
transition.updateFrame(node: backgroundNode, frame: bounds)
}
if let foregroundNode = self.foregroundNode {
transition.updateFrame(node: foregroundNode, frame: bounds)
}
transition.updateFrame(node: self.contentContainerNode, frame: bounds)
if let maskLayer = self.contentContainerNode.layer.mask {
transition.updateFrame(layer: maskLayer, frame: bounds)
}
if let contentNode = self.contentNode {
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
}
}
public func setStatus(_ status: Signal<MediaPlayerStatus?, NoError>) {
}
public func tap() {
}
}
@@ -0,0 +1,212 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import ComponentFlow
import SemanticStatusNode
import AnimatedTextComponent
public final class AdRemainingProgressComponent: Component {
public let initialTimestamp: Int32
public let minDisplayDuration: Int32
public let maxDisplayDuration: Int32
public let action: (Bool) -> Void
public init(
initialTimestamp: Int32,
minDisplayDuration: Int32,
maxDisplayDuration: Int32,
action: @escaping (Bool) -> Void
) {
self.initialTimestamp = initialTimestamp
self.minDisplayDuration = minDisplayDuration
self.maxDisplayDuration = maxDisplayDuration
self.action = action
}
public static func ==(lhs: AdRemainingProgressComponent, rhs: AdRemainingProgressComponent) -> Bool {
if lhs.initialTimestamp != rhs.initialTimestamp {
return false
}
if lhs.minDisplayDuration != rhs.minDisplayDuration {
return false
}
if lhs.maxDisplayDuration != rhs.maxDisplayDuration {
return false
}
return true
}
public final class View: HighlightTrackingButton {
private var component: AdRemainingProgressComponent?
private weak var componentState: EmptyComponentState?
private let node: SemanticStatusNode
private var textComponent = ComponentView<Empty>()
private var cancelIcon = UIImageView()
private var progress: Double = 1.0
override init(frame: CGRect) {
self.node = SemanticStatusNode(backgroundNodeColor: .clear, foregroundNodeColor: .white)
self.node.isUserInteractionEnabled = false
self.cancelIcon.alpha = 0.0
self.cancelIcon.isUserInteractionEnabled = false
super.init(frame: frame)
self.addSubview(self.node.view)
self.addSubview(self.cancelIcon)
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.highligthedChanged = { [weak self] highlighted in
if let self {
if highlighted {
self.layer.removeAnimation(forKey: "opacity")
self.alpha = 0.7
} else {
self.alpha = 1.0
self.layer.animateAlpha(from: 7, to: 1.0, duration: 0.2)
}
}
}
}
required init(coder: NSCoder) {
preconditionFailure()
}
@objc private func pressed() {
guard let component = self.component else {
return
}
component.action(self.progress < .ulpOfOne)
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
var timer: SwiftSignalKit.Timer?
func update(component: AdRemainingProgressComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
if self.component == nil {
self.timer = SwiftSignalKit.Timer(timeout: 0.25, repeat: true, completion: { [weak self] progress in
guard let self else {
return
}
self.componentState?.updated(transition: .easeInOut(duration: 0.2))
}, queue: Queue.mainQueue())
self.timer?.start()
}
self.component = component
self.componentState = state
let size = CGSize(width: 24.0, height: 24.0)
let color = UIColor(rgb: 0x64d2ff)
if self.cancelIcon.image == nil {
self.cancelIcon.image = generateCancelIcon(color: color)
self.node.foregroundNodeColor = color
}
var progress = 0.0
let currentTimestamp = CFAbsoluteTimeGetCurrent()
let minTimestamp = Double(component.initialTimestamp + component.minDisplayDuration)
let initialTimestamp = Double(component.initialTimestamp)
let remaining = min(9, max(1, minTimestamp - currentTimestamp))
var textIsHidden = false
if currentTimestamp >= initialTimestamp && currentTimestamp <= minTimestamp {
progress = (minTimestamp - currentTimestamp) / (minTimestamp - initialTimestamp)
} else {
progress = 0
textIsHidden = true
}
self.progress = progress
let textSize = self.textComponent.update(
transition: transition,
component: AnyComponent(
AnimatedTextComponent(
font: Font.regular(14.0),
color: color,
items: [AnimatedTextComponent.Item(id: 0, content: .number(Int(remaining), minDigits: 1))]
)
),
environment: {},
containerSize: size
)
let iconTransition = ComponentTransition(animation: .curve(duration: 0.25, curve: .spring))
if let textView = self.textComponent.view {
if textView.superview == nil {
textView.isUserInteractionEnabled = false
self.addSubview(textView)
}
textView.frame = CGRect(origin: CGPoint(x: (size.width - textSize.width) / 2.0, y: (size.height - textSize.height) / 2.0), size: textSize)
iconTransition.setAlpha(view: textView, alpha: textIsHidden ? 0.0 : 1.0)
iconTransition.setAlpha(view: self.cancelIcon, alpha: textIsHidden ? 1.0 : 0.0)
}
if let icon = self.cancelIcon.image {
self.cancelIcon.bounds = CGRect(origin: .zero, size: icon.size)
var iconScale = 0.7
var iconAlpha = 1.0
var iconPosition = CGPoint(x: size.width / 2.0 + 10.0, y: size.height / 2.0 - 10.0)
if progress > 0.8 {
iconScale = 0.01
iconAlpha = 0.0
} else if progress < .ulpOfOne {
iconScale = 1.0
iconPosition = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
}
iconTransition.setAlpha(view: self.cancelIcon, alpha: iconAlpha)
iconTransition.setScale(view: self.cancelIcon, scale: iconScale)
iconTransition.setPosition(view: self.cancelIcon, position: iconPosition)
}
self.node.frame = CGRect(origin: .zero, size: size)
self.node.transitionToState(.progress(value: max(0.0, min(1.0, progress)), cancelEnabled: false, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 1.0, lineWidth: 1.0 + UIScreenPixel), animateRotation: false), updateCutout: false)
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private func generateCancelIcon(color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 12.0, height: 12.0), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let lineWidth = 2.0 - UIScreenPixel
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.setStrokeColor(color.cgColor)
context.move(to: CGPoint(x: lineWidth / 2.0, y: lineWidth / 2.0))
context.addLine(to: CGPoint(x: size.width - lineWidth / 2.0, y: size.height - lineWidth / 2.0))
context.strokePath()
context.move(to: CGPoint(x: size.width - lineWidth / 2.0, y: lineWidth / 2.0))
context.addLine(to: CGPoint(x: lineWidth / 2.0, y: size.height - lineWidth / 2.0))
context.strokePath()
})
}
@@ -0,0 +1,353 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import Lottie
import TelegramPresentationData
import AnimationUI
import AccountContext
import RadialStatusNode
import StickerResources
import AppBundle
class ChatAnimationGalleryItem: GalleryItem {
var id: AnyHashable {
return self.message.stableId
}
let context: AccountContext
let presentationData: PresentationData
let message: Message
let location: MessageHistoryEntryLocation?
init(context: AccountContext, presentationData: PresentationData, message: Message, location: MessageHistoryEntryLocation?) {
self.context = context
self.presentationData = presentationData
self.message = message
self.location = location
}
func node(synchronous: Bool) -> GalleryItemNode {
let node = ChatAnimationGalleryItemNode(context: self.context, presentationData: self.presentationData)
for media in self.message.media {
if let file = media as? TelegramMediaFile {
node.setFile(context: self.context, fileReference: .message(message: MessageReference(self.message), media: file))
break
}
}
node.setMessage(self.message)
return node
}
func updateNode(node: GalleryItemNode, synchronous: Bool) {
if let node = node as? ChatAnimationGalleryItemNode {
node.setMessage(self.message)
}
}
func thumbnailItem() -> (Int64, GalleryThumbnailItem)? {
return nil
}
}
private var backgroundButtonIcon: UIImage = {
return generateImage(CGSize(width: 20.0, height: 20.0), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setLineWidth(1.0)
context.setStrokeColor(UIColor.white.cgColor)
context.setFillColor(UIColor.white.cgColor)
context.strokeEllipse(in: bounds.insetBy(dx: 0.5, dy: 0.5))
context.addEllipse(in: bounds.insetBy(dx: 0.5, dy: 0.5))
context.clip()
context.fill(CGRect(x: 0.0, y: 0.0, width: 10.0, height: 20.0))
})!
}()
final class ChatAnimationGalleryItemNode: ZoomableContentGalleryItemNode {
private let context: AccountContext
private var presentationData: PresentationData
private var message: Message?
fileprivate let _title = Promise<String>()
fileprivate let _rightBarButtonItems = Promise<[UIBarButtonItem]?>()
private let containerNode: ASDisplayNode
private let animationNode: AnimationNode
private let statusNodeContainer: HighlightableButtonNode
private let statusNode: RadialStatusNode
private let footerContentNode: ChatItemGalleryFooterContentNode
private var contextAndMedia: (AccountContext, AnyMediaReference)?
private var disposable = MetaDisposable()
private var fetchDisposable = MetaDisposable()
private let statusDisposable = MetaDisposable()
private var status: MediaResourceStatus?
init(context: AccountContext, presentationData: PresentationData) {
self.context = context
self.presentationData = presentationData
self.containerNode = ASDisplayNode()
self.containerNode.backgroundColor = .black
self.animationNode = AnimationNode()
self.containerNode.addSubnode(self.animationNode)
self.footerContentNode = ChatItemGalleryFooterContentNode(context: context, presentationData: presentationData)
self.statusNodeContainer = HighlightableButtonNode()
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5))
self.statusNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 50.0, height: 50.0))
self.statusNode.isHidden = true
super.init()
self.statusNodeContainer.addSubnode(self.statusNode)
self.addSubnode(self.statusNodeContainer)
self.statusNodeContainer.addTarget(self, action: #selector(self.statusPressed), forControlEvents: .touchUpInside)
self.statusNodeContainer.isUserInteractionEnabled = false
}
deinit {
self.disposable.dispose()
self.fetchDisposable.dispose()
self.statusDisposable.dispose()
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
let statusSize = CGSize(width: 50.0, height: 50.0)
transition.updateFrame(node: self.statusNodeContainer, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - statusSize.width) / 2.0), y: floor((layout.size.height - statusSize.height) / 2.0)), size: statusSize))
transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(), size: statusSize))
}
fileprivate func setMessage(_ message: Message) {
self.footerContentNode.setMessage(message)
}
func setFile(context: AccountContext, fileReference: FileMediaReference) {
if self.contextAndMedia == nil || !self.contextAndMedia!.1.media.isEqual(to: fileReference.media) {
let signal = chatMessageAnimatedStickerBackingData(postbox: context.account.postbox, fileReference: fileReference, synchronousLoad: false)
|> mapToSignal { value -> Signal<Data, NoError> in
if value._1, let data = value._0 {
return .single(data)
} else {
return .complete()
}
}
self.disposable.set((signal |> deliverOnMainQueue).start(next: { [weak self] next in
guard let strongSelf = self else {
return
}
if let json = try? JSONSerialization.jsonObject(with: next, options: []) as? [String: Any] {
let containerSize = CGSize(width: 640.0, height: 640.0)
strongSelf.animationNode.setAnimation(json: json)
strongSelf.zoomableContent = (containerSize, strongSelf.containerNode)
if let animationSize = strongSelf.animationNode.preferredSize() {
let size = animationSize.fitted(containerSize)
strongSelf.animationNode.frame = CGRect(origin: CGPoint(x: floor((containerSize.width - size.width) / 2.0), y: floor((containerSize.height - size.height) / 2.0)), size: size)
}
strongSelf.animationNode.loop()
}
}))
self.setupStatus(resource: fileReference.media.resource)
self._title.set(.single("\(fileReference.media.fileName ?? "") - \(dataSizeString(fileReference.media.size ?? 0, forceDecimal: false, formatting: DataSizeStringFormatting(presentationData: self.presentationData)))"))
self._rightBarButtonItems.set(.single([]))
}
self.contextAndMedia = (context, fileReference.abstract)
}
@objc private func toggleSpeedButtonPressed() {
if self.animationNode.speed == 1.0 {
self.animationNode.speed = 0.1
} else {
self.animationNode.speed = 1.0
}
}
@objc private func toggleBackgroundButtonPressed() {
let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut)
if self.containerNode.backgroundColor == .white {
transition.updateBackgroundColor(node: self.containerNode, color: .black)
} else {
transition.updateBackgroundColor(node: self.containerNode, color: .white)
}
}
private func setupStatus(resource: MediaResource) {
self.statusDisposable.set((self.context.account.postbox.mediaBox.resourceStatus(resource)
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self {
let previousStatus = strongSelf.status
strongSelf.status = status
switch status {
case .Remote, .Paused:
strongSelf.statusNode.isHidden = false
strongSelf.statusNode.alpha = 1.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = true
strongSelf.statusNode.transitionToState(.download(.white), completion: {})
case let .Fetching(_, progress):
strongSelf.statusNode.isHidden = false
strongSelf.statusNode.alpha = 1.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = true
let adjustedProgress = max(progress, 0.027)
strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true), completion: {})
case .Local:
if let previousStatus = previousStatus, case .Fetching = previousStatus {
strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: 1.0, cancelEnabled: true, animateRotation: true), completion: {
if let strongSelf = self {
strongSelf.statusNode.alpha = 0.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = false
strongSelf.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { _ in
if let strongSelf = self {
strongSelf.statusNode.transitionToState(.none, animated: false, completion: {})
}
})
}
})
} else if !strongSelf.statusNode.isHidden && !strongSelf.statusNode.alpha.isZero {
strongSelf.statusNode.alpha = 0.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = false
strongSelf.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { _ in
if let strongSelf = self {
strongSelf.statusNode.transitionToState(.none, animated: false, completion: {})
}
})
}
}
}
}))
}
override func animateIn(from node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.containerNode.view)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.containerNode.view.superview)
self.containerNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.containerNode.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
transformedFrame.origin = CGPoint()
let transform = CATransform3DScale(self.containerNode.layer.transform, transformedFrame.size.width / self.containerNode.layer.bounds.size.width, transformedFrame.size.height / self.containerNode.layer.bounds.size.height, 1.0)
self.containerNode.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: self.containerNode.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25)
self.statusNodeContainer.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.statusNodeContainer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateScale(from: 0.5, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
}
override func animateOut(to node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.containerNode.view)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.containerNode.view.superview)
let transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
let transformedCopyViewInitialFrame = self.containerNode.view.convert(self.containerNode.view.bounds, to: self.view)
var positionCompleted = false
var boundsCompleted = false
var copyCompleted = false
let (maybeCopyView, copyViewBackgrond) = node.2()
copyViewBackgrond?.alpha = 0.0
let copyView = maybeCopyView!
self.view.insertSubview(copyView, belowSubview: self.containerNode.view)
copyView.frame = transformedSelfFrame
let intermediateCompletion = { [weak copyView] in
if positionCompleted && boundsCompleted && copyCompleted {
copyView?.removeFromSuperview()
completion()
}
}
copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, removeOnCompletion: false)
copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height)
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
copyCompleted = true
intermediateCompletion()
})
self.containerNode.layer.animatePosition(from: self.containerNode.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
positionCompleted = true
intermediateCompletion()
})
self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
transformedFrame.origin = CGPoint()
let transform = CATransform3DScale(self.containerNode.layer.transform, transformedFrame.size.width / self.containerNode.layer.bounds.size.width, transformedFrame.size.height / self.containerNode.layer.bounds.size.height, 1.0)
self.containerNode.layer.animate(from: NSValue(caTransform3D: self.containerNode.layer.transform), to: NSValue(caTransform3D: transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
boundsCompleted = true
intermediateCompletion()
})
self.statusNodeContainer.layer.animatePosition(from: self.statusNodeContainer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.statusNodeContainer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, removeOnCompletion: false)
}
override func visibilityUpdated(isVisible: Bool) {
super.visibilityUpdated(isVisible: isVisible)
if let (_, mediaReference) = self.contextAndMedia, let _ = mediaReference.concrete(TelegramMediaFile.self) {
if isVisible {
} else {
self.fetchDisposable.set(nil)
}
}
}
override func title() -> Signal<String, NoError> {
return self._title.get()
}
override func rightBarButtonItems() -> Signal<[UIBarButtonItem]?, NoError> {
return self._rightBarButtonItems.get()
}
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
@objc func statusPressed() {
if let (_, mediaReference) = self.contextAndMedia, let status = self.status {
var resource: MediaResourceReference?
var statsCategory: MediaResourceStatsCategory?
if let fileReference = mediaReference.concrete(TelegramMediaFile.self) {
resource = fileReference.resourceReference(fileReference.media.resource)
statsCategory = statsCategoryForFileWithAttributes(fileReference.media.attributes)
}
if let resource = resource {
switch status {
case .Fetching:
self.context.account.postbox.mediaBox.cancelInteractiveResourceFetch(resource.resource)
case .Remote:
self.fetchDisposable.set(fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: (self.message?.id.peerId).flatMap(MediaResourceUserLocation.peer) ?? .other, userContentType: .file, reference: resource, statsCategory: statsCategory ?? .generic).start())
default:
break
}
}
}
}
}
@@ -0,0 +1,397 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Postbox
import Display
import SwiftSignalKit
import WebKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import RadialStatusNode
class ChatDocumentGalleryItem: GalleryItem {
var id: AnyHashable {
return self.message.stableId
}
let context: AccountContext
let presentationData: PresentationData
let message: Message
let location: MessageHistoryEntryLocation?
init(context: AccountContext, presentationData: PresentationData, message: Message, location: MessageHistoryEntryLocation?) {
self.context = context
self.presentationData = presentationData
self.message = message
self.location = location
}
func node(synchronous: Bool) -> GalleryItemNode {
let node = ChatDocumentGalleryItemNode(context: self.context, presentationData: self.presentationData)
for media in self.message.media {
if let file = media as? TelegramMediaFile {
node.setFile(context: context, fileReference: .message(message: MessageReference(self.message), media: file))
break
} else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
if let file = content.file {
node.setFile(context: context, fileReference: .message(message: MessageReference(self.message), media: file))
break
}
}
}
if let location = self.location {
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(location.index + 1)", "\(location.count)").string))
}
node.setMessage(self.message)
return node
}
func updateNode(node: GalleryItemNode, synchronous: Bool) {
if let node = node as? ChatDocumentGalleryItemNode, let location = self.location {
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(location.index + 1)", "\(location.count)").string))
node.setMessage(self.message)
}
}
func thumbnailItem() -> (Int64, GalleryThumbnailItem)? {
return nil
}
}
private let registeredURLProtocol: Void = {
URLProtocol.registerClass(ChatDocumentURLProtocol.self)
}()
private final class ChatDocumentURLProtocol: URLProtocol {
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override class func canInit(with request: URLRequest) -> Bool {
if let mainDocumentURL = request.mainDocumentURL {
if mainDocumentURL.scheme == "file" && request.url != mainDocumentURL {
return true
}
}
return false
}
override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool {
return super.requestIsCacheEquivalent(a, to: b)
}
override func startLoading() {
}
override func stopLoading() {
}
}
class ChatDocumentGalleryItemNode: ZoomableContentGalleryItemNode, WKNavigationDelegate {
fileprivate let _title = Promise<String>()
private let statusNodeContainer: HighlightableButtonNode
private let statusNode: RadialStatusNode
private let webView: UIView
private var contextAndFile: (AccountContext, FileMediaReference)?
private let dataDisposable = MetaDisposable()
private var itemIsVisible = false
private var message: Message?
private let footerContentNode: ChatItemGalleryFooterContentNode
private var fetchDisposable = MetaDisposable()
private let statusDisposable = MetaDisposable()
private var status: MediaResourceStatus?
init(context: AccountContext, presentationData: PresentationData) {
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
let preferences = WKPreferences()
preferences.javaScriptEnabled = false
let configuration = WKWebViewConfiguration()
configuration.preferences = preferences
let webView = WKWebView(frame: CGRect(), configuration: configuration)
webView.allowsLinkPreview = false
webView.allowsBackForwardNavigationGestures = false
self.webView = webView
} else {
let _ = registeredURLProtocol
let webView = UIWebView()
webView.scalesPageToFit = true
self.webView = webView
}
self.footerContentNode = ChatItemGalleryFooterContentNode(context: context, presentationData: presentationData)
self.statusNodeContainer = HighlightableButtonNode()
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5))
self.statusNode.isHidden = true
super.init()
self.view.insertSubview(self.webView, belowSubview: self.scrollNode.view)
self.statusNodeContainer.addSubnode(self.statusNode)
self.addSubnode(self.statusNodeContainer)
self.statusNodeContainer.addTarget(self, action: #selector(self.statusPressed), forControlEvents: .touchUpInside)
self.statusNodeContainer.isUserInteractionEnabled = false
}
deinit {
self.dataDisposable.dispose()
self.fetchDisposable.dispose()
self.statusDisposable.dispose()
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
self.webView.frame = CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight - 44.0 - layout.insets(options: []).bottom))
let statusSize = CGSize(width: 50.0, height: 50.0)
transition.updateFrame(node: self.statusNodeContainer, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - statusSize.width) / 2.0), y: floor((layout.size.height - statusSize.height) / 2.0)), size: statusSize))
transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(), size: statusSize))
}
fileprivate func setMessage(_ message: Message) {
self.footerContentNode.setMessage(message)
}
override func navigationStyle() -> Signal<GalleryItemNodeNavigationStyle, NoError> {
return .single(.dark)
}
func setFile(context: AccountContext, fileReference: FileMediaReference) {
let updateFile = self.contextAndFile?.1.media != fileReference.media
self.contextAndFile = (context, fileReference)
if updateFile {
if fileReference.media.mimeType.hasPrefix("image/") {
if let webView = self.webView as? WKWebView {
webView.backgroundColor = .black
}
}
self.maybeLoadContent()
self.setupStatus(context: context, resource: fileReference.media.resource)
self.fetchDisposable.set(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: (self.message?.id.peerId).flatMap(MediaResourceUserLocation.peer) ?? .other, userContentType: .file, reference: fileReference.resourceReference(fileReference.media.resource)).start())
}
}
private func setupStatus(context: AccountContext, resource: MediaResource) {
self.statusDisposable.set((context.account.postbox.mediaBox.resourceStatus(resource)
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self {
let previousStatus = strongSelf.status
strongSelf.status = status
switch status {
case .Remote, .Paused:
strongSelf.statusNode.isHidden = false
strongSelf.statusNode.alpha = 1.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = true
strongSelf.statusNode.transitionToState(.download(.white), completion: {})
case let .Fetching(_, progress):
strongSelf.statusNode.isHidden = false
strongSelf.statusNode.alpha = 1.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = true
let adjustedProgress = max(progress, 0.027)
strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true), completion: {})
case .Local:
if let previousStatus = previousStatus, case .Fetching = previousStatus {
strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: 1.0, cancelEnabled: true, animateRotation: true), completion: {
if let strongSelf = self {
strongSelf.statusNode.alpha = 0.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = false
strongSelf.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { _ in
if let strongSelf = self {
strongSelf.statusNode.transitionToState(.none, animated: false, completion: {})
}
})
}
})
} else if !strongSelf.statusNode.isHidden && !strongSelf.statusNode.alpha.isZero {
strongSelf.statusNode.alpha = 0.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = false
strongSelf.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { _ in
if let strongSelf = self {
strongSelf.statusNode.transitionToState(.none, animated: false, completion: {})
}
})
}
}
}
}))
}
private func maybeLoadContent() {
if let (context, fileReference) = self.contextAndFile {
var pathExtension: String?
if let fileName = fileReference.media.fileName {
pathExtension = (fileName as NSString).pathExtension
}
let data = context.account.postbox.mediaBox.resourceData(fileReference.media.resource, pathExtension: pathExtension, option: .complete(waitUntilFetchStatus: false))
|> deliverOnMainQueue
self.dataDisposable.set(data.start(next: { [weak self] data in
if let strongSelf = self {
if data.complete {
if let webView = strongSelf.webView as? WKWebView {
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
let blockRules = """
[{
"trigger": {
"url-filter": ".*"
},
"action": {
"type": "block"
}
},
{
"trigger": {
"url-filter": "file://\(data.path)"
},
"action": {
"type": "ignore-previous-rules"
}
}]
"""
WKContentRuleListStore.default().compileContentRuleList(
forIdentifier: "ContentBlockingRules",
encodedContentRuleList: blockRules) { [weak webView] contentRuleList, error in
guard let webView = webView, let contentRuleList = contentRuleList else {
return
}
if let _ = error {
return
}
let configuration = webView.configuration
configuration.userContentController.add(contentRuleList)
webView.loadFileURL(URL(fileURLWithPath: data.path), allowingReadAccessTo: URL(fileURLWithPath: data.path))
}
}
}
}
}
}))
}
}
/*private func unloadContent() {
self.dataDisposable.set(nil)
self.webView.stopLoading()
self.webView.loadHTMLString("<html></html>", baseURL: nil)
}*/
override func visibilityUpdated(isVisible: Bool) {
super.visibilityUpdated(isVisible: isVisible)
if self.itemIsVisible != isVisible {
self.itemIsVisible = isVisible
if isVisible {
} else {
self.fetchDisposable.set(nil)
}
}
}
override func title() -> Signal<String, NoError> {
return self._title.get()
}
override func animateIn(from node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.webView)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.webView.superview)
self.webView.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.webView.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
transformedFrame.origin = CGPoint()
let transform = CATransform3DScale(self.webView.layer.transform, transformedFrame.size.width / self.webView.layer.bounds.size.width, transformedFrame.size.height / self.webView.layer.bounds.size.height, 1.0)
self.webView.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: self.webView.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25)
self.statusNodeContainer.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.statusNodeContainer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateScale(from: 0.5, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
}
override func animateOut(to node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.webView)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.webView.superview)
let transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
let transformedCopyViewInitialFrame = self.webView.convert(self.webView.bounds, to: self.view)
var positionCompleted = false
var boundsCompleted = false
var copyCompleted = false
let (maybeCopyView, copyViewBackgrond) = node.2()
copyViewBackgrond?.alpha = 0.0
let copyView = maybeCopyView!
self.view.insertSubview(copyView, belowSubview: self.webView)
copyView.frame = transformedSelfFrame
let intermediateCompletion = { [weak copyView] in
if positionCompleted && boundsCompleted && copyCompleted {
copyView?.removeFromSuperview()
completion()
}
}
copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, removeOnCompletion: false)
copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height)
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
copyCompleted = true
intermediateCompletion()
})
self.webView.layer.animatePosition(from: self.webView.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
positionCompleted = true
intermediateCompletion()
})
self.webView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
transformedFrame.origin = CGPoint()
let transform = CATransform3DScale(self.webView.layer.transform, transformedFrame.size.width / self.webView.layer.bounds.size.width, transformedFrame.size.height / self.webView.layer.bounds.size.height, 1.0)
self.webView.layer.animate(from: NSValue(caTransform3D: self.webView.layer.transform), to: NSValue(caTransform3D: transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
boundsCompleted = true
intermediateCompletion()
})
self.statusNodeContainer.layer.animatePosition(from: self.statusNodeContainer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.statusNodeContainer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, removeOnCompletion: false)
}
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
@objc func statusPressed() {
if let (context, fileReference) = self.contextAndFile, let status = self.status {
switch status {
case .Fetching:
context.account.postbox.mediaBox.cancelInteractiveResourceFetch(fileReference.media.resource)
case .Remote:
self.fetchDisposable.set(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: (self.message?.id.peerId).flatMap(MediaResourceUserLocation.peer) ?? .other, userContentType: .file, reference: fileReference.resourceReference(fileReference.media.resource)).start())
default:
break
}
}
}
}
@@ -0,0 +1,339 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Postbox
import Display
import SwiftSignalKit
import WebKit
import TelegramCore
import TelegramPresentationData
import AccountContext
import RadialStatusNode
import ShareController
class ChatExternalFileGalleryItem: GalleryItem {
var id: AnyHashable {
return self.message.stableId
}
let context: AccountContext
let presentationData: PresentationData
let message: Message
let location: MessageHistoryEntryLocation?
init(context: AccountContext, presentationData: PresentationData, message: Message, location: MessageHistoryEntryLocation?) {
self.context = context
self.presentationData = presentationData
self.message = message
self.location = location
}
func node(synchronous: Bool) -> GalleryItemNode {
let node = ChatExternalFileGalleryItemNode(context: self.context, presentationData: self.presentationData)
for media in self.message.media {
if let file = media as? TelegramMediaFile {
node.setFile(context: context, fileReference: .message(message: MessageReference(self.message), media: file))
break
} else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
if let file = content.file {
node.setFile(context: context, fileReference: .message(message: MessageReference(self.message), media: file))
break
}
}
}
if let location = self.location {
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(location.index + 1)", "\(location.count)").string))
}
node.setMessage(self.message)
return node
}
func updateNode(node: GalleryItemNode, synchronous: Bool) {
if let node = node as? ChatExternalFileGalleryItemNode, let location = self.location {
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(location.index + 1)", "\(location.count)").string))
node.setMessage(self.message)
}
}
func thumbnailItem() -> (Int64, GalleryThumbnailItem)? {
return nil
}
}
class ChatExternalFileGalleryItemNode: GalleryItemNode {
fileprivate let _title = Promise<String>()
private let statusNodeContainer: HighlightableButtonNode
private let statusNode: RadialStatusNode
private let containerNode: ASDisplayNode
private let fileNameNode: ImmediateTextNode
private let actionTitleNode: ImmediateTextNode
private let actionButtonNode: HighlightableButtonNode
private var contextAndFile: (AccountContext, FileMediaReference)?
private let dataDisposable = MetaDisposable()
private var itemIsVisible = false
private var message: Message?
private let footerContentNode: ChatItemGalleryFooterContentNode
private var fetchDisposable = MetaDisposable()
private let statusDisposable = MetaDisposable()
private var status: MediaResourceStatus?
init(context: AccountContext, presentationData: PresentationData) {
self.containerNode = ASDisplayNode()
self.containerNode.backgroundColor = .white
self.fileNameNode = ImmediateTextNode()
self.containerNode.addSubnode(self.fileNameNode)
self.actionTitleNode = ImmediateTextNode()
self.actionTitleNode.attributedText = NSAttributedString(string: presentationData.strings.Conversation_LinkDialogOpen, font: Font.regular(17.0), textColor: presentationData.theme.list.itemAccentColor)
self.containerNode.addSubnode(self.actionTitleNode)
self.actionButtonNode = HighlightableButtonNode()
self.containerNode.addSubnode(self.actionButtonNode)
self.footerContentNode = ChatItemGalleryFooterContentNode(context: context, presentationData: presentationData)
self.statusNodeContainer = HighlightableButtonNode()
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5))
self.statusNode.isHidden = true
super.init()
self.addSubnode(self.containerNode)
self.statusNodeContainer.addSubnode(self.statusNode)
self.addSubnode(self.statusNodeContainer)
self.statusNodeContainer.addTarget(self, action: #selector(self.statusPressed), forControlEvents: .touchUpInside)
self.statusNodeContainer.isUserInteractionEnabled = false
self.actionButtonNode.addTarget(self, action: #selector(self.actionButtonPressed), forControlEvents: .touchUpInside)
self.actionButtonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.actionTitleNode.layer.removeAnimation(forKey: "opacity")
strongSelf.actionTitleNode.alpha = 0.4
} else {
strongSelf.actionTitleNode.alpha = 1.0
strongSelf.actionTitleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
}
deinit {
self.dataDisposable.dispose()
self.fetchDisposable.dispose()
self.statusDisposable.dispose()
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight - 44.0 - layout.insets(options: []).bottom))
self.containerNode.frame = containerFrame
let fileNameSize = self.fileNameNode.updateLayout(containerFrame.insetBy(dx: 10.0, dy: 0.0).size)
let actionTitleSize = self.actionTitleNode.updateLayout(containerFrame.insetBy(dx: 10.0, dy: 0.0).size)
let spacing: CGFloat = 4.0
let contentHeight: CGFloat = fileNameSize.height + spacing + actionTitleSize.height
let contentOrigin = floor((containerFrame.size.height - contentHeight) / 2.0)
let fileNameFrame = CGRect(origin: CGPoint(x: floor((containerFrame.width - fileNameSize.width) / 2.0), y: contentOrigin), size: fileNameSize)
transition.updateFrame(node: self.fileNameNode, frame: fileNameFrame)
let actionTitleFrame = CGRect(origin: CGPoint(x: floor((containerFrame.width - actionTitleSize.width) / 2.0), y: fileNameFrame.maxY + spacing), size: actionTitleSize)
transition.updateFrame(node: self.actionTitleNode, frame: actionTitleFrame)
transition.updateFrame(node: self.actionButtonNode, frame: actionTitleFrame.insetBy(dx: -8.0, dy: -8.0))
let statusSize = CGSize(width: 50.0, height: 50.0)
transition.updateFrame(node: self.statusNodeContainer, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - statusSize.width) / 2.0), y: floor((layout.size.height - statusSize.height) / 2.0)), size: statusSize))
transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(), size: statusSize))
}
fileprivate func setMessage(_ message: Message) {
self.message = message
self.footerContentNode.setMessage(message)
}
override func navigationStyle() -> Signal<GalleryItemNodeNavigationStyle, NoError> {
return .single(.dark)
}
func setFile(context: AccountContext, fileReference: FileMediaReference) {
let updateFile = self.contextAndFile?.1.media != fileReference.media
self.contextAndFile = (context, fileReference)
if updateFile {
self.fileNameNode.attributedText = NSAttributedString(string: fileReference.media.fileName ?? " ", font: Font.regular(17.0), textColor: .black)
self.setupStatus(context: context, resource: fileReference.media.resource)
}
}
private func setupStatus(context: AccountContext, resource: MediaResource) {
self.statusDisposable.set((context.account.postbox.mediaBox.resourceStatus(resource)
|> deliverOnMainQueue).start(next: { [weak self] status in
if let strongSelf = self {
let previousStatus = strongSelf.status
strongSelf.status = status
switch status {
case .Remote, .Paused:
strongSelf.statusNode.isHidden = false
strongSelf.statusNode.alpha = 1.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = true
strongSelf.statusNode.transitionToState(.download(.white), completion: {})
case let .Fetching(_, progress):
strongSelf.statusNode.isHidden = false
strongSelf.statusNode.alpha = 1.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = true
let adjustedProgress = max(progress, 0.027)
strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true), completion: {})
case .Local:
if let previousStatus = previousStatus, case .Fetching = previousStatus {
strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: 1.0, cancelEnabled: true, animateRotation: true), completion: {
if let strongSelf = self {
strongSelf.statusNode.alpha = 0.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = false
strongSelf.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { _ in
if let strongSelf = self {
strongSelf.statusNode.transitionToState(.none, animated: false, completion: {})
}
})
}
})
} else if !strongSelf.statusNode.isHidden && !strongSelf.statusNode.alpha.isZero {
strongSelf.statusNode.alpha = 0.0
strongSelf.statusNodeContainer.isUserInteractionEnabled = false
strongSelf.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { _ in
if let strongSelf = self {
strongSelf.statusNode.transitionToState(.none, animated: false, completion: {})
}
})
}
}
}
}))
}
override func visibilityUpdated(isVisible: Bool) {
super.visibilityUpdated(isVisible: isVisible)
if self.itemIsVisible != isVisible {
self.itemIsVisible = isVisible
if isVisible {
} else {
self.fetchDisposable.set(nil)
}
}
}
override func title() -> Signal<String, NoError> {
return self._title.get()
}
override func animateIn(from node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.containerNode.view)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.containerNode.view.superview)
self.containerNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.containerNode.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
transformedFrame.origin = CGPoint()
let transform = CATransform3DScale(self.containerNode.layer.transform, transformedFrame.size.width / self.containerNode.layer.bounds.size.width, transformedFrame.size.height / self.containerNode.layer.bounds.size.height, 1.0)
self.containerNode.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: self.containerNode.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25)
self.statusNodeContainer.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.statusNodeContainer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
self.statusNodeContainer.layer.animateScale(from: 0.5, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
}
override func animateOut(to node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: self.containerNode.view)
let transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: self.containerNode.view.superview)
let transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
let transformedCopyViewInitialFrame = self.containerNode.view.convert(self.containerNode.view.bounds, to: self.view)
var positionCompleted = false
var boundsCompleted = false
var copyCompleted = false
let (maybeCopyView, copyViewBackgrond) = node.2()
copyViewBackgrond?.alpha = 0.0
let copyView = maybeCopyView!
self.view.insertSubview(copyView, belowSubview: self.containerNode.view)
copyView.frame = transformedSelfFrame
let intermediateCompletion = { [weak copyView] in
if positionCompleted && boundsCompleted && copyCompleted {
copyView?.removeFromSuperview()
completion()
}
}
copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, removeOnCompletion: false)
copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height)
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
copyCompleted = true
intermediateCompletion()
})
self.containerNode.layer.animatePosition(from: self.containerNode.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
positionCompleted = true
intermediateCompletion()
})
self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
transformedFrame.origin = CGPoint()
let transform = CATransform3DScale(self.containerNode.layer.transform, transformedFrame.size.width / self.containerNode.layer.bounds.size.width, transformedFrame.size.height / self.containerNode.layer.bounds.size.height, 1.0)
self.containerNode.layer.animate(from: NSValue(caTransform3D: self.containerNode.layer.transform), to: NSValue(caTransform3D: transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
boundsCompleted = true
intermediateCompletion()
})
self.statusNodeContainer.layer.animatePosition(from: self.statusNodeContainer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.statusNodeContainer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, removeOnCompletion: false)
}
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
@objc func statusPressed() {
if let (context, fileReference) = self.contextAndFile, let status = self.status {
switch status {
case .Fetching:
context.account.postbox.mediaBox.cancelInteractiveResourceFetch(fileReference.media.resource)
case .Remote:
self.fetchDisposable.set(fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: (self.message?.id.peerId).flatMap(MediaResourceUserLocation.peer) ?? .other, userContentType: .file, reference: fileReference.resourceReference(fileReference.media.resource)).start())
default:
break
}
}
}
@objc func actionButtonPressed() {
if let (context, _) = self.contextAndFile, let message = self.message, let status = self.status, case .Local = status {
let baseNavigationController = self.baseNavigationController()
(baseNavigationController?.topViewController as? ViewController)?.present(ShareController(context: context, subject: .messages([message]), showInChat: nil, externalShare: true, immediateExternalShare: true), in: .window(.root))
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import ComponentFlow
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import Postbox
import TelegramCore
import TelegramPresentationData
import ContextUI
import PlainButtonComponent
import AvatarNode
import AccountContext
import PhotoResources
import TextFormat
final class VideoAdComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let message: EngineMessage
let initialTimestamp: Int32
let action: (Bool) -> Void
let adAction: () -> Void
let moreAction: (ContextReferenceContentNode) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
message: EngineMessage,
initialTimestamp: Int32,
action: @escaping (Bool) -> Void,
adAction: @escaping () -> Void,
moreAction: @escaping (ContextReferenceContentNode) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.message = message
self.initialTimestamp = initialTimestamp
self.action = action
self.adAction = adAction
self.moreAction = moreAction
}
static func ==(lhs: VideoAdComponent, rhs: VideoAdComponent) -> Bool {
if lhs.message != rhs.message {
return false
}
if lhs.initialTimestamp != rhs.initialTimestamp {
return false
}
return true
}
final class View: UIView {
private var component: VideoAdComponent?
private weak var componentState: EmptyComponentState?
private let wrapperView: UIView
private let backgroundView: UIView
private let imageNode: TransformImageNode
private let title = ComponentView<Empty>()
private let text = ComponentView<Empty>()
private let button = ComponentView<Empty>()
private let buttonNode: ContextReferenceContentNode
private let progress = ComponentView<Empty>()
private var adIcon: UIImage?
override init(frame: CGRect) {
self.wrapperView = UIView()
self.wrapperView.clipsToBounds = true
self.wrapperView.layer.cornerRadius = 14.0
if #available(iOS 13.0, *) {
self.wrapperView.layer.cornerCurve = .continuous
}
self.backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
self.imageNode = TransformImageNode()
self.imageNode.isUserInteractionEnabled = false
self.buttonNode = ContextReferenceContentNode()
super.init(frame: frame)
self.addSubview(self.wrapperView)
self.wrapperView.addSubview(self.backgroundView)
self.wrapperView.addSubview(self.buttonNode.view)
self.wrapperView.addSubview(self.imageNode.view)
self.backgroundView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped)))
}
required init(coder: NSCoder) {
preconditionFailure()
}
@objc private func tapped() {
if let component = self.component {
component.adAction()
}
}
func update(component: VideoAdComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let isFirstTime = self.component == nil
self.component = component
let titleString = component.message.author?.compactDisplayTitle ?? ""
var media: Media?
if let photo = component.message.media.first as? TelegramMediaImage {
media = photo
} else if let file = component.message.media.first as? TelegramMediaFile {
media = file
}
if isFirstTime {
let signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>
if let photo = media as? TelegramMediaImage {
signal = mediaGridMessagePhoto(account: component.context.account, userLocation: .other, photoReference: .standalone(media: photo))
} else if let file = media as? TelegramMediaFile {
signal = mediaGridMessageVideo(postbox: component.context.account.postbox, userLocation: .other, videoReference: .standalone(media: file))
} else {
signal = .complete()
}
self.imageNode.setSignal(signal)
}
let color = UIColor(rgb: 0x64d2ff)
if self.adIcon == nil {
self.adIcon = generateAdIcon(color: color, strings: component.strings)
}
let leftInset: CGFloat = media != nil ? 51.0 : 16.0
let rightInset: CGFloat = 60.0
var titleRightInset: CGFloat = 0.0
if let adIcon = self.adIcon {
titleRightInset += adIcon.size.width + 16.0
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: titleString, font: Font.semibold(14.0), textColor: .white)))
),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset - titleRightInset, height: availableSize.height)
)
let textColor = UIColor.white
var entities: [MessageTextEntity] = []
if let attribute = component.message.attributes.first(where: { $0 is TextEntitiesMessageAttribute }) as? TextEntitiesMessageAttribute {
entities = attribute.entities
}
let attributedText = stringWithAppliedEntities(component.message.text, entities: entities, baseColor: textColor, linkColor: textColor, baseFont: Font.regular(14.0), linkFont: Font.regular(14.0), boldFont: Font.semibold(14.0), italicFont: Font.italic(14.0), boldItalicFont: Font.semiboldItalic(14.0), fixedFont: Font.monospace(14.0), blockQuoteFont: Font.regular(14.0), message: nil)
let textSize = self.text.update(
transition: .immediate,
component: AnyComponent(
MultilineTextWithEntitiesComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
placeholderColor: UIColor.white.withAlphaComponent(0.2),
text: .plain(attributedText),
maximumNumberOfLines: 0
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: availableSize.height)
)
let contentHeight = titleSize.height + 3.0 + textSize.height
let size = CGSize(width: availableSize.width, height: contentHeight + 24.0)
let imageSize = CGSize(width: 30.0, height: 30.0)
self.imageNode.frame = CGRect(origin: CGPoint(x: 10.0, y: floor((size.height - imageSize.height) / 2.0)), size: imageSize)
let makeLayout = self.imageNode.asyncLayout()
let apply = makeLayout(TransformImageArguments(corners: ImageCorners(radius: imageSize.width / 2.0), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: .zero))
apply()
let contentOriginY = floor((size.height - contentHeight) / 2.0)
let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: contentOriginY), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.wrapperView.addSubview(titleView)
}
titleView.frame = titleFrame
}
let textFrame = CGRect(origin: CGPoint(x: leftInset, y: contentOriginY + contentHeight - textSize.height), size: textSize)
if let textView = self.text.view {
if textView.superview == nil {
textView.isUserInteractionEnabled = false
self.wrapperView.addSubview(textView)
}
textView.frame = textFrame
}
let buttonSize = self.button.update(
transition: .immediate,
component: AnyComponent(
PlainButtonComponent(
content: AnyComponent(
Image(image: self.adIcon, contentMode: .center)
),
effectAlignment: .center,
action: { [weak self] in
if let self {
component.moreAction(self.buttonNode)
}
},
animateScale: false
)
),
environment: {},
containerSize: availableSize
)
let buttonFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 4.0, y: floor(titleFrame.midY - buttonSize.height / 2.0) + 1.0), size: buttonSize)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.wrapperView.addSubview(buttonView)
}
buttonView.frame = buttonFrame
}
self.buttonNode.frame = buttonFrame
let progressSize = self.progress.update(
transition: .immediate,
component: AnyComponent(
AdRemainingProgressComponent(
initialTimestamp: component.initialTimestamp,
minDisplayDuration: 10,
maxDisplayDuration: 30,
action: { [weak self] available in
guard let self, let component = self.component else {
return
}
component.action(available)
}
)
),
environment: {},
containerSize: availableSize
)
let progressFrame = CGRect(origin: CGPoint(x: size.width - progressSize.width - 16.0, y: floor((size.height - progressSize.height) / 2.0)), size: progressSize)
if let progressView = self.progress.view {
if progressView.superview == nil {
self.wrapperView.addSubview(progressView)
}
progressView.frame = progressFrame
}
self.wrapperView.frame = CGRect(origin: .zero, size: size)
self.backgroundView.frame = CGRect(origin: .zero, size: size)
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private func generateAdIcon(color: UIColor, strings: PresentationStrings) -> UIImage? {
let titleString = NSAttributedString(string: strings.ChatList_Search_Ad, font: Font.regular(11.0), textColor: color, paragraphAlignment: .center)
let stringRect = titleString.boundingRect(with: CGSize(width: 200.0, height: 20.0), options: .usesLineFragmentOrigin, context: nil)
return generateImage(CGSize(width: floor(stringRect.width) + 18.0, height: 15.0), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setFillColor(color.withMultipliedAlpha(0.1).cgColor)
context.addPath(UIBezierPath(roundedRect: bounds, cornerRadius: size.height / 2.0).cgPath)
context.fillPath()
context.setFillColor(color.cgColor)
let circleSize = CGSize(width: 2.0 - UIScreenPixel, height: 2.0 - UIScreenPixel)
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - 8.0, y: 3.0 + UIScreenPixel), size: circleSize))
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - 8.0, y: 7.0 - UIScreenPixel), size: circleSize))
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - 8.0, y: 10.0), size: circleSize))
let textRect = CGRect(
x: 5.0,
y: (size.height - stringRect.height) / 2.0 - UIScreenPixel,
width: stringRect.width,
height: stringRect.height
)
UIGraphicsPushContext(context)
titleString.draw(in: textRect)
UIGraphicsPopContext()
})
}
@@ -0,0 +1,556 @@
import Foundation
import UIKit
import UIKit.UIGestureRecognizerSubclass
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ImageContentAnalysis
private func findScrollView(view: UIView?) -> UIScrollView? {
if let view = view {
if let view = view as? UIScrollView {
return view
}
return findScrollView(view: view.superview)
} else {
return nil
}
}
private func cancelScrollViewGestures(view: UIView?) {
if let view = view {
if let gestureRecognizers = view.gestureRecognizers {
for recognizer in gestureRecognizers {
if let recognizer = recognizer as? UIPanGestureRecognizer {
switch recognizer.state {
case .began, .possible:
recognizer.state = .ended
default:
break
}
}
}
}
cancelScrollViewGestures(view: view.superview)
}
}
private func generateKnobImage(color: UIColor, diameter: CGFloat, inverted: Bool = false) -> UIImage? {
let f: (CGSize, CGContext) -> Void = { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(color.cgColor)
context.fill(CGRect(origin: CGPoint(x: (size.width - 2.0) / 2.0, y: size.width / 2.0), size: CGSize(width: 2.0, height: size.height - size.width / 2.0 - 1.0)))
context.fillEllipse(in: CGRect(origin: CGPoint(x: floor((size.width - diameter) / 2.0), y: floor((size.width - diameter) / 2.0)), size: CGSize(width: diameter, height: diameter)))
context.fillEllipse(in: CGRect(origin: CGPoint(x: (size.width - 2.0) / 2.0, y: size.width + 2.0), size: CGSize(width: 2.0, height: 2.0)))
}
let size = CGSize(width: 12.0, height: 12.0 + 2.0 + 2.0)
if inverted {
return generateImage(size, contextGenerator: f)?.stretchableImage(withLeftCapWidth: Int(size.width / 2.0), topCapHeight: Int(size.height) - (Int(size.width) + 1))
} else {
return generateImage(size, rotatedContext: f)?.stretchableImage(withLeftCapWidth: Int(size.width / 2.0), topCapHeight: Int(size.width) + 1)
}
}
private func generateSelectionsImage(size: CGSize, rects: [RecognizedContent.Rect], color: UIColor) -> UIImage? {
return generateImage(size, opaque: false, rotatedContext: { size, c in
let bounds = CGRect(origin: CGPoint(), size: size)
c.clear(bounds)
c.setFillColor(color.cgColor)
for rect in rects {
let path = UIBezierPath(rect: rect, radius: 2.5)
c.addPath(path.cgPath)
c.fillPath()
}
})
}
public final class RecognizedTextSelectionTheme {
public let selection: UIColor
public let knob: UIColor
public let knobDiameter: CGFloat
public init(selection: UIColor, knob: UIColor, knobDiameter: CGFloat = 12.0) {
self.selection = selection
self.knob = knob
self.knobDiameter = knobDiameter
}
}
private enum Knob {
case left
case right
}
private final class InternalGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive press: UIPress) -> Bool {
return true
}
}
private final class RecognizedTextSelectionGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
private let internalDelegate = InternalGestureRecognizerDelegate()
private var longTapTimer: Timer?
private var movingKnob: (Knob, CGPoint, CGPoint)?
private var currentLocation: CGPoint?
var beginSelection: ((CGPoint) -> Void)?
var knobAtPoint: ((CGPoint) -> (Knob, CGPoint)?)?
var moveKnob: ((Knob, CGPoint) -> Void)?
var finishedMovingKnob: (() -> Void)?
var clearSelection: (() -> Void)?
override init(target: Any?, action: Selector?) {
super.init(target: nil, action: nil)
self.delegate = self.internalDelegate
}
override public func reset() {
super.reset()
self.longTapTimer?.invalidate()
self.longTapTimer = nil
self.movingKnob = nil
self.currentLocation = nil
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
let currentLocation = touches.first?.location(in: self.view)
self.currentLocation = currentLocation
if let currentLocation = currentLocation {
if let (knob, knobPosition) = self.knobAtPoint?(currentLocation) {
self.movingKnob = (knob, knobPosition, currentLocation)
cancelScrollViewGestures(view: self.view?.superview)
self.state = .began
} else if self.longTapTimer == nil {
final class TimerTarget: NSObject {
let f: () -> Void
init(_ f: @escaping () -> Void) {
self.f = f
}
@objc func event() {
self.f()
}
}
let longTapTimer = Timer(timeInterval: 0.3, target: TimerTarget({ [weak self] in
self?.longTapEvent()
}), selector: #selector(TimerTarget.event), userInfo: nil, repeats: false)
self.longTapTimer = longTapTimer
RunLoop.main.add(longTapTimer, forMode: .common)
}
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
let currentLocation = touches.first?.location(in: self.view)
self.currentLocation = currentLocation
if let (knob, initialKnobPosition, initialGesturePosition) = self.movingKnob, let currentLocation = currentLocation {
self.moveKnob?(knob, CGPoint(x: initialKnobPosition.x + currentLocation.x - initialGesturePosition.x, y: initialKnobPosition.y + currentLocation.y - initialGesturePosition.y))
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
if let longTapTimer = self.longTapTimer {
self.longTapTimer = nil
longTapTimer.invalidate()
self.clearSelection?()
} else {
if let _ = self.currentLocation, let _ = self.movingKnob {
self.finishedMovingKnob?()
}
}
self.state = .ended
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.state = .cancelled
}
private func longTapEvent() {
if let currentLocation = self.currentLocation {
self.beginSelection?(currentLocation)
self.state = .ended
}
}
}
public final class RecognizedTextSelectionNodeView: UIView {
var hitTestImpl: ((CGPoint, UIEvent?) -> UIView?)?
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return self.hitTestImpl?(point, event)
}
}
public enum RecognizedTextSelectionAction {
case copy
case share
case lookup
case speak
case translate
}
public final class RecognizedTextSelectionNode: ASDisplayNode {
private let size: CGSize
private let theme: RecognizedTextSelectionTheme
private let strings: PresentationStrings
private let recognitions: [(string: String, rect: RecognizedContent.Rect)]
private let updateIsActive: (Bool) -> Void
private let present: (ViewController, Any?) -> Void
private weak var rootNode: ASDisplayNode?
private let performAction: (String, RecognizedTextSelectionAction) -> Void
private var highlightOverlay: ASImageNode?
private let leftKnob: ASImageNode
private let rightKnob: ASImageNode
private var selectedIndices: Set<Int>?
private var currentRects: [RecognizedContent.Rect]?
private var currentTopLeft: CGPoint?
private var currentBottomRight: CGPoint?
public let highlightAreaNode: ASDisplayNode
private var recognizer: RecognizedTextSelectionGestureRecognizer?
public init(size: CGSize, theme: RecognizedTextSelectionTheme, strings: PresentationStrings, recognitions: [RecognizedContent], updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: ASDisplayNode, performAction: @escaping (String, RecognizedTextSelectionAction) -> Void) {
self.size = size
self.theme = theme
self.strings = strings
let sortedRecognitions = recognitions.sorted(by: { lhs, rhs in
if abs(lhs.rect.leftMidPoint.y - rhs.rect.rightMidPoint.y) < min(lhs.rect.leftHeight, rhs.rect.leftHeight) / 2.0 {
return lhs.rect.leftMidPoint.x < rhs.rect.leftMidPoint.x
} else {
return lhs.rect.leftMidPoint.y > rhs.rect.leftMidPoint.y
}
})
var textRecognitions: [(String, RecognizedContent.Rect)] = []
for recognition in sortedRecognitions {
if case let .text(string, _) = recognition.content {
textRecognitions.append((string, recognition.rect))
// for word in words {
// textRecognitions.append((String(string[word.0]), word.1))
// }
}
}
self.recognitions = textRecognitions
self.updateIsActive = updateIsActive
self.present = present
self.rootNode = rootNode
self.performAction = performAction
self.leftKnob = ASImageNode()
self.leftKnob.isUserInteractionEnabled = false
self.leftKnob.image = generateKnobImage(color: theme.knob, diameter: theme.knobDiameter)
self.leftKnob.displaysAsynchronously = false
self.leftKnob.displayWithoutProcessing = true
self.leftKnob.alpha = 0.0
self.rightKnob = ASImageNode()
self.rightKnob.isUserInteractionEnabled = false
self.rightKnob.image = generateKnobImage(color: theme.knob, diameter: theme.knobDiameter, inverted: true)
self.rightKnob.displaysAsynchronously = false
self.rightKnob.displayWithoutProcessing = true
self.rightKnob.alpha = 0.0
self.highlightAreaNode = ASDisplayNode()
super.init()
self.setViewBlock({
return RecognizedTextSelectionNodeView()
})
self.addSubnode(self.leftKnob)
self.addSubnode(self.rightKnob)
}
override public func didLoad() {
super.didLoad()
(self.view as? RecognizedTextSelectionNodeView)?.hitTestImpl = { [weak self] point, event in
return self?.hitTest(point, with: event)
}
let recognizer = RecognizedTextSelectionGestureRecognizer(target: nil, action: nil)
recognizer.knobAtPoint = { [weak self] point in
return self?.knobAtPoint(point)
}
recognizer.moveKnob = { [weak self] knob, point in
guard let strongSelf = self, let _ = strongSelf.selectedIndices, let currentTopLeft = strongSelf.currentTopLeft, let currentBottomRight = strongSelf.currentBottomRight else {
return
}
let topLeftPoint: CGPoint
let bottomRightPoint: CGPoint
switch knob {
case .left:
topLeftPoint = point
bottomRightPoint = currentBottomRight
case .right:
topLeftPoint = currentTopLeft
bottomRightPoint = point
}
let selectionRect = CGRect(x: min(topLeftPoint.x, bottomRightPoint.x), y: min(topLeftPoint.y, bottomRightPoint.y), width: max(bottomRightPoint.x, topLeftPoint.x) - min(bottomRightPoint.x, topLeftPoint.x), height: max(bottomRightPoint.y, topLeftPoint.y) - min(bottomRightPoint.y, topLeftPoint.y))
var i = 0
var selectedIndices: Set<Int>?
for recognition in strongSelf.recognitions {
let rect = recognition.rect.convertTo(size: strongSelf.size, insets: UIEdgeInsets(top: -4.0, left: -2.0, bottom: -4.0, right: -2.0))
if selectionRect.intersects(rect.boundingFrame) {
if selectedIndices == nil {
selectedIndices = Set()
}
selectedIndices?.insert(i)
}
i += 1
}
strongSelf.selectedIndices = selectedIndices
strongSelf.updateSelection(range: selectedIndices, animateIn: false)
}
recognizer.finishedMovingKnob = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.displayMenu()
}
recognizer.beginSelection = { [weak self] point in
guard let strongSelf = self else {
return
}
let _ = strongSelf.dismissSelection()
var i = 0
var selectedIndices: Set<Int>?
var topLeft: CGPoint?
var bottomRight: CGPoint?
for recognition in strongSelf.recognitions {
let rect = recognition.rect.convertTo(size: strongSelf.size, insets: UIEdgeInsets(top: -4.0, left: -2.0, bottom: -4.0, right: -2.0))
if rect.boundingFrame.contains(point) {
topLeft = rect.topLeft
bottomRight = rect.bottomRight
selectedIndices = Set([i])
break
}
i += 1
}
strongSelf.selectedIndices = selectedIndices
strongSelf.currentTopLeft = topLeft
strongSelf.currentBottomRight = bottomRight
strongSelf.updateSelection(range: selectedIndices, animateIn: true)
strongSelf.displayMenu()
strongSelf.updateIsActive(true)
}
recognizer.clearSelection = { [weak self] in
let _ = self?.dismissSelection()
self?.updateIsActive(false)
}
self.recognizer = recognizer
self.view.addGestureRecognizer(recognizer)
}
public func updateLayout() {
if let selectedIndices = self.selectedIndices {
self.updateSelection(range: selectedIndices, animateIn: false)
}
}
private func updateSelection(range: Set<Int>?, animateIn: Bool) {
var rects: [RecognizedContent.Rect]? = nil
var startEdge: (position: CGPoint, height: CGFloat)?
var endEdge: (position: CGPoint, height: CGFloat)?
if let range = range {
var i = 0
rects = []
for recognition in self.recognitions {
let rect = recognition.rect.convertTo(size: self.size)
if range.contains(i) {
if startEdge == nil {
startEdge = (rect.leftMidPoint, rect.leftHeight)
}
rects?.append(rect)
}
i += 1
}
if let rect = rects?.last {
endEdge = (rect.rightMidPoint, rect.rightHeight)
}
}
self.currentRects = rects
if let rects = rects, let startEdge = startEdge, let endEdge = endEdge, !rects.isEmpty {
let highlightOverlay: ASImageNode
if let current = self.highlightOverlay {
highlightOverlay = current
} else {
highlightOverlay = ASImageNode()
self.highlightOverlay = highlightOverlay
self.highlightAreaNode.addSubnode(highlightOverlay)
}
highlightOverlay.frame = self.bounds
highlightOverlay.image = generateSelectionsImage(size: self.size, rects: rects, color: self.theme.selection.withAlphaComponent(1.0))
highlightOverlay.alpha = self.theme.selection.alpha
if let image = self.leftKnob.image {
self.leftKnob.frame = CGRect(origin: CGPoint(x: floor(startEdge.position.x - image.size.width / 2.0), y: startEdge.position.y - floorToScreenPixels(startEdge.height / 2.0) - self.theme.knobDiameter), size: CGSize(width: image.size.width, height: self.theme.knobDiameter + startEdge.height + 2.0))
self.rightKnob.frame = CGRect(origin: CGPoint(x: floor(endEdge.position.x + 1.0 - image.size.width / 2.0), y: endEdge.position.y - floorToScreenPixels(endEdge.height / 2.0)), size: CGSize(width: image.size.width, height: self.theme.knobDiameter + endEdge.height + 2.0))
}
if self.leftKnob.alpha.isZero {
highlightOverlay.layer.animateAlpha(from: 0.0, to: highlightOverlay.alpha, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue)
self.leftKnob.alpha = 1.0
self.leftKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.14, delay: 0.19)
self.rightKnob.alpha = 1.0
self.rightKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.14, delay: 0.19)
self.leftKnob.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.2, delay: 0.25, initialVelocity: 0.0, damping: 80.0)
self.rightKnob.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.2, delay: 0.25, initialVelocity: 0.0, damping: 80.0)
if animateIn {
var result = CGRect()
for rect in rects {
if result.isEmpty {
result = rect.boundingFrame
} else {
result = result.union(rect.boundingFrame)
}
}
highlightOverlay.layer.animateScale(from: 2.0, to: 1.0, duration: 0.26)
let fromResult = CGRect(origin: CGPoint(x: result.minX - result.width / 2.0, y: result.minY - result.height / 2.0), size: CGSize(width: result.width * 2.0, height: result.height * 2.0))
highlightOverlay.layer.animatePosition(from: CGPoint(x: (-fromResult.midX + highlightOverlay.bounds.midX) / 1.0, y: (-fromResult.midY + highlightOverlay.bounds.midY) / 1.0), to: CGPoint(), duration: 0.26, additive: true)
}
}
} else if let highlightOverlay = self.highlightOverlay {
self.highlightOverlay = nil
highlightOverlay.layer.animateAlpha(from: highlightOverlay.alpha, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightOverlay] _ in
highlightOverlay?.removeFromSupernode()
})
self.leftKnob.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18)
self.leftKnob.alpha = 0.0
self.leftKnob.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18)
self.rightKnob.alpha = 0.0
self.rightKnob.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18)
}
}
private func knobAtPoint(_ point: CGPoint) -> (Knob, CGPoint)? {
if !self.leftKnob.alpha.isZero, self.leftKnob.frame.insetBy(dx: -4.0, dy: -8.0).contains(point) {
return (.left, self.leftKnob.frame.offsetBy(dx: 0.0, dy: self.leftKnob.frame.width / 2.0).center)
}
if !self.rightKnob.alpha.isZero, self.rightKnob.frame.insetBy(dx: -4.0, dy: -8.0).contains(point) {
return (.right, self.rightKnob.frame.offsetBy(dx: 0.0, dy: -self.rightKnob.frame.width / 2.0).center)
}
if !self.leftKnob.alpha.isZero, self.leftKnob.frame.insetBy(dx: -14.0, dy: -14.0).contains(point) {
return (.left, self.leftKnob.frame.offsetBy(dx: 0.0, dy: self.leftKnob.frame.width / 2.0).center)
}
if !self.rightKnob.alpha.isZero, self.rightKnob.frame.insetBy(dx: -14.0, dy: -14.0).contains(point) {
return (.right, self.rightKnob.frame.offsetBy(dx: 0.0, dy: -self.rightKnob.frame.width / 2.0).center)
}
return nil
}
public func dismissSelection() -> Bool {
if let _ = self.selectedIndices {
self.selectedIndices = nil
self.updateSelection(range: nil, animateIn: false)
return true
} else {
return false
}
}
private func displayMenu() {
guard let currentRects = self.currentRects, !currentRects.isEmpty, let selectedIndices = self.selectedIndices else {
return
}
var completeRect = currentRects[0].boundingFrame
for i in 0 ..< currentRects.count {
completeRect = completeRect.union(currentRects[i].boundingFrame)
}
completeRect = completeRect.insetBy(dx: 0.0, dy: -12.0)
var selectedText = ""
for i in 0 ..< self.recognitions.count {
if selectedIndices.contains(i) {
let (string, _) = self.recognitions[i]
if !selectedText.isEmpty {
selectedText += "\n"
}
selectedText.append(contentsOf: string.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
var actions: [ContextMenuAction] = []
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in
self?.performAction(selectedText, .copy)
let _ = self?.dismissSelection()
}))
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuLookUp, accessibilityLabel: self.strings.Conversation_ContextMenuLookUp), action: { [weak self] in
self?.performAction(selectedText, .lookup)
let _ = self?.dismissSelection()
}))
if #available(iOS 15.0, *) {
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuTranslate, accessibilityLabel: self.strings.Conversation_ContextMenuTranslate), action: { [weak self] in
self?.performAction(selectedText, .translate)
let _ = self?.dismissSelection()
}))
}
// if isSpeakSelectionEnabled() {
// actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuSpeak, accessibilityLabel: self.strings.Conversation_ContextMenuSpeak), action: { [weak self] in
// self?.performAction(selectedText, .speak)
// let _ = self?.dismissSelection()
// }))
// }
actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in
self?.performAction(selectedText, .share)
let _ = self?.dismissSelection()
}))
self.present(makeContextMenuController(actions: actions, catchTapsOutside: false, hasHapticFeedback: false), ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in
guard let strongSelf = self, let rootNode = strongSelf.rootNode else {
return nil
}
return (strongSelf, completeRect, rootNode, rootNode.bounds)
}, bounce: false))
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.knobAtPoint(point) != nil {
return self.view
}
if self.bounds.contains(point) {
for recognition in self.recognitions {
let mappedRect = recognition.rect.convertTo(size: self.bounds.size)
if mappedRect.boundingFrame.insetBy(dx: -20.0, dy: -20.0).contains(point) {
return self.view
}
}
return nil
}
return nil
}
}
@@ -0,0 +1,656 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import RadialStatusNode
import ScreenCaptureDetection
import AppBundle
import LocalizedPeerData
import TooltipUI
import TelegramNotices
private func galleryMediaForMedia(media: Media) -> Media? {
if let media = media as? TelegramMediaImage {
return media
} else if let file = media as? TelegramMediaFile {
if file.mimeType.hasPrefix("audio/") {
return nil
} else if !file.isVideo && file.mimeType.hasPrefix("video/") {
return file
} else {
return file
}
}
return nil
}
private func mediaForMessage(message: Message) -> Media? {
for media in message.media {
if let result = galleryMediaForMedia(media: media) {
return result
} else if let webpage = media as? TelegramMediaWebpage {
switch webpage.content {
case let .Loaded(content):
if let embedUrl = content.embedUrl, !embedUrl.isEmpty {
return webpage
} else if let image = content.image {
if let result = galleryMediaForMedia(media: image) {
return result
}
} else if let file = content.file {
if let result = galleryMediaForMedia(media: file) {
return result
}
}
case .Pending:
break
}
}
}
return nil
}
private final class SecretMediaPreviewControllerNode: GalleryControllerNode {
fileprivate var timeoutNode: RadialStatusNode?
private var validLayout: (ContainerViewLayout, CGFloat)?
var beginTimeAndTimeout: (Double, Double, Bool)? {
didSet {
if let (beginTime, timeout, isOutgoing) = self.beginTimeAndTimeout {
var beginTime = beginTime
if self.timeoutNode == nil {
let timeoutNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5))
self.timeoutNode = timeoutNode
let icon: RadialStatusNodeState.SecretTimeoutIcon
let timeoutValue = Int32(timeout)
let state: RadialStatusNodeState
if timeoutValue == 0 && isOutgoing {
state = .staticTimeout
} else if timeoutValue == viewOnceTimeout {
beginTime = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970
if let image = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/ViewOnce"), color: .white) {
icon = .image(image)
} else {
icon = .flame
}
state = .secretTimeout(color: .white, icon: icon, beginTime: beginTime, timeout: timeout, sparks: isOutgoing ? false : true)
} else {
state = .secretTimeout(color: .white, icon: .flame, beginTime: beginTime, timeout: timeout, sparks: true)
}
timeoutNode.transitionToState(state, completion: {})
self.addSubnode(timeoutNode)
timeoutNode.addTarget(self, action: #selector(self.statusTapGesture), forControlEvents: .touchUpInside)
if let (layout, navigationHeight) = self.validLayout {
self.layoutTimeoutNode(layout, navigationBarHeight: navigationHeight, transition: .immediate)
}
}
} else if let timeoutNode = self.timeoutNode {
self.timeoutNode = nil
timeoutNode.removeFromSupernode()
}
}
}
var statusPressed: (UIView) -> Void = { _ in }
@objc private func statusTapGesture() {
if let sourceView = self.timeoutNode?.view {
self.statusPressed(sourceView)
}
}
var onDismissTransitionUpdate: (CGFloat) -> Void = { _ in }
override func animateIn(animateContent: Bool, useSimpleAnimation: Bool) {
super.animateIn(animateContent: animateContent, useSimpleAnimation: useSimpleAnimation)
self.timeoutNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
override func animateOut(animateContent: Bool, completion: @escaping () -> Void) {
super.animateOut(animateContent: animateContent, completion: completion)
if let timeoutNode = self.timeoutNode {
timeoutNode.layer.animateAlpha(from: timeoutNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false)
}
}
override func updateDismissTransition(_ value: CGFloat) {
self.timeoutNode?.alpha = value
self.onDismissTransitionUpdate(value)
}
override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
self.validLayout = (layout, navigationBarHeight)
self.layoutTimeoutNode(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
private func layoutTimeoutNode(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
if let timeoutNode = self.timeoutNode {
let diameter: CGFloat = 28.0
transition.updateFrame(node: timeoutNode, frame: CGRect(origin: CGPoint(x: layout.size.width - layout.safeInsets.right - diameter - 9.0, y: navigationBarHeight - 9.0 - diameter), size: CGSize(width: diameter, height: diameter)))
}
}
}
public final class SecretMediaPreviewController: ViewController {
private let context: AccountContext
private let messageId: MessageId
private let _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
private var didSetReady = false
private let disposable = MetaDisposable()
private let markMessageAsConsumedDisposable = MetaDisposable()
private var controllerNode: SecretMediaPreviewControllerNode {
return self.displayNode as! SecretMediaPreviewControllerNode
}
private var messageView: MessageView?
private var currentNodeMessageId: MessageId?
private var currentNodeMessageIsVideo = false
private var currentNodeMessageIsViewOnce = false
private var currentMessageIsDismissed = false
private var tempFile: TempBoxFile?
private let centralItemAttributesDisposable = DisposableSet();
private let footerContentNode = Promise<(GalleryFooterContentNode?, GalleryOverlayContentNode?)>()
private let _hiddenMedia = Promise<(MessageId, Media)?>(nil)
private var hiddenMediaManagerIndex: Int?
private let presentationData: PresentationData
private var screenCaptureEventsDisposable: Disposable?
private weak var tooltipController: TooltipScreen?
public init(context: AccountContext, messageId: MessageId) {
self.context = context
self.messageId = messageId
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: NavigationBarPresentationData(theme: GalleryController.darkNavigationTheme, strings: NavigationBarStrings(presentationStrings: self.presentationData.strings)))
let backItem = UIBarButtonItem(backButtonAppearanceWithTitle: presentationData.strings.Common_Back, target: self, action: #selector(self.donePressed))
self.navigationItem.leftBarButtonItem = backItem
self.statusBar.statusBarStyle = .White
self.disposable.set((context.account.postbox.messageView(messageId) |> deliverOnMainQueue).start(next: { [weak self] view in
if let strongSelf = self {
strongSelf.messageView = view
if strongSelf.isViewLoaded {
strongSelf.applyMessageView()
}
}
}))
self.hiddenMediaManagerIndex = self.context.sharedContext.mediaManager.galleryHiddenMediaManager.addSource(self._hiddenMedia.get()
|> map { messageIdAndMedia in
if let (messageId, media) = messageIdAndMedia {
return .chat(context.account.id, messageId, media)
} else {
return nil
}
})
self.centralItemAttributesDisposable.add(self.footerContentNode.get().start(next: { [weak self] footerContentNode, _ in
guard let self else {
return
}
self.controllerNode.updatePresentationState({
$0.withUpdatedFooterContentNode(footerContentNode)
}, transition: .immediate)
}))
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable.dispose()
self.markMessageAsConsumedDisposable.dispose()
if let hiddenMediaManagerIndex = self.hiddenMediaManagerIndex {
self.context.sharedContext.mediaManager.galleryHiddenMediaManager.removeSource(hiddenMediaManagerIndex)
}
self.screenCaptureEventsDisposable?.dispose()
if let tempFile = self.tempFile {
TempBox.shared.dispose(tempFile)
}
self.centralItemAttributesDisposable.dispose()
}
@objc func donePressed() {
self.dismiss(forceAway: false)
}
public override func loadDisplayNode() {
let controllerInteraction = GalleryControllerInteraction(presentController: { [weak self] controller, arguments in
if let strongSelf = self {
strongSelf.present(controller, in: .window(.root), with: arguments, blockInteraction: true)
}
}, pushController: { _ in
}, dismissController: { [weak self] in
self?.dismiss(forceAway: true)
}, replaceRootController: { _, _ in
}, editMedia: { _ in
}, controller: { [weak self] in
return self
})
self.displayNode = SecretMediaPreviewControllerNode(context: self.context, controllerInteraction: controllerInteraction)
self.displayNodeDidLoad()
self.controllerNode.statusPressed = { [weak self] _ in
if let self {
self.presentViewOnceTooltip()
}
}
self.controllerNode.onDismissTransitionUpdate = { [weak self] _ in
if let self {
self.dismissAllTooltips()
}
}
self.controllerNode.statusBar = self.statusBar
self.controllerNode.navigationBar = self.navigationBar
self.controllerNode.transitionDataForCentralItem = { [weak self] in
if let strongSelf = self {
if let _ = strongSelf.controllerNode.pager.centralItemNode(), let presentationArguments = strongSelf.presentationArguments as? GalleryControllerPresentationArguments {
if let message = strongSelf.messageView?.message {
if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media) {
return (transitionArguments.transitionNode, transitionArguments.addToTransitionSurface)
}
}
}
}
return nil
}
self.controllerNode.dismiss = { [weak self] in
self?._hiddenMedia.set(.single(nil))
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.controllerNode.beginCustomDismiss = { [weak self] _ in
if let strongSelf = self {
strongSelf._hiddenMedia.set(.single(nil))
let animatedOutNode = true
strongSelf.controllerNode.animateOut(animateContent: animatedOutNode, completion: {
})
}
}
self.controllerNode.completeCustomDismiss = { [weak self] _ in
self?._hiddenMedia.set(.single(nil))
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.controllerNode.pager.centralItemIndexUpdated = { [weak self] index in
if let strongSelf = self {
var hiddenItem: (MessageId, Media)?
if let _ = index {
if let message = strongSelf.messageView?.message, let media = mediaForMessage(message: message) {
var beginTimeAndTimeout: (Double, Double, Bool)?
var videoDuration: Double?
for media in message.media {
if let file = media as? TelegramMediaFile {
videoDuration = file.duration
}
}
var timerStarted = false
let isOutgoing = !message.flags.contains(.Incoming)
if let attribute = message.autoclearAttribute {
strongSelf.currentNodeMessageIsViewOnce = attribute.timeout == viewOnceTimeout
if let countdownBeginTime = attribute.countdownBeginTime {
timerStarted = true
if let videoDuration = videoDuration, attribute.timeout != viewOnceTimeout {
beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, max(videoDuration, Double(attribute.timeout)), isOutgoing)
} else {
beginTimeAndTimeout = (Double(countdownBeginTime), Double(attribute.timeout), isOutgoing)
}
} else if isOutgoing {
beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, attribute.timeout != viewOnceTimeout ? 0.0 : Double(viewOnceTimeout), isOutgoing)
}
} else if let attribute = message.autoremoveAttribute {
strongSelf.currentNodeMessageIsViewOnce = attribute.timeout == viewOnceTimeout
if let countdownBeginTime = attribute.countdownBeginTime {
timerStarted = true
if let videoDuration = videoDuration, attribute.timeout != viewOnceTimeout {
beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, max(videoDuration, Double(attribute.timeout)), isOutgoing)
} else {
beginTimeAndTimeout = (Double(countdownBeginTime), Double(attribute.timeout), isOutgoing)
}
} else if isOutgoing {
beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, attribute.timeout != viewOnceTimeout ? 0.0 : Double(viewOnceTimeout), isOutgoing)
}
}
if let file = media as? TelegramMediaFile {
if file.isAnimated {
strongSelf.title = strongSelf.presentationData.strings.SecretGif_Title
} else {
if strongSelf.currentNodeMessageIsViewOnce {
strongSelf.title = strongSelf.presentationData.strings.SecretVideo_ViewOnce_Title
} else {
strongSelf.title = strongSelf.presentationData.strings.SecretVideo_Title
}
}
} else {
if strongSelf.currentNodeMessageIsViewOnce {
strongSelf.title = strongSelf.presentationData.strings.SecretImage_ViewOnce_Title
} else {
strongSelf.title = strongSelf.presentationData.strings.SecretImage_Title
}
}
if let beginTimeAndTimeout = beginTimeAndTimeout {
strongSelf.controllerNode.beginTimeAndTimeout = beginTimeAndTimeout
}
if message.flags.contains(.Incoming) || strongSelf.currentNodeMessageIsVideo {
if let node = strongSelf.controllerNode.pager.centralItemNode() {
strongSelf.footerContentNode.set(node.footerContent())
}
} else {
if timerStarted {
strongSelf.controllerNode.updatePresentationState({
$0.withUpdatedFooterContentNode(nil)
}, transition: .immediate)
} else {
let contentNode = SecretMediaPreviewFooterContentNode()
let peerTitle = messageMainPeer(EngineMessage(message))?.compactDisplayTitle ?? ""
let text: String
if let file = media as? TelegramMediaFile {
if file.isAnimated {
text = strongSelf.presentationData.strings.SecretGIF_NotViewedYet(peerTitle).string
} else {
text = strongSelf.presentationData.strings.SecretVideo_NotViewedYet(peerTitle).string
}
} else {
text = strongSelf.presentationData.strings.SecretImage_NotViewedYet(peerTitle).string
}
contentNode.setText(text)
strongSelf.controllerNode.updatePresentationState({
$0.withUpdatedFooterContentNode(contentNode)
}, transition: .immediate)
}
}
hiddenItem = (message.id, media)
}
}
if strongSelf.didSetReady {
strongSelf._hiddenMedia.set(.single(hiddenItem))
}
}
}
if let _ = self.messageView {
self.applyMessageView()
}
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if self.screenCaptureEventsDisposable == nil {
self.screenCaptureEventsDisposable = (screenCaptureEvents()
|> deliverOnMainQueue).start(next: { [weak self] _ in
if let strongSelf = self, strongSelf.traceVisibility() {
if strongSelf.messageId.peerId.namespace == Namespaces.Peer.CloudUser {
let _ = enqueueMessages(account: strongSelf.context.account, peerId: strongSelf.messageId.peerId, messages: [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaAction(action: TelegramMediaActionType.historyScreenshot)), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]).start()
} else if strongSelf.messageId.peerId.namespace == Namespaces.Peer.SecretChat {
let _ = strongSelf.context.engine.messages.addSecretChatMessageScreenshot(peerId: strongSelf.messageId.peerId).start()
}
}
})
}
var nodeAnimatesItself = false
if let centralItemNode = self.controllerNode.pager.centralItemNode(), let message = self.messageView?.message {
if let media = mediaForMessage(message: message) {
if let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments, let transitionArguments = presentationArguments.transitionArguments(message.id, media) {
nodeAnimatesItself = true
centralItemNode.activateAsInitial()
if presentationArguments.animated {
centralItemNode.animateIn(from: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: {})
}
self._hiddenMedia.set(.single((message.id, media)))
} else if self.isPresentedInPreviewingContext() {
centralItemNode.activateAsInitial()
}
}
}
self.controllerNode.setControlsHidden(false, animated: false)
if let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments {
if presentationArguments.animated {
self.controllerNode.animateIn(animateContent: !nodeAnimatesItself, useSimpleAnimation: false)
}
}
if self.currentNodeMessageIsViewOnce {
let _ = (ApplicationSpecificNotice.incrementViewOnceTooltip(accountManager: self.context.sharedContext.accountManager)
|> deliverOnMainQueue).start(next: { [weak self] count in
guard let self else {
return
}
if count < 2 {
self.presentViewOnceTooltip()
}
})
}
}
private func dismiss(forceAway: Bool) {
self.dismissAllTooltips()
var animatedOutNode = true
var animatedOutInterface = false
let completion = { [weak self] in
if animatedOutNode && animatedOutInterface {
self?._hiddenMedia.set(.single(nil))
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
}
if let centralItemNode = self.controllerNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments, let message = self.messageView?.message {
if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media), !forceAway {
animatedOutNode = false
centralItemNode.animateOut(to: transitionArguments.transitionNode, addToTransitionSurface: transitionArguments.addToTransitionSurface, completion: {
animatedOutNode = true
completion()
})
}
}
self.controllerNode.animateOut(animateContent: animatedOutNode, completion: {
animatedOutInterface = true
completion()
})
}
private func applyMessageView() {
var message: Message?
if let messageView = self.messageView, let m = messageView.message {
message = m
for media in m.media {
if media is TelegramMediaExpiredContent {
message = nil
break
}
}
}
if let message = message {
if self.currentNodeMessageId != message.id {
self.currentNodeMessageId = message.id
var tempFilePath: String?
var duration: Double = 0.0
for media in message.media {
if let file = media as? TelegramMediaFile {
if let path = self.context.account.postbox.mediaBox.completedResourcePath(file.resource) {
let tempFile = TempBox.shared.file(path: path, fileName: file.fileName ?? "file")
self.tempFile = tempFile
tempFilePath = tempFile.path
self.currentNodeMessageIsVideo = true
}
duration = file.duration ?? 0.0
break
}
}
let entry = GalleryEntry(entry: MessageHistoryEntry(message: message, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false)))
guard let item = galleryItemForEntry(context: self.context, presentationData: self.presentationData, entry: entry, streamVideos: false, hideControls: true, isSecret: true, playbackRate: { nil }, peerIsCopyProtected: true, tempFilePath: tempFilePath, playbackCompleted: { [weak self] in
if let self {
if self.currentNodeMessageIsViewOnce || (duration < 30.0 && !self.currentMessageIsDismissed) {
if let node = self.controllerNode.pager.centralItemNode() as? UniversalVideoGalleryItemNode {
node.seekToStart()
}
} else {
self.dismiss(forceAway: false)
}
}
}, present: { _, _ in }) else {
self._ready.set(.single(true))
return
}
self.controllerNode.pager.replaceItems([item], centralItemIndex: 0)
let ready = self.controllerNode.pager.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void())) |> afterNext { [weak self] _ in
self?.didSetReady = true
}
self._ready.set(ready |> map { true })
self.markMessageAsConsumedDisposable.set(self.context.engine.messages.markMessageContentAsConsumedInteractively(messageId: message.id).start())
} else {
var beginTimeAndTimeout: (Double, Double, Bool)?
var videoDuration: Double?
for media in message.media {
if let file = media as? TelegramMediaFile {
videoDuration = file.duration
}
}
let isOutgoing = !message.flags.contains(.Incoming)
if let attribute = message.autoclearAttribute {
if let countdownBeginTime = attribute.countdownBeginTime {
if let videoDuration = videoDuration, attribute.timeout != viewOnceTimeout {
beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, max(videoDuration, Double(attribute.timeout)), isOutgoing)
} else {
beginTimeAndTimeout = (Double(countdownBeginTime), Double(attribute.timeout), isOutgoing)
}
} else if isOutgoing {
beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, attribute.timeout != viewOnceTimeout ? 0.0 : Double(viewOnceTimeout), isOutgoing)
}
} else if let attribute = message.autoremoveAttribute {
if let countdownBeginTime = attribute.countdownBeginTime {
if let videoDuration = videoDuration, attribute.timeout != viewOnceTimeout {
beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, max(videoDuration, Double(attribute.timeout)), isOutgoing)
} else {
beginTimeAndTimeout = (Double(countdownBeginTime), Double(attribute.timeout), isOutgoing)
}
} else if isOutgoing {
beginTimeAndTimeout = (CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970, attribute.timeout != viewOnceTimeout ? 0.0 : Double(viewOnceTimeout), isOutgoing)
}
}
if self.isNodeLoaded {
if let beginTimeAndTimeout = beginTimeAndTimeout {
self.controllerNode.beginTimeAndTimeout = beginTimeAndTimeout
}
}
}
} else {
if !self.didSetReady {
self._ready.set(.single(true))
}
if !(self.currentNodeMessageIsVideo || self.currentNodeMessageIsViewOnce) {
self.dismiss()
}
self.currentMessageIsDismissed = true
}
}
private func dismissAllTooltips() {
if let tooltipController = self.tooltipController {
self.tooltipController = nil
tooltipController.dismiss()
}
}
private func presentViewOnceTooltip() {
guard self.currentNodeMessageIsViewOnce, let sourceView = self.controllerNode.timeoutNode?.view else {
return
}
self.dismissAllTooltips()
let absoluteFrame = sourceView.convert(sourceView.bounds, to: nil)
let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.maxY + 2.0), size: CGSize())
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let iconName = "anim_autoremove_on"
let text: String
if self.currentNodeMessageIsVideo {
text = presentationData.strings.Gallery_ViewOnceVideoTooltip
} else {
text = presentationData.strings.Gallery_ViewOncePhotoTooltip
}
let tooltipController = TooltipScreen(
account: self.context.account,
sharedContext: self.context.sharedContext,
text: .plain(text: text),
balancedTextLayout: true,
constrainWidth: 210.0,
style: .customBlur(UIColor(rgb: 0x18181a), 0.0),
arrowStyle: .small,
icon: .animation(name: iconName, delay: 0.1, tintColor: nil),
location: .point(location, .top),
displayDuration: .default,
inset: 8.0,
cornerRadius: 8.0,
shouldDismissOnTouch: { _, _ in
return .ignore
}
)
self.tooltipController = tooltipController
self.present(tooltipController, in: .window(.root))
}
public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.frame = CGRect(origin: CGPoint(), size: layout.size)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
override public func dismiss(completion: (() -> Void)? = nil) {
self.presentingViewController?.dismiss(animated: false, completion: completion)
}
}
@@ -0,0 +1,46 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import SwiftSignalKit
private let textFont = Font.regular(16.0)
final class SecretMediaPreviewFooterContentNode: GalleryFooterContentNode {
private var currentText: String?
private let textNode: ImmediateTextNode
override init() {
self.textNode = ImmediateTextNode()
self.textNode.isUserInteractionEnabled = false
self.textNode.displaysAsynchronously = false
super.init()
self.addSubnode(self.textNode)
}
func setText(_ text: String) {
if self.currentText != text {
self.currentText = text
self.textNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: .white)
self.requestLayout?(.immediate)
}
}
override func updateLayout(size: CGSize, metrics: LayoutMetrics, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, contentInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
let width = size.width
let panelHeight: CGFloat = 44.0 + bottomInset
let sideInset: CGFloat = leftInset + 8.0
let textSize = self.textNode.updateLayout(CGSize(width: width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floor((width - textSize.width) / 2.0), y: floor((44.0 - textSize.height) / 2.0)), size: textSize))
return panelHeight
}
}
@@ -0,0 +1,283 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
open class ZoomableContentGalleryItemNode: GalleryItemNode, ASScrollViewDelegate {
public let scrollNode: ASScrollNode
private var containerLayout: ContainerViewLayout?
private var ignoreZoom = false
private var ignoreZoomTransition: ContainedViewLayoutTransition?
public var zoomableContent: (CGSize, ASDisplayNode)? {
didSet {
if oldValue?.1 !== self.zoomableContent?.1 {
if let node = oldValue?.1 {
node.view.removeFromSuperview()
}
if let node = self.zoomableContent?.1 {
self.scrollNode.addSubnode(node)
}
}
self.resetScrollViewContents(transition: .immediate)
self.centerScrollViewContents(transition: .immediate)
}
}
override public init() {
self.scrollNode = ASScrollNode()
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.scrollNode.view.contentInsetAdjustmentBehavior = .never
}
super.init()
self.scrollNode.view.delegate = self.wrappedScrollViewDelegate
self.scrollNode.view.showsVerticalScrollIndicator = false
self.scrollNode.view.showsHorizontalScrollIndicator = false
self.scrollNode.view.clipsToBounds = false
self.scrollNode.view.scrollsToTop = false
self.scrollNode.view.delaysContentTouches = false
let edgeWidth: CGFloat = 44.0
let tapRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.contentTap(_:)))
tapRecognizer.tapActionAtPoint = { [weak self] location in
if let strongSelf = self {
let pointInNode = strongSelf.scrollNode.view.convert(location, to: strongSelf.view)
if pointInNode.x < edgeWidth || pointInNode.x > strongSelf.frame.width - edgeWidth {
return .waitForSingleTap
}
}
return .waitForDoubleTap
}
self.scrollNode.view.addGestureRecognizer(tapRecognizer)
self.addSubnode(self.scrollNode)
}
open func contentTapAction() -> Bool {
return false
}
@objc open func contentTap(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
if recognizer.state == .ended {
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation {
let pointInNode = self.scrollNode.view.convert(location, to: self.view)
if pointInNode.x < 44.0 || pointInNode.x > self.frame.width - 44.0 {
} else {
if self.contentTapAction() {
return
}
switch gesture {
case .tap:
self.toggleControlsVisibility()
case .doubleTap:
if let contentView = self.zoomableContent?.1.view, self.scrollNode.view.zoomScale.isLessThanOrEqualTo(self.scrollNode.view.minimumZoomScale) {
let pointInView = self.scrollNode.view.convert(location, to: contentView)
let newZoomScale = self.scrollNode.view.maximumZoomScale
let scrollViewSize = self.scrollNode.view.bounds.size
let w = scrollViewSize.width / newZoomScale
let h = scrollViewSize.height / newZoomScale
let x = pointInView.x - (w / 2.0)
let y = pointInView.y - (h / 2.0)
let rectToZoomTo = CGRect(x: x, y: y, width: w, height: h)
self.scrollNode.view.zoom(to: rectToZoomTo, animated: true)
} else {
self.scrollNode.view.setZoomScale(self.scrollNode.view.minimumZoomScale, animated: true)
}
default:
break
}
}
}
}
}
override open func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
var shouldResetContents = false
if let containerLayout = self.containerLayout {
shouldResetContents = !containerLayout.size.equalTo(layout.size)
} else {
shouldResetContents = true
}
self.containerLayout = layout
if shouldResetContents {
var previousFrame: CGRect?
var previousScale: CGFloat?
if let (_, contentNode) = self.zoomableContent {
previousFrame = contentNode.view.frame
let t = contentNode.layer.transform
previousScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
}
transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: layout.size))
self.resetScrollViewContents(transition: .immediate)
if let (_, contentNode) = self.zoomableContent, let previousFrame = previousFrame, let previousScale = previousScale {
transition.animatePosition(node: contentNode, from: CGPoint(x: previousFrame.midX, y: previousFrame.midY))
switch transition {
case .immediate:
break
case let .animated(duration, curve):
let t = contentNode.layer.transform
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
contentNode.layer.animateScale(from: previousScale, to: currentScale, duration: duration, timingFunction: curve.timingFunction)
}
}
}
}
private func resetScrollViewContents(transition: ContainedViewLayoutTransition) {
guard let (contentSize, contentNode) = self.zoomableContent else {
return
}
let boundsSize = self.scrollNode.view.bounds.size
if contentSize.width.isLessThanOrEqualTo(0.0) || contentSize.height.isLessThanOrEqualTo(0.0) || boundsSize.width.isLessThanOrEqualTo(0.0) || boundsSize.height.isLessThanOrEqualTo(0.0) {
return
}
let normalizedContentSize = contentSize.fitted(boundsSize)
self.ignoreZoom = true
self.ignoreZoomTransition = transition
self.scrollNode.view.minimumZoomScale = 1.0
self.scrollNode.view.maximumZoomScale = 1.0
//self.scrollView.normalZoomScale = 1.0
self.scrollNode.view.zoomScale = 1.0
if contentNode.view is TilingView {
contentNode.frame = CGRect(origin: CGPoint(), size: normalizedContentSize)
self.scrollNode.view.contentSize = normalizedContentSize
contentNode.transform = CATransform3DIdentity
} else {
self.scrollNode.view.contentSize = contentSize
contentNode.transform = CATransform3DIdentity
contentNode.frame = CGRect(origin: CGPoint(), size: contentSize)
}
self.centerScrollViewContents(transition: transition)
self.ignoreZoom = false
let updatedZoomScale = self.scrollNode.view.zoomScale != self.scrollNode.view.minimumZoomScale
self.scrollNode.view.zoomScale = self.scrollNode.view.minimumZoomScale
if !updatedZoomScale {
self.scrollViewDidZoom(self.scrollNode.view)
}
self.ignoreZoomTransition = nil
}
private func centerScrollViewContents(transition: ContainedViewLayoutTransition) {
guard let (contentSize, contentNode) = self.zoomableContent else {
return
}
let boundsSize = self.scrollNode.view.bounds.size
if contentSize.width.isLessThanOrEqualTo(0.0) || contentSize.height.isLessThanOrEqualTo(0.0) || boundsSize.width.isLessThanOrEqualTo(0.0) || boundsSize.height.isLessThanOrEqualTo(0.0) {
return
}
var minScale: CGFloat
var maxScale: CGFloat
if contentNode.view is TilingView {
let normalizedContentSize = contentSize.fitted(boundsSize)
let scaleWidth = boundsSize.width / normalizedContentSize.width
let scaleHeight = boundsSize.height / normalizedContentSize.height
minScale = min(scaleWidth, scaleHeight)
minScale = 1.0
maxScale = max(scaleWidth, scaleHeight)
maxScale = max(maxScale, minScale * 4.0)
if (abs(maxScale - minScale) < 0.01) {
maxScale = minScale
}
if !self.scrollNode.view.minimumZoomScale.isEqual(to: minScale) {
self.scrollNode.view.minimumZoomScale = minScale
}
if !self.scrollNode.view.maximumZoomScale.isEqual(to: maxScale) {
self.scrollNode.view.maximumZoomScale = maxScale
}
if let contentView = contentNode.view as? TilingView {
contentView.setMaximumZoomScale(maxScale, normalizedSize: normalizedContentSize)
}
} else {
let scaleWidth = boundsSize.width / contentSize.width
let scaleHeight = boundsSize.height / contentSize.height
let minScale = min(scaleWidth, scaleHeight)
maxScale = max(scaleWidth, scaleHeight)
maxScale = max(maxScale, minScale * 3.0)
if (abs(maxScale - minScale) < 0.01) {
maxScale = minScale
}
if !self.scrollNode.view.minimumZoomScale.isEqual(to: minScale) {
self.scrollNode.view.minimumZoomScale = minScale
}
if !self.scrollNode.view.maximumZoomScale.isEqual(to: maxScale) {
self.scrollNode.view.maximumZoomScale = maxScale
}
}
var contentFrame = contentNode.view.frame
if boundsSize.width > contentFrame.size.width {
contentFrame.origin.x = (boundsSize.width - contentFrame.size.width) / 2.0
} else {
contentFrame.origin.x = 0.0
}
if boundsSize.height >= contentFrame.size.height {
contentFrame.origin.y = (boundsSize.height - contentFrame.size.height) / 2.0
} else {
contentFrame.origin.y = 0.0
}
if !self.ignoreZoom {
transition.updateFrame(view: contentNode.view, frame: contentFrame)
}
}
open func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return self.zoomableContent?.1.view
}
open func scrollViewDidZoom(_ scrollView: UIScrollView) {
if !self.ignoreZoom {
self.centerScrollViewContents(transition: self.ignoreZoomTransition ?? .immediate)
}
if self.scrollNode.view.zoomScale.isEqual(to: self.scrollNode.view.minimumZoomScale) {
self.scrollNode.view.isScrollEnabled = false
} else {
self.scrollNode.view.isScrollEnabled = true
}
}
override open func contentSize() -> CGSize? {
if let (_, contentNode) = self.zoomableContent {
let size = contentNode.view.convert(contentNode.bounds, to: self.view).size
return CGSize(width: floor(size.width), height: floor(size.height))
}
return nil
}
}