WinterGram/submodules/TelegramUI/Components/ChatList/ChatListTabsComponent/Sources/ChatListTabsComponent.swift
2025-12-16 20:55:47 +08:00

490 lines
21 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import AccountContext
import TelegramCore
import MultilineTextWithEntitiesComponent
import TextBadgeComponent
import LiquidLens
public final class ChatListTabsComponent: Component {
public enum Tab: Equatable {
public struct UnreadCount: Equatable {
public let value: Int
public let hasUnmuted: Bool
public init(value: Int, hasUnmuted: Bool) {
self.value = value
self.hasUnmuted = hasUnmuted
}
}
public enum Id: Hashable {
case all
case filter(id: Int32)
}
case all
case filter(id: Int32, text: ChatFolderTitle, unread: UnreadCount)
public var id: Id {
switch self {
case .all:
return .all
case let .filter(id, _, _):
return .filter(id: id)
}
}
}
public let context: AccountContext
public let theme: PresentationTheme
public let strings: PresentationStrings
public let tabs: [Tab]
public let selectedTab: Tab.Id?
public let selectTab: (Tab.Id) -> Void
public init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
tabs: [Tab],
selectedTab: Tab.Id?,
selectTab: @escaping (Tab.Id) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.tabs = tabs
self.selectedTab = selectedTab
self.selectTab = selectTab
}
public static func ==(lhs: ChatListTabsComponent, rhs: ChatListTabsComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.tabs != rhs.tabs {
return false
}
if lhs.selectedTab != rhs.selectedTab {
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
}
}
public final class View: UIView, UIScrollViewDelegate {
private let lensView: LiquidLensView
private let scrollView: ScrollView
private let selectionView: UIImageView
private var itemViews: [Tab.Id: ComponentView<Empty>] = [:]
private var ignoreScrolling: Bool = false
private var tabSwitchFraction: CGFloat = 0.0
private var temporaryLiftTimer: Foundation.Timer?
private var layoutData: LayoutData?
private var component: ChatListTabsComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.lensView = LiquidLensView(kind: .noContainer)
self.scrollView = ScrollView()
self.selectionView = UIImageView()
//self.scrollView.addSubview(self.selectionView)
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 = false
self.scrollView.delegate = self
self.addSubview(self.lensView)
self.lensView.contentView.addSubview(self.scrollView)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return self.scrollView.hitTest(self.convert(point, to: self.scrollView), with: event)
}
public func updateTabSwitchFraction(fraction: CGFloat, transition: ComponentTransition) {
self.tabSwitchFraction = -fraction
self.state?.updated(transition: transition, 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: layoutData.size, selectionX: -self.scrollView.contentOffset.x + layoutData.selectedItemFrame.minX, selectionWidth: layoutData.selectedItemFrame.width, isDark: component.theme.overallDarkAppearance, isLifted: self.temporaryLiftTimer != nil, transition: transition)
}
func update(component: ChatListTabsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
if self.component?.selectedTab != component.selectedTab {
self.tabSwitchFraction = 0.0
self.temporaryLiftTimer?.invalidate()
self.temporaryLiftTimer = nil
if !transition.animation.isImmediate {
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))
}
})
}
}
self.component = component
self.state = state
let size = CGSize(width: availableSize.width, height: 40.0)
let sideInset: CGFloat = 3.0
var contentWidth: CGFloat = sideInset
var validIds: [Tab.Id] = []
for tab in component.tabs {
let tabId = tab.id
validIds.append(tabId)
var itemTransition = transition
let itemView: ComponentView<Empty>
if let current = self.itemViews[tabId] {
itemView = current
} else {
itemTransition = itemTransition.withAnimation(.none)
itemView = ComponentView()
self.itemViews[tabId] = itemView
}
let itemSize = itemView.update(
transition: itemTransition,
component: AnyComponent(ItemComponent(
context: component.context,
theme: component.theme,
strings: component.strings,
tab: tab,
selectAction: { [weak self] in
guard let self, let component = self.component else {
return
}
component.selectTab(tabId)
}
)),
environment: {},
containerSize: CGSize(width: 1000.0, height: size.height)
)
let itemFrame = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: itemSize)
if let itemComponentView = itemView.view {
if itemComponentView.superview == nil {
self.scrollView.addSubview(itemComponentView)
transition.animateAlpha(view: itemComponentView, from: 0.0, to: 1.0)
transition.animateScale(view: itemComponentView, from: 0.001, to: 1.0)
}
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
}
contentWidth += itemSize.width
}
contentWidth += sideInset
var removedIds: [Tab.Id] = []
for (id, itemView) in self.itemViews {
if !validIds.contains(id) {
removedIds.append(id)
if let itemComponentView = itemView.view {
transition.setScale(view: itemComponentView, scale: 0.001)
transition.setAlpha(view: itemComponentView, alpha: 0.0, completion: { [weak itemComponentView] _ in
itemComponentView?.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]?.view {
var selectedItemFrameValue = itemView.frame
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]?.view {
pendingItemFrame = nextItemView.frame
}
} else if self.tabSwitchFraction < 0.0 && i != 0 {
if let previousItemView = self.itemViews[component.tabs[i - 1].id]?.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
}
}
}
if let selectedItemFrame {
var selectionTransition = transition
if self.selectionView.isHidden {
self.selectionView.isHidden = false
selectionTransition = selectionTransition.withAnimation(.none)
}
let selectionFrame = CGRect(origin: CGPoint(x: selectedItemFrame.minX, y: 3.0), size: CGSize(width: selectedItemFrame.width, height: size.height - 3.0 * 2.0))
if self.selectionView.image?.size.height != selectionFrame.height {
self.selectionView.image = generateStretchableFilledCircleImage(diameter: selectionFrame.height, color: .white)?.withRenderingMode(.alwaysTemplate)
}
self.selectionView.tintColor = component.theme.chat.inputPanel.panelControlColor.withAlphaComponent(0.1)
selectionTransition.setFrame(view: self.selectionView, frame: selectionFrame)
} else {
self.selectionView.isHidden = true
}
self.layoutData = LayoutData(
size: size,
selectedItemFrame: selectedItemFrame ?? CGRect()
)
self.ignoreScrolling = true
let contentSize = CGSize(width: contentWidth, height: size.height)
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: size))
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
transition.setFrame(view: self.lensView, frame: CGRect(origin: CGPoint(), size: size))
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 {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let tab: ChatListTabsComponent.Tab
let selectAction: () -> Void
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, tab: ChatListTabsComponent.Tab, selectAction: @escaping () -> Void) {
self.context = context
self.theme = theme
self.strings = strings
self.tab = tab
self.selectAction = selectAction
}
static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.tab != rhs.tab {
return false
}
return true
}
final class View: UIView {
let title = ComponentView<Empty>()
var badge: ComponentView<Empty>?
var component: ItemComponent?
override init(frame: CGRect) {
super.init(frame: frame)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))))
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.component?.selectAction()
}
}
func update(component: ItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let sideInset: CGFloat = 16.0
let badgeSpacing: CGFloat = 5.0
let font = Font.medium(15.0)
let titleString: NSAttributedString
var badgeData: (title: String, isActive: Bool)?
switch component.tab {
case .all:
titleString = NSAttributedString(string: component.strings.ChatList_Tabs_All, font: font, textColor: component.theme.chat.inputPanel.panelControlColor)
case let .filter(_, text, unread):
titleString = text.attributedString(font: font, textColor: component.theme.chat.inputPanel.panelControlColor)
if unread.value != 0 {
badgeData = ("\(unread.value)", unread.hasUnmuted)
}
}
let titleSize = self.title.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)
)
var size = CGSize(width: sideInset + titleSize.width, height: availableSize.height)
if let badgeData {
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.isActive ? 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.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
} 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()
})
}
}
size.width += sideInset
let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((size.height - titleSize.height) * 0.5)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.layer.anchorPoint = CGPoint()
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}