Тапбар — dual-layer маскировка, плавные анимации и badge
This commit is contained in:
46
Rosetta/DesignSystem/Components/RosettaTab.swift
Normal file
46
Rosetta/DesignSystem/Components/RosettaTab.swift
Normal 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 }
|
||||||
@@ -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,97 +120,167 @@ 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.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
|
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 {
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user