mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-08-23 22:12:43 +00:00
1082 lines
46 KiB
Swift
1082 lines
46 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AsyncDisplayKit
|
|
import ComponentFlow
|
|
import PlainButtonComponent
|
|
import MultilineTextWithEntitiesComponent
|
|
import TextFormat
|
|
import AccountContext
|
|
import TelegramPresentationData
|
|
|
|
public final class TabSelectorComponent: Component {
|
|
public final class ItemEnvironment: Equatable {
|
|
public let selectionFraction: CGFloat
|
|
|
|
init(selectionFraction: CGFloat) {
|
|
self.selectionFraction = selectionFraction
|
|
}
|
|
|
|
public static func ==(lhs: ItemEnvironment, rhs: ItemEnvironment) -> Bool {
|
|
if lhs.selectionFraction != rhs.selectionFraction {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
public struct Colors: Equatable {
|
|
public var foreground: UIColor
|
|
public var selection: UIColor
|
|
public var simple: Bool
|
|
|
|
public init(
|
|
foreground: UIColor,
|
|
selection: UIColor,
|
|
simple: Bool = false
|
|
) {
|
|
self.foreground = foreground
|
|
self.selection = selection
|
|
self.simple = simple
|
|
}
|
|
}
|
|
|
|
public struct CustomLayout: Equatable {
|
|
public var font: UIFont
|
|
public var spacing: CGFloat
|
|
public var innerSpacing: CGFloat?
|
|
public var lineSelection: Bool
|
|
public var verticalInset: CGFloat
|
|
public var allowScroll: Bool
|
|
|
|
public init(font: UIFont, spacing: CGFloat, innerSpacing: CGFloat? = nil, lineSelection: Bool = false, verticalInset: CGFloat = 0.0, allowScroll: Bool = true) {
|
|
self.font = font
|
|
self.spacing = spacing
|
|
self.innerSpacing = innerSpacing
|
|
self.lineSelection = lineSelection
|
|
self.verticalInset = verticalInset
|
|
self.allowScroll = allowScroll
|
|
}
|
|
}
|
|
|
|
public final class Item: Equatable {
|
|
public enum Content: Equatable {
|
|
case text(String)
|
|
case component(AnyComponent<ItemEnvironment>)
|
|
}
|
|
|
|
public let id: AnyHashable
|
|
public let content: Content
|
|
public let isReorderable: Bool
|
|
public let contextAction: ((ASDisplayNode, ContextGesture) -> Void)?
|
|
|
|
public init(
|
|
id: AnyHashable,
|
|
content: Content,
|
|
isReorderable: Bool = false,
|
|
contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil
|
|
) {
|
|
self.id = id
|
|
self.content = content
|
|
self.isReorderable = isReorderable
|
|
self.contextAction = contextAction
|
|
}
|
|
|
|
convenience public init(
|
|
id: AnyHashable,
|
|
title: String,
|
|
isReorderable: Bool = false,
|
|
contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil
|
|
) {
|
|
self.init(id: id, content: .text(title), isReorderable: isReorderable, contextAction: contextAction)
|
|
}
|
|
|
|
public static func ==(lhs: Item, rhs: Item) -> Bool {
|
|
if lhs.id != rhs.id {
|
|
return false
|
|
}
|
|
if lhs.content != rhs.content {
|
|
return false
|
|
}
|
|
if lhs.isReorderable != rhs.isReorderable {
|
|
return false
|
|
}
|
|
if (lhs.contextAction == nil) != (rhs.contextAction == nil) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
public let context: AccountContext?
|
|
public let colors: Colors
|
|
public let theme: PresentationTheme
|
|
public let customLayout: CustomLayout?
|
|
public let items: [Item]
|
|
public let selectedId: AnyHashable?
|
|
public let reorderItem: ((AnyHashable, AnyHashable) -> Void)?
|
|
public let setSelectedId: (AnyHashable) -> Void
|
|
public let transitionFraction: CGFloat?
|
|
|
|
public init(
|
|
context: AccountContext? = nil,
|
|
colors: Colors,
|
|
theme: PresentationTheme,
|
|
customLayout: CustomLayout? = nil,
|
|
items: [Item],
|
|
selectedId: AnyHashable?,
|
|
reorderItem: ((AnyHashable, AnyHashable) -> Void)? = nil,
|
|
setSelectedId: @escaping (AnyHashable) -> Void,
|
|
transitionFraction: CGFloat? = nil
|
|
) {
|
|
self.context = context
|
|
self.colors = colors
|
|
self.theme = theme
|
|
self.customLayout = customLayout
|
|
self.items = items
|
|
self.selectedId = selectedId
|
|
self.reorderItem = reorderItem
|
|
self.setSelectedId = setSelectedId
|
|
self.transitionFraction = transitionFraction
|
|
}
|
|
|
|
public static func ==(lhs: TabSelectorComponent, rhs: TabSelectorComponent) -> Bool {
|
|
if lhs.context !== rhs.context {
|
|
return false
|
|
}
|
|
if lhs.colors != rhs.colors {
|
|
return false
|
|
}
|
|
if lhs.theme !== rhs.theme {
|
|
return false
|
|
}
|
|
if lhs.customLayout != rhs.customLayout {
|
|
return false
|
|
}
|
|
if lhs.items != rhs.items {
|
|
return false
|
|
}
|
|
if lhs.selectedId != rhs.selectedId {
|
|
return false
|
|
}
|
|
if (lhs.reorderItem == nil) != (rhs.reorderItem == nil) {
|
|
return false
|
|
}
|
|
if lhs.transitionFraction != rhs.transitionFraction {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
final class VisibleItem: UIView {
|
|
let action: () -> Void
|
|
let contextAction: (ASDisplayNode, ContextGesture) -> Void
|
|
|
|
let extractedContainerNode: ContextExtractedContentContainingNode
|
|
let containerNode: ContextControllerSourceNode
|
|
|
|
let containerButton: UIView
|
|
var extractedBackgroundView: UIImageView?
|
|
|
|
let title = ComponentView<Empty>()
|
|
|
|
var item: Item?
|
|
|
|
var tapGesture: UITapGestureRecognizer?
|
|
var theme: PresentationTheme?
|
|
var size: CGSize?
|
|
var isReordering: Bool = false
|
|
|
|
init(action: @escaping () -> Void, contextAction: @escaping (ASDisplayNode, ContextGesture) -> Void) {
|
|
self.action = action
|
|
self.contextAction = contextAction
|
|
|
|
self.extractedContainerNode = ContextExtractedContentContainingNode()
|
|
self.containerNode = ContextControllerSourceNode()
|
|
|
|
self.containerButton = UIView()
|
|
|
|
super.init(frame: CGRect())
|
|
|
|
self.extractedContainerNode.contentNode.view.addSubview(self.containerButton)
|
|
|
|
self.containerNode.addSubnode(self.extractedContainerNode)
|
|
self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode
|
|
self.addSubview(self.containerNode.view)
|
|
|
|
//self.containerButton.addSubview(self.iconContainer)
|
|
|
|
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:)))
|
|
self.tapGesture = tapGesture
|
|
self.containerButton.addGestureRecognizer(tapGesture)
|
|
tapGesture.isEnabled = false
|
|
|
|
self.containerNode.activated = { [weak self] gesture, _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.contextAction(self.extractedContainerNode, gesture)
|
|
}
|
|
|
|
self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
|
|
guard let self, let theme = self.theme, let size = self.size else {
|
|
return
|
|
}
|
|
|
|
if isExtracted {
|
|
let extractedBackgroundView: UIImageView
|
|
if let current = self.extractedBackgroundView {
|
|
extractedBackgroundView = current
|
|
} else {
|
|
extractedBackgroundView = UIImageView(image: generateStretchableFilledCircleImage(diameter: size.height, color: theme.contextMenu.backgroundColor))
|
|
self.extractedBackgroundView = extractedBackgroundView
|
|
self.extractedContainerNode.contentNode.view.insertSubview(extractedBackgroundView, at: 0)
|
|
extractedBackgroundView.frame = self.extractedContainerNode.contentNode.bounds.insetBy(dx: 0.0, dy: 0.0)
|
|
extractedBackgroundView.alpha = 0.0
|
|
}
|
|
transition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 1.0)
|
|
} else if let extractedBackgroundView = self.extractedBackgroundView {
|
|
self.extractedBackgroundView = nil
|
|
let alphaTransition: ContainedViewLayoutTransition
|
|
if transition.isAnimated {
|
|
alphaTransition = .animated(duration: 0.18, curve: .easeInOut)
|
|
} else {
|
|
alphaTransition = .immediate
|
|
}
|
|
alphaTransition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 0.0, completion: { [weak extractedBackgroundView] _ in
|
|
extractedBackgroundView?.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
|
|
self.containerNode.isGestureEnabled = false
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
@objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) {
|
|
if case .ended = recognizer.state {
|
|
self.action()
|
|
}
|
|
}
|
|
|
|
private func updateIsShaking(animated: Bool) {
|
|
if self.isReordering {
|
|
if self.containerButton.layer.animation(forKey: "shaking_position") == nil {
|
|
let degreesToRadians: (_ x: CGFloat) -> CGFloat = { x in
|
|
return .pi * x / 180.0
|
|
}
|
|
|
|
let duration: Double = 0.4
|
|
let displacement: CGFloat = 1.0
|
|
let degreesRotation: CGFloat = 2.0
|
|
|
|
let negativeDisplacement = -1.0 * displacement
|
|
let position = CAKeyframeAnimation.init(keyPath: "position")
|
|
position.beginTime = 0.8
|
|
position.duration = duration
|
|
position.values = [
|
|
NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)),
|
|
NSValue(cgPoint: CGPoint(x: 0, y: 0)),
|
|
NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)),
|
|
NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)),
|
|
NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement))
|
|
]
|
|
position.calculationMode = .linear
|
|
position.isRemovedOnCompletion = false
|
|
position.repeatCount = Float.greatestFiniteMagnitude
|
|
position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100))
|
|
position.isAdditive = true
|
|
|
|
let transform = CAKeyframeAnimation.init(keyPath: "transform")
|
|
transform.beginTime = 2.6
|
|
transform.duration = 0.3
|
|
transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ)
|
|
transform.values = [
|
|
degreesToRadians(-1.0 * degreesRotation),
|
|
degreesToRadians(degreesRotation),
|
|
degreesToRadians(-1.0 * degreesRotation)
|
|
]
|
|
transform.calculationMode = .linear
|
|
transform.isRemovedOnCompletion = false
|
|
transform.repeatCount = Float.greatestFiniteMagnitude
|
|
transform.isAdditive = true
|
|
transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100))
|
|
|
|
self.containerButton.layer.add(position, forKey: "shaking_position")
|
|
self.containerButton.layer.add(transform, forKey: "shaking_rotation")
|
|
}
|
|
} else if self.containerButton.layer.animation(forKey: "shaking_position") != nil {
|
|
if let presentationLayer = self.containerButton.layer.presentation() {
|
|
let transition: ComponentTransition = .easeInOut(duration: 0.1)
|
|
if presentationLayer.position != self.containerButton.layer.position {
|
|
transition.animatePosition(layer: self.containerButton.layer, from: CGPoint(x: presentationLayer.position.x - self.containerButton.layer.position.x, y: presentationLayer.position.y - self.containerButton.layer.position.y), to: CGPoint(), additive: true)
|
|
}
|
|
if !CATransform3DIsIdentity(presentationLayer.transform) {
|
|
transition.setTransform(layer: self.containerButton.layer, transform: CATransform3DIdentity)
|
|
}
|
|
}
|
|
|
|
self.containerButton.layer.removeAnimation(forKey: "shaking_position")
|
|
self.containerButton.layer.removeAnimation(forKey: "shaking_rotation")
|
|
}
|
|
}
|
|
|
|
func update(theme: PresentationTheme, size: CGSize, item: Item, isReordering: Bool, transition: ComponentTransition) {
|
|
self.theme = theme
|
|
self.size = size
|
|
self.isReordering = isReordering
|
|
self.item = item
|
|
|
|
self.containerNode.isGestureEnabled = item.contextAction != nil && !isReordering
|
|
self.tapGesture?.isEnabled = !isReordering
|
|
|
|
transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size))
|
|
|
|
self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))
|
|
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
|
|
self.updateIsShaking(animated: !transition.animation.isImmediate)
|
|
}
|
|
}
|
|
|
|
public final class View: UIScrollView {
|
|
private var component: TabSelectorComponent?
|
|
private weak var state: EmptyComponentState?
|
|
|
|
private let selectionView: UIImageView
|
|
private var visibleItems: [AnyHashable: VisibleItem] = [:]
|
|
|
|
private var didInitiallyScroll = false
|
|
|
|
private var reorderRecognizer: ReorderGestureRecognizer?
|
|
private weak var reorderingItem: VisibleItem?
|
|
private var reorderingItemPosition: (initial: CGFloat, offset: CGFloat) = (0.0, 0.0)
|
|
|
|
override init(frame: CGRect) {
|
|
self.selectionView = UIImageView()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.showsVerticalScrollIndicator = false
|
|
self.showsHorizontalScrollIndicator = false
|
|
self.scrollsToTop = false
|
|
self.delaysContentTouches = false
|
|
self.canCancelContentTouches = true
|
|
self.contentInsetAdjustmentBehavior = .never
|
|
self.alwaysBounceVertical = false
|
|
self.clipsToBounds = false
|
|
|
|
self.addSubview(self.selectionView)
|
|
|
|
let reorderRecognizer = ReorderGestureRecognizer(
|
|
shouldBegin: { [weak self] point in
|
|
guard let self, let component = self.component, component.reorderItem != nil else {
|
|
return (allowed: false, requiresLongPress: false, item: nil)
|
|
}
|
|
|
|
var item: VisibleItem?
|
|
for (_, visibleItem) in self.visibleItems {
|
|
if visibleItem.bounds.contains(self.convert(point, to: visibleItem)) {
|
|
item = visibleItem
|
|
break
|
|
}
|
|
}
|
|
|
|
if let item, let itemValue = item.item, itemValue.isReorderable {
|
|
return (allowed: true, requiresLongPress: false, item: item)
|
|
} else {
|
|
return (allowed: false, requiresLongPress: false, item: nil)
|
|
}
|
|
},
|
|
willBegin: { point in
|
|
},
|
|
began: { [weak self] item in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.setReorderingItem(item: item)
|
|
},
|
|
ended: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.setReorderingItem(item: nil)
|
|
},
|
|
moved: { [weak self] distance in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.moveReorderingItem(distance: distance.x)
|
|
},
|
|
isActiveUpdated: { _ in
|
|
}
|
|
)
|
|
self.reorderRecognizer = reorderRecognizer
|
|
self.addGestureRecognizer(reorderRecognizer)
|
|
reorderRecognizer.isEnabled = false
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
}
|
|
|
|
override public func touchesShouldCancel(in view: UIView) -> Bool {
|
|
return true
|
|
}
|
|
|
|
private func setReorderingItem(item: VisibleItem?) {
|
|
self.reorderingItem = item
|
|
if let item {
|
|
self.reorderingItemPosition.initial = item.frame.minX
|
|
self.reorderingItemPosition.offset = 0.0
|
|
} else {
|
|
self.reorderingItemPosition = (0.0, 0.0)
|
|
}
|
|
self.state?.updated(transition: .easeInOut(duration: 0.2))
|
|
}
|
|
|
|
private func moveReorderingItem(distance: CGFloat) {
|
|
guard let reorderingItem = self.reorderingItem else {
|
|
return
|
|
}
|
|
let previousPosition = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + reorderingItem.bounds.width * 0.5
|
|
self.reorderingItemPosition.offset = distance
|
|
let updatedPosition = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + reorderingItem.bounds.width * 0.5
|
|
|
|
self.state?.updated(transition: .immediate)
|
|
|
|
if let component = self.component, let reorderItem = component.reorderItem {
|
|
var currentId: AnyHashable?
|
|
var reorderToId: AnyHashable?
|
|
for (id, item) in self.visibleItems {
|
|
if item === reorderingItem {
|
|
currentId = id
|
|
continue
|
|
}
|
|
guard let targetItem = item.item else {
|
|
continue
|
|
}
|
|
if !targetItem.isReorderable {
|
|
continue
|
|
}
|
|
if reorderToId != nil {
|
|
continue
|
|
}
|
|
let itemCenter = item.center.x
|
|
if previousPosition < itemCenter && updatedPosition > itemCenter {
|
|
reorderToId = id
|
|
} else if previousPosition > itemCenter && updatedPosition < itemCenter {
|
|
reorderToId = id
|
|
}
|
|
}
|
|
if let currentId, let reorderToId {
|
|
reorderItem(currentId, reorderToId)
|
|
}
|
|
}
|
|
}
|
|
|
|
func update(component: TabSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
let selectionColorUpdated = component.colors.selection != self.component?.colors.selection
|
|
|
|
self.component = component
|
|
self.state = state
|
|
|
|
self.reorderRecognizer?.isEnabled = component.reorderItem != nil
|
|
|
|
let baseHeight: CGFloat = 28.0
|
|
|
|
var verticalInset: CGFloat = 0.0
|
|
if let customLayout = component.customLayout {
|
|
verticalInset = customLayout.verticalInset * 2.0
|
|
}
|
|
|
|
var innerInset: CGFloat = component.customLayout?.innerSpacing ?? 12.0
|
|
var spacing: CGFloat = component.customLayout?.spacing ?? 2.0
|
|
|
|
let itemFont: UIFont
|
|
var isLineSelection = false
|
|
let allowScroll: Bool
|
|
if let customLayout = component.customLayout {
|
|
itemFont = customLayout.font
|
|
isLineSelection = customLayout.lineSelection
|
|
allowScroll = customLayout.allowScroll || component.items.count > 3
|
|
} else {
|
|
itemFont = Font.semibold(14.0)
|
|
allowScroll = true
|
|
}
|
|
|
|
if selectionColorUpdated {
|
|
if isLineSelection {
|
|
self.selectionView.image = generateImage(CGSize(width: 5.0, height: 3.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
context.setFillColor(component.colors.selection.cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: 4.0, height: 4.0)))
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - 4.0, y: 0.0), size: CGSize(width: 4.0, height: 4.0)))
|
|
context.fill(CGRect(x: 2.0, y: 0.0, width: size.width - 4.0, height: 4.0))
|
|
context.fill(CGRect(x: 0.0, y: 2.0, width: size.width, height: 2.0))
|
|
})?.resizableImage(withCapInsets: UIEdgeInsets(top: 3.0, left: 3.0, bottom: 0.0, right: 3.0), resizingMode: .stretch)
|
|
} else {
|
|
self.selectionView.image = generateStretchableFilledCircleImage(diameter: baseHeight, color: component.colors.selection)
|
|
}
|
|
}
|
|
|
|
var innerContentWidth: CGFloat = 0.0
|
|
|
|
let selectedIndex = component.items.firstIndex(where: { $0.id == component.selectedId })
|
|
|
|
var validIds: [AnyHashable] = []
|
|
var index = 0
|
|
var itemViews: [AnyHashable: (VisibleItem, CGSize, ComponentTransition)] = [:]
|
|
|
|
for item in component.items {
|
|
var itemTransition = transition
|
|
let itemView: VisibleItem
|
|
if let current = self.visibleItems[item.id] {
|
|
itemView = current
|
|
} else {
|
|
let itemId = item.id
|
|
itemView = VisibleItem(action: { [weak self] in
|
|
guard let self, let component = self.component else {
|
|
return
|
|
}
|
|
guard let item = component.items.first(where: { $0.id == itemId }) else {
|
|
return
|
|
}
|
|
component.setSelectedId(item.id)
|
|
}, contextAction: { [weak self] sourceNode, gesture in
|
|
guard let self, let component = self.component else {
|
|
return
|
|
}
|
|
guard let item = component.items.first(where: { $0.id == itemId }) else {
|
|
return
|
|
}
|
|
item.contextAction?(sourceNode, gesture)
|
|
})
|
|
self.visibleItems[item.id] = itemView
|
|
itemTransition = itemTransition.withAnimation(.none)
|
|
}
|
|
|
|
let itemId = item.id
|
|
validIds.append(itemId)
|
|
|
|
var selectionFraction: CGFloat = 0.0
|
|
if let transitionFraction = component.transitionFraction, let selectedIndex {
|
|
if item.id == component.selectedId {
|
|
selectionFraction = 1.0 - abs(transitionFraction)
|
|
} else {
|
|
if index == selectedIndex - 1 && transitionFraction < 0.0 {
|
|
selectionFraction = abs(transitionFraction)
|
|
} else if index == selectedIndex + 1 && transitionFraction > 0.0 {
|
|
selectionFraction = abs(transitionFraction)
|
|
}
|
|
}
|
|
} else {
|
|
selectionFraction = item.id == component.selectedId ? 1.0 : 0.0
|
|
}
|
|
|
|
var useSelectionFraction = isLineSelection
|
|
if case .component = item.content {
|
|
useSelectionFraction = true
|
|
}
|
|
|
|
let itemSize = itemView.title.update(
|
|
transition: .immediate,
|
|
component: AnyComponent(ItemComponent(
|
|
context: component.context,
|
|
content: item.content,
|
|
font: itemFont,
|
|
color: component.colors.foreground,
|
|
selectedColor: component.colors.selection,
|
|
selectionFraction: useSelectionFraction ? selectionFraction : 0.0
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 200.0, height: 100.0)
|
|
)
|
|
|
|
innerContentWidth += itemSize.width
|
|
itemViews[item.id] = (itemView, itemSize, itemTransition)
|
|
index += 1
|
|
}
|
|
|
|
let estimatedContentWidth = 2.0 * spacing + innerContentWidth + (CGFloat(component.items.count - 1) * (spacing + innerInset))
|
|
if estimatedContentWidth > availableSize.width && !allowScroll {
|
|
spacing = (availableSize.width - innerContentWidth) / CGFloat(component.items.count + 1)
|
|
innerInset = 0.0
|
|
}
|
|
|
|
var contentWidth: CGFloat = spacing
|
|
var previousBackgroundRect: CGRect?
|
|
var selectedBackgroundRect: CGRect?
|
|
var nextBackgroundRect: CGRect?
|
|
var selectedItemIsReordering = false
|
|
|
|
for item in component.items {
|
|
guard let (itemView, itemSize, itemTransition) = itemViews[item.id] else {
|
|
continue
|
|
}
|
|
if contentWidth > spacing {
|
|
contentWidth += spacing
|
|
}
|
|
let baseItemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: verticalInset + floor((baseHeight - itemSize.height) * 0.5)), size: itemSize)
|
|
var itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: verticalInset), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight))
|
|
let itemTitleFrame = CGRect(origin: CGPoint(x: baseItemTitleFrame.minX - itemBackgroundRect.minX, y: baseItemTitleFrame.minY - itemBackgroundRect.minY), size: baseItemTitleFrame.size)
|
|
contentWidth = itemBackgroundRect.maxX
|
|
|
|
if self.reorderingItem === itemView {
|
|
itemBackgroundRect.origin.x = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset
|
|
if item.id == component.selectedId {
|
|
selectedItemIsReordering = true
|
|
}
|
|
}
|
|
|
|
if item.id == component.selectedId {
|
|
selectedBackgroundRect = itemBackgroundRect
|
|
}
|
|
if selectedBackgroundRect == nil {
|
|
previousBackgroundRect = itemBackgroundRect
|
|
} else if nextBackgroundRect == nil, itemBackgroundRect != selectedBackgroundRect {
|
|
nextBackgroundRect = itemBackgroundRect
|
|
}
|
|
|
|
if itemView.superview == nil {
|
|
self.addSubview(itemView)
|
|
}
|
|
|
|
if let itemTitleView = itemView.title.view {
|
|
if itemTitleView.superview == nil {
|
|
itemTitleView.layer.anchorPoint = CGPoint()
|
|
itemTitleView.isUserInteractionEnabled = false
|
|
itemView.containerButton.addSubview(itemTitleView)
|
|
}
|
|
|
|
itemTransition.setPosition(view: itemView, position: itemBackgroundRect.center)
|
|
itemTransition.setBounds(view: itemView, bounds: CGRect(origin: CGPoint(), size: itemBackgroundRect.size))
|
|
|
|
if self.reorderingItem === itemView {
|
|
itemTransition.setTransform(view: itemView, transform: CATransform3DMakeScale(1.1, 1.1, 1.0))
|
|
} else {
|
|
itemTransition.setTransform(view: itemView, transform: CATransform3DIdentity)
|
|
}
|
|
|
|
itemView.update(theme: component.theme, size: itemBackgroundRect.size, item: item, isReordering: item.isReorderable && component.reorderItem != nil, transition: itemTransition)
|
|
|
|
itemTransition.setPosition(view: itemTitleView, position: CGPoint(x: itemTitleFrame.minX, y: itemTitleFrame.minY))
|
|
itemTransition.setBounds(view: itemTitleView, bounds: CGRect(origin: CGPoint(), size: itemTitleFrame.size))
|
|
|
|
var itemAlpha: CGFloat = item.id == component.selectedId || isLineSelection || component.colors.simple ? 1.0 : 0.4
|
|
if component.reorderItem != nil && !item.isReorderable {
|
|
itemAlpha *= 0.5
|
|
itemView.isUserInteractionEnabled = false
|
|
} else {
|
|
itemView.isUserInteractionEnabled = true
|
|
}
|
|
|
|
itemTransition.setAlpha(view: itemTitleView, alpha: itemAlpha)
|
|
}
|
|
}
|
|
contentWidth += spacing
|
|
|
|
var removeIds: [AnyHashable] = []
|
|
for (id, itemView) in self.visibleItems {
|
|
if !validIds.contains(id) {
|
|
removeIds.append(id)
|
|
itemView.removeFromSuperview()
|
|
}
|
|
}
|
|
for id in removeIds {
|
|
self.visibleItems.removeValue(forKey: id)
|
|
}
|
|
|
|
if let selectedBackgroundRect {
|
|
self.selectionView.alpha = 1.0
|
|
|
|
if isLineSelection {
|
|
var effectiveBackgroundRect = selectedBackgroundRect
|
|
if let transitionFraction = component.transitionFraction {
|
|
if transitionFraction < 0.0 {
|
|
if let previousBackgroundRect {
|
|
effectiveBackgroundRect = effectiveBackgroundRect.interpolate(with: previousBackgroundRect, fraction: abs(transitionFraction))
|
|
}
|
|
} else if transitionFraction > 0.0 {
|
|
if let nextBackgroundRect {
|
|
effectiveBackgroundRect = effectiveBackgroundRect.interpolate(with: nextBackgroundRect, fraction: abs(transitionFraction))
|
|
}
|
|
}
|
|
}
|
|
|
|
var mappedSelectionFrame = effectiveBackgroundRect.insetBy(dx: innerInset, dy: 0.0)
|
|
mappedSelectionFrame.origin.y = mappedSelectionFrame.maxY + 6.0
|
|
mappedSelectionFrame.size.height = 3.0
|
|
transition.setPosition(view: self.selectionView, position: mappedSelectionFrame.center)
|
|
transition.setBounds(view: self.selectionView, bounds: CGRect(origin: CGPoint(), size: mappedSelectionFrame.size))
|
|
transition.setTransform(view: self.selectionView, transform: CATransform3DIdentity)
|
|
} else {
|
|
transition.setPosition(view: self.selectionView, position: selectedBackgroundRect.center)
|
|
transition.setBounds(view: self.selectionView, bounds: CGRect(origin: CGPoint(), size: selectedBackgroundRect.size))
|
|
if selectedItemIsReordering {
|
|
transition.setTransform(view: self.selectionView, transform: CATransform3DMakeScale(1.1, 1.1, 1.0))
|
|
} else {
|
|
transition.setTransform(view: self.selectionView, transform: CATransform3DIdentity)
|
|
}
|
|
}
|
|
} else {
|
|
self.selectionView.alpha = 0.0
|
|
}
|
|
|
|
let contentSize = CGSize(width: contentWidth, height: baseHeight + verticalInset * 2.0)
|
|
if self.contentSize != contentSize {
|
|
self.contentSize = contentSize
|
|
}
|
|
self.disablesInteractiveTransitionGestureRecognizer = contentWidth > availableSize.width
|
|
|
|
if let selectedBackgroundRect, self.bounds.width > 0.0 && !self.didInitiallyScroll {
|
|
self.scrollRectToVisible(selectedBackgroundRect.insetBy(dx: -spacing, dy: 0.0), animated: false)
|
|
self.didInitiallyScroll = true
|
|
}
|
|
|
|
return CGSize(width: min(contentWidth, availableSize.width), height: baseHeight + verticalInset * 2.0)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
extension CGRect {
|
|
func interpolate(with other: CGRect, fraction: CGFloat) -> CGRect {
|
|
return CGRect(
|
|
x: self.origin.x * (1.0 - fraction) + (other.origin.x) * fraction,
|
|
y: self.origin.y * (1.0 - fraction) + (other.origin.y) * fraction,
|
|
width: self.size.width * (1.0 - fraction) + (other.size.width) * fraction,
|
|
height: self.size.height * (1.0 - fraction) + (other.size.height) * fraction
|
|
)
|
|
}
|
|
}
|
|
|
|
private final class ItemComponent: CombinedComponent {
|
|
let context: AccountContext?
|
|
let content: TabSelectorComponent.Item.Content
|
|
let font: UIFont
|
|
let color: UIColor
|
|
let selectedColor: UIColor
|
|
let selectionFraction: CGFloat
|
|
|
|
init(
|
|
context: AccountContext?,
|
|
content: TabSelectorComponent.Item.Content,
|
|
font: UIFont,
|
|
color: UIColor,
|
|
selectedColor: UIColor,
|
|
selectionFraction: CGFloat
|
|
) {
|
|
self.context = context
|
|
self.content = content
|
|
self.font = font
|
|
self.color = color
|
|
self.selectedColor = selectedColor
|
|
self.selectionFraction = selectionFraction
|
|
}
|
|
|
|
static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool {
|
|
if lhs.context !== rhs.context {
|
|
return false
|
|
}
|
|
if lhs.content != rhs.content {
|
|
return false
|
|
}
|
|
if lhs.font != rhs.font {
|
|
return false
|
|
}
|
|
if lhs.color != rhs.color {
|
|
return false
|
|
}
|
|
if lhs.selectedColor != rhs.selectedColor {
|
|
return false
|
|
}
|
|
if lhs.selectionFraction != rhs.selectionFraction {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
static var body: Body {
|
|
let title = Child(MultilineTextWithEntitiesComponent.self)
|
|
let selectedTitle = Child(MultilineTextWithEntitiesComponent.self)
|
|
let contentComponent = Child(environment: TabSelectorComponent.ItemEnvironment.self)
|
|
|
|
return { context in
|
|
let component = context.component
|
|
|
|
switch component.content {
|
|
case let .text(text):
|
|
let attributedTitle = NSMutableAttributedString(string: text, font: component.font, textColor: component.color)
|
|
var range = (attributedTitle.string as NSString).range(of: "⭐️")
|
|
if range.location != NSNotFound {
|
|
attributedTitle.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: range)
|
|
}
|
|
|
|
let title = title.update(
|
|
component: MultilineTextWithEntitiesComponent(
|
|
context: component.context,
|
|
animationCache: component.context?.animationCache,
|
|
animationRenderer: component.context?.animationRenderer,
|
|
placeholderColor: .white,
|
|
text: .plain(attributedTitle)
|
|
),
|
|
availableSize: context.availableSize,
|
|
transition: .immediate
|
|
)
|
|
context.add(title
|
|
.position(CGPoint(x: title.size.width / 2.0, y: title.size.height / 2.0))
|
|
.opacity(1.0 - component.selectionFraction)
|
|
)
|
|
|
|
let selectedAttributedTitle = NSMutableAttributedString(string: text, font: component.font, textColor: component.selectedColor)
|
|
range = (selectedAttributedTitle.string as NSString).range(of: "⭐️")
|
|
if range.location != NSNotFound {
|
|
selectedAttributedTitle.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: range)
|
|
}
|
|
|
|
let selectedTitle = selectedTitle.update(
|
|
component: MultilineTextWithEntitiesComponent(
|
|
context: nil,
|
|
animationCache: nil,
|
|
animationRenderer: nil,
|
|
placeholderColor: .white,
|
|
text: .plain(selectedAttributedTitle)
|
|
),
|
|
availableSize: context.availableSize,
|
|
transition: .immediate
|
|
)
|
|
context.add(selectedTitle
|
|
.position(CGPoint(x: selectedTitle.size.width / 2.0, y: selectedTitle.size.height / 2.0))
|
|
.opacity(component.selectionFraction)
|
|
)
|
|
|
|
return title.size
|
|
case let .component(contentComponentValue):
|
|
let content = contentComponent.update(
|
|
contentComponentValue,
|
|
environment: {
|
|
TabSelectorComponent.ItemEnvironment(selectionFraction: component.selectionFraction)
|
|
},
|
|
availableSize: context.availableSize,
|
|
transition: .immediate
|
|
)
|
|
context.add(content
|
|
.position(CGPoint(x: content.size.width / 2.0, y: content.size.height / 2.0))
|
|
)
|
|
|
|
return content.size
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class ReorderGestureRecognizer: UIGestureRecognizer {
|
|
private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: TabSelectorComponent.VisibleItem?)
|
|
private let willBegin: (CGPoint) -> Void
|
|
private let began: (TabSelectorComponent.VisibleItem) -> Void
|
|
private let ended: () -> Void
|
|
private let moved: (CGPoint) -> Void
|
|
private let isActiveUpdated: (Bool) -> Void
|
|
|
|
private var initialLocation: CGPoint?
|
|
private var longTapTimer: Foundation.Timer?
|
|
private var longPressTimer: Foundation.Timer?
|
|
|
|
private var itemView: TabSelectorComponent.VisibleItem?
|
|
|
|
public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: TabSelectorComponent.VisibleItem?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (TabSelectorComponent.VisibleItem) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) {
|
|
self.shouldBegin = shouldBegin
|
|
self.willBegin = willBegin
|
|
self.began = began
|
|
self.ended = ended
|
|
self.moved = moved
|
|
self.isActiveUpdated = isActiveUpdated
|
|
|
|
super.init(target: nil, action: nil)
|
|
}
|
|
|
|
deinit {
|
|
self.longTapTimer?.invalidate()
|
|
self.longPressTimer?.invalidate()
|
|
}
|
|
|
|
private func startLongTapTimer() {
|
|
self.longTapTimer?.invalidate()
|
|
let longTapTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false, block: { [weak self] _ in
|
|
self?.longTapTimerFired()
|
|
})
|
|
self.longTapTimer = longTapTimer
|
|
}
|
|
|
|
private func stopLongTapTimer() {
|
|
self.itemView = nil
|
|
self.longTapTimer?.invalidate()
|
|
self.longTapTimer = nil
|
|
}
|
|
|
|
private func startLongPressTimer() {
|
|
self.longPressTimer?.invalidate()
|
|
let longPressTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.6, repeats: false, block: { [weak self] _ in
|
|
self?.longPressTimerFired()
|
|
})
|
|
self.longPressTimer = longPressTimer
|
|
}
|
|
|
|
private func stopLongPressTimer() {
|
|
self.itemView = nil
|
|
self.longPressTimer?.invalidate()
|
|
self.longPressTimer = nil
|
|
}
|
|
|
|
override public func reset() {
|
|
super.reset()
|
|
|
|
self.itemView = nil
|
|
self.stopLongTapTimer()
|
|
self.stopLongPressTimer()
|
|
self.initialLocation = nil
|
|
|
|
self.isActiveUpdated(false)
|
|
}
|
|
|
|
private func longTapTimerFired() {
|
|
guard let location = self.initialLocation else {
|
|
return
|
|
}
|
|
|
|
self.longTapTimer?.invalidate()
|
|
self.longTapTimer = nil
|
|
|
|
self.willBegin(location)
|
|
}
|
|
|
|
private func longPressTimerFired() {
|
|
guard let _ = self.initialLocation else {
|
|
return
|
|
}
|
|
|
|
self.isActiveUpdated(true)
|
|
self.state = .began
|
|
self.longPressTimer?.invalidate()
|
|
self.longPressTimer = nil
|
|
self.longTapTimer?.invalidate()
|
|
self.longTapTimer = nil
|
|
if let itemView = self.itemView {
|
|
self.began(itemView)
|
|
}
|
|
self.isActiveUpdated(true)
|
|
}
|
|
|
|
override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesBegan(touches, with: event)
|
|
|
|
if self.numberOfTouches > 1 {
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
self.ended()
|
|
return
|
|
}
|
|
|
|
if self.state == .possible {
|
|
if let location = touches.first?.location(in: self.view) {
|
|
let (allowed, requiresLongPress, itemView) = self.shouldBegin(location)
|
|
if allowed {
|
|
self.isActiveUpdated(true)
|
|
|
|
self.itemView = itemView
|
|
self.initialLocation = location
|
|
if requiresLongPress {
|
|
self.startLongTapTimer()
|
|
self.startLongPressTimer()
|
|
} else {
|
|
self.state = .began
|
|
if let itemView = self.itemView {
|
|
self.began(itemView)
|
|
}
|
|
}
|
|
} else {
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
}
|
|
} else {
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
}
|
|
}
|
|
}
|
|
|
|
override public func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesEnded(touches, with: event)
|
|
|
|
self.initialLocation = nil
|
|
|
|
self.stopLongTapTimer()
|
|
if self.longPressTimer != nil {
|
|
self.stopLongPressTimer()
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
}
|
|
if self.state == .began || self.state == .changed {
|
|
self.isActiveUpdated(false)
|
|
self.ended()
|
|
self.state = .failed
|
|
}
|
|
}
|
|
|
|
override public func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesCancelled(touches, with: event)
|
|
|
|
self.initialLocation = nil
|
|
|
|
self.stopLongTapTimer()
|
|
if self.longPressTimer != nil {
|
|
self.isActiveUpdated(false)
|
|
self.stopLongPressTimer()
|
|
self.state = .failed
|
|
}
|
|
if self.state == .began || self.state == .changed {
|
|
self.isActiveUpdated(false)
|
|
self.ended()
|
|
self.state = .failed
|
|
}
|
|
}
|
|
|
|
override public func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
super.touchesMoved(touches, with: event)
|
|
|
|
if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) {
|
|
self.state = .changed
|
|
let offset = CGPoint(x: location.x - initialLocation.x, y: 0.0)
|
|
self.moved(offset)
|
|
} else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil {
|
|
let touchLocation = touch.location(in: self.view)
|
|
let dX = touchLocation.x - initialTapLocation.x
|
|
|
|
if dX > 3.0 {
|
|
self.stopLongTapTimer()
|
|
self.stopLongPressTimer()
|
|
self.initialLocation = nil
|
|
self.isActiveUpdated(false)
|
|
self.state = .failed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|