diff --git a/Rosetta/DesignSystem/Components/RosettaTab.swift b/Rosetta/DesignSystem/Components/RosettaTab.swift new file mode 100644 index 0000000..8d80f17 --- /dev/null +++ b/Rosetta/DesignSystem/Components/RosettaTab.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Tab model for the main tab bar — shared by MainTabView and RosettaTabBar. +enum RosettaTab: String, CaseIterable, Sendable { + case chats, calls, settings + + static let interactionOrder: [RosettaTab] = [.calls, .chats, .settings] + + var label: String { + switch self { + case .chats: return "Chats" + case .calls: return "Calls" + case .settings: return "Settings" + } + } + + var animationName: String { + switch self { + case .chats: return "TabChats" + case .calls: return "TabCalls" + case .settings: return "TabSettings" + } + } + + var icon: String { + switch self { + case .chats: return "bubble.left.and.bubble.right" + case .calls: return "phone" + case .settings: return "gearshape" + } + } + + var selectedIcon: String { + switch self { + case .chats: return "bubble.left.and.bubble.right.fill" + case .calls: return "phone.fill" + case .settings: return "gearshape.fill" + } + } + + var interactionIndex: Int { + Self.interactionOrder.firstIndex(of: self) ?? 0 + } +} + +struct TabBadge { let tab: RosettaTab; let text: String } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index a5a2931..aa2cc9f 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -2,55 +2,6 @@ import SwiftUI import UIKit import Lottie -// MARK: - Tab - -enum RosettaTab: String, CaseIterable, Sendable { - case chats, calls, settings - - static let interactionOrder: [RosettaTab] = [.calls, .chats, .settings] - - var label: String { - switch self { - case .chats: return "Chats" - case .calls: return "Calls" - case .settings: return "Settings" - } - } - - var animationName: String { - switch self { - case .chats: return "TabChats" - case .calls: return "TabCalls" - case .settings: return "TabSettings" - } - } - - var icon: String { - switch self { - case .chats: return "bubble.left.and.bubble.right" - case .calls: return "phone" - case .settings: return "gearshape" - } - } - - var selectedIcon: String { - switch self { - case .chats: return "bubble.left.and.bubble.right.fill" - case .calls: return "phone.fill" - case .settings: return "gearshape.fill" - } - } - - var interactionIndex: Int { - Self.interactionOrder.firstIndex(of: self) ?? 0 - } -} - -struct TabBadge { let tab: RosettaTab; let text: String } -struct TabBarSwipeState { - let fromTab: RosettaTab; let hoveredTab: RosettaTab; let fractionalIndex: CGFloat -} - // MARK: - Colors private enum TabBarUIColors { @@ -58,7 +9,6 @@ private enum TabBarUIColors { static let selectedIcon = UIColor(RosettaColors.primaryBlue) static let text = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(red: 0, green: 0, blue: 0, alpha: 0.8) } static let selectedText = UIColor(RosettaColors.primaryBlue) - // Badge always red/white — matches Telegram screenshots in both themes static let badgeBg = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1) static let badgeText = UIColor.white static let selectionFill = UIColor.white.withAlphaComponent(0.07) @@ -69,6 +19,12 @@ private enum TabBarUIColors { private final class TabSelectionGesture: UIGestureRecognizer { private(set) var initialLocation: CGPoint = .zero + override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + delaysTouchesBegan = false + delaysTouchesEnded = false + } + override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) initialLocation = touches.first?.location(in: view) ?? .zero @@ -96,9 +52,7 @@ final class RosettaTabBarUIView: UIView { private let innerInset: CGFloat = 4 private let itemHeight: CGFloat = 56 private let perItemWidth: CGFloat = 90 - /// Telegram LiquidLens inset — visible gap between lens and capsule edge. private let lensInset: CGFloat = 4 - /// Telegram: 1.15 scale when gesture active. Toned down for subtlety. private let liftedScale: CGFloat = 1.08 private var barWidth: CGFloat { perItemWidth * CGFloat(tabs.count) + innerInset * 2 } @@ -108,17 +62,23 @@ final class RosettaTabBarUIView: UIView { // MARK: State var selectedIndex: Int = 1 { - didSet { if oldValue != selectedIndex { updateSelection(animated: true) } } + didSet { + if oldValue != selectedIndex { + updateLensClip(animated: true) + updateLabelColors() + } + } } var onTabSelected: ((RosettaTab) -> Void)? var badgeText: String? { didSet { layoutBadge() } } private var isDragging = false + private var didDrag = false // true if .changed fired (not just a tap) private var dragLensX: CGFloat = 0 private var dragStartLensX: CGFloat = 0 private var hoveredIndex: Int = 1 - // MARK: Subviews + // MARK: Subviews — base (unselected, always visible) private let glassBackground = TelegramGlassUIView(frame: .zero) private let selectionView: UIView = { @@ -129,9 +89,28 @@ final class RosettaTabBarUIView: UIView { }() private var iconViews: [LottieAnimationView] = [] private var labelViews: [UILabel] = [] + + // MARK: Subviews — selected overlay (clipped to lens, Telegram LiquidLens parity) + + private let selectedClipView: UIView = { + let v = UIView() + v.clipsToBounds = true + v.isUserInteractionEnabled = false + return v + }() + private let selectedContentView: UIView = { + let v = UIView() + v.isUserInteractionEnabled = false + return v + }() + private var selectedIconViews: [LottieAnimationView] = [] + private var badgeBgView: UIView? private var badgeLabel: UILabel? - private var appliedColorHex: [Int: UInt64] = [:] + + // Cached RGBA for label interpolation — avoids dynamic UIColor resolution per frame. + private var textRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = (1, 1, 1, 1) + private var selectedTextRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 1) // MARK: Init @@ -141,98 +120,168 @@ final class RosettaTabBarUIView: UIView { addSubview(glassBackground) addSubview(selectionView) - for (i, tab) in tabs.enumerated() { - let lottie = LottieAnimationView() - lottie.contentMode = .scaleAspectFit - lottie.isUserInteractionEnabled = false - lottie.backgroundBehavior = .pauseAndRestore - if let anim = LottieAnimationCache.shared.animation(named: tab.animationName) { - lottie.animation = anim - } else { - lottie.animation = LottieAnimation.named(tab.animationName) - } - lottie.loopMode = .playOnce - lottie.currentProgress = 1.0 - addSubview(lottie) - iconViews.append(lottie) - - let label = UILabel() - label.text = tab.label - label.font = .systemFont(ofSize: 10, weight: .semibold) - label.textAlignment = .center - addSubview(label) - labelViews.append(label) - applyColor(to: i, selected: i == selectedIndex, animated: false) + for tab in tabs { + let icon = makeLottie(for: tab); addSubview(icon); iconViews.append(icon) + let label = makeLabel(for: tab); addSubview(label); labelViews.append(label) } + addSubview(selectedClipView) + selectedClipView.addSubview(selectedContentView) + + for tab in tabs { + let icon = makeLottie(for: tab); selectedContentView.addSubview(icon); selectedIconViews.append(icon) + } + + applyAllColors() let g = TabSelectionGesture(target: self, action: #selector(handleGesture(_:))) g.delegate = self addGestureRecognizer(g) } required init?(coder: NSCoder) { fatalError() } - override var intrinsicContentSize: CGSize { CGSize(width: barWidth, height: barHeight) } + // MARK: Helpers + + private func makeLottie(for tab: RosettaTab) -> LottieAnimationView { + let v = LottieAnimationView() + v.contentMode = .scaleAspectFit + v.isUserInteractionEnabled = false + v.backgroundBehavior = .pauseAndRestore + v.animation = LottieAnimationCache.shared.animation(named: tab.animationName) + ?? LottieAnimation.named(tab.animationName) + v.loopMode = .playOnce; v.currentProgress = 1.0 + return v + } + + private func makeLabel(for tab: RosettaTab) -> UILabel { + let l = UILabel(); l.text = tab.label + l.font = .systemFont(ofSize: 10, weight: .semibold); l.textAlignment = .center + return l + } + + private func setLottieColor(_ view: LottieAnimationView, _ color: UIColor) { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + view.setValueProvider( + ColorValueProvider(LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a))), + keypath: AnimationKeypath(keypath: "**.Color") + ) + } + + private func applyAllColors() { + cacheColorComponents() + for i in 0.. CGRect { - let iw = itemW let lensH = bounds.height - lensInset * 2 - let lensW = iw - let lensX = innerInset + CGFloat(index) * iw - return CGRect(x: lensX, y: lensInset, width: lensW, height: lensH) + return CGRect(x: innerInset + CGFloat(index) * itemW, y: lensInset, width: itemW, height: lensH) } - private func updateSelectionFrame(animated: Bool) { + private func updateLensClip(animated: Bool) { guard bounds.width > 0 else { return } let lensH = bounds.height - lensInset * 2 - selectionView.layer.cornerRadius = lensH / 2 + let target: CGRect if isDragging { - // Lens follows finger — clamped to capsule bounds - let lensW = itemW - let minX = lensInset - let maxX = bounds.width - lensW - lensInset - let clampedX = max(minX, min(dragLensX, maxX)) - selectionView.frame = CGRect(x: clampedX, y: lensInset, width: lensW, height: lensH) + let clampedX = max(lensInset, min(dragLensX, bounds.width - itemW - lensInset)) + target = CGRect(x: clampedX, y: lensInset, width: itemW, height: lensH) } else { - let target = lensFrame(for: selectedIndex) - if animated { - UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.78, - initialSpringVelocity: 0, options: .beginFromCurrentState) { - self.selectionView.frame = target - self.selectionView.transform = .identity - } - } else { - selectionView.frame = target - selectionView.transform = .identity + target = lensFrame(for: selectedIndex) + } + + let apply = { + self.selectionView.frame = target + self.selectedClipView.frame = target + self.selectedContentView.frame = CGRect( + x: -target.origin.x, y: -target.origin.y, + width: self.bounds.width, height: self.bounds.height + ) + if !self.isDragging { + self.selectionView.transform = .identity } } + if animated && !isDragging { + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.82, + initialSpringVelocity: 0, options: .beginFromCurrentState, animations: apply) + } else { + // Disable implicit CoreAnimation during drag — direct property set like Telegram's .immediate + CATransaction.begin() + CATransaction.setDisableActions(true) + apply() + CATransaction.commit() + } } // MARK: Gesture @@ -242,90 +291,52 @@ final class RosettaTabBarUIView: UIView { switch g.state { case .began: isDragging = true - hoveredIndex = selectedIndex - // Start lens at selected tab position + didDrag = false + hoveredIndex = max(0, min(Int((g.initialLocation.x - innerInset) / iw), tabs.count - 1)) dragStartLensX = innerInset + CGFloat(selectedIndex) * iw dragLensX = dragStartLensX - - // Lifted state: scale up selection indicator (Telegram isLifted) - UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7, + UIView.animate(withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .beginFromCurrentState) { self.selectionView.transform = CGAffineTransform(scaleX: self.liftedScale, y: self.liftedScale) } - updateSelectionFrame(animated: false) + updateLensClip(animated: false) case .changed: + didDrag = true let loc = g.location(in: self) - let translation = loc.x - g.initialLocation.x - dragLensX = dragStartLensX + translation - - // Update hovered tab - let newHover = max(0, min(Int((loc.x - innerInset) / iw), tabs.count - 1)) - if newHover != hoveredIndex { - hoveredIndex = newHover - // Smooth color transition (Telegram: colors blend when hovering) - UIView.animate(withDuration: 0.2) { - for i in 0..