2025-02-14 19:11:58 +04:00

347 lines
16 KiB
Swift

import Foundation
import UIKit
import Display
import TelegramPresentationData
import ComponentFlow
import ComponentDisplayAdapters
import SwitchComponent
import EntityKeyboard
import AccountContext
import HierarchyTrackingLayer
import TelegramCore
private final class CaretIndicatorView: UIImageView {
private let hierarchyTrackingLayer: HierarchyTrackingLayer
override init(frame: CGRect) {
self.hierarchyTrackingLayer = HierarchyTrackingLayer()
super.init(frame: frame)
self.layer.addSublayer(self.hierarchyTrackingLayer)
self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in
self?.restartAnimations(delayStart: false)
}
}
required init?(coder: NSCoder) {
preconditionFailure()
}
func restartAnimations(delayStart: Bool) {
self.layer.removeAnimation(forKey: "caret")
let animation = CAKeyframeAnimation(keyPath: "opacity")
animation.values = [1.0 as NSNumber, 0.0 as NSNumber, 1.0 as NSNumber, 1.0 as NSNumber]
let firstDuration = 0.3
let secondDuration = 0.25
let restDuration = 0.35
let duration = firstDuration + secondDuration + restDuration
let keyTimes: [NSNumber] = [0.0 as NSNumber, (firstDuration / duration) as NSNumber, ((firstDuration + secondDuration) / duration) as NSNumber, ((firstDuration + secondDuration + restDuration) / duration) as NSNumber]
animation.keyTimes = keyTimes
animation.duration = duration
animation.repeatCount = Float.greatestFiniteMagnitude
animation.fillMode = .both
if delayStart {
animation.beginTime = self.layer.convertTime(CACurrentMediaTime(), from: nil) + 0.8 * UIView.animationDurationFactor()
}
self.layer.add(animation, forKey: "caret")
}
}
final class EmojiListInputComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let placeholder: String
let reactionItems: [EmojiComponentReactionItem]
let isInputActive: Bool
let caretPosition: Int
let activateInput: () -> Void
let setCaretPosition: (Int) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
placeholder: String,
reactionItems: [EmojiComponentReactionItem],
isInputActive: Bool,
caretPosition: Int,
activateInput: @escaping () -> Void,
setCaretPosition: @escaping (Int) -> Void
) {
self.context = context
self.theme = theme
self.placeholder = placeholder
self.reactionItems = reactionItems
self.isInputActive = isInputActive
self.caretPosition = caretPosition
self.activateInput = activateInput
self.setCaretPosition = setCaretPosition
}
static func ==(lhs: EmojiListInputComponent, rhs: EmojiListInputComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.placeholder != rhs.placeholder {
return false
}
if lhs.reactionItems != rhs.reactionItems {
return false
}
if lhs.isInputActive != rhs.isInputActive {
return false
}
if lhs.caretPosition != rhs.caretPosition {
return false
}
return true
}
final class View: UIView {
private var component: EmojiListInputComponent?
private weak var state: EmptyComponentState?
private var itemLayers: [Int64: EmojiKeyboardItemLayer] = [:]
private let trailingPlaceholder = ComponentView<Empty>()
private let caretIndicator: CaretIndicatorView
override init(frame: CGRect) {
self.caretIndicator = CaretIndicatorView(frame: CGRect())
self.caretIndicator.image = generateImage(CGSize(width: 2.0, height: 4.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor.white.cgColor)
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: size.width * 0.5).cgPath)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 1, topCapHeight: 2).withRenderingMode(.alwaysTemplate)
super.init(frame: frame)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
required init(coder: NSCoder) {
preconditionFailure()
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
let point = recognizer.location(in: self)
var tapOnItem = false
for (itemId, itemLayer) in self.itemLayers {
if itemLayer.frame.insetBy(dx: -6.0, dy: -6.0).contains(point) {
if let itemIndex = component.reactionItems.firstIndex(where: { $0.file.fileId.id == itemId }) {
var caretPosition = point.x >= itemLayer.frame.midX ? (itemIndex + 1) : itemIndex
caretPosition = max(0, min(component.reactionItems.count, caretPosition))
component.setCaretPosition(caretPosition)
component.activateInput()
}
tapOnItem = true
break
}
}
if !tapOnItem {
component.setCaretPosition(component.reactionItems.count)
component.activateInput()
}
}
}
func caretRect() -> CGRect? {
if !self.caretIndicator.isHidden {
return self.caretIndicator.frame
} else {
return nil
}
}
func update(component: EmojiListInputComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let verticalInset: CGFloat = 12.0
let placeholderSpacing: CGFloat = 6.0
let minItemSize: CGFloat = 24.0
let itemSpacingFactor: CGFloat = 0.15
let minSideInset: CGFloat = 12.0
self.backgroundColor = component.theme.list.itemBlocksBackgroundColor
self.layer.cornerRadius = 12.0
let maxItemsWidth = availableSize.width - minSideInset * 2.0
let itemsPerRow = Int(floor((maxItemsWidth + minItemSize * itemSpacingFactor) / (minItemSize + minItemSize * itemSpacingFactor)))
let itemSizePlusSpacing = maxItemsWidth / CGFloat(itemsPerRow)
let itemSize = floor(itemSizePlusSpacing * (1.0 - itemSpacingFactor))
let itemSpacing = floor(itemSizePlusSpacing * itemSpacingFactor)
let sideInset = floor((availableSize.width - (itemSize * CGFloat(itemsPerRow) + itemSpacing * CGFloat(itemsPerRow - 1))) * 0.5)
var rowCount = (component.reactionItems.count + (itemsPerRow - 1)) / itemsPerRow
rowCount = max(1, rowCount)
if let previousComponent = self.component, (previousComponent.reactionItems.count != component.reactionItems.count || previousComponent.isInputActive != component.isInputActive) {
self.caretIndicator.restartAnimations(delayStart: true)
}
self.component = component
self.state = state
let trailingPlaceholderSize = self.trailingPlaceholder.update(
transition: .immediate,
component: AnyComponent(Text(text: component.placeholder, font: Font.regular(17.0), color: component.theme.list.itemPlaceholderTextColor)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0)
)
var lastRowItemCount = component.reactionItems.count % itemsPerRow
if lastRowItemCount == 0 && !component.reactionItems.isEmpty {
lastRowItemCount = itemsPerRow
}
let trailingLineWidth = sideInset + CGFloat(lastRowItemCount) * (itemSize + itemSpacing) + placeholderSpacing
var contentHeight: CGFloat = verticalInset * 2.0 + CGFloat(rowCount) * itemSize + CGFloat(max(0, rowCount - 1)) * itemSpacing
let trailingPlaceholderFrame: CGRect
if availableSize.width - sideInset - trailingLineWidth < trailingPlaceholderSize.width {
contentHeight += itemSize + itemSpacing
trailingPlaceholderFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset + CGFloat(rowCount) * (itemSize + itemSpacing) + floor((itemSize - trailingPlaceholderSize.height) * 0.5)), size: trailingPlaceholderSize)
} else {
trailingPlaceholderFrame = CGRect(origin: CGPoint(x: trailingLineWidth, y: verticalInset + CGFloat(rowCount - 1) * (itemSize + itemSpacing) + floor((itemSize - trailingPlaceholderSize.height) * 0.5)), size: trailingPlaceholderSize)
}
if let trailingPlaceholderView = self.trailingPlaceholder.view {
if trailingPlaceholderView.superview == nil {
trailingPlaceholderView.layer.anchorPoint = CGPoint()
self.addSubview(trailingPlaceholderView)
self.addSubview(self.caretIndicator)
}
transition.setPosition(view: trailingPlaceholderView, position: trailingPlaceholderFrame.origin)
trailingPlaceholderView.bounds = CGRect(origin: CGPoint(), size: trailingPlaceholderFrame.size)
}
self.caretIndicator.tintColor = component.theme.list.itemAccentColor
self.caretIndicator.isHidden = !component.isInputActive
var caretFrame = CGRect(origin: CGPoint(x: trailingPlaceholderFrame.minX, y: trailingPlaceholderFrame.minY + floorToScreenPixels((trailingPlaceholderFrame.height - 22.0) * 0.5)), size: CGSize(width: 2.0, height: 22.0))
var validIds: [Int64] = []
for i in 0 ..< component.reactionItems.count {
let item = component.reactionItems[i]
let itemKey = item.file.fileId.id
validIds.append(itemKey)
let itemFrame = CGRect(origin: CGPoint(x: sideInset + CGFloat(i % itemsPerRow) * (itemSize + itemSpacing), y: verticalInset + CGFloat(i / itemsPerRow) * (itemSize + itemSpacing)), size: CGSize(width: itemSize, height: itemSize))
var itemTransition = transition
var animateIn = false
let itemLayer: EmojiKeyboardItemLayer
if let current = self.itemLayers[itemKey] {
itemLayer = current
} else {
itemTransition = .immediate
animateIn = true
let animationData = EntityKeyboardAnimationData(
file: item.file
)
itemLayer = EmojiKeyboardItemLayer(
item: EmojiPagerContentComponent.Item(
animationData: animationData,
content: .animation(animationData),
itemFile: item.file,
subgroupId: nil,
icon: .none,
tintMode: item.file.isCustomTemplateEmoji ? .primary : .none
),
context: component.context,
attemptSynchronousLoad: false,
content: EmojiPagerContentComponent.ItemContent.animation(animationData),
cache: component.context.animationCache,
renderer: component.context.animationRenderer,
placeholderColor: component.theme.list.mediaPlaceholderColor,
blurredBadgeColor: .clear,
accentIconColor: component.theme.list.itemPrimaryTextColor,
pointSize: CGSize(width: 32.0, height: 32.0),
onUpdateDisplayPlaceholder: { _, _ in
}
)
self.itemLayers[itemKey] = itemLayer
self.layer.addSublayer(itemLayer)
}
itemLayer.isVisibleForAnimations = true
switch itemLayer.item.tintMode {
case .none:
itemLayer.layerTintColor = nil
case .accent, .primary, .custom:
itemLayer.layerTintColor = component.theme.list.itemPrimaryTextColor.cgColor
}
itemTransition.setFrame(layer: itemLayer, frame: itemFrame)
if component.caretPosition == i {
caretFrame = CGRect(origin: CGPoint(x: itemFrame.minX - 2.0, y: itemFrame.minY + floorToScreenPixels((itemFrame.height - 22.0) * 0.5)), size: CGSize(width: 2.0, height: 22.0))
} else if i == component.reactionItems.count - 1 && component.caretPosition == i + 1 {
caretFrame = CGRect(origin: CGPoint(x: itemFrame.maxX + itemSpacing, y: itemFrame.minY + floorToScreenPixels((itemFrame.height - 22.0) * 0.5)), size: CGSize(width: 2.0, height: 22.0))
}
if animateIn, !transition.animation.isImmediate {
itemLayer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
itemLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
var removedIds: [Int64] = []
for (key, itemLayer) in self.itemLayers {
if !validIds.contains(key) {
removedIds.append(key)
if !transition.animation.isImmediate {
itemLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemLayer] _ in
itemLayer?.removeFromSuperlayer()
})
itemLayer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false)
} else {
itemLayer.removeFromSuperlayer()
}
}
}
for key in removedIds {
self.itemLayers.removeValue(forKey: key)
}
if !transition.animation.isImmediate && abs(caretFrame.midY - self.caretIndicator.center.y) > 2.0 {
if let caretSnapshot = self.caretIndicator.snapshotView(afterScreenUpdates: false) {
caretSnapshot.frame = self.caretIndicator.frame
self.insertSubview(caretSnapshot, aboveSubview: self.caretIndicator)
caretSnapshot.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak caretSnapshot] _ in
caretSnapshot?.removeFromSuperview()
})
caretSnapshot.layer.animateScale(from: 1.0, to: 0.001, duration: 0.15, removeOnCompletion: false)
}
self.caretIndicator.frame = caretFrame
self.caretIndicator.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
self.caretIndicator.layer.animateScale(from: 0.001, to: 1.0, duration: 0.15)
} else {
transition.setFrame(view: self.caretIndicator, frame: caretFrame)
}
return CGSize(width: availableSize.width, height: contentHeight)
}
}
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)
}
}