Fix chat send button behavior
This commit is contained in:
@@ -45,6 +45,15 @@ struct LottieView: UIViewRepresentable, Equatable {
|
||||
lhs.isPlaying == rhs.isPlaying
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var didPlayOnce = false
|
||||
var lastAnimationName = ""
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> LottieAnimationView {
|
||||
let animationView: LottieAnimationView
|
||||
if let cached = LottieAnimationCache.shared.animation(named: animationName) {
|
||||
@@ -57,21 +66,45 @@ struct LottieView: UIViewRepresentable, Equatable {
|
||||
animationView.animationSpeed = animationSpeed
|
||||
animationView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
animationView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
if isPlaying {
|
||||
animationView.play()
|
||||
}
|
||||
context.coordinator.lastAnimationName = animationName
|
||||
playIfNeeded(animationView, coordinator: context.coordinator)
|
||||
return animationView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
|
||||
if isPlaying {
|
||||
if !uiView.isAnimationPlaying {
|
||||
uiView.play()
|
||||
if context.coordinator.lastAnimationName != animationName {
|
||||
if let cached = LottieAnimationCache.shared.animation(named: animationName) {
|
||||
uiView.animation = cached
|
||||
} else {
|
||||
uiView.animation = LottieAnimation.named(animationName)
|
||||
}
|
||||
} else {
|
||||
context.coordinator.lastAnimationName = animationName
|
||||
context.coordinator.didPlayOnce = false
|
||||
}
|
||||
|
||||
uiView.loopMode = loopMode
|
||||
uiView.animationSpeed = animationSpeed
|
||||
|
||||
if !isPlaying {
|
||||
context.coordinator.didPlayOnce = false
|
||||
if uiView.isAnimationPlaying {
|
||||
uiView.stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
playIfNeeded(uiView, coordinator: context.coordinator)
|
||||
}
|
||||
|
||||
private func playIfNeeded(_ view: LottieAnimationView, coordinator: Coordinator) {
|
||||
if loopMode == .playOnce {
|
||||
guard !coordinator.didPlayOnce else { return }
|
||||
coordinator.didPlayOnce = true
|
||||
view.play()
|
||||
return
|
||||
}
|
||||
if !view.isAnimationPlaying {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Glass Effect ID Modifier (iOS 26+)
|
||||
|
||||
private struct GlassEffectIDModifier: ViewModifier {
|
||||
let id: String
|
||||
let namespace: Namespace.ID?
|
||||
|
||||
nonisolated func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *), let namespace {
|
||||
content.glassEffectID(id, in: namespace)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab
|
||||
|
||||
enum RosettaTab: CaseIterable, Sendable {
|
||||
@@ -23,11 +8,13 @@ enum RosettaTab: CaseIterable, Sendable {
|
||||
case settings
|
||||
case search
|
||||
|
||||
static let interactionOrder: [RosettaTab] = [.chats, .settings, .search]
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .chats: return "Chats"
|
||||
case .settings: return "Settings"
|
||||
case .search: return ""
|
||||
case .search: return "Search"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +33,10 @@ enum RosettaTab: CaseIterable, Sendable {
|
||||
case .search: return "magnifyingglass"
|
||||
}
|
||||
}
|
||||
|
||||
var interactionIndex: Int {
|
||||
Self.interactionOrder.firstIndex(of: self) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Badge
|
||||
@@ -55,27 +46,47 @@ struct TabBadge {
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct TabBarSwipeState {
|
||||
let fromTab: RosettaTab
|
||||
let hoveredTab: RosettaTab
|
||||
let fractionalIndex: CGFloat
|
||||
}
|
||||
|
||||
// MARK: - RosettaTabBar
|
||||
|
||||
struct RosettaTabBar: View {
|
||||
let selectedTab: RosettaTab
|
||||
var onTabSelected: ((RosettaTab) -> Void)?
|
||||
var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)?
|
||||
var badges: [TabBadge] = []
|
||||
|
||||
@Namespace private var glassNS
|
||||
@State private var tabFrames: [RosettaTab: CGRect] = [:]
|
||||
@State private var interactionState: TabPressInteraction?
|
||||
|
||||
private static let tabBarSpace = "RosettaTabBarSpace"
|
||||
private let lensLiftOffset: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26, *) {
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
tabBarContent
|
||||
}
|
||||
interactiveTabBarContent
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
tabBarContent
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var interactiveTabBarContent: some View {
|
||||
tabBarContent
|
||||
.coordinateSpace(name: Self.tabBarSpace)
|
||||
.onPreferenceChange(TabFramePreferenceKey.self) { frames in
|
||||
tabFrames = frames
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(tabSelectionGesture)
|
||||
.overlay(alignment: .topLeading) {
|
||||
liftedLensOverlay
|
||||
}
|
||||
.onDisappear {
|
||||
interactionState = nil
|
||||
onSwipeStateChanged?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private var tabBarContent: some View {
|
||||
@@ -84,29 +95,185 @@ struct RosettaTabBar: View {
|
||||
searchPill
|
||||
}
|
||||
}
|
||||
|
||||
private var visualSelectedTab: RosettaTab {
|
||||
if let interactionState, interactionState.isLifted {
|
||||
return interactionState.hoveredTab
|
||||
}
|
||||
return selectedTab
|
||||
}
|
||||
|
||||
private var tabSelectionGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .named(Self.tabBarSpace))
|
||||
.onChanged(handleGestureChanged)
|
||||
.onEnded(handleGestureEnded)
|
||||
}
|
||||
|
||||
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()
|
||||
interactionState = state
|
||||
publishSwipeState(for: state)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
// Content on top — NOT clipped (lens can pop out)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in
|
||||
TabItemButton(
|
||||
TabItemView(
|
||||
tab: tab,
|
||||
isSelected: tab == selectedTab,
|
||||
badgeText: badges.first(where: { $0.tab == tab })?.text,
|
||||
onTap: { onTabSelected?(tab) },
|
||||
glassNamespace: glassNS
|
||||
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 clipped separately — content stays unclipped
|
||||
.background {
|
||||
mainPillGlass
|
||||
}
|
||||
@@ -114,106 +281,117 @@ private extension RosettaTabBar {
|
||||
|
||||
@ViewBuilder
|
||||
var mainPillGlass: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
|
||||
} else {
|
||||
ZStack {
|
||||
// 1. Material
|
||||
Capsule().fill(.ultraThinMaterial)
|
||||
// 2. Dark tint
|
||||
Capsule().fill(Color.black.opacity(0.22))
|
||||
// 3. Highlight
|
||||
Capsule().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
// 4a. Outer stroke
|
||||
Capsule().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
// 4b. Inner stroke
|
||||
Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
}
|
||||
// 5. Shadows
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
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 Button
|
||||
// MARK: - Tab Item
|
||||
|
||||
private struct TabItemButton: View {
|
||||
private struct TabItemView: View {
|
||||
let tab: RosettaTab
|
||||
let isSelected: Bool
|
||||
let isCoveredByLens: Bool
|
||||
let badgeText: String?
|
||||
let onTap: () -> Void
|
||||
var glassNamespace: Namespace.ID?
|
||||
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
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))
|
||||
VStack(spacing: 1) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(tabColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background {
|
||||
if isSelected && !pressed {
|
||||
RoundedRectangle(cornerRadius: 100)
|
||||
.fill(RosettaColors.adaptive(
|
||||
light: Color(hex: 0xEDEDED),
|
||||
dark: Color.white.opacity(0.12)
|
||||
))
|
||||
.padding(.horizontal, -8)
|
||||
.padding(.vertical, -6)
|
||||
.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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Lens: padding → glass bubble → scale → lift
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(14)
|
||||
.background {
|
||||
if pressed {
|
||||
lensBubble
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(pressed ? 1.12 : 1)
|
||||
.offset(y: pressed ? -28 : 0)
|
||||
.shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14)
|
||||
.shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0)
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
if !pressed {
|
||||
pressed = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in pressed = false }
|
||||
)
|
||||
.modifier(GlassEffectIDModifier(id: "\(tab)", namespace: glassNamespace))
|
||||
.opacity(isCoveredByLens ? 0.07 : 1)
|
||||
.animation(.easeInOut(duration: 0.14), value: isCoveredByLens)
|
||||
.accessibilityLabel(tab.label)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
@@ -226,177 +404,126 @@ private struct TabItemButton: View {
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Lens Bubble
|
||||
|
||||
@ViewBuilder
|
||||
private var lensBubble: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular.interactive(), in: .circle)
|
||||
} else {
|
||||
ZStack {
|
||||
// 1. Material
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
// 2. Dark tint
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
// 3. Highlight (top→bottom, screen blend)
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
).blendMode(.screen)
|
||||
// 4a. Outer stroke
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
// 4b. Inner stroke
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
// 6. Rainbow (thin, subtle, screen blend)
|
||||
Circle().stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.cyan.opacity(0.55),
|
||||
Color.blue.opacity(0.55),
|
||||
Color.purple.opacity(0.55),
|
||||
Color.pink.opacity(0.55),
|
||||
Color.orange.opacity(0.55),
|
||||
Color.yellow.opacity(0.45),
|
||||
Color.cyan.opacity(0.55),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
lineWidth: 1.4
|
||||
).blendMode(.screen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Pill
|
||||
|
||||
private extension RosettaTabBar {
|
||||
var searchPill: some View {
|
||||
SearchPillButton(
|
||||
isSelected: selectedTab == .search,
|
||||
onTap: { onTabSelected?(.search) },
|
||||
glassNamespace: glassNS
|
||||
SearchPillView(
|
||||
isSelected: visualSelectedTab == .search,
|
||||
isCoveredByLens: isCoveredByLens(.search)
|
||||
)
|
||||
.tabFramePreference(tab: .search, in: Self.tabBarSpace)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SearchPillButton: View {
|
||||
private struct SearchPillView: View {
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
var glassNamespace: Namespace.ID?
|
||||
|
||||
@State private var pressed = false
|
||||
let isCoveredByLens: Bool
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
isSelected
|
||||
? RosettaColors.primaryBlue
|
||||
: RosettaColors.adaptive(
|
||||
light: Color(hex: 0x404040),
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Lens
|
||||
.padding(14)
|
||||
.background {
|
||||
if pressed {
|
||||
searchLensBubble
|
||||
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(pressed ? 1.15 : 1)
|
||||
.offset(y: pressed ? -28 : 0)
|
||||
.shadow(color: .black.opacity(pressed ? 0.45 : 0), radius: 22, y: 14)
|
||||
.shadow(color: Color.cyan.opacity(pressed ? 0.12 : 0), radius: 20, y: 0)
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.65), value: pressed)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
if !pressed {
|
||||
pressed = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in pressed = false }
|
||||
)
|
||||
.frame(width: 62, height: 62)
|
||||
// Background clipped separately
|
||||
.background { searchPillGlass }
|
||||
.modifier(GlassEffectIDModifier(id: "search", namespace: glassNamespace))
|
||||
.accessibilityLabel("Search")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
// MARK: Lens for search
|
||||
|
||||
@ViewBuilder
|
||||
private var searchLensBubble: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Circle()
|
||||
.fill(.clear)
|
||||
.glassEffect(.regular.interactive(), in: .circle)
|
||||
} else {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
isSelected
|
||||
? RosettaColors.primaryBlue
|
||||
: RosettaColors.adaptive(
|
||||
light: Color(hex: 0x404040),
|
||||
dark: Color(hex: 0x8E8E93)
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
Circle().stroke(
|
||||
AngularGradient(
|
||||
colors: [
|
||||
Color.cyan.opacity(0.55),
|
||||
Color.blue.opacity(0.55),
|
||||
Color.purple.opacity(0.55),
|
||||
Color.pink.opacity(0.55),
|
||||
Color.orange.opacity(0.55),
|
||||
Color.yellow.opacity(0.45),
|
||||
Color.cyan.opacity(0.55),
|
||||
],
|
||||
center: .center
|
||||
),
|
||||
lineWidth: 1.4
|
||||
).blendMode(.screen)
|
||||
}
|
||||
}
|
||||
)
|
||||
.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 {
|
||||
if #available(iOS 26, *) {
|
||||
Circle().fill(.clear).glassEffect(.regular, in: .circle)
|
||||
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 {
|
||||
ZStack {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
Circle().fill(Color.black.opacity(0.22))
|
||||
Circle().fill(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.14), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
).blendMode(.screen)
|
||||
Circle().stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 1).padding(1.5)
|
||||
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, badgeText.count > 2 ? 5 : 0)
|
||||
.frame(minWidth: 20, minHeight: 20)
|
||||
.background(Capsule().fill(RosettaColors.error))
|
||||
.offset(x: 16, y: -9)
|
||||
}
|
||||
}
|
||||
|
||||
Text(tab.label)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.primaryBlue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry Helpers
|
||||
|
||||
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))]
|
||||
)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 22, y: 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,13 +534,6 @@ private struct SearchPillButton: View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Hold a tab to see the lens")
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
RosettaTabBar(
|
||||
selectedTab: .chats,
|
||||
badges: [
|
||||
|
||||
Reference in New Issue
Block a user