Fix chat send button behavior

This commit is contained in:
2026-03-02 03:22:19 +05:00
parent d1fcc04125
commit 8238fd1940
27 changed files with 3423 additions and 610 deletions

View File

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

View File

@@ -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 (topbottom, 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: [