187 lines
5.7 KiB
Swift
187 lines
5.7 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Glass Back Button (circular, like screenshot reference)
|
|
|
|
struct GlassBackButton: View {
|
|
let action: () -> Void
|
|
var size: CGFloat = 44
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: size, height: size)
|
|
}
|
|
.background {
|
|
glassCircle
|
|
}
|
|
.clipShape(Circle())
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var glassCircle: some View {
|
|
if #available(iOS 26, *) {
|
|
Circle()
|
|
.fill(Color.white.opacity(0.08))
|
|
.glassEffect(.regular, in: .circle)
|
|
} else {
|
|
Circle()
|
|
.fill(.ultraThinMaterial)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Primary Button (Liquid Glass)
|
|
|
|
struct RosettaPrimaryButtonStyle: ButtonStyle {
|
|
var isEnabled: Bool = true
|
|
|
|
private var fillColor: Color {
|
|
isEnabled ? RosettaColors.primaryBlue : Color(white: 0.22)
|
|
}
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
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)
|
|
}
|
|
|
|
private func glassBackground(isPressed: Bool) -> some View {
|
|
Capsule()
|
|
.fill(fillColor.opacity(isPressed ? 0.7 : 1.0))
|
|
}
|
|
}
|
|
|
|
// MARK: - Shine Overlay (Telegram-style color sweep)
|
|
//
|
|
// Instead of a white glare, a lighter/cyan tint of the button's own colour
|
|
// glides across — exactly the look Telegram uses on its primary CTA.
|
|
|
|
struct ShineModifier: ViewModifier {
|
|
let speed: Double
|
|
let highlightWidth: CGFloat
|
|
let tintColor: Color
|
|
let delay: Double
|
|
|
|
@State private var phase: CGFloat = 0
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.overlay {
|
|
GeometryReader { geo in
|
|
let w = geo.size.width * highlightWidth
|
|
let travel = geo.size.width + w
|
|
let currentX = -w + phase * travel
|
|
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .clear, location: 0),
|
|
.init(color: tintColor, location: 0.45),
|
|
.init(color: tintColor, location: 0.55),
|
|
.init(color: .clear, location: 1),
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
.frame(width: w, height: geo.size.height)
|
|
.offset(x: currentX)
|
|
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
|
|
}
|
|
.clipShape(Capsule())
|
|
.allowsHitTesting(false)
|
|
}
|
|
.onAppear {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
withAnimation(
|
|
.easeInOut(duration: speed)
|
|
.repeatForever(autoreverses: false)
|
|
) {
|
|
phase = 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func shine(
|
|
speed: Double = 2.0,
|
|
highlightWidth: CGFloat = 0.55,
|
|
tintColor: Color = Color(red: 0.35, green: 0.82, blue: 0.98).opacity(0.50),
|
|
delay: Double = 0.3
|
|
) -> some View {
|
|
modifier(ShineModifier(
|
|
speed: speed,
|
|
highlightWidth: highlightWidth,
|
|
tintColor: tintColor,
|
|
delay: delay
|
|
))
|
|
}
|
|
|
|
/// Legacy alias
|
|
func shimmer() -> some View {
|
|
shine()
|
|
}
|
|
}
|
|
|
|
// MARK: - Stagger Animation Helper
|
|
|
|
struct StaggeredAppearance: ViewModifier {
|
|
let key: String
|
|
let index: Int
|
|
let baseDelay: Double
|
|
let stagger: Double
|
|
@State private var isVisible: Bool
|
|
|
|
/// Tracks which screen keys have already animated so we don't replay on re-navigation.
|
|
private static var animatedKeys: Set<String> = []
|
|
|
|
init(key: String, index: Int, baseDelay: Double, stagger: Double) {
|
|
self.key = key
|
|
self.index = index
|
|
self.baseDelay = baseDelay
|
|
self.stagger = stagger
|
|
// Start visible immediately if this screen already animated — no flash frame.
|
|
_isVisible = State(initialValue: Self.animatedKeys.contains(key))
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.opacity(isVisible ? 1.0 : 0.0)
|
|
.offset(y: isVisible ? 0 : 12)
|
|
.onAppear {
|
|
guard !isVisible else { return }
|
|
let delay = baseDelay + Double(index) * stagger
|
|
withAnimation(.easeOut(duration: 0.4).delay(delay)) {
|
|
isVisible = true
|
|
}
|
|
Self.animatedKeys.insert(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func staggeredAppearance(
|
|
key: String = "default",
|
|
index: Int,
|
|
baseDelay: Double = 0.2,
|
|
stagger: Double = 0.05
|
|
) -> some View {
|
|
modifier(StaggeredAppearance(key: key, index: index, baseDelay: baseDelay, stagger: stagger))
|
|
}
|
|
}
|