Откат случайно включённых изменений дизайн-системы
Предыдущий коммит случайно включил изменения из рабочей директории: упрощение GlassModifier, GlassModifiers, RosettaTabBar, ButtonStyles, GlassCard и других файлов, что сломало iOS 26 glass-эффекты и внешний вид tab bar. Восстановлены оригинальные файлы из состояния до этих изменений. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,16 +38,16 @@ struct AvatarView: View {
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if isOnline {
|
||||
Circle()
|
||||
.fill(RosettaColors.figmaBlue)
|
||||
.frame(width: size * 0.19, height: size * 0.19)
|
||||
.fill(Color(hex: 0x4CD964))
|
||||
.frame(width: badgeSize, height: badgeSize)
|
||||
.overlay {
|
||||
Circle()
|
||||
.stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.04)
|
||||
.stroke(RosettaColors.Adaptive.background, lineWidth: size * 0.05)
|
||||
}
|
||||
.offset(x: 0, y: -size * 0.06)
|
||||
.offset(x: -1, y: 1)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(isSavedMessages ? "Saved Messages" : initials)
|
||||
|
||||
@@ -19,9 +19,20 @@ struct GlassBackButton: View {
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var glassCircle: some View {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.15))
|
||||
if #available(iOS 26, *) {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.glassEffect(.regular, in: .circle)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.overlay {
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.12), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +46,19 @@ struct RosettaPrimaryButtonStyle: ButtonStyle {
|
||||
}
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background { glassBackground(isPressed: configuration.isPressed) }
|
||||
.clipShape(Capsule())
|
||||
Group {
|
||||
if #available(iOS 26, *) {
|
||||
configuration.label
|
||||
.background {
|
||||
Capsule().fill(fillColor.opacity(configuration.isPressed ? 0.7 : 1.0))
|
||||
}
|
||||
.glassEffect(.regular, in: Capsule())
|
||||
} else {
|
||||
configuration.label
|
||||
.background { glassBackground(isPressed: configuration.isPressed) }
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.scaleEffect(configuration.isPressed && isEnabled ? 0.97 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
.allowsHitTesting(isEnabled)
|
||||
@@ -46,6 +67,20 @@ struct RosettaPrimaryButtonStyle: ButtonStyle {
|
||||
private func glassBackground(isPressed: Bool) -> some View {
|
||||
Capsule()
|
||||
.fill(fillColor.opacity(isPressed ? 0.7 : 1.0))
|
||||
.overlay {
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(isEnabled ? 0.18 : 0.05),
|
||||
Color.clear,
|
||||
Color.black.opacity(0.08),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,25 @@ struct GlassCard<Content: View>: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(RosettaColors.adaptive(
|
||||
light: Color.black.opacity(fillOpacity),
|
||||
dark: Color.white.opacity(fillOpacity)
|
||||
))
|
||||
}
|
||||
if #available(iOS 26, *) {
|
||||
content()
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius))
|
||||
} else {
|
||||
content()
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(RosettaColors.adaptive(
|
||||
light: Color.black.opacity(fillOpacity),
|
||||
dark: Color.white.opacity(fillOpacity)
|
||||
))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(RosettaColors.adaptive(
|
||||
light: Color.black.opacity(0.06),
|
||||
dark: Color.white.opacity(0.08)
|
||||
), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,81 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Glass Modifier
|
||||
// MARK: - Glass Modifier (5-layer glass that works on black)
|
||||
//
|
||||
// Solid adaptive background — no glass or material effects
|
||||
// Layer stack:
|
||||
// 1. .ultraThinMaterial — system blur
|
||||
// 2. black.opacity(0.22) — dark tint (depth on dark mode)
|
||||
// 3. white→clear gradient — highlight / light refraction, blendMode(.screen)
|
||||
// 4. double stroke — outer weak + inner stronger = glass edge
|
||||
// 5. shadow — depth
|
||||
|
||||
struct GlassModifier: ViewModifier {
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
private var fillColor: Color {
|
||||
RosettaColors.adaptive(
|
||||
light: Color(hex: 0xF2F2F7),
|
||||
dark: Color(hex: 0x1C1C1E)
|
||||
)
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill(fillColor)
|
||||
}
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.background {
|
||||
shape.fill(.clear)
|
||||
.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.background {
|
||||
ZStack {
|
||||
shape.fill(.ultraThinMaterial)
|
||||
shape.fill(Color.black.opacity(0.22))
|
||||
shape.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
shape.stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
shape.stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Extension
|
||||
|
||||
extension View {
|
||||
/// Solid background with rounded corners.
|
||||
/// 5-layer frosted glass background.
|
||||
func glass(cornerRadius: CGFloat = 24) -> some View {
|
||||
modifier(GlassModifier(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
/// Solid capsule background — convenience for pill-shaped elements.
|
||||
/// Glass capsule — convenience for pill-shaped elements.
|
||||
@ViewBuilder
|
||||
func glassCapsule() -> some View {
|
||||
background {
|
||||
Capsule().fill(
|
||||
RosettaColors.adaptive(
|
||||
light: Color(hex: 0xF2F2F7),
|
||||
dark: Color(hex: 0x1C1C1E)
|
||||
)
|
||||
)
|
||||
if #available(iOS 26, *) {
|
||||
background {
|
||||
Capsule().fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
}
|
||||
} else {
|
||||
background {
|
||||
ZStack {
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
Capsule().fill(Color.black.opacity(0.22))
|
||||
Capsule().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import SwiftUI
|
||||
/// Applies glassmorphism effect to the navigation bar on iOS 26+, falling back to ultra-thin material.
|
||||
struct GlassNavBarModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.toolbarBackground(RosettaColors.Adaptive.background, for: .navigationBar)
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +25,12 @@ extension View {
|
||||
/// Applies glassmorphism capsule effect on iOS 26+.
|
||||
struct GlassSearchBarModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,297 +52,479 @@ struct TabBarSwipeState {
|
||||
let fractionalIndex: CGFloat
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar Colors
|
||||
|
||||
private enum TabBarColors {
|
||||
static let pillBackground = Color(hex: 0x2C2C2E)
|
||||
static let selectionBackground = Color(hex: 0x3A3A3C)
|
||||
static let selectedTint = Color(hex: 0x008BFF)
|
||||
static let unselectedTint = Color.white
|
||||
static let pillBorder = Color.white.opacity(0.08)
|
||||
}
|
||||
|
||||
// MARK: - Preference Keys
|
||||
|
||||
private struct TabWidthPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: [Int: CGFloat] = [:]
|
||||
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
|
||||
value.merge(nextValue()) { $1 }
|
||||
}
|
||||
}
|
||||
|
||||
private struct TabOriginPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: [Int: CGFloat] = [:]
|
||||
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
|
||||
value.merge(nextValue()) { $1 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RosettaTabBar
|
||||
|
||||
struct RosettaTabBar: View {
|
||||
let selectedTab: RosettaTab
|
||||
var onTabSelected: ((RosettaTab) -> Void)?
|
||||
var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)?
|
||||
var badges: [TabBadge] = []
|
||||
|
||||
private let allTabs = RosettaTab.interactionOrder
|
||||
private let tabCount = RosettaTab.interactionOrder.count
|
||||
@State private var tabFrames: [RosettaTab: CGRect] = [:]
|
||||
@State private var interactionState: TabPressInteraction?
|
||||
|
||||
// Drag state
|
||||
@State private var isDragging = false
|
||||
@State private var dragFractional: CGFloat = 0
|
||||
@State private var dragStartIndex: CGFloat = 0
|
||||
|
||||
// Measured tab geometry
|
||||
@State private var tabWidths: [Int: CGFloat] = [:]
|
||||
@State private var tabOrigins: [Int: CGFloat] = [:]
|
||||
|
||||
/// Cached badge text to avoid reading DialogRepository inside body
|
||||
/// (which creates @Observable tracking and causes re-render storms during drag).
|
||||
@State private var cachedBadgeText: String?
|
||||
|
||||
private var effectiveFractional: CGFloat {
|
||||
isDragging ? dragFractional : CGFloat(selectedTab.interactionIndex)
|
||||
}
|
||||
private static let tabBarSpace = "RosettaTabBarSpace"
|
||||
private let lensLiftOffset: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
// Single pill with all tabs — same structure as iOS 26 system TabView
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(allTabs.enumerated()), id: \.element) { index, tab in
|
||||
tabContent(tab: tab, index: index)
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.preference(
|
||||
key: TabWidthPreferenceKey.self,
|
||||
value: [index: geo.size.width]
|
||||
)
|
||||
.preference(
|
||||
key: TabOriginPreferenceKey.self,
|
||||
value: [index: geo.frame(in: .named("tabBar")).minX]
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.coordinateSpace(name: "tabBar")
|
||||
.onPreferenceChange(TabWidthPreferenceKey.self) { tabWidths = $0 }
|
||||
.onPreferenceChange(TabOriginPreferenceKey.self) { tabOrigins = $0 }
|
||||
.background(alignment: .leading) {
|
||||
selectionIndicator
|
||||
}
|
||||
.background {
|
||||
if #available(iOS 26.0, *) {
|
||||
// iOS 26+ — liquid glass material for the capsule pill
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
} else {
|
||||
// iOS < 26 — solid dark capsule
|
||||
Capsule()
|
||||
.fill(TabBarColors.pillBackground)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(TabBarColors.pillBorder, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
.gesture(dragGesture)
|
||||
.modifier(TabBarShadowModifier())
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.onAppear { Task { @MainActor in refreshBadges() } }
|
||||
.onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } }
|
||||
interactiveTabBarContent
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
/// Reads DialogRepository outside the body's observation scope.
|
||||
private func refreshBadges() {
|
||||
let repo = DialogRepository.shared
|
||||
let unread = repo.sortedDialogs
|
||||
.filter { !$0.isMuted }
|
||||
.reduce(0) { $0 + $1.unreadCount }
|
||||
if unread <= 0 {
|
||||
cachedBadgeText = nil
|
||||
} else {
|
||||
cachedBadgeText = unread > 999 ? "\(unread / 1000)K" : "\(unread)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection Indicator
|
||||
|
||||
@ViewBuilder
|
||||
private var selectionIndicator: some View {
|
||||
let frac = effectiveFractional
|
||||
let nearestIdx = Int(frac.rounded()).clamped(to: 0...(tabCount - 1))
|
||||
let width = tabWidths[nearestIdx] ?? 80
|
||||
let xOffset = interpolatedOrigin(for: frac)
|
||||
|
||||
Group {
|
||||
if #available(iOS 26.0, *) {
|
||||
Capsule().fill(.thinMaterial)
|
||||
.frame(width: width)
|
||||
.offset(x: xOffset)
|
||||
} else {
|
||||
Capsule().fill(TabBarColors.selectionBackground)
|
||||
.frame(width: width - 4)
|
||||
.padding(.vertical, 4)
|
||||
.offset(x: xOffset)
|
||||
private var interactiveTabBarContent: some View {
|
||||
tabBarContent
|
||||
.coordinateSpace(name: Self.tabBarSpace)
|
||||
.onPreferenceChange(TabFramePreferenceKey.self) { frames in
|
||||
tabFrames = frames
|
||||
}
|
||||
}
|
||||
.animation(
|
||||
isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82),
|
||||
value: frac
|
||||
)
|
||||
}
|
||||
|
||||
private func interpolatedOrigin(for fractional: CGFloat) -> CGFloat {
|
||||
let lower = Int(fractional).clamped(to: 0...(tabCount - 1))
|
||||
let upper = (lower + 1).clamped(to: 0...(tabCount - 1))
|
||||
let t = fractional - CGFloat(lower)
|
||||
let lowerX = tabOrigins[lower] ?? 0
|
||||
let upperX = tabOrigins[upper] ?? lowerX
|
||||
return lowerX + (upperX - lowerX) * t
|
||||
}
|
||||
|
||||
// MARK: - Drag Gesture
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 8)
|
||||
.onChanged { value in
|
||||
if !isDragging {
|
||||
isDragging = true
|
||||
dragStartIndex = CGFloat(selectedTab.interactionIndex)
|
||||
}
|
||||
|
||||
let avgTabWidth = totalTabWidth / CGFloat(tabCount)
|
||||
guard avgTabWidth > 0 else { return }
|
||||
let delta = value.translation.width / avgTabWidth
|
||||
let newFrac = (dragStartIndex - delta)
|
||||
.clamped(to: 0...CGFloat(tabCount - 1))
|
||||
|
||||
dragFractional = newFrac
|
||||
|
||||
let nearestIdx = Int(newFrac.rounded()).clamped(to: 0...(tabCount - 1))
|
||||
onSwipeStateChanged?(TabBarSwipeState(
|
||||
fromTab: selectedTab,
|
||||
hoveredTab: allTabs[nearestIdx],
|
||||
fractionalIndex: newFrac
|
||||
))
|
||||
.contentShape(Rectangle())
|
||||
.gesture(tabSelectionGesture)
|
||||
.overlay(alignment: .topLeading) {
|
||||
liftedLensOverlay
|
||||
}
|
||||
.onEnded { value in
|
||||
let avgTabWidth = totalTabWidth / CGFloat(tabCount)
|
||||
let velocity = avgTabWidth > 0 ? value.predictedEndTranslation.width / avgTabWidth : 0
|
||||
let projected = dragFractional - velocity * 0.15
|
||||
let snappedIdx = Int(projected.rounded()).clamped(to: 0...(tabCount - 1))
|
||||
let targetTab = allTabs[snappedIdx]
|
||||
|
||||
isDragging = false
|
||||
dragFractional = CGFloat(snappedIdx)
|
||||
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
onTabSelected?(targetTab)
|
||||
.onDisappear {
|
||||
interactionState = nil
|
||||
onSwipeStateChanged?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private var totalTabWidth: CGFloat {
|
||||
tabWidths.values.reduce(0, +)
|
||||
private var tabBarContent: some View {
|
||||
HStack(spacing: 8) {
|
||||
mainTabsPill
|
||||
searchPill
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Content
|
||||
private var visualSelectedTab: RosettaTab {
|
||||
if let interactionState, interactionState.isLifted {
|
||||
return interactionState.hoveredTab
|
||||
}
|
||||
return selectedTab
|
||||
}
|
||||
|
||||
private func tabContent(tab: RosettaTab, index: Int) -> some View {
|
||||
let frac = effectiveFractional
|
||||
let distance = abs(frac - CGFloat(index))
|
||||
let blend = (1 - distance).clamped(to: 0...1)
|
||||
let tint = tintColor(blend: blend)
|
||||
let isEffectivelySelected = blend > 0.5
|
||||
let badge: String? = (tab == .chats) ? cachedBadgeText : nil
|
||||
private var tabSelectionGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace))
|
||||
.onChanged(handleGestureChanged)
|
||||
.onEnded(handleGestureEnded)
|
||||
}
|
||||
|
||||
return Button {
|
||||
guard !isDragging else { return }
|
||||
private func handleGestureChanged(_ value: DragGesture.Value) {
|
||||
guard !tabFrames.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
if interactionState == nil {
|
||||
guard let startTab = tabAtStart(location: value.startLocation),
|
||||
let startFrame = tabFrames[startTab]
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let state = TabPressInteraction(
|
||||
id: UUID(),
|
||||
startTab: startTab,
|
||||
startCenterX: startFrame.midX,
|
||||
currentCenterX: startFrame.midX,
|
||||
hoveredTab: startTab,
|
||||
isLifted: true
|
||||
)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
onTabSelected?(tab)
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: isEffectivelySelected ? tab.selectedIcon : tab.icon)
|
||||
.font(.system(size: 22, weight: .regular))
|
||||
.foregroundStyle(tint)
|
||||
.frame(height: 28)
|
||||
interactionState = state
|
||||
publishSwipeState(for: state)
|
||||
return
|
||||
}
|
||||
|
||||
if let badge {
|
||||
Text(badge)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
guard var state = interactionState else {
|
||||
return
|
||||
}
|
||||
|
||||
state.currentCenterX = clampedCenterX(state.startCenterX + value.translation.width)
|
||||
|
||||
if let nearest = nearestTab(toX: state.currentCenterX), nearest != state.hoveredTab {
|
||||
state.hoveredTab = nearest
|
||||
if state.isLifted {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
interactionState = state
|
||||
publishSwipeState(for: state)
|
||||
}
|
||||
|
||||
private func handleGestureEnded(_ value: DragGesture.Value) {
|
||||
guard let state = interactionState else {
|
||||
return
|
||||
}
|
||||
|
||||
let targetTab = nearestTab(toX: value.location.x) ?? state.hoveredTab
|
||||
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.72)) {
|
||||
interactionState = nil
|
||||
}
|
||||
|
||||
onSwipeStateChanged?(nil)
|
||||
onTabSelected?(targetTab)
|
||||
}
|
||||
|
||||
private func publishSwipeState(for state: TabPressInteraction) {
|
||||
guard state.isLifted,
|
||||
let fractionalIndex = fractionalIndex(for: state.currentCenterX)
|
||||
else {
|
||||
onSwipeStateChanged?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
onSwipeStateChanged?(
|
||||
TabBarSwipeState(
|
||||
fromTab: state.startTab,
|
||||
hoveredTab: state.hoveredTab,
|
||||
fractionalIndex: fractionalIndex
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func tabAtStart(location: CGPoint) -> RosettaTab? {
|
||||
guard let nearest = nearestTab(toX: location.x),
|
||||
let frame = tabFrames[nearest]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return frame.insetBy(dx: -18, dy: -18).contains(location) ? nearest : nil
|
||||
}
|
||||
|
||||
private func nearestTab(toX x: CGFloat) -> RosettaTab? {
|
||||
tabFrames.min { lhs, rhs in
|
||||
abs(lhs.value.midX - x) < abs(rhs.value.midX - x)
|
||||
}?.key
|
||||
}
|
||||
|
||||
private func clampedCenterX(_ value: CGFloat) -> CGFloat {
|
||||
let centers = tabFrames.values.map(\.midX)
|
||||
guard let minX = centers.min(), let maxX = centers.max() else {
|
||||
return value
|
||||
}
|
||||
return min(max(value, minX), maxX)
|
||||
}
|
||||
|
||||
private func fractionalIndex(for centerX: CGFloat) -> CGFloat? {
|
||||
let centers = RosettaTab.interactionOrder.compactMap { tab -> CGFloat? in
|
||||
tabFrames[tab]?.midX
|
||||
}
|
||||
|
||||
guard centers.count == RosettaTab.interactionOrder.count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if centerX <= centers[0] {
|
||||
return 0
|
||||
}
|
||||
if centerX >= centers[centers.count - 1] {
|
||||
return CGFloat(centers.count - 1)
|
||||
}
|
||||
|
||||
for index in 0 ..< centers.count - 1 {
|
||||
let left = centers[index]
|
||||
let right = centers[index + 1]
|
||||
guard centerX >= left, centerX <= right else {
|
||||
continue
|
||||
}
|
||||
let progress = (centerX - left) / max(1, right - left)
|
||||
return CGFloat(index) + progress
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func badgeText(for tab: RosettaTab) -> String? {
|
||||
badges.first(where: { $0.tab == tab })?.text
|
||||
}
|
||||
|
||||
private func isCoveredByLens(_ tab: RosettaTab) -> Bool {
|
||||
interactionState?.isLifted == true && interactionState?.hoveredTab == tab
|
||||
}
|
||||
|
||||
private func lensDiameter(for tab: RosettaTab) -> CGFloat {
|
||||
switch tab {
|
||||
case .search:
|
||||
return 88
|
||||
default:
|
||||
return 104
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Tabs Pill
|
||||
|
||||
private extension RosettaTabBar {
|
||||
var mainTabsPill: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in
|
||||
TabItemView(
|
||||
tab: tab,
|
||||
isSelected: tab == visualSelectedTab,
|
||||
isCoveredByLens: isCoveredByLens(tab),
|
||||
badgeText: badgeText(for: tab)
|
||||
)
|
||||
.tabFramePreference(tab: tab, in: Self.tabBarSpace)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 3)
|
||||
.frame(height: 62)
|
||||
.background {
|
||||
mainPillGlass
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var mainPillGlass: some View {
|
||||
ZStack {
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
Capsule().fill(Color.black.opacity(0.34))
|
||||
Capsule().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.08), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1)
|
||||
Capsule().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var liftedLensOverlay: some View {
|
||||
if let state = interactionState,
|
||||
state.isLifted,
|
||||
let hoveredFrame = tabFrames[state.hoveredTab]
|
||||
{
|
||||
let diameter = lensDiameter(for: state.hoveredTab)
|
||||
|
||||
ZStack {
|
||||
lensBubble
|
||||
LensTabContentView(
|
||||
tab: state.hoveredTab,
|
||||
badgeText: badgeText(for: state.hoveredTab)
|
||||
)
|
||||
.padding(.top, state.hoveredTab == .search ? 0 : 8)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.position(x: state.currentCenterX, y: hoveredFrame.midY - lensLiftOffset)
|
||||
.shadow(color: .black.opacity(0.42), radius: 24, y: 15)
|
||||
.shadow(color: Color.cyan.opacity(0.10), radius: 20, y: 1)
|
||||
.allowsHitTesting(false)
|
||||
.transition(.scale(scale: 0.86).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.74), value: state.isLifted)
|
||||
.zIndex(20)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var lensBubble: some View {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.38))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.08), Color.white.opacity(0.01), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
Circle().stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.06), lineWidth: 1).padding(1.6)
|
||||
Circle().stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.cyan.opacity(0.34),
|
||||
Color.blue.opacity(0.28),
|
||||
Color.pink.opacity(0.28),
|
||||
Color.orange.opacity(0.30),
|
||||
Color.yellow.opacity(0.20),
|
||||
Color.cyan.opacity(0.34),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
lineWidth: 1.1
|
||||
).blendMode(.screen)
|
||||
}
|
||||
.compositingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Item
|
||||
|
||||
private struct TabItemView: View {
|
||||
let tab: RosettaTab
|
||||
let isSelected: Bool
|
||||
let isCoveredByLens: Bool
|
||||
let badgeText: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 1) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(tabColor)
|
||||
.frame(height: 30)
|
||||
|
||||
if let badgeText {
|
||||
Text(badgeText)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, badgeText.count > 2 ? 4 : 0)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.background(Capsule().fill(RosettaColors.error))
|
||||
.offset(x: 10, y: -4)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tab.label)
|
||||
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
|
||||
.foregroundStyle(tabColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(14)
|
||||
.opacity(isCoveredByLens ? 0.07 : 1)
|
||||
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
|
||||
.accessibilityLabel(tab.label)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
private var tabColor: Color {
|
||||
isSelected
|
||||
? RosettaColors.primaryBlue
|
||||
: RosettaColors.adaptive(
|
||||
light: Color(hex: 0x404040),
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Pill
|
||||
|
||||
private extension RosettaTabBar {
|
||||
var searchPill: some View {
|
||||
SearchPillView(
|
||||
isSelected: visualSelectedTab == .search,
|
||||
isCoveredByLens: isCoveredByLens(.search)
|
||||
)
|
||||
.tabFramePreference(tab: .search, in: Self.tabBarSpace)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SearchPillView: View {
|
||||
let isSelected: Bool
|
||||
let isCoveredByLens: Bool
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
isSelected
|
||||
? RosettaColors.primaryBlue
|
||||
: RosettaColors.adaptive(
|
||||
light: Color(hex: 0x404040),
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
)
|
||||
.frame(width: 62, height: 62)
|
||||
.opacity(isCoveredByLens ? 0.08 : 1)
|
||||
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
|
||||
.background { searchPillGlass }
|
||||
.accessibilityLabel("Search")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var searchPillGlass: some View {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.34))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.08), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Circle().stroke(Color.white.opacity(0.12), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.08), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LensTabContentView: View {
|
||||
let tab: RosettaTab
|
||||
let badgeText: String?
|
||||
|
||||
var body: some View {
|
||||
if tab == .search {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 29, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
} else {
|
||||
VStack(spacing: 3) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: tab.selectedIcon)
|
||||
.font(.system(size: 30))
|
||||
.foregroundStyle(.white)
|
||||
.frame(height: 36)
|
||||
|
||||
if let badgeText {
|
||||
Text(badgeText)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, badge.count > 2 ? 4 : 0)
|
||||
.frame(minWidth: 18, minHeight: 18)
|
||||
.padding(.horizontal, badgeText.count > 2 ? 5 : 0)
|
||||
.frame(minWidth: 20, minHeight: 20)
|
||||
.background(Capsule().fill(RosettaColors.error))
|
||||
.offset(x: 10, y: -4)
|
||||
.offset(x: 16, y: -9)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tab.label)
|
||||
.font(.system(size: 10, weight: isEffectivelySelected ? .bold : .medium))
|
||||
.foregroundStyle(tint)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
}
|
||||
.frame(minWidth: 66, maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(tab.label)
|
||||
.accessibilityAddTraits(isEffectivelySelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
// MARK: - Color Interpolation
|
||||
|
||||
/// Pre-computed RGBA to avoid creating UIColor on every drag frame.
|
||||
private static let unselectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
UIColor(TabBarColors.unselectedTint).getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return (r, g, b, a)
|
||||
}()
|
||||
|
||||
private static let selectedRGBA: (CGFloat, CGFloat, CGFloat, CGFloat) = {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
UIColor(TabBarColors.selectedTint).getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return (r, g, b, a)
|
||||
}()
|
||||
|
||||
private func tintColor(blend: CGFloat) -> Color {
|
||||
let t = blend.clamped(to: 0...1)
|
||||
let (fr, fg, fb, fa) = Self.unselectedRGBA
|
||||
let (tr, tg, tb, ta) = Self.selectedRGBA
|
||||
return Color(
|
||||
red: fr + (tr - fr) * t,
|
||||
green: fg + (tg - fg) * t,
|
||||
blue: fb + (tb - fb) * t,
|
||||
opacity: fa + (ta - fa) * t
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shadow (iOS < 26 only)
|
||||
|
||||
/// Glass has built-in depth on iOS 26+, so shadow is only needed on older versions.
|
||||
private struct TabBarShadowModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Comparable Clamping
|
||||
// MARK: - Geometry Helpers
|
||||
|
||||
private extension Comparable {
|
||||
func clamped(to range: ClosedRange<Self>) -> Self {
|
||||
min(max(self, range.lowerBound), range.upperBound)
|
||||
private struct TabPressInteraction {
|
||||
let id: UUID
|
||||
let startTab: RosettaTab
|
||||
let startCenterX: CGFloat
|
||||
var currentCenterX: CGFloat
|
||||
var hoveredTab: RosettaTab
|
||||
var isLifted: Bool
|
||||
}
|
||||
|
||||
private struct TabFramePreferenceKey: PreferenceKey {
|
||||
static var defaultValue: [RosettaTab: CGRect] = [:]
|
||||
|
||||
static func reduce(value: inout [RosettaTab: CGRect], nextValue: () -> [RosettaTab: CGRect]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func tabFramePreference(tab: RosettaTab, in coordinateSpace: String) -> some View {
|
||||
background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(
|
||||
key: TabFramePreferenceKey.self,
|
||||
value: [tab: proxy.frame(in: .named(coordinateSpace))]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +533,12 @@ private extension Comparable {
|
||||
#Preview {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.black.ignoresSafeArea()
|
||||
RosettaTabBar(selectedTab: .chats)
|
||||
|
||||
RosettaTabBar(
|
||||
selectedTab: .chats,
|
||||
badges: [
|
||||
TabBadge(tab: .chats, text: "7"),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user