Тапбар — 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 UIKit
import Lottie 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 // MARK: - Colors
private enum TabBarUIColors { private enum TabBarUIColors {
@@ -58,7 +9,6 @@ private enum TabBarUIColors {
static let selectedIcon = UIColor(RosettaColors.primaryBlue) 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 text = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(red: 0, green: 0, blue: 0, alpha: 0.8) }
static let selectedText = UIColor(RosettaColors.primaryBlue) 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 badgeBg = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1)
static let badgeText = UIColor.white static let badgeText = UIColor.white
static let selectionFill = UIColor.white.withAlphaComponent(0.07) static let selectionFill = UIColor.white.withAlphaComponent(0.07)
@@ -69,6 +19,12 @@ private enum TabBarUIColors {
private final class TabSelectionGesture: UIGestureRecognizer { private final class TabSelectionGesture: UIGestureRecognizer {
private(set) var initialLocation: CGPoint = .zero 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) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event) super.touchesBegan(touches, with: event)
initialLocation = touches.first?.location(in: view) ?? .zero initialLocation = touches.first?.location(in: view) ?? .zero
@@ -96,9 +52,7 @@ final class RosettaTabBarUIView: UIView {
private let innerInset: CGFloat = 4 private let innerInset: CGFloat = 4
private let itemHeight: CGFloat = 56 private let itemHeight: CGFloat = 56
private let perItemWidth: CGFloat = 90 private let perItemWidth: CGFloat = 90
/// Telegram LiquidLens inset visible gap between lens and capsule edge.
private let lensInset: CGFloat = 4 private let lensInset: CGFloat = 4
/// Telegram: 1.15 scale when gesture active. Toned down for subtlety.
private let liftedScale: CGFloat = 1.08 private let liftedScale: CGFloat = 1.08
private var barWidth: CGFloat { perItemWidth * CGFloat(tabs.count) + innerInset * 2 } private var barWidth: CGFloat { perItemWidth * CGFloat(tabs.count) + innerInset * 2 }
@@ -108,17 +62,23 @@ final class RosettaTabBarUIView: UIView {
// MARK: State // MARK: State
var selectedIndex: Int = 1 { var selectedIndex: Int = 1 {
didSet { if oldValue != selectedIndex { updateSelection(animated: true) } } didSet {
if oldValue != selectedIndex {
updateLensClip(animated: true)
updateLabelColors()
}
}
} }
var onTabSelected: ((RosettaTab) -> Void)? var onTabSelected: ((RosettaTab) -> Void)?
var badgeText: String? { didSet { layoutBadge() } } var badgeText: String? { didSet { layoutBadge() } }
private var isDragging = false private var isDragging = false
private var didDrag = false // true if .changed fired (not just a tap)
private var dragLensX: CGFloat = 0 private var dragLensX: CGFloat = 0
private var dragStartLensX: CGFloat = 0 private var dragStartLensX: CGFloat = 0
private var hoveredIndex: Int = 1 private var hoveredIndex: Int = 1
// MARK: Subviews // MARK: Subviews base (unselected, always visible)
private let glassBackground = TelegramGlassUIView(frame: .zero) private let glassBackground = TelegramGlassUIView(frame: .zero)
private let selectionView: UIView = { private let selectionView: UIView = {
@@ -129,9 +89,28 @@ final class RosettaTabBarUIView: UIView {
}() }()
private var iconViews: [LottieAnimationView] = [] private var iconViews: [LottieAnimationView] = []
private var labelViews: [UILabel] = [] 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 badgeBgView: UIView?
private var badgeLabel: UILabel? 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 // MARK: Init
@@ -141,98 +120,168 @@ final class RosettaTabBarUIView: UIView {
addSubview(glassBackground) addSubview(glassBackground)
addSubview(selectionView) addSubview(selectionView)
for (i, tab) in tabs.enumerated() { for tab in tabs {
let lottie = LottieAnimationView() let icon = makeLottie(for: tab); addSubview(icon); iconViews.append(icon)
lottie.contentMode = .scaleAspectFit let label = makeLabel(for: tab); addSubview(label); labelViews.append(label)
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)
} }
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(_:))) let g = TabSelectionGesture(target: self, action: #selector(handleGesture(_:)))
g.delegate = self g.delegate = self
addGestureRecognizer(g) addGestureRecognizer(g)
} }
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: CGSize { CGSize(width: barWidth, height: barHeight) } 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 // MARK: Layout
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
let w = bounds.width; let h = bounds.height let h = bounds.height
glassBackground.frame = bounds glassBackground.frame = bounds
glassBackground.updateGlass() glassBackground.updateGlass()
updateSelectionFrame(animated: false)
let iw = itemW let iw = itemW
for i in 0..<tabs.count { for i in 0..<tabs.count {
let x = innerInset + CGFloat(i) * iw let x = innerInset + CGFloat(i) * iw
let iconSize: CGFloat = 44 let iconSize: CGFloat = 44
let iconX = x + (iw - iconSize) / 2 let iconFrame = CGRect(x: x + (iw - iconSize) / 2, y: innerInset + 1, width: iconSize, height: iconSize)
let iconY = innerInset + 1 iconViews[i].frame = iconFrame
iconViews[i].frame = CGRect(x: iconX, y: iconY, width: iconSize, height: iconSize) selectedIconViews[i].frame = iconFrame
let labelH: CGFloat = 14 let labelFrame = CGRect(x: x, y: h - innerInset - 17, width: iw, height: 14)
let labelY = h - innerInset - labelH - 1 labelViews[i].frame = labelFrame
labelViews[i].frame = CGRect(x: x, y: labelY, width: iw, height: labelH)
} }
// 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() layoutBadge()
} }
// MARK: Selection Indicator // MARK: Lens Clip (Telegram LiquidLens masking)
private func lensFrame(for index: Int) -> CGRect { private func lensFrame(for index: Int) -> CGRect {
let iw = itemW
let lensH = bounds.height - lensInset * 2 let lensH = bounds.height - lensInset * 2
let lensW = iw return CGRect(x: innerInset + CGFloat(index) * itemW, y: lensInset, width: itemW, height: lensH)
let lensX = innerInset + CGFloat(index) * iw
return CGRect(x: lensX, y: lensInset, width: lensW, height: lensH)
} }
private func updateSelectionFrame(animated: Bool) { private func updateLensClip(animated: Bool) {
guard bounds.width > 0 else { return } guard bounds.width > 0 else { return }
let lensH = bounds.height - lensInset * 2 let lensH = bounds.height - lensInset * 2
selectionView.layer.cornerRadius = lensH / 2
let target: CGRect
if isDragging { if isDragging {
// Lens follows finger clamped to capsule bounds let clampedX = max(lensInset, min(dragLensX, bounds.width - itemW - lensInset))
let lensW = itemW target = CGRect(x: clampedX, y: lensInset, width: itemW, height: lensH)
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)
} else { } else {
let target = lensFrame(for: selectedIndex) target = lensFrame(for: selectedIndex)
if animated { }
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.78,
initialSpringVelocity: 0, options: .beginFromCurrentState) { let apply = {
self.selectionView.frame = target self.selectionView.frame = target
self.selectionView.transform = .identity self.selectedClipView.frame = target
} self.selectedContentView.frame = CGRect(
} else { x: -target.origin.x, y: -target.origin.y,
selectionView.frame = target width: self.bounds.width, height: self.bounds.height
selectionView.transform = .identity )
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 // MARK: Gesture
@@ -242,90 +291,52 @@ final class RosettaTabBarUIView: UIView {
switch g.state { switch g.state {
case .began: case .began:
isDragging = true isDragging = true
hoveredIndex = selectedIndex didDrag = false
// Start lens at selected tab position hoveredIndex = max(0, min(Int((g.initialLocation.x - innerInset) / iw), tabs.count - 1))
dragStartLensX = innerInset + CGFloat(selectedIndex) * iw dragStartLensX = innerInset + CGFloat(selectedIndex) * iw
dragLensX = dragStartLensX dragLensX = dragStartLensX
UIView.animate(withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.8,
// Lifted state: scale up selection indicator (Telegram isLifted)
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7,
initialSpringVelocity: 0, options: .beginFromCurrentState) { initialSpringVelocity: 0, options: .beginFromCurrentState) {
self.selectionView.transform = CGAffineTransform(scaleX: self.liftedScale, y: self.liftedScale) self.selectionView.transform = CGAffineTransform(scaleX: self.liftedScale, y: self.liftedScale)
} }
updateSelectionFrame(animated: false) updateLensClip(animated: false)
case .changed: case .changed:
didDrag = true
let loc = g.location(in: self) let loc = g.location(in: self)
let translation = loc.x - g.initialLocation.x dragLensX = dragStartLensX + (loc.x - g.initialLocation.x)
dragLensX = dragStartLensX + translation hoveredIndex = max(0, min(Int((loc.x - innerInset) / iw), tabs.count - 1))
let fractionalIdx = max(0, min((dragLensX - innerInset) / iw, CGFloat(tabs.count - 1)))
// Update hovered tab updateLabelsForFraction(fractionalIdx)
let newHover = max(0, min(Int((loc.x - innerInset) / iw), tabs.count - 1)) updateLensClip(animated: false)
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)
case .ended: case .ended:
isDragging = false isDragging = false
let target = tabs[hoveredIndex] let targetChanged = hoveredIndex != selectedIndex
if hoveredIndex != selectedIndex { if targetChanged {
iconViews[hoveredIndex].play(fromProgress: 0, toProgress: 1, loopMode: .playOnce) iconViews[hoveredIndex].play(fromProgress: 0, toProgress: 1, loopMode: .playOnce)
selectedIndex = hoveredIndex selectedIconViews[hoveredIndex].play(fromProgress: 0, toProgress: 1, loopMode: .playOnce)
onTabSelected?(target) 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 { } else {
// Spring back with un-lift updateLensClip(animated: true)
updateSelection(animated: true) updateLabelColors()
} }
case .cancelled: case .cancelled:
isDragging = false isDragging = false
updateSelection(animated: true) updateLensClip(animated: true)
updateLabelColors()
default: break 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 // MARK: Badge
private func layoutBadge() { private func layoutBadge() {
@@ -333,12 +344,12 @@ final class RosettaTabBarUIView: UIView {
badgeBgView?.isHidden = true; return badgeBgView?.isHidden = true; return
} }
let chatsIdx = RosettaTab.chats.interactionIndex let chatsIdx = RosettaTab.chats.interactionIndex
let iconFrame = iconViews[chatsIdx].frame let iw = itemW
if badgeBgView == nil { if badgeBgView == nil {
let bg = UIView(); bg.layer.masksToBounds = true; addSubview(bg); badgeBgView = bg let bg = UIView(); bg.layer.masksToBounds = true; addSubview(bg); badgeBgView = bg
let lbl = UILabel(); lbl.font = .systemFont(ofSize: 13); lbl.textAlignment = .center let lbl = UILabel(); lbl.font = .systemFont(ofSize: 13, weight: .regular)
bg.addSubview(lbl); badgeLabel = lbl lbl.textAlignment = .center; bg.addSubview(lbl); badgeLabel = lbl
} }
guard let bg = badgeBgView, let lbl = badgeLabel else { return } guard let bg = badgeBgView, let lbl = badgeLabel else { return }
bg.isHidden = false bg.isHidden = false
@@ -347,19 +358,19 @@ final class RosettaTabBarUIView: UIView {
lbl.text = text; lbl.sizeToFit() lbl.text = text; lbl.sizeToFit()
let textW = lbl.frame.width 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 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 bg.layer.cornerRadius = bgH / 2
lbl.frame = CGRect(x: (bgW - textW) / 2, y: 0.5, width: textW, height: bgH - 1) lbl.frame = CGRect(x: (bgW - textW) / 2, y: 0.5, width: textW, height: bgH - 1)
bringSubviewToFront(bg)
} }
override func traitCollectionDidChange(_ prev: UITraitCollection?) { override func traitCollectionDidChange(_ prev: UITraitCollection?) {
super.traitCollectionDidChange(prev) super.traitCollectionDidChange(prev)
if traitCollection.hasDifferentColorAppearance(comparedTo: prev) { if traitCollection.hasDifferentColorAppearance(comparedTo: prev) {
appliedColorHex.removeAll() applyAllColors()
updateSelection(animated: false)
layoutBadge()
} }
} }
} }