Files
Swiftgram/submodules/TelegramUI/Components/HorizontalTabsComponent/Sources/HorizontalTabsComponent.swift
2025-12-20 03:24:48 +08:00

1195 lines
54 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import AccountContext
import TelegramCore
import MultilineTextWithEntitiesComponent
import TextBadgeComponent
import LiquidLens
import HeaderPanelContainerComponent
private class ReorderingGestureRecognizerTimerTarget: NSObject {
private let f: () -> Void
init(_ f: @escaping () -> Void) {
self.f = f
super.init()
}
@objc func timerEvent() {
self.f()
}
}
private final class InternalGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if otherGestureRecognizer is UIPanGestureRecognizer {
return true
} else {
return false
}
}
}
private final class ReorderingGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
private let internalDelegate = InternalGestureRecognizerDelegate()
private let shouldBegin: (CGPoint) -> Bool
private let began: (CGPoint) -> Void
private let ended: () -> Void
private let moved: (CGFloat) -> Void
private var initialLocation: CGPoint?
private var delayTimer: Foundation.Timer?
var currentLocation: CGPoint?
init(shouldBegin: @escaping (CGPoint) -> Bool, began: @escaping (CGPoint) -> Void, ended: @escaping () -> Void, moved: @escaping (CGFloat) -> Void) {
self.shouldBegin = shouldBegin
self.began = began
self.ended = ended
self.moved = moved
super.init(target: nil, action: nil)
self.delegate = self.internalDelegate
}
override func reset() {
super.reset()
self.initialLocation = nil
self.delayTimer?.invalidate()
self.delayTimer = nil
self.currentLocation = nil
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
guard let location = touches.first?.location(in: self.view) else {
self.state = .failed
return
}
if self.state == .possible {
if self.delayTimer == nil {
if !self.shouldBegin(location) {
self.state = .failed
return
}
self.initialLocation = location
let timer = Foundation.Timer(timeInterval: 0.2, target: ReorderingGestureRecognizerTimerTarget { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.delayTimer = nil
strongSelf.state = .began
strongSelf.began(location)
}, selector: #selector(ReorderingGestureRecognizerTimerTarget.timerEvent), userInfo: nil, repeats: false)
self.delayTimer = timer
RunLoop.main.add(timer, forMode: .common)
} else {
self.state = .failed
}
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
self.delayTimer?.invalidate()
if self.state == .began || self.state == .changed {
self.ended()
}
self.state = .failed
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
if self.state == .began || self.state == .changed {
self.delayTimer?.invalidate()
self.ended()
self.state = .failed
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
guard let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) else {
return
}
let offset = location.x - initialLocation.x
self.currentLocation = location
if self.delayTimer != nil {
if abs(offset) > 4.0 {
self.delayTimer?.invalidate()
self.state = .failed
return
}
} else {
if self.state == .began || self.state == .changed {
self.state = .changed
self.moved(offset)
}
}
}
}
public final class HorizontalTabsComponent: Component {
public final class Tab: Equatable {
public typealias Id = AnyHashable
public struct Badge: Equatable {
public var title: String
public var isAccent: Bool
public init(title: String, isAccent: Bool) {
self.title = title
self.isAccent = isAccent
}
}
public struct Title: Equatable {
public let text: String
public let entities: [MessageTextEntity]
public let enableAnimations: Bool
public init(text: String, entities: [MessageTextEntity], enableAnimations: Bool) {
self.text = text
self.entities = entities
self.enableAnimations = enableAnimations
}
}
public enum Content: Equatable {
case title(Title)
case custom(AnyComponent<Empty>)
}
public let id: AnyHashable
public let content: Content
public let badge: Badge?
public let action: () -> Void
public let contextAction: ((ContextExtractedContentContainingView, ContextGesture?) -> Void)?
public let deleteAction: (() -> Void)?
public init(id: AnyHashable, content: Content, badge: Badge?, action: @escaping () -> Void, contextAction: ((ContextExtractedContentContainingView, ContextGesture?) -> Void)?, deleteAction: (() -> Void)?) {
self.id = id
self.content = content
self.badge = badge
self.action = action
self.contextAction = contextAction
self.deleteAction = deleteAction
}
public static func ==(lhs: Tab, rhs: Tab) -> Bool {
if lhs.id != rhs.id {
return false
}
if lhs.content != rhs.content {
return false
}
if lhs.badge != rhs.badge {
return false
}
if (lhs.contextAction == nil) != (rhs.contextAction == nil) {
return false
}
if (lhs.deleteAction == nil) != (rhs.deleteAction == nil) {
return false
}
return true
}
}
public enum Layout {
case fit
case fill
}
public let context: AccountContext?
public let theme: PresentationTheme
public let tabs: [Tab]
public let selectedTab: Tab.Id?
public let isEditing: Bool
public let layout: Layout
public init(
context: AccountContext?,
theme: PresentationTheme,
tabs: [Tab],
selectedTab: Tab.Id?,
isEditing: Bool,
layout: Layout = .fill
) {
self.context = context
self.theme = theme
self.tabs = tabs
self.selectedTab = selectedTab
self.isEditing = isEditing
self.layout = layout
}
public static func ==(lhs: HorizontalTabsComponent, rhs: HorizontalTabsComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.tabs != rhs.tabs {
return false
}
if lhs.selectedTab != rhs.selectedTab {
return false
}
if lhs.isEditing != rhs.isEditing {
return false
}
if lhs.layout != rhs.layout {
return false
}
return true
}
private final class ScrollView: UIScrollView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
private struct LayoutData {
var size: CGSize
var selectedItemFrame: CGRect
init(size: CGSize, selectedItemFrame: CGRect) {
self.size = size
self.selectedItemFrame = selectedItemFrame
}
}
private final class ItemView {
let regularView = ComponentView<Empty>()
let selectedView = ComponentView<Empty>()
init() {
}
}
public final class View: UIView, UIScrollViewDelegate, HeaderPanelContainerChildView {
private let lensView: LiquidLensView
private let scrollView: ScrollView
private let selectedScrollView: UIView
private var itemViews: [Tab.Id: ItemView] = [:]
private var ignoreScrolling: Bool = false
private var tabSwitchFraction: CGFloat = 0.0
private var isDraggingTabs: Bool = false
private var temporaryLiftTimer: Foundation.Timer?
private var tapRecognizer: UITapGestureRecognizer?
private var reorderingGesture: ReorderingGestureRecognizer?
private var reorderingItem: AnyHashable?
private var reorderingItemPosition: (initial: CGFloat, offset: CGFloat)?
private var reorderingAutoScrollAnimator: ConstantDisplayLinkAnimator?
private var initialReorderedItemIds: [AnyHashable]?
private var reorderedItemIds: [AnyHashable]?
private var layoutData: LayoutData?
private var component: HorizontalTabsComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.lensView = LiquidLensView(kind: .noContainer)
self.scrollView = ScrollView()
self.selectedScrollView = UIView()
self.selectedScrollView.clipsToBounds = true
super.init(frame: frame)
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = false
self.scrollView.scrollsToTop = false
self.scrollView.clipsToBounds = true
self.scrollView.delegate = self
self.scrollView.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in
guard let self else {
return false
}
return self.scrollView.contentOffset.x > .ulpOfOne
}
self.addSubview(self.lensView)
self.lensView.contentView.addSubview(self.scrollView)
self.lensView.selectedContentView.addSubview(self.selectedScrollView)
/*self.lensView.onUpdatedIsAnimating = { [weak self] _ in
guard let self else {
return
}
self.alpha = self.lensView.isAnimating ? 1.0 : 0.7
}*/
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:)))
self.tapRecognizer = tapRecognizer
self.addGestureRecognizer(tapRecognizer)
let reorderingGesture = ReorderingGestureRecognizer(shouldBegin: { [weak self] point in
guard let self else {
return false
}
for (_, itemView) in self.itemViews {
guard let itemView = itemView.regularView.view else {
continue
}
if itemView.convert(itemView.bounds, to: self).contains(point) {
return true
}
}
return false
}, began: { [weak self] point in
guard let self else {
return
}
self.initialReorderedItemIds = self.reorderedItemIds
for (id, itemView) in self.itemViews {
guard let regularItemView = itemView.regularView.view, let selectedItemView = itemView.selectedView.view else {
continue
}
let itemFrame = regularItemView.convert(regularItemView.bounds, to: self)
if itemFrame.contains(point) {
HapticFeedback().impact()
self.reorderingItem = id
regularItemView.frame = itemFrame
selectedItemView.frame = itemFrame
self.reorderingAutoScrollAnimator = ConstantDisplayLinkAnimator(update: { [weak self] in
guard let self, let currentLocation = self.reorderingGesture?.currentLocation else {
return
}
let edgeWidth: CGFloat = 20.0
if currentLocation.x <= edgeWidth {
var contentOffset = self.scrollView.contentOffset
contentOffset.x = max(0.0, contentOffset.x - 3.0)
self.scrollView.setContentOffset(contentOffset, animated: false)
} else if currentLocation.x >= self.bounds.width - edgeWidth {
var contentOffset = self.scrollView.contentOffset
contentOffset.x = max(0.0, min(self.scrollView.contentSize.width - self.scrollView.bounds.width, contentOffset.x + 3.0))
self.scrollView.setContentOffset(contentOffset, animated: false)
}
})
self.reorderingAutoScrollAnimator?.isPaused = false
self.addSubview(regularItemView)
self.addSubview(selectedItemView)
self.reorderingItemPosition = (regularItemView.frame.minX, 0.0)
self.state?.updated(transition: .easeInOut(duration: 0.25))
return
}
}
}, ended: { [weak self] in
guard let self, let reorderingItem = self.reorderingItem else {
return
}
if let itemView = self.itemViews[reorderingItem], let regularItemView = itemView.regularView.view, let selectedItemView = itemView.selectedView.view {
let projectedItemFrame = regularItemView.convert(regularItemView.bounds, to: self.scrollView)
regularItemView.frame = projectedItemFrame
selectedItemView.frame = projectedItemFrame
self.scrollView.addSubview(regularItemView)
self.selectedScrollView.addSubview(selectedItemView)
}
/*if strongSelf.currentParams?.canReorderAllChats == false, let firstItem = strongSelf.reorderedItemIds?.first, case .filter = firstItem {
strongSelf.reorderedItemIds = strongSelf.initialReorderedItemIds
strongSelf.presentPremiumTip?()
}*/
self.reorderingItem = nil
self.reorderingItemPosition = nil
self.reorderingAutoScrollAnimator?.invalidate()
self.reorderingAutoScrollAnimator = nil
self.state?.updated(transition: .easeInOut(duration: 0.25))
}, moved: { [weak self] offset in
guard let self, let reorderingItem = self.reorderingItem else {
return
}
let minIndex = 0
if let reorderingItemView = self.itemViews[reorderingItem], let regularItemView = reorderingItemView.regularView.view, let _ = reorderingItemView.selectedView.view, let (initial, _) = self.reorderingItemPosition, let reorderedItemIds = self.reorderedItemIds, let currentItemIndex = reorderedItemIds.firstIndex(of: reorderingItem) {
for (id, otherItemView) in self.itemViews {
guard let itemIndex = reorderedItemIds.firstIndex(of: id) else {
continue
}
guard let otherRegularItemView = otherItemView.regularView.view else {
continue
}
if id != reorderingItem {
let itemFrame = otherRegularItemView.convert(otherRegularItemView.bounds, to: self)
if regularItemView.frame.intersects(itemFrame) {
let targetIndex: Int
if regularItemView.frame.midX < itemFrame.midX {
targetIndex = max(minIndex, itemIndex - 1)
} else {
targetIndex = max(minIndex, min(reorderedItemIds.count - 1, itemIndex))
}
if targetIndex != currentItemIndex {
HapticFeedback().tap()
var updatedReorderedItemIds = reorderedItemIds
if targetIndex > currentItemIndex {
updatedReorderedItemIds.insert(reorderingItem, at: targetIndex + 1)
updatedReorderedItemIds.remove(at: currentItemIndex)
} else {
updatedReorderedItemIds.remove(at: currentItemIndex)
updatedReorderedItemIds.insert(reorderingItem, at: targetIndex)
}
self.reorderedItemIds = updatedReorderedItemIds
self.state?.updated(transition: .easeInOut(duration: 0.25))
}
break
}
}
}
self.reorderingItemPosition = (initial, offset)
}
self.state?.updated(transition: .immediate)
})
self.reorderingGesture = reorderingGesture
self.addGestureRecognizer(reorderingGesture)
reorderingGesture.isEnabled = false
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setOverlayContainerView(overlayContainerView: UIView) {
self.lensView.setLiftedContainer(view: overlayContainerView)
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return self.scrollView.hitTest(self.convert(point, to: self.scrollView), with: event)
}
@objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
let point = recognizer.location(in: self)
for (id, itemView) in self.itemViews {
guard let itemView = itemView.regularView.view else {
continue
}
if itemView.convert(itemView.bounds, to: self).contains(point) {
if let tab = component.tabs.first(where: { $0.id == id }) {
tab.action()
}
}
}
}
}
public func updateTabSwitchFraction(fraction: CGFloat, isDragging: Bool, transition: ComponentTransition) {
self.tabSwitchFraction = -fraction
self.isDraggingTabs = isDragging
self.state?.updated(transition: transition, isLocal: true)
/*if self.isDraggingTabs != isDragging {
self.isDraggingTabs = isDragging
if !isDragging {
self.temporaryLiftTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false, block: { [weak self] timer in
guard let self else {
return
}
if self.temporaryLiftTimer === timer {
self.temporaryLiftTimer = nil
self.state?.updated(transition: .spring(duration: 0.5))
}
})
} else {
self.state?.updated(transition: .spring(duration: 0.4), isLocal: true)
}
}*/
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
if self.ignoreScrolling {
return
}
self.updateScrolling(transition: .immediate)
}
private func updateScrolling(transition: ComponentTransition) {
guard let component = self.component, let layoutData = self.layoutData else {
return
}
self.lensView.update(size: CGSize(width: layoutData.size.width - 3.0 * 2.0, height: layoutData.size.height - 3.0 * 2.0), selectionOrigin: CGPoint(x: -self.scrollView.contentOffset.x + layoutData.selectedItemFrame.minX, y: 0.0), selectionSize: CGSize(width: layoutData.selectedItemFrame.width + 6.0, height: layoutData.size.height - 3.0 * 2.0), inset: 0.0, isDark: component.theme.overallDarkAppearance, isLifted: self.temporaryLiftTimer != nil, transition: transition)
transition.setPosition(view: self.selectedScrollView, position: CGRect(origin: CGPoint(x: 3.0, y: 0.0), size: CGSize(width: layoutData.size.width - 3.0 * 2.0, height: layoutData.size.height - 3.0 * 2.0)).center)
transition.setBounds(view: self.selectedScrollView, bounds: CGRect(origin: CGPoint(x: self.scrollView.contentOffset.x, y: 0.0), size: CGSize(width: layoutData.size.width - 3.0 * 2.0, height: layoutData.size.height - 3.0 * 2.0)))
}
func update(component: HorizontalTabsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
var shouldFocusOnSelectedTab = self.isDraggingTabs
if component.isEditing {
if self.reorderedItemIds == nil {
self.reorderedItemIds = component.tabs.map(\.id)
}
} else {
self.reorderedItemIds = nil
}
if self.component?.selectedTab != component.selectedTab {
self.tabSwitchFraction = 0.0
if !self.isDraggingTabs {
self.temporaryLiftTimer?.invalidate()
self.temporaryLiftTimer = nil
if !transition.animation.isImmediate {
self.temporaryLiftTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false, block: { [weak self] timer in
guard let self else {
return
}
if self.temporaryLiftTimer === timer {
self.temporaryLiftTimer = nil
self.state?.updated(transition: .spring(duration: 0.5))
}
})
}
}
shouldFocusOnSelectedTab = true
}
self.component = component
self.state = state
self.reorderingGesture?.isEnabled = component.isEditing
let sizeHeight: CGFloat = 40.0
let sideInset: CGFloat = 0.0
var contentWidth: CGFloat = sideInset
var validIds: [Tab.Id] = []
var orderedTabs = component.tabs
if let reorderedItemIds = self.reorderedItemIds {
orderedTabs.removeAll()
for id in reorderedItemIds {
if let item = component.tabs.first(where: { $0.id == id }) {
orderedTabs.append(item)
}
}
for tab in component.tabs {
if !orderedTabs.contains(where: { $0.id == tab.id }) {
orderedTabs.append(tab)
}
}
}
for tab in orderedTabs {
let tabId = tab.id
validIds.append(tabId)
var itemTransition = transition
let itemView: ItemView
if let current = self.itemViews[tabId] {
itemView = current
} else {
itemTransition = itemTransition.withAnimation(.none)
itemView = ItemView()
self.itemViews[tabId] = itemView
}
var itemEditing: ItemComponent.Editing?
if component.isEditing {
itemEditing = ItemComponent.Editing(isEditable: true)
}
let itemSize = itemView.regularView.update(
transition: itemTransition,
component: AnyComponent(ItemComponent(
context: component.context,
theme: component.theme,
tab: tab,
isSelected: false,
editing: itemEditing
)),
environment: {},
containerSize: CGSize(width: 1000.0, height: sizeHeight - 3.0 * 2.0)
)
let _ = itemView.selectedView.update(
transition: itemTransition,
component: AnyComponent(ItemComponent(
context: component.context,
theme: component.theme,
tab: tab,
isSelected: true,
editing: itemEditing
)),
environment: {},
containerSize: CGSize(width: 1000.0, height: sizeHeight - 3.0 * 2.0)
)
var itemFrame = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: itemSize)
if tabId == self.reorderingItem, let (initial, offset) = self.reorderingItemPosition {
itemFrame.origin = CGPoint(x: initial + offset, y: 3.0 + itemFrame.minY)
}
if let itemRegularView = itemView.regularView.view, let itemSelectedView = itemView.selectedView.view {
if itemRegularView.superview == nil {
self.scrollView.addSubview(itemRegularView)
self.selectedScrollView.addSubview(itemSelectedView)
transition.animateAlpha(view: itemRegularView, from: 0.0, to: 1.0)
transition.animateScale(view: itemRegularView, from: 0.001, to: 1.0)
transition.animateAlpha(view: itemSelectedView, from: 0.0, to: 1.0)
transition.animateScale(view: itemSelectedView, from: 0.001, to: 1.0)
}
itemTransition.setFrame(view: itemRegularView, frame: itemFrame)
itemTransition.setFrame(view: itemSelectedView, frame: itemFrame)
if tabId == self.reorderingItem {
itemTransition.setSublayerTransform(view: itemRegularView, transform: CATransform3DMakeScale(1.2, 1.2, 1.0))
itemTransition.setSublayerTransform(view: itemSelectedView, transform: CATransform3DMakeScale(1.2, 1.2, 1.0))
itemTransition.setAlpha(view: itemRegularView, alpha: 0.9)
itemTransition.setAlpha(view: itemSelectedView, alpha: 0.0)
} else {
itemTransition.setSublayerTransform(view: itemRegularView, transform: CATransform3DIdentity)
itemTransition.setSublayerTransform(view: itemSelectedView, transform: CATransform3DIdentity)
itemTransition.setAlpha(view: itemRegularView, alpha: 1.0)
itemTransition.setAlpha(view: itemSelectedView, alpha: 1.0)
}
}
contentWidth += itemSize.width
}
contentWidth += sideInset
var removedIds: [Tab.Id] = []
for (id, itemView) in self.itemViews {
if !validIds.contains(id) {
removedIds.append(id)
if let itemRegularView = itemView.regularView.view, let itemSelectedView = itemView.selectedView.view {
transition.setScale(view: itemRegularView, scale: 0.001)
transition.setAlpha(view: itemRegularView, alpha: 0.0, completion: { [weak itemRegularView] _ in
itemRegularView?.removeFromSuperview()
})
transition.setScale(view: itemSelectedView, scale: 0.001)
transition.setAlpha(view: itemSelectedView, alpha: 0.0, completion: { [weak itemSelectedView] _ in
itemSelectedView?.removeFromSuperview()
})
}
}
}
for id in removedIds {
self.itemViews.removeValue(forKey: id)
}
var selectedItemFrame: CGRect?
if let selectedTab = component.selectedTab {
for i in 0 ..< component.tabs.count {
if component.tabs[i].id == selectedTab {
if let itemView = self.itemViews[component.tabs[i].id]?.regularView.view {
var selectedItemFrameValue = itemView.frame
if selectedTab == self.reorderingItem {
selectedItemFrameValue = itemView.convert(itemView.bounds, to: self.scrollView)
}
var pendingItemFrame: CGRect?
if self.tabSwitchFraction != 0.0 {
if self.tabSwitchFraction > 0.0 && i != component.tabs.count - 1 {
if let nextItemView = self.itemViews[component.tabs[i + 1].id]?.regularView.view {
pendingItemFrame = nextItemView.frame
}
} else if self.tabSwitchFraction < 0.0 && i != 0 {
if let previousItemView = self.itemViews[component.tabs[i - 1].id]?.regularView.view {
pendingItemFrame = previousItemView.frame
}
}
}
if let pendingItemFrame {
let fraction = abs(self.tabSwitchFraction)
selectedItemFrameValue.origin.x = selectedItemFrameValue.minX * (1.0 - fraction) + pendingItemFrame.minX * fraction
selectedItemFrameValue.size.width = selectedItemFrameValue.width * (1.0 - fraction) + pendingItemFrame.width * fraction
}
selectedItemFrame = selectedItemFrameValue
}
break
}
}
}
let contentSize = CGSize(width: contentWidth, height: sizeHeight - 3.0 * 2.0)
let sizeWidth: CGFloat
switch component.layout {
case .fill:
sizeWidth = availableSize.width
case .fit:
sizeWidth = min(availableSize.width, contentWidth + 3.0 * 2.0)
}
let size = CGSize(width: sizeWidth, height: sizeHeight)
self.layoutData = LayoutData(
size: size,
selectedItemFrame: selectedItemFrame ?? CGRect()
)
self.ignoreScrolling = true
let scrollViewFrame = CGRect(origin: CGPoint(x: 3.0, y: 0.0), size: CGSize(width: size.width - 3.0 * 2.0, height: size.height - 3.0 * 2.0))
transition.setPosition(view: self.scrollView, position: scrollViewFrame.center)
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
var scrollViewBounds = CGRect(origin: self.scrollView.bounds.origin, size: scrollViewFrame.size)
if shouldFocusOnSelectedTab || self.scrollView.bounds.size != scrollViewBounds.size {
if shouldFocusOnSelectedTab, let selectedItemFrame {
if scrollViewBounds.minX + scrollViewBounds.width < selectedItemFrame.maxX {
scrollViewBounds.origin.x = selectedItemFrame.maxX - scrollViewBounds.width
}
if scrollViewBounds.minX > selectedItemFrame.minX {
scrollViewBounds.origin.x = selectedItemFrame.minX
}
transition.setBounds(view: self.scrollView, bounds: scrollViewBounds)
}
}
self.scrollView.layer.cornerRadius = (size.height - 3.0 * 2.0) * 0.5
self.selectedScrollView.layer.cornerRadius = (size.height - 3.0 * 2.0) * 0.5
transition.setFrame(view: self.lensView, frame: CGRect(origin: CGPoint(x: 3.0, y: 3.0), size: CGSize(width: size.width - 3.0 * 2.0, height: size.height - 3.0 * 2.0)))
self.lensView.clipsToBounds = true
self.lensView.layer.cornerRadius = (size.height - 3.0 * 2.0) * 0.5
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
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 final class ItemComponent: Component {
struct Editing: Equatable {
var isEditable: Bool
init(isEditable: Bool) {
self.isEditable = isEditable
}
}
let context: AccountContext?
let theme: PresentationTheme
let tab: HorizontalTabsComponent.Tab
let isSelected: Bool
let editing: Editing?
init(context: AccountContext?, theme: PresentationTheme, tab: HorizontalTabsComponent.Tab, isSelected: Bool, editing: Editing?) {
self.context = context
self.theme = theme
self.tab = tab
self.isSelected = isSelected
self.editing = editing
}
static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.tab != rhs.tab {
return false
}
if lhs.isSelected != rhs.isSelected {
return false
}
if lhs.editing != rhs.editing {
return false
}
return true
}
final class View: UIView {
let extractedContainerView: ContextExtractedContentContainingView
let containerView: ContextControllerSourceView
var titleContent: ComponentView<Empty>?
var customContent: ComponentView<Empty>?
var badge: ComponentView<Empty>?
var deleteIcon: (button: HighlightTrackingButton, icon: UIImageView)?
var tapRecognizer: UITapGestureRecognizer?
var component: ItemComponent?
override init(frame: CGRect) {
self.extractedContainerView = ContextExtractedContentContainingView()
self.containerView = ContextControllerSourceView()
super.init(frame: frame)
//self.extractedContainerView.contentView.addSubview(self.extractedBackgroundNode)
self.containerView.addSubview(self.extractedContainerView)
self.containerView.targetViewForActivationProgress = self.extractedContainerView.contentView
self.addSubview(self.containerView)
self.containerView.activated = { [weak self] gesture, _ in
guard let self, let component = self.component else {
return
}
component.tab.contextAction?(self.extractedContainerView, gesture)
}
self.extractedContainerView.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
guard let self, let component else {
return
}
let _ = component
/*if isExtracted, let theme = strongSelf.theme {
strongSelf.extractedBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 28.0, color: theme.contextMenu.backgroundColor)
}
transition.updateAlpha(node: strongSelf.extractedBackgroundNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in
if !isExtracted {
self?.extractedBackgroundNode.image = nil
}
})*/
}
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateIsShaking(animated: Bool) {
guard let component = self.component else {
return
}
if component.editing != nil {
if self.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.layer.add(position, forKey: "shaking_position")
self.layer.add(transform, forKey: "shaking_rotation")
}
} else if self.layer.animation(forKey: "shaking_position") != nil {
if let presentationLayer = self.layer.presentation() {
let transition: ComponentTransition = .easeInOut(duration: 0.1)
if presentationLayer.position != self.layer.position {
transition.animatePosition(layer: self.layer, from: CGPoint(x: presentationLayer.position.x - self.layer.position.x, y: presentationLayer.position.y - self.layer.position.y), to: CGPoint(), additive: true)
}
if !CATransform3DIsIdentity(presentationLayer.transform) {
transition.setTransform(layer: self.layer, transform: CATransform3DIdentity)
}
}
self.layer.removeAnimation(forKey: "shaking_position")
self.layer.removeAnimation(forKey: "shaking_rotation")
}
}
@objc private func deleteButtonPressed() {
self.component?.tab.deleteAction?()
}
func update(component: ItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.containerView.isGestureEnabled = component.editing == nil
self.tapRecognizer?.isEnabled = component.editing == nil
let sideInset: CGFloat = 16.0
let badgeSpacing: CGFloat = 5.0
var size = CGSize(width: sideInset, height: availableSize.height)
var titleContentSize: CGSize?
if case let .title(title) = component.tab.content {
let titleContent: ComponentView<Empty>
if let current = self.titleContent {
titleContent = current
} else {
titleContent = ComponentView()
self.titleContent = titleContent
}
let font = Font.medium(15.0)
let rawAttributedString = ChatTextInputStateText(text: title.text, attributes: title.entities.compactMap { entity -> ChatTextInputStateTextAttribute? in
if case let .CustomEmoji(_, fileId) = entity.type {
return ChatTextInputStateTextAttribute(type: .customEmoji(stickerPack: nil, fileId: fileId, enableAnimation: title.enableAnimations), range: entity.range)
}
return nil
}).attributedText()
let titleString = NSMutableAttributedString(attributedString: rawAttributedString)
titleString.addAttributes([
.font: font,
.foregroundColor: component.theme.chat.inputPanel.panelControlColor
], range: NSRange(location: 0, length: titleString.length))
titleContentSize = titleContent.update(
transition: .immediate,
component: AnyComponent(MultilineTextWithEntitiesComponent(
context: component.context,
animationCache: component.context?.animationCache,
animationRenderer: component.context?.animationRenderer,
placeholderColor: component.theme.chat.inputPanel.panelControlColor.withMultipliedAlpha(0.1),
text: .plain(titleString),
displaysAsynchronously: false
)),
environment: {},
containerSize: CGSize(width: 300.0, height: 100.0)
)
} else if let titleContent = self.titleContent {
self.titleContent = nil
titleContent.view?.removeFromSuperview()
}
var customContentSize: CGSize?
if case let .custom(custom) = component.tab.content {
let customContent: ComponentView<Empty>
if let current = self.customContent {
customContent = current
} else {
customContent = ComponentView()
self.customContent = customContent
}
customContentSize = customContent.update(
transition: transition,
component: custom,
environment: {},
containerSize: CGSize(width: 300.0, height: 100.0)
)
} else if let customContent = self.customContent {
self.customContent = nil
customContent.view?.removeFromSuperview()
}
if let titleContentSize {
size.width += titleContentSize.width
}
if let customContentSize {
size.width += customContentSize.width
}
if let badgeData = component.tab.badge, component.tab.deleteAction == nil {
let badge: ComponentView<Empty>
var badgeTransition = transition
if let current = self.badge {
badge = current
} else {
badgeTransition = badgeTransition.withAnimation(.none)
badge = ComponentView()
self.badge = badge
}
let badgeSize = badge.update(
transition: badgeTransition,
component: AnyComponent(TextBadgeComponent(
text: badgeData.title,
font: Font.medium(12.0),
background: badgeData.isAccent ? component.theme.list.itemCheckColors.fillColor : component.theme.chatList.unreadBadgeInactiveBackgroundColor,
foreground: component.theme.list.itemCheckColors.foregroundColor,
insets: UIEdgeInsets(top: 1.0, left: 5.0, bottom: 2.0, right: 5.0)
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
size.width += badgeSpacing
let badgeFrame = CGRect(origin: CGPoint(x: size.width, y: floorToScreenPixels((size.height - badgeSize.height) * 0.5)), size: badgeSize)
if let badgeView = badge.view {
if badgeView.superview == nil {
self.extractedContainerView.contentView.addSubview(badgeView)
transition.animateAlpha(view: badgeView, from: 0.0, to: 1.0)
transition.animateScale(view: badgeView, from: 0.001, to: 1.0)
}
badgeTransition.setFrame(view: badgeView, frame: badgeFrame)
}
size.width += badgeSize.width - 2.0
} else if let badge = self.badge {
self.badge = nil
if let badgeView = badge.view {
transition.setFrame(view: badgeView, frame: badgeView.bounds.size.centered(around: CGPoint(x: size.width + sideInset - badgeView.bounds.width * 0.5, y: size.height * 0.5)))
transition.setScale(view: badgeView, scale: 0.001)
transition.setAlpha(view: badgeView, alpha: 0.0, completion: { [weak badgeView] _ in
badgeView?.removeFromSuperview()
})
}
}
if component.tab.deleteAction != nil {
let deleteIcon: (button: HighlightTrackingButton, icon: UIImageView)
if let current = self.deleteIcon {
deleteIcon = current
} else {
deleteIcon = (HighlightTrackingButton(), UIImageView())
self.deleteIcon = deleteIcon
deleteIcon.button.addSubview(deleteIcon.icon)
deleteIcon.icon.image = generateImage(CGSize(width: 12.0, height: 12.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(1.33)
context.setLineCap(.round)
context.move(to: CGPoint(x: 1.0, y: 1.0))
context.addLine(to: CGPoint(x: size.width - 1.0, y: size.height - 1.0))
context.strokePath()
context.move(to: CGPoint(x: size.width - 1.0, y: 1.0))
context.addLine(to: CGPoint(x: 1.0, y: size.height - 1.0))
context.strokePath()
})?.withRenderingMode(.alwaysTemplate)
deleteIcon.button.addTarget(self, action: #selector(self.deleteButtonPressed), for: .touchUpInside)
}
deleteIcon.icon.tintColor = component.theme.chat.inputPanel.panelControlColor
if let image = deleteIcon.icon.image {
let deleteButtonFrame = CGRect(origin: CGPoint(x: size.width + 2.0, y: 0.0), size: CGSize(width: image.size.width + 6.0 * 2.0, height: size.height))
let deleteIconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((deleteButtonFrame.width - image.size.width) * 0.5), y: floorToScreenPixels((deleteButtonFrame.height - image.size.height) * 0.5)), size: image.size)
if deleteIcon.button.superview == nil {
self.addSubview(deleteIcon.button)
deleteIcon.button.frame = deleteButtonFrame
deleteIcon.icon.frame = deleteIconFrame
transition.animateAlpha(view: deleteIcon.button, from: 0.0, to: 1.0)
transition.animateScale(view: deleteIcon.button, from: 0.001, to: 1.0)
}
transition.setFrame(view: deleteIcon.button, frame: deleteButtonFrame)
transition.setFrame(view: deleteIcon.icon, frame: deleteIconFrame)
size.width += deleteButtonFrame.width - 3.0
}
} else if let deleteIcon = self.deleteIcon {
self.deleteIcon = nil
let (button, _) = deleteIcon
transition.setScale(view: button, scale: 0.001)
transition.setAlpha(view: button, alpha: 0.0, completion: { [weak button] _ in
button?.removeFromSuperview()
})
}
size.width += sideInset
if let titleView = self.titleContent?.view, let titleContentSize {
let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((size.height - titleContentSize.height) * 0.5)), size: titleContentSize)
if titleView.superview == nil {
titleView.layer.anchorPoint = CGPoint()
self.extractedContainerView.contentView.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
if let customView = self.customContent?.view, let customContentSize {
let customFrame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((size.height - customContentSize.height) * 0.5)), size: customContentSize)
if customView.superview == nil {
customView.layer.anchorPoint = CGPoint()
self.extractedContainerView.contentView.addSubview(customView)
}
transition.setFrame(view: customView, frame: customFrame)
}
transition.setFrame(view: self.extractedContainerView, frame: CGRect(origin: CGPoint(), size: size))
transition.setFrame(view: self.extractedContainerView.contentView, frame: CGRect(origin: CGPoint(), size: size))
let extractedBackgroundFrame = CGRect(origin: CGPoint(), size: size)
self.extractedContainerView.contentRect = CGRect(origin: CGPoint(x: extractedBackgroundFrame.minX, y: 0.0), size: CGSize(width: extractedBackgroundFrame.width, height: size.height))
transition.setFrame(view: self.containerView, frame: CGRect(origin: CGPoint(), size: size))
self.updateIsShaking(animated: !transition.animation.isImmediate)
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)
}
}