2023-07-12 20:17:01 +02:00

525 lines
30 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import ComponentDisplayAdapters
import SwiftSignalKit
import TelegramCore
import AccountContext
import TelegramPresentationData
import PeerListItemComponent
import EmojiTextAttachmentView
import TextFormat
import ContextUI
import StickerPeekUI
import UndoUI
final class StickersResultPanelComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let files: [TelegramMediaFile]
let action: (TelegramMediaFile) -> Void
let present: (ViewController) -> Void
let presentInGlobalOverlay: (ViewController) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
files: [TelegramMediaFile],
action: @escaping (TelegramMediaFile) -> Void,
present: @escaping (ViewController) -> Void,
presentInGlobalOverlay: @escaping (ViewController) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.files = files
self.action = action
self.present = present
self.presentInGlobalOverlay = presentInGlobalOverlay
}
static func ==(lhs: StickersResultPanelComponent, rhs: StickersResultPanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.files != rhs.files {
return false
}
return true
}
private struct ItemLayout: Equatable {
var containerSize: CGSize
var bottomInset: CGFloat
var topInset: CGFloat
var sideInset: CGFloat
var itemSize: CGSize
var itemSpacing: CGFloat
var itemsPerRow: Int
var itemCount: Int
var contentSize: CGSize
init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemSize: CGSize, itemSpacing: CGFloat, itemsPerRow: Int, itemCount: Int) {
self.containerSize = containerSize
self.bottomInset = bottomInset
self.topInset = topInset
self.sideInset = sideInset
self.itemSize = itemSize
self.itemSpacing = itemSpacing
self.itemsPerRow = itemsPerRow
self.itemCount = itemCount
let rowsCount = ceil(CGFloat(itemCount) / CGFloat(itemsPerRow))
self.contentSize = CGSize(width: containerSize.width, height: topInset + rowsCount * (itemSize.height + itemSpacing) - itemSpacing + bottomInset)
}
func visibleItems(for rect: CGRect) -> Range<Int>? {
let offsetRect = rect.offsetBy(dx: 0.0, dy: -self.topInset)
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemSize.height + self.itemSpacing)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemSize.height + self.itemSpacing)))
let minVisibleIndex = minVisibleRow * self.itemsPerRow
let maxVisibleIndex = maxVisibleRow * self.itemsPerRow + self.itemsPerRow
if maxVisibleIndex >= minVisibleIndex {
return minVisibleIndex ..< (maxVisibleIndex + 1)
} else {
return nil
}
}
func itemFrame(for index: Int) -> CGRect {
let rowIndex = Int(floor(CGFloat(index) / CGFloat(self.itemsPerRow)))
let columnIndex = index % self.itemsPerRow
return CGRect(origin: CGPoint(x: self.sideInset + CGFloat(columnIndex) * (self.itemSize.width + self.itemSpacing), y: self.topInset + CGFloat(rowIndex) * (self.itemSize.height + self.itemSpacing)), size: self.itemSize)
}
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
private let backgroundView: BlurredBackgroundView
private let containerView: UIView
private let scrollView: UIScrollView
private var itemLayout: ItemLayout?
private var visibleLayers: [EngineMedia.Id: InlineStickerItemLayer] = [:]
private var fadingMaskLayer: FadingMaskLayer?
private var ignoreScrolling = false
private var component: StickersResultPanelComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true)
self.backgroundView.isUserInteractionEnabled = false
self.containerView = UIView()
self.scrollView = ScrollView()
self.scrollView.canCancelContentTouches = true
self.scrollView.delaysContentTouches = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.alwaysBounceVertical = true
self.scrollView.indicatorStyle = .white
super.init(frame: frame)
self.clipsToBounds = true
self.scrollView.delegate = self
self.addSubview(self.backgroundView)
self.addSubview(self.containerView)
self.containerView.addSubview(self.scrollView)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
let peekRecognizer = PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in
if let self, let component = self.component {
let presentationData = component.strings
let convertedPoint = self.scrollView.convert(point, from: self)
guard self.scrollView.bounds.contains(convertedPoint) else {
return nil
}
var selectedLayer: InlineStickerItemLayer?
for (_, layer) in self.visibleLayers {
if layer.frame.contains(convertedPoint) {
selectedLayer = layer
break
}
}
if let selectedLayer, let file = selectedLayer.file {
return component.context.engine.stickers.isStickerSaved(id: file.fileId)
|> deliverOnMainQueue
|> map { [weak self] isStarred -> (UIView, CGRect, PeekControllerContent)? in
if let self, let component = self.component {
let menuItems: [ContextMenuItem] = []
let _ = menuItems
let _ = presentationData
// if strongSelf.peerId != strongSelf.context.account.peerId && strongSelf.peerId?.namespace != Namespaces.Peer.SecretChat {
// menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Conversation_SendMessage_SendSilently, icon: { theme in
// return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/SilentIcon"), color: theme.actionSheet.primaryTextColor)
// }, action: { _, f in
// if let strongSelf = self, let peekController = strongSelf.peekController {
// if let animationNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.animationNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), true, false, nil, true, animationNode.view, animationNode.bounds, nil, [])
// } else if let imageNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.imageNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), true, false, nil, true, imageNode.view, imageNode.bounds, nil, [])
// }
// }
// f(.default)
// })))
// }
//
// menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Conversation_SendMessage_ScheduleMessage, icon: { theme in
// return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/ScheduleIcon"), color: theme.actionSheet.primaryTextColor)
// }, action: { _, f in
// if let strongSelf = self, let peekController = strongSelf.peekController {
// if let animationNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.animationNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), false, true, nil, true, animationNode.view, animationNode.bounds, nil, [])
// } else if let imageNode = (peekController.contentNode as? StickerPreviewPeekContentNode)?.imageNode {
// let _ = controllerInteraction.sendSticker(.standalone(media: item.file), false, true, nil, true, imageNode.view, imageNode.bounds, nil, [])
// }
// }
// f(.default)
// })))
// menuItems.append(
// .action(ContextMenuActionItem(text: isStarred ? presentationData.strings.Stickers_RemoveFromFavorites : presentationData.strings.Stickers_AddToFavorites, icon: { theme in generateTintedImage(image: isStarred ? UIImage(bundleImageName: "Chat/Context Menu/Unfave") : UIImage(bundleImageName: "Chat/Context Menu/Fave"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
// f(.default)
//
// if let self, let component = self.component {
// let _ = (component.context.engine.stickers.toggleStickerSaved(file: file, saved: !isStarred)
// |> deliverOnMainQueue).start(next: { [weak self] result in
// guard let self, let component = self.component else {
// return
// }
// switch result {
// case .generic:
// let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: component.context, file: file, loop: true, title: nil, text: !isStarred ? presentationData.strings.Conversation_StickerAddedToFavorites : presentationData.strings.Conversation_StickerRemovedFromFavorites, undoText: nil, customAction: nil), elevatedLayout: false, action: { _ in return false })
// component.presentInGlobalOverlay(controller)
// case let .limitExceeded(limit, premiumLimit):
// let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
// let text: String
// if limit == premiumLimit || premiumConfiguration.isPremiumDisabled {
// text = presentationData.strings.Premium_MaxFavedStickersFinalText
// } else {
// text = presentationData.strings.Premium_MaxFavedStickersText("\(premiumLimit)").string
// }
//
// let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: component.context, file: file, loop: true, title: presentationData.strings.Premium_MaxFavedStickersTitle("\(limit)").string, text: text, undoText: nil, customAction: nil), elevatedLayout: false, action: { [weak self] action in
// if let self, let component = self.component {
// if case .info = action {
// let controller = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .savedStickers)
// // strongSelf.getControllerInteraction?()?.navigationController()?.pushViewController(controller)
// return true
// }
// }
// return false
// })
// component.presentInGlobalOverlay(controller)
// }
// })
// }
// }))
// )
//
// menuItems.append(
// .action(ContextMenuActionItem(text: presentationData.strings.StickerPack_ViewPack, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Sticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
// f(.default)
//
// if let self, let component = self.component {
// loop: for attribute in file.attributes {
// switch attribute {
// case let .Sticker(_, packReference, _):
// if let packReference = packReference {
// let controller = component.context.sharedContext.makeStickerPackScreen(context: component.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: nil, sendSticker: { [weak self] file, sourceNode, sourceRect in
// if let self, let component = self.component {
// component.action(file)
// return true
// } else {
// return false
// }
// })
// component.present(controller)
// }
// break loop
// default:
// break
// }
// }
// }
// }))
// )
return (self, self.scrollView.convert(selectedLayer.frame, to: self), StickerPreviewPeekContent(context: component.context, theme: component.theme, strings: component.strings, item: .pack(file), menu: menuItems, openPremiumIntro: { [weak self] in
guard let self, let component = self.component else {
return
}
let controller = component.context.sharedContext.makePremiumIntroController(context: component.context, source: .stickers)
component.present(controller)
// let controller = PremiumIntroScreen(context: component.context, source: .stickers)
// controllerInteraction.navigationController()?.pushViewController(controller)
}))
} else {
return nil
}
}
}
}
return nil
}, present: { [weak self] content, sourceView, sourceRect in
if let self, let component = self.component {
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: component.theme)
let controller = PeekController(presentationData: presentationData, content: content, sourceView: {
return (sourceView, sourceRect)
})
component.presentInGlobalOverlay(controller)
return controller
}
return nil
}, updateContent: { [weak self] content in
if let self {
var item: TelegramMediaFile?
if let content = content as? StickerPreviewPeekContent, case let .pack(contentItem) = content.item {
item = contentItem
}
let _ = item
let _ = self
//strongSelf.updatePreviewingItem(file: item, animated: true)
}
})
self.addGestureRecognizer(peekRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let location = recognizer.location(in: self.scrollView)
if self.scrollView.bounds.contains(location) {
var closestFile: (file: TelegramMediaFile, distance: CGFloat)?
for (_, itemLayer) in self.visibleLayers {
guard let file = itemLayer.file else {
continue
}
if itemLayer.frame.contains(location) {
closestFile = (file, 0.0)
}
}
if let (file, _) = closestFile {
self.component?.action(file)
}
}
}
}
func animateIn(transition: Transition) {
let offset = self.scrollView.contentOffset.y * -1.0 + 10.0
Transition.immediate.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset))
transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: 0.0))
}
func animateOut(transition: Transition, completion: @escaping () -> Void) {
let offset = self.scrollView.contentOffset.y * -1.0 + 10.0
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset), completion: { _ in
completion()
})
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
private func updateScrolling(transition: Transition) {
guard let component = self.component, let itemLayout = self.itemLayout else {
return
}
let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -200.0)
var synchronousLoad = false
if let hint = transition.userData(PeerListItemComponent.TransitionHint.self) {
synchronousLoad = hint.synchronousLoad
}
var visibleIds = Set<EngineMedia.Id>()
if let range = itemLayout.visibleItems(for: visibleBounds) {
for index in range.lowerBound ..< range.upperBound {
guard index < component.files.count else {
continue
}
let itemFrame = itemLayout.itemFrame(for: index)
let item = component.files[index]
visibleIds.insert(item.fileId)
let itemLayer: InlineStickerItemLayer
if let current = self.visibleLayers[item.fileId] {
itemLayer = current
itemLayer.dynamicColor = .white
} else {
itemLayer = InlineStickerItemLayer(
context: component.context,
userLocation: .other,
attemptSynchronousLoad: synchronousLoad,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: item.fileId.id, file: item),
file: item,
cache: component.context.animationCache,
renderer: component.context.animationRenderer,
placeholderColor: UIColor(rgb: 0xffffff).mixedWith(UIColor(rgb: 0x1c1c1d), alpha: 0.9),
pointSize: itemFrame.size,
dynamicColor: .white
)
self.visibleLayers[item.fileId] = itemLayer
self.scrollView.layer.addSublayer(itemLayer)
}
itemLayer.frame = itemFrame
itemLayer.isVisibleForAnimations = true
}
}
var removedIds: [EngineMedia.Id] = []
for (id, itemLayer) in self.visibleLayers {
if !visibleIds.contains(id) {
itemLayer.removeFromSuperlayer()
removedIds.append(id)
}
}
for id in removedIds {
self.visibleLayers.removeValue(forKey: id)
}
let backgroundSize = CGSize(width: self.scrollView.frame.width, height: self.scrollView.frame.height + 20.0)
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: 0.0, y: max(0.0, self.scrollView.contentOffset.y * -1.0)), size: backgroundSize))
self.backgroundView.update(size: backgroundSize, cornerRadius: 11.0, transition: transition.containedViewLayoutTransition)
}
func update(component: StickersResultPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
//let itemUpdated = self.component?.results != component.results
self.component = component
self.state = state
let minimizedHeight = min(availableSize.height, 500.0)
self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.7), transition: transition.containedViewLayoutTransition)
let itemsPerRow = min(8, max(5, Int(availableSize.width / 80)))
let sideInset: CGFloat = 2.0
let itemSpacing: CGFloat = 2.0
let itemSize = floor((availableSize.width - sideInset * 2.0 - itemSpacing * (CGFloat(itemsPerRow) - 1.0)) / CGFloat(itemsPerRow))
let itemLayout = ItemLayout(
containerSize: CGSize(width: availableSize.width, height: minimizedHeight),
bottomInset: 40.0,
topInset: 9.0,
sideInset: sideInset,
itemSize: CGSize(width: itemSize, height: itemSize),
itemSpacing: itemSpacing,
itemsPerRow: itemsPerRow,
itemCount: component.files.count
)
self.itemLayout = itemLayout
let scrollContentSize = itemLayout.contentSize
self.ignoreScrolling = true
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: minimizedHeight)))
let visibleTopContentHeight = min(scrollContentSize.height, itemSize * 3.0 + 19.0)
let topInset = availableSize.height - visibleTopContentHeight
let scrollContentInsets = UIEdgeInsets(top: topInset, left: 0.0, bottom: 19.0, right: 0.0)
let scrollIndicatorInsets = UIEdgeInsets(top: topInset + 17.0, left: 0.0, bottom: 19.0, right: 0.0)
if self.scrollView.contentInset != scrollContentInsets {
self.scrollView.contentInset = scrollContentInsets
}
if self.scrollView.scrollIndicatorInsets != scrollIndicatorInsets {
self.scrollView.scrollIndicatorInsets = scrollIndicatorInsets
}
if self.scrollView.contentSize != scrollContentSize {
self.scrollView.contentSize = scrollContentSize
}
let maskLayer: FadingMaskLayer
if let current = self.fadingMaskLayer {
maskLayer = current
} else {
maskLayer = FadingMaskLayer()
self.fadingMaskLayer = maskLayer
}
if self.containerView.layer.mask == nil {
self.containerView.layer.mask = maskLayer
}
maskLayer.frame = CGRect(origin: .zero, size: self.scrollView.frame.size)
self.containerView.frame = CGRect(origin: .zero, size: availableSize)
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class FadingMaskLayer: SimpleLayer {
let gradientLayer = SimpleLayer()
let fillLayer = SimpleLayer()
override func layoutSublayers() {
let gradientHeight: CGFloat = 110.0
if self.gradientLayer.contents == nil {
self.addSublayer(self.gradientLayer)
self.addSublayer(self.fillLayer)
let gradientImage = generateGradientImage(size: CGSize(width: 1.0, height: gradientHeight), colors: [UIColor.white, UIColor.white, UIColor.white.withAlphaComponent(0.0), UIColor.white.withAlphaComponent(0.0)], locations: [0.0, 0.4, 0.9, 1.0], direction: .vertical)
self.gradientLayer.contents = gradientImage?.cgImage
self.gradientLayer.contentsGravity = .resize
self.fillLayer.backgroundColor = UIColor.white.cgColor
}
self.fillLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.width, height: self.bounds.height - gradientHeight))
self.gradientLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: self.bounds.height - gradientHeight), size: CGSize(width: self.bounds.width, height: gradientHeight))
}
}