Files
Swiftgram/submodules/TelegramUI/Components/TabBarComponent/Sources/TabBarComponent.swift
2025-12-22 00:06:14 +02:00

750 lines
32 KiB
Swift

import SGSimpleSettings
import Foundation
import UIKit
import Display
import TelegramPresentationData
import ComponentFlow
import ComponentDisplayAdapters
import GlassBackgroundComponent
import MultilineTextComponent
import LottieComponent
import UIKitRuntimeUtils
import BundleIconComponent
import TextBadgeComponent
import LiquidLens
import AppBundle
private final class TabSelectionRecognizer: UIGestureRecognizer {
private var initialLocation: CGPoint?
private var currentLocation: CGPoint?
override init(target: Any?, action: Selector?) {
super.init(target: target, action: action)
self.delaysTouchesBegan = false
self.delaysTouchesEnded = false
}
override func reset() {
super.reset()
self.initialLocation = nil
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
if self.initialLocation == nil {
self.initialLocation = touches.first?.location(in: self.view)
}
self.currentLocation = self.initialLocation
self.state = .began
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
self.state = .ended
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
self.state = .cancelled
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
self.currentLocation = touches.first?.location(in: self.view)
self.state = .changed
}
func translation(in: UIView?) -> CGPoint {
if let initialLocation = self.initialLocation, let currentLocation = self.currentLocation {
return CGPoint(x: currentLocation.x - initialLocation.x, y: currentLocation.y - initialLocation.y)
}
return CGPoint()
}
}
public final class TabBarSearchView: UIView {
private let backgroundView: GlassBackgroundView
private let iconView: GlassBackgroundView.ContentImageView
override public init(frame: CGRect) {
self.backgroundView = GlassBackgroundView()
self.iconView = GlassBackgroundView.ContentImageView()
super.init(frame: frame)
self.addSubview(self.backgroundView)
self.backgroundView.contentView.addSubview(self.iconView)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func update(size: CGSize, isDark: Bool, tintColor: GlassBackgroundView.TintColor, iconColor: UIColor, transition: ComponentTransition) {
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
self.backgroundView.update(size: size, cornerRadius: size.height * 0.5, isDark: isDark, tintColor: tintColor, transition: transition)
if self.iconView.image == nil {
self.iconView.image = UIImage(bundleImageName: "Navigation/Search")?.withRenderingMode(.alwaysTemplate)
}
self.iconView.tintColor = iconColor
if let image = self.iconView.image {
transition.setFrame(view: self.iconView, frame: CGRect(origin: CGPoint(x: floor((size.width - image.size.width) * 0.5), y: floor((size.height - image.size.height) * 0.5)), size: image.size))
}
}
}
public final class TabBarComponent: Component {
public final class Item: Equatable {
public let item: UITabBarItem
public let action: (Bool) -> Void
public let contextAction: ((ContextGesture, ContextExtractedContentContainingView) -> Void)?
fileprivate var id: AnyHashable {
return AnyHashable(ObjectIdentifier(self.item))
}
public init(item: UITabBarItem, action: @escaping (Bool) -> Void, contextAction: ((ContextGesture, ContextExtractedContentContainingView) -> Void)?) {
self.item = item
self.action = action
self.contextAction = contextAction
}
public static func ==(lhs: Item, rhs: Item) -> Bool {
if lhs === rhs {
return true
}
if lhs.item !== rhs.item {
return false
}
if (lhs.contextAction == nil) != (rhs.contextAction == nil) {
return false
}
return true
}
}
public let theme: PresentationTheme
public let items: [Item]
public let selectedId: AnyHashable?
public let isTablet: Bool
public init(
theme: PresentationTheme,
items: [Item],
selectedId: AnyHashable?,
isTablet: Bool
) {
self.theme = theme
self.items = items
self.selectedId = selectedId
self.isTablet = isTablet
}
public static func ==(lhs: TabBarComponent, rhs: TabBarComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.items != rhs.items {
return false
}
if lhs.selectedId != rhs.selectedId {
return false
}
if lhs.isTablet != rhs.isTablet {
return false
}
return true
}
public final class View: UIView, UITabBarDelegate, UIGestureRecognizerDelegate {
private let liquidLensView: LiquidLensView
private let contextGestureContainerView: ContextControllerSourceView
private var itemViews: [AnyHashable: ComponentView<Empty>] = [:]
private var selectedItemViews: [AnyHashable: ComponentView<Empty>] = [:]
private var tabSelectionRecognizer: TabSelectionRecognizer?
private var itemWithActiveContextGesture: AnyHashable?
private var component: TabBarComponent?
private weak var state: EmptyComponentState?
private var selectionGestureState: (startX: CGFloat, currentX: CGFloat)?
private var overrideSelectedItemId: AnyHashable?
public override init(frame: CGRect) {
self.liquidLensView = LiquidLensView()
self.contextGestureContainerView = ContextControllerSourceView()
self.contextGestureContainerView.isGestureEnabled = true
super.init(frame: frame)
if #available(iOS 17.0, *) {
self.traitOverrides.verticalSizeClass = .compact
self.traitOverrides.horizontalSizeClass = .compact
}
self.addSubview(self.contextGestureContainerView)
self.contextGestureContainerView.addSubview(self.liquidLensView)
let tabSelectionRecognizer = TabSelectionRecognizer(target: self, action: #selector(self.onTabSelectionGesture(_:)))
self.tabSelectionRecognizer = tabSelectionRecognizer
self.addGestureRecognizer(tabSelectionRecognizer)
self.contextGestureContainerView.shouldBegin = { [weak self] point in
guard let self, let component = self.component else {
return false
}
if let itemId = self.item(at: point) {
guard let item = component.items.first(where: { $0.id == itemId }) else {
return false
}
if item.contextAction == nil {
return false
}
self.itemWithActiveContextGesture = itemId
let startPoint = point
self.contextGestureContainerView.contextGesture?.externalUpdated = { [weak self] _, point in
guard let self else {
return
}
let dist = sqrt(pow(startPoint.x - point.x, 2.0) + pow(startPoint.y - point.y, 2.0))
if dist > 10.0 {
self.contextGestureContainerView.contextGesture?.cancel()
}
}
return true
}
return false
}
self.contextGestureContainerView.customActivationProgress = { _, _ in
}
self.contextGestureContainerView.activated = { [weak self] gesture, _ in
guard let self, let component = self.component else {
return
}
guard let itemWithActiveContextGesture = self.itemWithActiveContextGesture else {
return
}
var itemView: ItemComponent.View?
itemView = self.itemViews[itemWithActiveContextGesture]?.view as? ItemComponent.View
guard let itemView else {
return
}
if let tabSelectionRecognizer = self.tabSelectionRecognizer {
tabSelectionRecognizer.state = .cancelled
}
guard let item = component.items.first(where: { $0.id == itemWithActiveContextGesture }) else {
return
}
item.contextAction?(gesture, itemView.contextContainerView)
}
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
guard let component = self.component else {
return
}
if let index = tabBar.items?.firstIndex(where: { $0 === item }) {
if index < component.items.count {
component.items[index].action(false)
}
}
}
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
@objc private func onTabSelectionGesture(_ recognizer: TabSelectionRecognizer) {
switch recognizer.state {
case .began:
if let itemId = self.item(at: recognizer.location(in: self)), let itemView = self.itemViews[itemId]?.view {
let startX = itemView.frame.minX - 4.0
self.selectionGestureState = (startX, startX)
self.state?.updated(transition: .spring(duration: 0.4), isLocal: true)
}
case .changed:
if var selectionGestureState = self.selectionGestureState {
selectionGestureState.currentX = selectionGestureState.startX + recognizer.translation(in: self).x
self.selectionGestureState = selectionGestureState
self.state?.updated(transition: .immediate, isLocal: true)
}
case .ended, .cancelled:
self.selectionGestureState = nil
if let component = self.component, let itemId = self.item(at: recognizer.location(in: self)) {
guard let item = component.items.first(where: { $0.id == itemId }) else {
return
}
self.overrideSelectedItemId = itemId
item.action(false)
}
self.state?.updated(transition: .spring(duration: 0.4), isLocal: true)
default:
break
}
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return super.hitTest(point, with: event)
}
public func frameForItem(at index: Int) -> CGRect? {
guard let component = self.component else {
return nil
}
if index < 0 || index >= component.items.count {
return nil
}
guard let itemView = self.itemViews[component.items[index].id]?.view else {
return nil
}
return self.convert(itemView.bounds, from: itemView)
}
private func item(at point: CGPoint) -> AnyHashable? {
var closestItem: (AnyHashable, CGFloat)?
for (id, itemView) in self.itemViews {
guard let itemView = itemView.view else {
continue
}
if itemView.frame.contains(point) {
return id
} else {
let distance = abs(point.x - itemView.center.x)
if let closestItemValue = closestItem {
if closestItemValue.1 > distance {
closestItem = (id, distance)
}
} else {
closestItem = (id, distance)
}
}
}
return closestItem?.0
}
public override func didMoveToWindow() {
super.didMoveToWindow()
self.state?.updated()
}
func update(component: TabBarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let innerInset: CGFloat = 4.0
let availableSize = CGSize(width: min(500.0, availableSize.width), height: availableSize.height)
let previousComponent = self.component
self.component = component
self.state = state
let _ = innerInset
let _ = availableSize
let _ = previousComponent
self.overrideUserInterfaceStyle = component.theme.overallDarkAppearance ? .dark : .light
var itemSize = CGSize(width: floor((availableSize.width - innerInset * 2.0) / CGFloat(component.items.count)), height: SGSimpleSettings.shared.showTabNames ? 56.0 : 40.0)
if !SGSimpleSettings.shared.wideTabBar { itemSize.width = min(94.0, itemSize.width) }
let contentWidth: CGFloat = innerInset * 2.0 + CGFloat(component.items.count) * itemSize.width
let size = CGSize(width: min(availableSize.width, contentWidth), height: itemSize.height + innerInset * 2.0)
var validIds: [AnyHashable] = []
var selectionFrame: CGRect?
for index in 0 ..< component.items.count {
let item = component.items[index]
validIds.append(item.id)
let itemView: ComponentView<Empty>
var itemTransition = transition
if let current = self.itemViews[item.id] {
itemView = current
} else {
itemTransition = itemTransition.withAnimation(.none)
itemView = ComponentView()
self.itemViews[item.id] = itemView
}
let selectedItemView: ComponentView<Empty>
if let current = self.selectedItemViews[item.id] {
selectedItemView = current
} else {
selectedItemView = ComponentView()
self.selectedItemViews[item.id] = selectedItemView
}
let isItemSelected: Bool
if let overrideSelectedItemId = self.overrideSelectedItemId {
isItemSelected = overrideSelectedItemId == item.id
} else {
isItemSelected = component.selectedId == item.id
}
let _ = itemView.update(
transition: itemTransition,
component: AnyComponent(ItemComponent(
item: item,
theme: component.theme,
isSelected: false
)),
environment: {},
containerSize: itemSize
)
let _ = selectedItemView.update(
transition: itemTransition,
component: AnyComponent(ItemComponent(
item: item,
theme: component.theme,
isSelected: true
)),
environment: {},
containerSize: itemSize
)
let itemFrame = CGRect(origin: CGPoint(x: innerInset + CGFloat(index) * itemSize.width, y: floor((size.height - itemSize.height) * 0.5)), size: itemSize)
if let itemComponentView = itemView.view as? ItemComponent.View, let selectedItemComponentView = selectedItemView.view as? ItemComponent.View {
if itemComponentView.superview == nil {
itemComponentView.isUserInteractionEnabled = false
selectedItemComponentView.isUserInteractionEnabled = false
self.liquidLensView.contentView.addSubview(itemComponentView)
self.liquidLensView.selectedContentView.addSubview(selectedItemComponentView)
}
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
itemTransition.setPosition(view: selectedItemComponentView, position: itemFrame.center)
itemTransition.setBounds(view: selectedItemComponentView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size))
itemTransition.setScale(view: selectedItemComponentView, scale: self.selectionGestureState != nil ? 1.15 : 1.0)
if let previousComponent, previousComponent.selectedId != item.id, isItemSelected {
itemComponentView.playSelectionAnimation()
selectedItemComponentView.playSelectionAnimation()
}
}
if isItemSelected {
selectionFrame = itemFrame
}
}
var removeIds: [AnyHashable] = []
for (id, itemView) in self.itemViews {
if !validIds.contains(id) {
removeIds.append(id)
itemView.view?.removeFromSuperview()
self.selectedItemViews[id]?.view?.removeFromSuperview()
}
}
for id in removeIds {
self.itemViews.removeValue(forKey: id)
self.selectedItemViews.removeValue(forKey: id)
}
transition.setFrame(view: self.contextGestureContainerView, frame: CGRect(origin: CGPoint(), size: size))
transition.setFrame(view: self.liquidLensView, frame: CGRect(origin: CGPoint(), size: size))
let lensSelection: (x: CGFloat, width: CGFloat)
if let selectionGestureState = self.selectionGestureState {
lensSelection = (selectionGestureState.currentX, itemSize.width + innerInset * 2.0)
} else if let selectionFrame {
lensSelection = (selectionFrame.minX - innerInset, itemSize.width + innerInset * 2.0)
} else {
lensSelection = (0.0, itemSize.width)
}
self.liquidLensView.update(size: size, selectionX: lensSelection.x, selectionWidth: lensSelection.width, isDark: component.theme.overallDarkAppearance, isLifted: self.selectionGestureState != nil, 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 item: TabBarComponent.Item
let theme: PresentationTheme
let isSelected: Bool
init(item: TabBarComponent.Item, theme: PresentationTheme, isSelected: Bool) {
self.item = item
self.theme = theme
self.isSelected = isSelected
}
static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool {
if lhs.item != rhs.item {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.isSelected != rhs.isSelected {
return false
}
return true
}
final class View: UIView {
let contextContainerView: ContextExtractedContentContainingView
private var imageIcon: ComponentView<Empty>?
private var animationIcon: ComponentView<Empty>?
private let title = ComponentView<Empty>()
private var badge: ComponentView<Empty>?
private var component: ItemComponent?
private weak var state: EmptyComponentState?
private var setImageListener: Int?
private var setSelectedImageListener: Int?
private var setBadgeListener: Int?
override init(frame: CGRect) {
self.contextContainerView = ContextExtractedContentContainingView()
super.init(frame: frame)
self.addSubview(self.contextContainerView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let component = self.component {
if let setImageListener = self.setImageListener {
component.item.item.removeSetImageListener(setImageListener)
}
if let setSelectedImageListener = self.setSelectedImageListener {
component.item.item.removeSetSelectedImageListener(setSelectedImageListener)
}
if let setBadgeListener = self.setBadgeListener {
component.item.item.removeSetBadgeListener(setBadgeListener)
}
}
}
func playSelectionAnimation() {
if let animationIconView = self.animationIcon?.view as? LottieComponent.View {
animationIconView.playOnce()
}
}
func update(component: ItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
if previousComponent?.item.item !== component.item.item {
if let setImageListener = self.setImageListener {
self.component?.item.item.removeSetImageListener(setImageListener)
}
if let setSelectedImageListener = self.setSelectedImageListener {
self.component?.item.item.removeSetSelectedImageListener(setSelectedImageListener)
}
if let setBadgeListener = self.setBadgeListener {
self.component?.item.item.removeSetBadgeListener(setBadgeListener)
}
self.setImageListener = component.item.item.addSetImageListener { [weak self] _ in
guard let self else {
return
}
self.state?.updated(transition: .immediate, isLocal: true)
}
self.setSelectedImageListener = component.item.item.addSetSelectedImageListener { [weak self] _ in
guard let self else {
return
}
self.state?.updated(transition: .immediate, isLocal: true)
}
self.setBadgeListener = UITabBarItem_addSetBadgeListener(component.item.item) { [weak self] _ in
guard let self else {
return
}
self.state?.updated(transition: .immediate, isLocal: true)
}
}
self.component = component
self.state = state
if let animationName = component.item.item.animationName {
if let imageIcon = self.imageIcon {
self.imageIcon = nil
imageIcon.view?.removeFromSuperview()
}
let animationIcon: ComponentView<Empty>
var iconTransition = transition
if let current = self.animationIcon {
animationIcon = current
} else {
iconTransition = iconTransition.withAnimation(.none)
animationIcon = ComponentView()
self.animationIcon = animationIcon
}
let iconSize = animationIcon.update(
transition: iconTransition,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(
name: animationName
),
color: component.isSelected ? component.theme.rootController.tabBar.selectedTextColor : component.theme.rootController.tabBar.textColor,
placeholderColor: nil,
startingPosition: .end,
size: CGSize(width: 48.0, height: 48.0),
loop: false
)),
environment: {},
containerSize: CGSize(width: 48.0, height: 48.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: -4.0), size: iconSize).offsetBy(dx: component.item.item.animationOffset.x, dy: component.item.item.animationOffset.y)
if let animationIconView = animationIcon.view {
if animationIconView.superview == nil {
if let badgeView = self.badge?.view {
self.contextContainerView.contentView.insertSubview(animationIconView, belowSubview: badgeView)
} else {
self.contextContainerView.contentView.addSubview(animationIconView)
}
}
iconTransition.setFrame(view: animationIconView, frame: iconFrame)
}
} else {
if let animationIcon = self.animationIcon {
self.animationIcon = nil
animationIcon.view?.removeFromSuperview()
}
let imageIcon: ComponentView<Empty>
var iconTransition = transition
if let current = self.imageIcon {
imageIcon = current
} else {
iconTransition = iconTransition.withAnimation(.none)
imageIcon = ComponentView()
self.imageIcon = imageIcon
}
let iconSize = imageIcon.update(
transition: iconTransition,
component: AnyComponent(Image(
image: component.isSelected ? component.item.item.selectedImage : component.item.item.image,
tintColor: nil,
contentMode: .center
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: 3.0), size: iconSize)
if let imageIconView = imageIcon.view {
if imageIconView.superview == nil {
if let badgeView = self.badge?.view {
self.contextContainerView.contentView.insertSubview(imageIconView, belowSubview: badgeView)
} else {
self.contextContainerView.contentView.addSubview(imageIconView)
}
}
iconTransition.setFrame(view: imageIconView, frame: iconFrame)
}
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.item.item.title ?? " ", font: Font.semibold(10.0), textColor: component.isSelected ? component.theme.rootController.tabBar.selectedTextColor : component.theme.rootController.tabBar.textColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 100.0)
)
let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: availableSize.height - 8.0 - titleSize.height), size: titleSize)
if SGSimpleSettings.shared.showTabNames, let titleView = self.title.view {
if titleView.superview == nil {
self.contextContainerView.contentView.addSubview(titleView)
}
titleView.frame = titleFrame
}
if let badgeText = component.item.item.badgeValue, !badgeText.isEmpty {
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: badgeText,
font: Font.regular(13.0),
background: component.theme.rootController.tabBar.badgeBackgroundColor,
foreground: component.theme.rootController.tabBar.badgeTextColor,
insets: UIEdgeInsets(top: 0.0, left: 6.0, bottom: 1.0, right: 6.0)
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let contentWidth: CGFloat = 25.0
let badgeFrame = CGRect(origin: CGPoint(x: floor(availableSize.width / 2.0) + contentWidth - badgeSize.width - 1.0, y: 5.0), size: badgeSize)
if let badgeView = badge.view {
if badgeView.superview == nil {
self.contextContainerView.contentView.addSubview(badgeView)
}
badgeTransition.setFrame(view: badgeView, frame: badgeFrame)
}
} else if let badge = self.badge {
self.badge = nil
badge.view?.removeFromSuperview()
}
transition.setFrame(view: self.contextContainerView, frame: CGRect(origin: CGPoint(), size: availableSize))
transition.setFrame(view: self.contextContainerView.contentView, frame: CGRect(origin: CGPoint(), size: availableSize))
self.contextContainerView.contentRect = CGRect(origin: CGPoint(), size: availableSize)
return availableSize
}
}
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)
}
}