Тапбар — dual-layer маскировка, плавные анимации и badge

This commit is contained in:
2026-04-10 17:57:54 +05:00
parent baf4985837
commit 49fc49ffda
2 changed files with 236 additions and 179 deletions

View File

@@ -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 }

View File

@@ -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<UITouch>, 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,97 +120,167 @@ 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..<tabs.count {
setLottieColor(iconViews[i], TabBarUIColors.icon)
setLottieColor(selectedIconViews[i], TabBarUIColors.selectedIcon)
}
updateLabelColors()
}
private func cacheColorComponents() {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
TabBarUIColors.text.getRed(&r, green: &g, blue: &b, alpha: &a)
textRGBA = (r, g, b, a)
TabBarUIColors.selectedText.getRed(&r, green: &g, blue: &b, alpha: &a)
selectedTextRGBA = (r, g, b, a)
}
private func updateLabelColors() {
let active = selectedIndex
for i in 0..<tabs.count {
labelViews[i].textColor = (i == active) ? TabBarUIColors.selectedText : TabBarUIColors.text
}
}
/// Smooth label color interpolation during drag uses cached RGBA to avoid per-frame UIColor resolution.
private func updateLabelsForFraction(_ fraction: CGFloat) {
let clamped = max(0, min(fraction, CGFloat(tabs.count - 1)))
let lower = Int(clamped)
let upper = min(lower + 1, tabs.count - 1)
let t = clamped - CGFloat(lower)
let (r1, g1, b1, a1) = textRGBA
let (r2, g2, b2, a2) = selectedTextRGBA
for i in 0..<tabs.count {
let s: CGFloat
if lower == upper { s = (i == lower) ? 1.0 : 0.0 }
else if i == lower { s = 1.0 - t }
else if i == upper { s = t }
else { s = 0.0 }
labelViews[i].textColor = UIColor(
red: r1 + (r2 - r1) * s, green: g1 + (g2 - g1) * s,
blue: b1 + (b2 - b1) * s, alpha: a1 + (a2 - a1) * s
)
}
}
// MARK: Layout
override func layoutSubviews() {
super.layoutSubviews()
let w = bounds.width; let h = bounds.height
let h = bounds.height
glassBackground.frame = bounds
glassBackground.updateGlass()
updateSelectionFrame(animated: false)
let iw = itemW
for i in 0..<tabs.count {
let x = innerInset + CGFloat(i) * iw
let iconSize: CGFloat = 44
let iconX = x + (iw - iconSize) / 2
let iconY = innerInset + 1
iconViews[i].frame = CGRect(x: iconX, y: iconY, width: iconSize, height: iconSize)
let iconFrame = CGRect(x: x + (iw - iconSize) / 2, y: innerInset + 1, width: iconSize, height: iconSize)
iconViews[i].frame = iconFrame
selectedIconViews[i].frame = iconFrame
let labelH: CGFloat = 14
let labelY = h - innerInset - labelH - 1
labelViews[i].frame = CGRect(x: x, y: labelY, width: iw, height: labelH)
let labelFrame = CGRect(x: x, y: h - innerInset - 17, width: iw, height: 14)
labelViews[i].frame = labelFrame
}
// cornerRadius only changes on bounds change, not during drag.
let lensH = bounds.height - lensInset * 2
selectionView.layer.cornerRadius = lensH / 2
selectedClipView.layer.cornerRadius = lensH / 2
updateLensClip(animated: false)
layoutBadge()
}
// MARK: Selection Indicator
// MARK: Lens Clip (Telegram LiquidLens masking)
private func lensFrame(for index: Int) -> 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) {
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
}
} else {
selectionView.frame = target
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()
}
}
@@ -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..<self.tabs.count {
self.applyColor(to: i, selected: i == self.hoveredIndex, animated: false)
}
}
}
updateSelectionFrame(animated: false)
dragLensX = dragStartLensX + (loc.x - g.initialLocation.x)
hoveredIndex = max(0, min(Int((loc.x - innerInset) / iw), tabs.count - 1))
let fractionalIdx = max(0, min((dragLensX - innerInset) / iw, CGFloat(tabs.count - 1)))
updateLabelsForFraction(fractionalIdx)
updateLensClip(animated: false)
case .ended:
isDragging = false
let target = tabs[hoveredIndex]
if hoveredIndex != selectedIndex {
let targetChanged = hoveredIndex != selectedIndex
if targetChanged {
iconViews[hoveredIndex].play(fromProgress: 0, toProgress: 1, loopMode: .playOnce)
selectedIndex = hoveredIndex
onTabSelected?(target)
selectedIconViews[hoveredIndex].play(fromProgress: 0, toProgress: 1, loopMode: .playOnce)
selectedIndex = hoveredIndex // didSet updateLensClip(animated: true) + updateLabelColors()
// Defer SwiftUI state update to next frame prevents layout pass
// from blocking the current frame and causing a freeze.
let tab = tabs[hoveredIndex]
DispatchQueue.main.async { [weak self] in
self?.onTabSelected?(tab)
}
} else {
// Spring back with un-lift
updateSelection(animated: true)
updateLensClip(animated: true)
updateLabelColors()
}
case .cancelled:
isDragging = false
updateSelection(animated: true)
updateLensClip(animated: true)
updateLabelColors()
default: break
}
}
// MARK: Selection Update
private func updateSelection(animated: Bool) {
for i in 0..<tabs.count {
applyColor(to: i, selected: i == selectedIndex, animated: animated)
}
updateSelectionFrame(animated: animated)
}
// MARK: Colors
private func applyColor(to index: Int, selected: Bool, animated: Bool) {
let iconColor = selected ? TabBarUIColors.selectedIcon : TabBarUIColors.icon
let txtColor = selected ? TabBarUIColors.selectedText : TabBarUIColors.text
let apply = {
self.labelViews[index].textColor = txtColor
}
if animated {
UIView.animate(withDuration: 0.25, animations: apply)
} else {
apply()
}
// Lottie color cache hex to skip redundant setValueProvider
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
iconColor.getRed(&r, green: &g, blue: &b, alpha: &a)
let hex = (UInt64(r * 255) << 24) | (UInt64(g * 255) << 16) | (UInt64(b * 255) << 8) | UInt64(a * 255)
guard appliedColorHex[index] != hex else { return }
appliedColorHex[index] = hex
let lc = LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a))
iconViews[index].setValueProvider(ColorValueProvider(lc), keypath: AnimationKeypath(keypath: "**.Color"))
}
// MARK: Badge
private func layoutBadge() {
@@ -333,12 +344,12 @@ final class RosettaTabBarUIView: UIView {
badgeBgView?.isHidden = true; return
}
let chatsIdx = RosettaTab.chats.interactionIndex
let iconFrame = iconViews[chatsIdx].frame
let iw = itemW
if badgeBgView == nil {
let bg = UIView(); bg.layer.masksToBounds = true; addSubview(bg); badgeBgView = bg
let lbl = UILabel(); lbl.font = .systemFont(ofSize: 13); lbl.textAlignment = .center
bg.addSubview(lbl); badgeLabel = lbl
let lbl = UILabel(); lbl.font = .systemFont(ofSize: 13, weight: .regular)
lbl.textAlignment = .center; bg.addSubview(lbl); badgeLabel = lbl
}
guard let bg = badgeBgView, let lbl = badgeLabel else { return }
bg.isHidden = false
@@ -347,19 +358,19 @@ final class RosettaTabBarUIView: UIView {
lbl.text = text; lbl.sizeToFit()
let textW = lbl.frame.width
let bgW = text.count == 1 ? 18.0 : max(18.0, textW + 10)
let bgW = text.count == 1 ? 18.0 : max(18.0, textW + 11)
let bgH: CGFloat = 18
bg.frame = CGRect(x: iconFrame.maxX - 6, y: iconFrame.minY - 1, width: bgW, height: bgH)
let itemX = innerInset + CGFloat(chatsIdx) * iw
bg.frame = CGRect(x: itemX + floor(iw / 2) + 25 - bgW - 1, y: 7, width: bgW, height: bgH)
bg.layer.cornerRadius = bgH / 2
lbl.frame = CGRect(x: (bgW - textW) / 2, y: 0.5, width: textW, height: bgH - 1)
bringSubviewToFront(bg)
}
override func traitCollectionDidChange(_ prev: UITraitCollection?) {
super.traitCollectionDidChange(prev)
if traitCollection.hasDifferentColorAppearance(comparedTo: prev) {
appliedColorHex.removeAll()
updateSelection(animated: false)
layoutBadge()
applyAllColors()
}
}
}