Редизайн tab bar на UIKit — иконки, жесты и анимации 1:1 как в Telegram-iOS

This commit is contained in:
2026-04-10 06:09:39 +05:00
parent adad5b8b83
commit baf4985837
23 changed files with 1797 additions and 566 deletions

View File

@@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "ic_tb_calls.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View File

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "ic_tb_chats.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View File

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "ic_tb_settings.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@@ -0,0 +1,86 @@
import UIKit
// MARK: - CALayer Filter Helpers (Private API same approach as Telegram iOS)
extension CALayer {
static func blurFilter() -> NSObject? {
return makeFilter(name: "gaussianBlur")
}
static func colorMatrixFilter() -> NSObject? {
return makeFilter(name: "colorMatrix")
}
private static func makeFilter(name: String) -> NSObject? {
let className = ["CA", "Filter"].joined()
guard let cls = NSClassFromString(className) as? NSObject.Type else { return nil }
let sel = NSSelectorFromString("filterWithName:")
guard cls.responds(to: sel) else { return nil }
return cls.perform(sel, with: name)?.takeUnretainedValue() as? NSObject
}
}
// MARK: - CABackdropLayer Helpers
enum BackdropLayerHelper {
static func createBackdropLayer() -> CALayer? {
let className = ["CA", "Backdrop", "Layer"].joined()
guard let cls = NSClassFromString(className) as? CALayer.Type else { return nil }
return cls.init()
}
static func setScale(_ layer: CALayer, scale: Double) {
let sel = NSSelectorFromString("setScale:")
guard layer.responds(to: sel) else { return }
layer.perform(sel, with: NSNumber(value: scale))
}
}
// MARK: - Device Capability
enum DeviceCapability {
static let isGraphicallyCapable: Bool = {
var length = MemoryLayout<UInt32>.size
var cpuCount: UInt32 = 0
sysctlbyname("hw.ncpu", &cpuCount, &length, nil, 0)
return cpuCount >= 4
}()
}
// MARK: - BackdropLayerDelegate (disables implicit animations)
final class BackdropLayerDelegate: NSObject, CALayerDelegate {
static let shared = BackdropLayerDelegate()
func action(for layer: CALayer, forKey event: String) -> CAAction? {
return NSNull()
}
}
// MARK: - UIColor Mixing (Telegram parity)
extension UIColor {
func mixedWith(_ other: UIColor, alpha: CGFloat) -> UIColor {
let alpha = min(1.0, max(0.0, alpha))
let oneMinusAlpha = 1.0 - alpha
var r1: CGFloat = 0.0
var r2: CGFloat = 0.0
var g1: CGFloat = 0.0
var g2: CGFloat = 0.0
var b1: CGFloat = 0.0
var b2: CGFloat = 0.0
var a1: CGFloat = 0.0
var a2: CGFloat = 0.0
if self.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) &&
other.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
{
let r = r1 * oneMinusAlpha + r2 * alpha
let g = g1 * oneMinusAlpha + g2 * alpha
let b = b1 * oneMinusAlpha + b2 * alpha
let a = a1 * oneMinusAlpha + a2 * alpha
return UIColor(red: r, green: g, blue: b, alpha: a)
}
return self
}
}

View File

@@ -0,0 +1,298 @@
import UIKit
// MARK: - Glass Image Generation (Telegram iOS parity)
//
// Port of GlassBackgroundView.generateLegacyGlassImage and generateForegroundImage
// from Telegram-iOS GlassBackgroundComponent.swift.
enum GlassImageGeneration {
// MARK: - Badge Path (asymmetric rounded rect)
private static func addBadgePath(in context: CGContext, rect: CGRect) {
context.saveGState()
context.translateBy(x: rect.minX, y: rect.minY)
context.scaleBy(x: rect.width / 78.0, y: rect.height / 78.0)
context.move(to: CGPoint(x: 0, y: 39))
context.addCurve(to: CGPoint(x: 39, y: 0), control1: CGPoint(x: 0, y: 17.4609), control2: CGPoint(x: 17.4609, y: 0))
context.addLine(to: CGPoint(x: 42, y: 0))
context.addCurve(to: CGPoint(x: 78, y: 36), control1: CGPoint(x: 61.8823, y: 0), control2: CGPoint(x: 78, y: 16.1177))
context.addLine(to: CGPoint(x: 78, y: 39))
context.addCurve(to: CGPoint(x: 39, y: 78), control1: CGPoint(x: 78, y: 60.5391), control2: CGPoint(x: 60.5391, y: 78))
context.addLine(to: CGPoint(x: 36, y: 78))
context.addCurve(to: CGPoint(x: 0, y: 42), control1: CGPoint(x: 16.1177, y: 78), control2: CGPoint(x: 0, y: 61.8823))
context.addLine(to: CGPoint(x: 0, y: 39))
context.closePath()
context.restoreGState()
}
// MARK: - Legacy Glass Image
static func generateLegacyGlassImage(
size: CGSize,
inset: CGFloat,
borderWidthFactor: CGFloat = 1.0,
isDark: Bool,
fillColor: UIColor
) -> UIImage {
var size = size
if size == .zero {
size = CGSize(width: 2.0, height: 2.0)
}
let innerSize = size
size.width += inset * 2.0
size.height += inset * 2.0
return UIGraphicsImageRenderer(size: size).image { ctx in
let context = ctx.cgContext
context.clear(CGRect(origin: .zero, size: size))
let addShadow: (CGContext, Bool, CGPoint, CGFloat, CGFloat, UIColor, CGBlendMode) -> Void = { context, isOuter, position, blur, spread, shadowColor, blendMode in
var blur = blur
if isOuter {
blur += abs(spread)
context.beginTransparencyLayer(auxiliaryInfo: nil)
context.saveGState()
defer {
context.restoreGState()
context.endTransparencyLayer()
}
let spreadRect = CGRect(origin: CGPoint(x: inset, y: inset), size: innerSize).insetBy(dx: 0.25, dy: 0.25)
let spreadPath = UIBezierPath(
roundedRect: spreadRect,
cornerRadius: min(spreadRect.width, spreadRect.height) * 0.5
).cgPath
context.setShadow(offset: CGSize(width: position.x, height: position.y), blur: blur, color: shadowColor.cgColor)
context.setFillColor(UIColor.black.withAlphaComponent(1.0).cgColor)
context.addPath(spreadPath)
context.fillPath()
let cleanRect = CGRect(origin: CGPoint(x: inset, y: inset), size: innerSize)
let cleanPath = UIBezierPath(
roundedRect: cleanRect,
cornerRadius: min(cleanRect.width, cleanRect.height) * 0.5
).cgPath
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.addPath(cleanPath)
context.fillPath()
context.setBlendMode(.normal)
} else {
let image = UIGraphicsImageRenderer(size: size).image(actions: { ctx in
let context = ctx.cgContext
context.clear(CGRect(origin: .zero, size: size))
let spreadRect = CGRect(origin: CGPoint(x: inset, y: inset), size: innerSize).insetBy(dx: -spread - 0.33, dy: -spread - 0.33)
context.setShadow(offset: CGSize(width: position.x, height: position.y), blur: blur, color: shadowColor.cgColor)
context.setFillColor(shadowColor.cgColor)
let enclosingRect = spreadRect.insetBy(dx: -10000.0, dy: -10000.0)
context.addPath(UIBezierPath(rect: enclosingRect).cgPath)
addBadgePath(in: context, rect: spreadRect)
context.fillPath(using: .evenOdd)
})
UIGraphicsPushContext(context)
image.draw(in: CGRect(origin: .zero, size: size), blendMode: blendMode, alpha: 1.0)
UIGraphicsPopContext()
}
}
// Outer shadows
addShadow(context, true, CGPoint(), 30.0, 0.0, UIColor(white: 0.0, alpha: 0.045), .normal)
addShadow(context, true, CGPoint(), 20.0, 0.0, UIColor(white: 0.0, alpha: 0.01), .normal)
var a: CGFloat = 0.0
var b: CGFloat = 0.0
var s: CGFloat = 0.0
fillColor.getHue(nil, saturation: &s, brightness: &b, alpha: &a)
let innerImage = UIGraphicsImageRenderer(size: size).image { ctx in
let context = ctx.cgContext
context.setFillColor(fillColor.cgColor)
let ellipseRect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset)
context.fillEllipse(in: ellipseRect)
let lineWidth: CGFloat = (isDark ? 0.8 : 0.8) * borderWidthFactor
let strokeColor: UIColor
let blendMode: CGBlendMode
let baseAlpha: CGFloat = isDark ? 0.3 : 0.6
if s == 0.0 && abs(a - 0.7) < 0.1 && !isDark {
blendMode = .normal
strokeColor = UIColor(white: 1.0, alpha: baseAlpha)
} else if s <= 0.3 && !isDark {
blendMode = .normal
strokeColor = UIColor(white: 1.0, alpha: 0.7 * baseAlpha)
} else if b >= 0.2 {
let maxAlpha: CGFloat = isDark ? 0.7 : 0.8
blendMode = .overlay
strokeColor = UIColor(white: 1.0, alpha: max(0.5, min(1.0, maxAlpha * s)) * baseAlpha)
} else {
blendMode = .normal
strokeColor = UIColor(white: 1.0, alpha: 0.5 * baseAlpha)
}
context.setStrokeColor(strokeColor.cgColor)
var strokeEllipseRect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset)
context.addEllipse(in: strokeEllipseRect)
context.clip()
strokeEllipseRect = CGRect(origin: .zero, size: size).insetBy(dx: inset, dy: inset).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5)
context.setBlendMode(blendMode)
let radius = strokeEllipseRect.height * 0.5
let smallerRadius = radius - lineWidth * 1.33
context.move(to: CGPoint(x: strokeEllipseRect.minX, y: strokeEllipseRect.minY + radius))
context.addArc(tangent1End: CGPoint(x: strokeEllipseRect.minX, y: strokeEllipseRect.minY), tangent2End: CGPoint(x: strokeEllipseRect.minX + radius, y: strokeEllipseRect.minY), radius: radius)
context.addLine(to: CGPoint(x: strokeEllipseRect.maxX - smallerRadius, y: strokeEllipseRect.minY))
context.addArc(tangent1End: CGPoint(x: strokeEllipseRect.maxX, y: strokeEllipseRect.minY), tangent2End: CGPoint(x: strokeEllipseRect.maxX, y: strokeEllipseRect.minY + smallerRadius), radius: smallerRadius)
context.addLine(to: CGPoint(x: strokeEllipseRect.maxX, y: strokeEllipseRect.maxY - radius))
context.addArc(tangent1End: CGPoint(x: strokeEllipseRect.maxX, y: strokeEllipseRect.maxY), tangent2End: CGPoint(x: strokeEllipseRect.maxX - radius, y: strokeEllipseRect.maxY), radius: radius)
context.addLine(to: CGPoint(x: strokeEllipseRect.minX + smallerRadius, y: strokeEllipseRect.maxY))
context.addArc(tangent1End: CGPoint(x: strokeEllipseRect.minX, y: strokeEllipseRect.maxY), tangent2End: CGPoint(x: strokeEllipseRect.minX, y: strokeEllipseRect.maxY - smallerRadius), radius: smallerRadius)
context.closePath()
context.strokePath()
context.resetClip()
context.setBlendMode(.normal)
}
innerImage.draw(in: CGRect(origin: .zero, size: size))
}.stretchableImage(withLeftCapWidth: Int(size.width * 0.5), topCapHeight: Int(size.height * 0.5))
}
// MARK: - Foreground Image (with inner shadows and gradient border)
static func generateForegroundImage(
size: CGSize,
isDark: Bool,
fillColor: UIColor
) -> UIImage {
var size = size
if size == .zero {
size = CGSize(width: 1.0, height: 1.0)
}
return UIGraphicsImageRenderer(size: size).image { ctx in
let context = ctx.cgContext
context.clear(CGRect(origin: .zero, size: size))
let maxColor = UIColor(white: 1.0, alpha: isDark ? 0.2 : 0.9)
let minColor = UIColor(white: 1.0, alpha: 0.0)
context.setFillColor(fillColor.cgColor)
context.fillEllipse(in: CGRect(origin: .zero, size: size))
let lineWidth: CGFloat = isDark ? 0.33 : 0.66
context.saveGState()
// Inner shadows
let darkShadeColor = UIColor(white: isDark ? 1.0 : 0.0, alpha: isDark ? 0.0 : 0.035)
let lightShadeColor = UIColor(white: isDark ? 0.0 : 1.0, alpha: isDark ? 0.0 : 0.035)
let innerShadowBlur: CGFloat = 24.0
context.resetClip()
context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5))
context.clip()
context.addRect(CGRect(origin: .zero, size: size).insetBy(dx: -100.0, dy: -100.0))
context.addEllipse(in: CGRect(origin: .zero, size: size))
context.setFillColor(UIColor.black.cgColor)
context.setShadow(offset: CGSize(width: 10.0, height: -10.0), blur: innerShadowBlur, color: darkShadeColor.cgColor)
context.fillPath(using: .evenOdd)
context.resetClip()
context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5))
context.clip()
context.addRect(CGRect(origin: .zero, size: size).insetBy(dx: -100.0, dy: -100.0))
context.addEllipse(in: CGRect(origin: .zero, size: size))
context.setFillColor(UIColor.black.cgColor)
context.setShadow(offset: CGSize(width: -10.0, height: 10.0), blur: innerShadowBlur, color: lightShadeColor.cgColor)
context.fillPath(using: .evenOdd)
context.restoreGState()
context.setLineWidth(lineWidth)
// Left half gradient border
context.addRect(CGRect(x: 0.0, y: 0.0, width: size.width * 0.5, height: size.height))
context.clip()
context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5))
context.replacePathWithStrokedPath()
context.clip()
do {
var locations: [CGFloat] = [0.0, 0.5, 0.5 + 0.2, 1.0 - 0.1, 1.0]
let colors: [CGColor] = [maxColor.cgColor, maxColor.cgColor, minColor.cgColor, minColor.cgColor, maxColor.cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
}
// Right half gradient border
context.resetClip()
context.addRect(CGRect(x: size.width - size.width * 0.5, y: 0.0, width: size.width * 0.5, height: size.height))
context.clip()
context.addEllipse(in: CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5))
context.replacePathWithStrokedPath()
context.clip()
do {
var locations: [CGFloat] = [0.0, 0.1, 0.5 - 0.2, 0.5, 1.0]
let colors: [CGColor] = [maxColor.cgColor, minColor.cgColor, minColor.cgColor, maxColor.cgColor, maxColor.cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
}
}.stretchableImage(withLeftCapWidth: Int(size.width * 0.5), topCapHeight: Int(size.height * 0.5))
}
// MARK: - Shadow Image
static func generateShadowImage(cornerRadius: CGFloat) -> UIImage? {
let inset: CGFloat = 32
let innerInset: CGFloat = 0.5
let diameter = cornerRadius * 2
let totalSize = CGSize(width: inset * 2 + diameter, height: inset * 2 + diameter)
let image = UIGraphicsImageRenderer(size: totalSize).image { ctx in
let context = ctx.cgContext
context.clear(CGRect(origin: .zero, size: totalSize))
context.setFillColor(UIColor.black.cgColor)
context.setShadow(
offset: CGSize(width: 0, height: 1),
blur: 40.0,
color: UIColor(white: 0.0, alpha: 0.04).cgColor
)
let ellipseRect = CGRect(
x: inset + innerInset,
y: inset + innerInset,
width: totalSize.width - (inset + innerInset) * 2,
height: totalSize.height - (inset + innerInset) * 2
)
context.fillEllipse(in: ellipseRect)
context.setFillColor(UIColor.clear.cgColor)
context.setBlendMode(.copy)
context.fillEllipse(in: ellipseRect)
}
return image.stretchableImage(
withLeftCapWidth: Int(inset + cornerRadius),
topCapHeight: Int(inset + cornerRadius)
)
}
}

View File

@@ -0,0 +1,557 @@
import Foundation
import UIKit
// MARK: - Mesh Transform Structs (matching CAMutableMeshTransform memory layout)
struct MeshTransformMeshFace {
var indices: (UInt32, UInt32, UInt32, UInt32)
var w: (Float, Float, Float, Float)
}
struct MeshTransformPoint3D {
var x: CGFloat
var y: CGFloat
var z: CGFloat
}
struct MeshTransformMeshVertex {
var from: CGPoint
var to: MeshTransformPoint3D
}
// MARK: - MeshTransform (wraps private CAMutableMeshTransform)
final class GlassMeshTransform {
typealias Value = NSObject
typealias Point3D = MeshTransformPoint3D
typealias Vertex = MeshTransformMeshVertex
typealias Face = MeshTransformMeshFace
private var vertices: ContiguousArray<Vertex> = []
private var faces: ContiguousArray<Face> = []
func add(_ vertex: Vertex) {
vertices.append(vertex)
}
func add(_ face: Face) {
faces.append(face)
}
func makeValue() -> Value? {
let result = vertices.withUnsafeMutableBufferPointer { vertices -> NSObject? in
return faces.withUnsafeMutableBufferPointer { faces -> NSObject? in
return Self.invokeCreateCustomMethod(
vertexCount: UInt(vertices.count),
vertices: vertices.baseAddress!,
faceCount: UInt(faces.count),
faces: faces.baseAddress!,
depthNormalization: "none"
)
}
}
if let result {
Self.invokeSetSubdivisionSteps(object: result, value: 0)
}
return result
}
// MARK: - Private API Access
private static let transformClass: NSObject? = {
let name = ("CAMutable" as NSString).appendingFormat("MeshTransform")
return NSClassFromString(name as String) as AnyObject as? NSObject
}()
@inline(__always)
private static func getMethod<T>(object: NSObject, selector: String) -> T? {
guard let method = object.method(for: NSSelectorFromString(selector)) else { return nil }
return unsafeBitCast(method, to: T.self)
}
private static var cachedCreateCustomMethod: ((@convention(c) (AnyObject, Selector, UInt, UnsafeMutableRawPointer, UInt, UnsafeMutableRawPointer, NSString) -> NSObject?), Selector)?
private static func invokeCreateCustomMethod(
vertexCount: UInt,
vertices: UnsafeMutablePointer<MeshTransformMeshVertex>,
faceCount: UInt,
faces: UnsafeMutablePointer<MeshTransformMeshFace>,
depthNormalization: String
) -> NSObject? {
guard let transformClass else { return nil }
let rawVertices = UnsafeMutableRawPointer(vertices)
let rawFaces = UnsafeMutableRawPointer(faces)
if let cached = cachedCreateCustomMethod {
return cached.0(transformClass, cached.1, vertexCount, rawVertices, faceCount, rawFaces, depthNormalization as NSString)
}
let selectorName = ("meshTransf" as NSString).appending("ormWithVertexCount:vertices:faceCount:faces:depthNormalization:")
let method: (@convention(c) (AnyObject, Selector, UInt, UnsafeMutableRawPointer, UInt, UnsafeMutableRawPointer, NSString) -> NSObject?)? = getMethod(object: transformClass, selector: selectorName)
if let method {
let selector = NSSelectorFromString(selectorName)
cachedCreateCustomMethod = (method, selector)
return method(transformClass, selector, vertexCount, rawVertices, faceCount, rawFaces, depthNormalization as NSString)
}
return nil
}
private static var cachedSetSubdivisionSteps: ((@convention(c) (AnyObject, Selector, Int) -> Void), Selector)?
private static func invokeSetSubdivisionSteps(object: NSObject, value: Int) {
if let cached = cachedSetSubdivisionSteps {
cached.0(object, cached.1, value)
return
}
let method: (@convention(c) (AnyObject, Selector, Int) -> Void)? = getMethod(object: object, selector: "setSubdivisionSteps:")
if let method {
let selector = NSSelectorFromString("setSubdivisionSteps:")
cachedSetSubdivisionSteps = (method, selector)
method(object, selector, value)
}
}
}
// MARK: - Bezier Helpers
private func a(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat {
return 1.0 - 3.0 * a2 + 3.0 * a1
}
private func b(_ a1: CGFloat, _ a2: CGFloat) -> CGFloat {
return 3.0 * a2 - 6.0 * a1
}
private func c(_ a1: CGFloat) -> CGFloat {
return 3.0 * a1
}
private func calcBezier(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat {
return ((a(a1, a2) * t + b(a1, a2)) * t + c(a1)) * t
}
private func calcSlope(_ t: CGFloat, _ a1: CGFloat, _ a2: CGFloat) -> CGFloat {
return 3.0 * a(a1, a2) * t * t + 2.0 * b(a1, a2) * t + c(a1)
}
private func getTForX(_ x: CGFloat, _ x1: CGFloat, _ x2: CGFloat) -> CGFloat {
var t = x
for _ in 0..<4 {
let currentSlope = calcSlope(t, x1, x2)
if currentSlope == 0.0 {
return t
}
let currentX = calcBezier(t, x1, x2) - x
t -= currentX / currentSlope
}
return t
}
private func bezierPoint(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x: CGFloat) -> CGFloat {
var value = calcBezier(getTForX(x, x1, x2), y1, y2)
if value >= 0.997 {
value = 1.0
}
return value
}
// MARK: - Displacement Bezier
struct DisplacementBezier {
var x1: CGFloat
var y1: CGFloat
var x2: CGFloat
var y2: CGFloat
}
// MARK: - SDF + Displacement
func roundedRectSDF(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> CGFloat {
let px = x - width / 2
let py = y - height / 2
let bx = width / 2
let by = height / 2
let qx = abs(px) - bx + cornerRadius
let qy = abs(py) - by + cornerRadius
let outsideDist = hypot(max(qx, 0), max(qy, 0))
let insideDist = min(max(qx, qy), 0)
return outsideDist + insideDist - cornerRadius
}
private func roundedRectGradient(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, cornerRadius: CGFloat) -> (nx: CGFloat, ny: CGFloat) {
let px = x - width / 2
let py = y - height / 2
let bx = width / 2
let by = height / 2
let qx = abs(px) - bx + cornerRadius
let qy = abs(py) - by + cornerRadius
var nx: CGFloat = 0
var ny: CGFloat = 0
if qx > 0 && qy > 0 {
let d = hypot(qx, qy)
if d > 0 {
nx = qx / d
ny = qy / d
}
} else if qx > qy {
nx = 1
ny = 0
} else {
nx = 0
ny = 1
}
if px < 0 { nx = -nx }
if py < 0 { ny = -ny }
return (nx, ny)
}
private func computeDisplacement(
x: CGFloat, y: CGFloat,
width: CGFloat, height: CGFloat,
cornerRadius: CGFloat,
edgeDistance: CGFloat,
bezier: DisplacementBezier
) -> (dx: CGFloat, dy: CGFloat, sdf: CGFloat) {
let sdf = roundedRectSDF(x: x, y: y, width: width, height: height, cornerRadius: cornerRadius)
let (nx, ny) = roundedRectGradient(x: x, y: y, width: width, height: height, cornerRadius: cornerRadius)
let inwardX = -nx
let inwardY = -ny
let distFromEdge = -sdf
let weight = max(0, min(1, 1.0 - distFromEdge / edgeDistance))
var dx = inwardX * weight
var dy = inwardY * weight
let mag = hypot(dx, dy)
if mag > 0 {
let newMag = bezierPoint(bezier.x1, bezier.y1, bezier.x2, bezier.y2, mag)
let scale = newMag / mag
dx *= scale
dy *= scale
}
return (dx, dy, sdf)
}
// MARK: - Mesh Template Cache
private struct GlassMeshCacheKey: Hashable {
var cornerRadius: CGFloat
var edgeDistance: CGFloat
var cornerResolution: Int
var outerEdgeDistance: CGFloat
var bezierX1: CGFloat
var bezierY1: CGFloat
var bezierX2: CGFloat
var bezierY2: CGFloat
init(cornerRadius: CGFloat, edgeDistance: CGFloat, cornerResolution: Int, outerEdgeDistance: CGFloat, bezier: DisplacementBezier) {
self.cornerRadius = cornerRadius
self.edgeDistance = edgeDistance
self.cornerResolution = cornerResolution
self.outerEdgeDistance = outerEdgeDistance
self.bezierX1 = bezier.x1
self.bezierY1 = bezier.y1
self.bezierX2 = bezier.x2
self.bezierY2 = bezier.y2
}
}
private struct GlassMeshTemplate {
struct VertexTemplate {
var baseX: CGFloat
var sizeScaleX: CGFloat
var baseY: CGFloat
var sizeScaleY: CGFloat
var dispX: CGFloat
var dispY: CGFloat
var depth: CGFloat
}
var vertices: ContiguousArray<VertexTemplate>
var faces: ContiguousArray<GlassMeshTransform.Face>
}
private var glassMeshTemplateCache: [GlassMeshCacheKey: GlassMeshTemplate] = [:]
private func instantiateGlassMesh(
from template: GlassMeshTemplate,
size: CGSize,
displacementMagnitudeU: CGFloat,
displacementMagnitudeV: CGFloat
) -> GlassMeshTransform {
let W = size.width
let H = size.height
let insetPoints: CGFloat = -1.0
let insetUOffset = insetPoints / W
let insetVOffset = insetPoints / H
let usableUNorm = (W - insetPoints * 2) / W
let usableVNorm = (H - insetPoints * 2) / H
let transform = GlassMeshTransform()
for v in template.vertices {
let worldX = v.baseX + v.sizeScaleX * W
let worldY = v.baseY + v.sizeScaleY * H
let u = worldX / W
let vCoord = worldY / H
let mappedU = insetUOffset + u * usableUNorm
let mappedV = insetVOffset + vCoord * usableVNorm
let fromX = max(0.0, min(1.0, mappedU + v.dispX * displacementMagnitudeU))
let fromY = max(0.0, min(1.0, mappedV + v.dispY * displacementMagnitudeV))
transform.add(GlassMeshTransform.Vertex(
from: CGPoint(x: fromX, y: fromY),
to: GlassMeshTransform.Point3D(x: mappedU, y: mappedV, z: v.depth)
))
}
for face in template.faces {
transform.add(face)
}
return transform
}
private func generateGlassMeshTemplate(
cornerRadius: CGFloat,
edgeDistance: CGFloat,
cornerResolution: Int,
outerEdgeDistance: CGFloat,
bezier: DisplacementBezier
) -> GlassMeshTemplate {
let clampedRadius = cornerRadius
let refW = max(4 * clampedRadius, 100)
let refH = max(4 * clampedRadius, 100)
var vertices = ContiguousArray<GlassMeshTemplate.VertexTemplate>()
var faces = ContiguousArray<GlassMeshTransform.Face>()
var vertexIndex: Int = 0
func templateDisplacement(worldX: CGFloat, worldY: CGFloat) -> (CGFloat, CGFloat) {
let (rawDispX, rawDispY, sdf) = computeDisplacement(
x: worldX, y: worldY,
width: refW, height: refH,
cornerRadius: clampedRadius,
edgeDistance: edgeDistance,
bezier: bezier
)
let distToEdge = max(0.0, -sdf)
let edgeBand = max(0.0, outerEdgeDistance)
let edgeBoost: CGFloat
if edgeBand > 0 {
let t = max(0.0, min(1.0, (edgeBand - distToEdge) / edgeBand))
edgeBoost = 1.0 + t * t * (3 - 2 * t) * 0.5
} else {
edgeBoost = 1.0
}
return (rawDispX * edgeBoost, rawDispY * edgeBoost)
}
func addVertex(baseX: CGFloat, scaleX: CGFloat, baseY: CGFloat, scaleY: CGFloat, depth: CGFloat = 0) -> Int {
let worldX = baseX + scaleX * refW
let worldY = baseY + scaleY * refH
let (dispX, dispY) = templateDisplacement(worldX: worldX, worldY: worldY)
vertices.append(GlassMeshTemplate.VertexTemplate(
baseX: baseX, sizeScaleX: scaleX,
baseY: baseY, sizeScaleY: scaleY,
dispX: dispX, dispY: dispY, depth: depth
))
let idx = vertexIndex
vertexIndex += 1
return idx
}
func addQuadFace(_ i0: Int, _ i1: Int, _ i2: Int, _ i3: Int) {
faces.append(GlassMeshTransform.Face(
indices: (UInt32(i0), UInt32(i1), UInt32(i2), UInt32(i3)),
w: (0.0, 0.0, 0.0, 0.0)
))
}
let angularStepsBase = max(3, cornerResolution)
let angularSteps = angularStepsBase % 2 == 0 ? angularStepsBase : angularStepsBase + 1
let radialSteps = max(2, cornerResolution)
let horizontalSegments = max(2, cornerResolution / 2 + 1)
let verticalSegments = max(2, cornerResolution / 2 + 1)
let R = clampedRadius
func depthFactorsWithOuterBand(count: Int, band: CGFloat, maxRadius: CGFloat) -> [CGFloat] {
guard count > 0, maxRadius > 0 else { return [0, 1] }
let bandNorm = max(0, min(1, band / maxRadius))
let innerSegments = max(1, count - 1)
let innerMax = max(0, 1 - bandNorm)
var factors: [CGFloat] = (0...innerSegments).map { i in
innerMax * CGFloat(i) / CGFloat(innerSegments)
}
func appendUnique(_ value: CGFloat) {
if let last = factors.last, abs(last - value) < 1e-4 { return }
factors.append(value)
}
appendUnique(innerMax)
appendUnique(1.0)
return factors
}
let depthFactors = depthFactorsWithOuterBand(count: radialSteps, band: outerEdgeDistance, maxRadius: R)
let angularFactors = (0...angularSteps).map { CGFloat($0) / CGFloat(angularSteps) }
let outerToInner = depthFactors.reversed()
let topXCoeffs: [(base: CGFloat, scale: CGFloat)] = (0...horizontalSegments).map { i in
let t = CGFloat(i) / CGFloat(horizontalSegments)
return (base: R * (1 - 2 * t), scale: t)
}
let sideYCoeffs: [(base: CGFloat, scale: CGFloat)] = (0...verticalSegments).map { j in
let t = CGFloat(j) / CGFloat(verticalSegments)
return (base: R * (1 - 2 * t), scale: t)
}
let topYCoeffs: [(base: CGFloat, scale: CGFloat)] = outerToInner.map { factor in
(base: R * (1 - factor), scale: 0)
}
let bottomYCoeffs: [(base: CGFloat, scale: CGFloat)] = depthFactors.map { factor in
(base: -R * (1 - factor), scale: 1)
}
let leftXCoeffs: [(base: CGFloat, scale: CGFloat)] = outerToInner.map { factor in
(base: R * (1 - factor), scale: 0)
}
let rightXCoeffs: [(base: CGFloat, scale: CGFloat)] = depthFactors.map { factor in
(base: -R * (1 - factor), scale: 1)
}
func buildGridTemplate(
xCoeffs: [(base: CGFloat, scale: CGFloat)],
yCoeffs: [(base: CGFloat, scale: CGFloat)]
) {
var indexGrid: [[Int]] = []
for yc in yCoeffs {
var row: [Int] = []
for xc in xCoeffs {
row.append(addVertex(baseX: xc.base, scaleX: xc.scale, baseY: yc.base, scaleY: yc.scale))
}
indexGrid.append(row)
}
let numRows = indexGrid.count - 1
let numCols = indexGrid.first!.count - 1
for row in 0..<numRows {
for col in 0..<numCols {
addQuadFace(
indexGrid[row][col],
indexGrid[row][col + 1],
indexGrid[row + 1][col + 1],
indexGrid[row + 1][col]
)
}
}
}
func buildCornerTemplate(
centerBaseX: CGFloat, centerScaleX: CGFloat,
centerBaseY: CGFloat, centerScaleY: CGFloat,
startAngle: CGFloat, endAngle: CGFloat
) {
let ringRadials = outerToInner.filter { $0 > 0 }
guard !ringRadials.isEmpty else { return }
var ringIndices: [[Int]] = []
for radial in ringRadials {
let r = R * radial
var row: [Int] = []
for t in angularFactors {
let angle = startAngle + (endAngle - startAngle) * t
let offsetX = r * cos(angle)
let offsetY = r * sin(angle)
row.append(addVertex(
baseX: centerBaseX + offsetX, scaleX: centerScaleX,
baseY: centerBaseY + offsetY, scaleY: centerScaleY
))
}
ringIndices.append(row)
}
for r in 0..<(ringIndices.count - 1) {
let outerRing = ringIndices[r]
let innerRing = ringIndices[r + 1]
for i in 0..<(outerRing.count - 1) {
addQuadFace(outerRing[i], outerRing[i + 1], innerRing[i + 1], innerRing[i])
}
}
if let innermostRing = ringIndices.last {
let ringSegments = innermostRing.count - 1
guard ringSegments >= 2 else { return }
let centerAnchor = addVertex(
baseX: centerBaseX, scaleX: centerScaleX,
baseY: centerBaseY, scaleY: centerScaleY,
depth: -0.02
)
let stride = 2
var i = 0
while i + 2 <= ringSegments {
addQuadFace(centerAnchor, innermostRing[i], innermostRing[i + 1], innermostRing[i + 2])
i += stride
}
if i < ringSegments {
addQuadFace(centerAnchor, innermostRing[ringSegments - 1], innermostRing[ringSegments], innermostRing[ringSegments])
}
}
}
// Edge strips
buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: topYCoeffs)
buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: bottomYCoeffs)
buildGridTemplate(xCoeffs: leftXCoeffs, yCoeffs: sideYCoeffs)
buildGridTemplate(xCoeffs: rightXCoeffs, yCoeffs: sideYCoeffs)
// Center patch
buildGridTemplate(xCoeffs: topXCoeffs, yCoeffs: sideYCoeffs)
// Corners
buildCornerTemplate(centerBaseX: R, centerScaleX: 0, centerBaseY: R, centerScaleY: 0, startAngle: .pi, endAngle: 1.5 * .pi)
buildCornerTemplate(centerBaseX: -R, centerScaleX: 1, centerBaseY: R, centerScaleY: 0, startAngle: 1.5 * .pi, endAngle: 2 * .pi)
buildCornerTemplate(centerBaseX: -R, centerScaleX: 1, centerBaseY: -R, centerScaleY: 1, startAngle: .pi / 2, endAngle: 0)
buildCornerTemplate(centerBaseX: R, centerScaleX: 0, centerBaseY: -R, centerScaleY: 1, startAngle: .pi, endAngle: .pi / 2)
return GlassMeshTemplate(vertices: vertices, faces: faces)
}
// MARK: - Public Entry Point
func generateGlassMesh(
size: CGSize,
cornerRadius: CGFloat,
edgeDistance: CGFloat,
displacementMagnitudeU: CGFloat,
displacementMagnitudeV: CGFloat,
cornerResolution: Int,
outerEdgeDistance: CGFloat,
bezier: DisplacementBezier
) -> GlassMeshTransform {
let clampedRadius = min(cornerRadius, min(size.width, size.height) / 2)
let key = GlassMeshCacheKey(
cornerRadius: clampedRadius,
edgeDistance: edgeDistance,
cornerResolution: cornerResolution,
outerEdgeDistance: outerEdgeDistance,
bezier: bezier
)
let template: GlassMeshTemplate
if let cached = glassMeshTemplateCache[key] {
template = cached
} else {
template = generateGlassMeshTemplate(
cornerRadius: clampedRadius,
edgeDistance: edgeDistance,
cornerResolution: cornerResolution,
outerEdgeDistance: outerEdgeDistance,
bezier: bezier
)
glassMeshTemplateCache[key] = template
}
return instantiateGlassMesh(
from: template,
size: size,
displacementMagnitudeU: displacementMagnitudeU,
displacementMagnitudeV: displacementMagnitudeV
)
}

View File

@@ -1,16 +1,100 @@
import SwiftUI import SwiftUI
import UIKit
// MARK: - Telegram-style Navigation Bar Blur (NavigationBackgroundNode parity)
//
// Telegram uses UIVisualEffectView(UIBlurEffect(.light)) with stripped filters:
// keeps only gaussianBlur + colorSaturate, removes everything else.
// This is NOT the same as Apple's .regularMaterial.
/// UIViewRepresentable that replicates Telegram's NavigationBackgroundNode blur.
struct TelegramNavBlurView: UIViewRepresentable {
func makeUIView(context: Context) -> TelegramNavBlurUIView {
let view = TelegramNavBlurUIView(frame: .zero)
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ uiView: TelegramNavBlurUIView, context: Context) {}
}
/// UIKit implementation of Telegram's NavigationBackgroundNode blur.
/// Uses UIBlurEffect(.light) with only gaussianBlur + colorSaturate filters.
final class TelegramNavBlurUIView: UIView {
private let effectView: UIVisualEffectView
private let tintLayer = CALayer()
override init(frame: CGRect) {
self.effectView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
super.init(frame: frame)
clipsToBounds = true
isUserInteractionEnabled = false
// Strip UIVisualEffectView subviews (same as Telegram)
for subview in effectView.subviews {
if subview.description.contains("VisualEffectSubview") {
subview.isHidden = true
}
}
// Keep only gaussianBlur + colorSaturate filters on the backdrop sublayer
if let sublayer = effectView.layer.sublayers?[0], let filters = sublayer.filters {
sublayer.backgroundColor = nil
sublayer.isOpaque = false
let allowedKeys: [String] = ["colorSaturate", "gaussianBlur"]
sublayer.filters = filters.filter { filter in
guard let filter = filter as? NSObject else { return true }
let filterName = String(describing: filter)
return allowedKeys.contains(filterName)
}
}
addSubview(effectView)
// Tint overlay Telegram applies the bar color on top of blur
tintLayer.backgroundColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 0.0, alpha: 0.85)
: UIColor(white: 1.0, alpha: 0.85)
}.cgColor
layer.addSublayer(tintLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
effectView.frame = bounds
tintLayer.frame = bounds
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
tintLayer.backgroundColor = traitCollection.userInterfaceStyle == .dark
? UIColor(white: 0.0, alpha: 0.85).cgColor
: UIColor(white: 1.0, alpha: 0.85).cgColor
}
}
// MARK: - Glass Navigation Bar Modifier // MARK: - Glass Navigation Bar Modifier
/// iOS 26+: native glassmorphism (no explicit background needed). /// iOS 26+: native glassmorphism (no explicit background needed).
/// iOS < 26: frosted glass material (Telegram-style). /// iOS < 26: Telegram-style blur (UIBlurEffect(.light) + stripped filters).
struct GlassNavBarModifier: ViewModifier { struct GlassNavBarModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26, *) { if #available(iOS 26, *) {
content content
} else { } else {
content content
.toolbarBackground(.regularMaterial, for: .navigationBar) .toolbarBackground(.hidden, for: .navigationBar)
.background {
TelegramNavBlurView()
.ignoresSafeArea(edges: .top)
}
} }
} }
} }

View File

@@ -1,12 +1,11 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
import Lottie
// MARK: - Tab // MARK: - Tab
enum RosettaTab: String, CaseIterable, Sendable { enum RosettaTab: String, CaseIterable, Sendable {
case chats case chats, calls, settings
case calls
case settings
static let interactionOrder: [RosettaTab] = [.calls, .chats, .settings] static let interactionOrder: [RosettaTab] = [.calls, .chats, .settings]
@@ -18,6 +17,14 @@ enum RosettaTab: String, CaseIterable, Sendable {
} }
} }
var animationName: String {
switch self {
case .chats: return "TabChats"
case .calls: return "TabCalls"
case .settings: return "TabSettings"
}
}
var icon: String { var icon: String {
switch self { switch self {
case .chats: return "bubble.left.and.bubble.right" case .chats: return "bubble.left.and.bubble.right"
@@ -39,366 +46,396 @@ enum RosettaTab: String, CaseIterable, Sendable {
} }
} }
// MARK: - Tab Badge struct TabBadge { let tab: RosettaTab; let text: String }
struct TabBadge {
let tab: RosettaTab
let text: String
}
struct TabBarSwipeState { struct TabBarSwipeState {
let fromTab: RosettaTab let fromTab: RosettaTab; let hoveredTab: RosettaTab; let fractionalIndex: CGFloat
let hoveredTab: RosettaTab
let fractionalIndex: CGFloat
} }
// MARK: - Tab Bar Colors // MARK: - Colors
private enum TabBarColors { private enum TabBarUIColors {
static let pillBackground = RosettaColors.adaptive( static let icon = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(red: 0.6, green: 0.6, blue: 0.6, alpha: 1) }
light: Color(hex: 0xF2F2F7), static let selectedIcon = UIColor(RosettaColors.primaryBlue)
dark: Color(hex: 0x2C2C2E) 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 selectionBackground = RosettaColors.adaptive( // Badge always red/white matches Telegram screenshots in both themes
light: Color.white, static let badgeBg = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1)
dark: Color(hex: 0x3A3A3C) static let badgeText = UIColor.white
) static let selectionFill = UIColor.white.withAlphaComponent(0.07)
static let selectedTint = Color(hex: 0x008BFF)
static let unselectedTint = RosettaColors.adaptive(
light: Color(hex: 0x3C3C43).opacity(0.6),
dark: Color.white
)
static let pillBorder = RosettaColors.adaptive(
light: Color.black.opacity(0.08),
dark: Color.white.opacity(0.08)
)
} }
// MARK: - Preference Keys // MARK: - Gesture (Telegram TabSelectionRecognizer)
private struct TabWidthPreferenceKey: PreferenceKey { private final class TabSelectionGesture: UIGestureRecognizer {
static var defaultValue: [Int: CGFloat] = [:] private(set) var initialLocation: CGPoint = .zero
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
value.merge(nextValue()) { $1 } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
initialLocation = touches.first?.location(in: view) ?? .zero
state = .began
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
state = .changed
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
state = .ended
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
state = .cancelled
} }
} }
private struct TabOriginPreferenceKey: PreferenceKey { // MARK: - RosettaTabBarUIView
static var defaultValue: [Int: CGFloat] = [:]
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) { final class RosettaTabBarUIView: UIView {
value.merge(nextValue()) { $1 }
private let tabs = RosettaTab.interactionOrder
private let innerInset: CGFloat = 4
private let itemHeight: CGFloat = 56
private let perItemWidth: CGFloat = 90
/// Telegram LiquidLens inset visible gap between lens and capsule edge.
private let lensInset: CGFloat = 4
/// Telegram: 1.15 scale when gesture active. Toned down for subtlety.
private let liftedScale: CGFloat = 1.08
private var barWidth: CGFloat { perItemWidth * CGFloat(tabs.count) + innerInset * 2 }
private var barHeight: CGFloat { itemHeight + innerInset * 2 }
private var itemW: CGFloat { (bounds.width - innerInset * 2) / CGFloat(tabs.count) }
// MARK: State
var selectedIndex: Int = 1 {
didSet { if oldValue != selectedIndex { updateSelection(animated: true) } }
}
var onTabSelected: ((RosettaTab) -> Void)?
var badgeText: String? { didSet { layoutBadge() } }
private var isDragging = false
private var dragLensX: CGFloat = 0
private var dragStartLensX: CGFloat = 0
private var hoveredIndex: Int = 1
// MARK: Subviews
private let glassBackground = TelegramGlassUIView(frame: .zero)
private let selectionView: UIView = {
let v = UIView()
v.backgroundColor = TabBarUIColors.selectionFill
v.layer.masksToBounds = true
return v
}()
private var iconViews: [LottieAnimationView] = []
private var labelViews: [UILabel] = []
private var badgeBgView: UIView?
private var badgeLabel: UILabel?
private var appliedColorHex: [Int: UInt64] = [:]
// MARK: Init
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
addSubview(glassBackground)
addSubview(selectionView)
for (i, tab) in tabs.enumerated() {
let lottie = LottieAnimationView()
lottie.contentMode = .scaleAspectFit
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)
}
let g = TabSelectionGesture(target: self, action: #selector(handleGesture(_:)))
g.delegate = self
addGestureRecognizer(g)
}
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: CGSize { CGSize(width: barWidth, height: barHeight) }
// MARK: Layout
override func layoutSubviews() {
super.layoutSubviews()
let w = bounds.width; let h = bounds.height
glassBackground.frame = bounds
glassBackground.updateGlass()
updateSelectionFrame(animated: false)
let iw = itemW
for i in 0..<tabs.count {
let x = innerInset + CGFloat(i) * iw
let iconSize: CGFloat = 44
let iconX = x + (iw - iconSize) / 2
let iconY = innerInset + 1
iconViews[i].frame = CGRect(x: iconX, y: iconY, width: iconSize, height: iconSize)
let labelH: CGFloat = 14
let labelY = h - innerInset - labelH - 1
labelViews[i].frame = CGRect(x: x, y: labelY, width: iw, height: labelH)
}
layoutBadge()
}
// MARK: Selection Indicator
private func lensFrame(for index: Int) -> CGRect {
let iw = itemW
let lensH = bounds.height - lensInset * 2
let lensW = iw
let lensX = innerInset + CGFloat(index) * iw
return CGRect(x: lensX, y: lensInset, width: lensW, height: lensH)
}
private func updateSelectionFrame(animated: Bool) {
guard bounds.width > 0 else { return }
let lensH = bounds.height - lensInset * 2
selectionView.layer.cornerRadius = lensH / 2
if isDragging {
// Lens follows finger clamped to capsule bounds
let lensW = itemW
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 {
let target = lensFrame(for: selectedIndex)
if animated {
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.78,
initialSpringVelocity: 0, options: .beginFromCurrentState) {
self.selectionView.frame = target
self.selectionView.transform = .identity
}
} else {
selectionView.frame = target
selectionView.transform = .identity
}
} }
} }
// MARK: - RosettaTabBar // MARK: Gesture
struct RosettaTabBar: View { @objc private func handleGesture(_ g: TabSelectionGesture) {
let iw = itemW
switch g.state {
case .began:
isDragging = true
hoveredIndex = selectedIndex
// Start lens at selected tab position
dragStartLensX = innerInset + CGFloat(selectedIndex) * iw
dragLensX = dragStartLensX
// Lifted state: scale up selection indicator (Telegram isLifted)
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7,
initialSpringVelocity: 0, options: .beginFromCurrentState) {
self.selectionView.transform = CGAffineTransform(scaleX: self.liftedScale, y: self.liftedScale)
}
updateSelectionFrame(animated: false)
case .changed:
let loc = g.location(in: self)
let translation = loc.x - g.initialLocation.x
dragLensX = dragStartLensX + translation
// Update hovered tab
let newHover = max(0, min(Int((loc.x - innerInset) / iw), tabs.count - 1))
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:
isDragging = false
let target = tabs[hoveredIndex]
if hoveredIndex != selectedIndex {
iconViews[hoveredIndex].play(fromProgress: 0, toProgress: 1, loopMode: .playOnce)
selectedIndex = hoveredIndex
onTabSelected?(target)
} else {
// Spring back with un-lift
updateSelection(animated: true)
}
case .cancelled:
isDragging = false
updateSelection(animated: true)
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
private func layoutBadge() {
guard let text = badgeText, !text.isEmpty else {
badgeBgView?.isHidden = true; return
}
let chatsIdx = RosettaTab.chats.interactionIndex
let iconFrame = iconViews[chatsIdx].frame
if badgeBgView == nil {
let bg = UIView(); bg.layer.masksToBounds = true; addSubview(bg); badgeBgView = bg
let lbl = UILabel(); lbl.font = .systemFont(ofSize: 13); lbl.textAlignment = .center
bg.addSubview(lbl); badgeLabel = lbl
}
guard let bg = badgeBgView, let lbl = badgeLabel else { return }
bg.isHidden = false
bg.backgroundColor = TabBarUIColors.badgeBg
lbl.textColor = TabBarUIColors.badgeText
lbl.text = text; lbl.sizeToFit()
let textW = lbl.frame.width
let bgW = text.count == 1 ? 18.0 : max(18.0, textW + 10)
let bgH: CGFloat = 18
bg.frame = CGRect(x: iconFrame.maxX - 6, y: iconFrame.minY - 1, width: bgW, height: bgH)
bg.layer.cornerRadius = bgH / 2
lbl.frame = CGRect(x: (bgW - textW) / 2, y: 0.5, width: textW, height: bgH - 1)
}
override func traitCollectionDidChange(_ prev: UITraitCollection?) {
super.traitCollectionDidChange(prev)
if traitCollection.hasDifferentColorAppearance(comparedTo: prev) {
appliedColorHex.removeAll()
updateSelection(animated: false)
layoutBadge()
}
}
}
extension RosettaTabBarUIView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { false }
}
// MARK: - SwiftUI Container
struct RosettaTabBarContainer: View {
let selectedTab: RosettaTab let selectedTab: RosettaTab
var onTabSelected: ((RosettaTab) -> Void)? var onTabSelected: ((RosettaTab) -> Void)?
var onSwipeStateChanged: ((TabBarSwipeState?) -> Void)?
private let allTabs = RosettaTab.interactionOrder
private let tabCount = RosettaTab.interactionOrder.count
// 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? @State private var cachedBadgeText: String?
private let barWidth: CGFloat = 90 * 3 + 8
private var effectiveFractional: CGFloat {
isDragging ? dragFractional : CGFloat(selectedTab.interactionIndex)
}
var body: some View { var body: some View {
// Single pill with all tabs same structure as iOS 26 system TabView RosettaTabBarBridge(selectedTab: selectedTab, onTabSelected: onTabSelected, badgeText: cachedBadgeText)
HStack(spacing: 0) { .frame(width: barWidth, height: 64)
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 {
// TelegramGlassCapsule handles both iOS 26+ (UIGlassEffect)
// and iOS < 26 (CABackdropLayer), with isUserInteractionEnabled = false.
TelegramGlassCapsule()
}
.contentShape(Capsule())
.gesture(dragGesture)
.modifier(TabBarShadowModifier()) .modifier(TabBarShadowModifier())
.padding(.horizontal, 25) .padding(.bottom, 8)
.padding(.top, 16) .onAppear { refreshBadges() }
.padding(.bottom, 12) .onChange(of: selectedTab) { _, _ in refreshBadges() }
.onAppear { Task { @MainActor in refreshBadges() } }
.onChange(of: selectedTab) { _, _ in Task { @MainActor in refreshBadges() } }
// Observation-isolated badge refresh: checks dialogsVersion on every
// DialogRepository mutation. Only calls refreshBadges() when version changes.
.overlay { .overlay {
BadgeVersionObserver(onVersionChanged: refreshBadges) BadgeVersionObserver(onVersionChanged: refreshBadges)
.frame(width: 0, height: 0) .frame(width: 0, height: 0).allowsHitTesting(false)
.allowsHitTesting(false)
} }
} }
/// Reads DialogRepository outside the body's observation scope.
private func refreshBadges() { private func refreshBadges() {
let repo = DialogRepository.shared let unread = DialogRepository.shared.sortedDialogs
let unread = repo.sortedDialogs .filter { !$0.isMuted }.reduce(0) { $0 + $1.unreadCount }
.filter { !$0.isMuted } cachedBadgeText = unread <= 0 ? nil : (unread > 999 ? "\(unread / 1000)K" : "\(unread)")
.reduce(0) { $0 + $1.unreadCount }
if unread <= 0 {
cachedBadgeText = nil
} else {
cachedBadgeText = unread > 999 ? "\(unread / 1000)K" : "\(unread)"
} }
} }
// MARK: - Selection Indicator private struct RosettaTabBarBridge: UIViewRepresentable {
let selectedTab: RosettaTab
var onTabSelected: ((RosettaTab) -> Void)?
var badgeText: String?
@ViewBuilder func makeUIView(context: Context) -> RosettaTabBarUIView {
private var selectionIndicator: some View { let v = RosettaTabBarUIView(frame: .zero)
let frac = effectiveFractional v.selectedIndex = selectedTab.interactionIndex
let nearestIdx = Int(frac.rounded()).clamped(to: 0...(tabCount - 1)) v.onTabSelected = onTabSelected; v.badgeText = badgeText; return v
let width = tabWidths[nearestIdx] ?? 80
let xOffset = interpolatedOrigin(for: frac)
Group {
if #available(iOS 26.0, *) {
// iOS 26+ native liquid glass
Capsule().fill(.clear)
.glassEffect(.regular, in: .capsule)
.allowsHitTesting(false)
.frame(width: width)
.offset(x: xOffset)
} else {
// iOS < 26 thin frosted glass
// +2 centers the narrowed (width-4) pill within the tab
Capsule().fill(.thinMaterial)
.frame(width: width - 4)
.padding(.vertical, 4)
.offset(x: xOffset + 2)
} }
} func updateUIView(_ v: RosettaTabBarUIView, context: Context) {
.animation( let idx = selectedTab.interactionIndex
isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82), if v.selectedIndex != idx { v.selectedIndex = idx }
value: frac v.onTabSelected = onTabSelected; v.badgeText = badgeText
)
}
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
))
}
.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)
onSwipeStateChanged?(nil)
} }
} }
private var totalTabWidth: CGFloat {
tabWidths.values.reduce(0, +)
}
// MARK: - Tab Content
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
return Button {
guard !isDragging else { return }
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)
if let badge {
Text(badge)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, badge.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: isEffectivelySelected ? .bold : .medium))
.foregroundStyle(tint)
}
.frame(minWidth: 66, maxWidth: .infinity)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.accessibilityLabel(tab.label)
.accessibilityAddTraits(isEffectivelySelected ? .isSelected : [])
}
// MARK: - Color Interpolation
@Environment(\.colorScheme) private var colorScheme
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)
// Unselected tint depends on theme compute at render time
let isDark = colorScheme == .dark
let fr: CGFloat = isDark ? 1.0 : 0.235
let fg: CGFloat = isDark ? 1.0 : 0.235
let fb: CGFloat = isDark ? 1.0 : 0.263
let fa: CGFloat = isDark ? 1.0 : 0.6
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 { private struct TabBarShadowModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26.0, *) { if #available(iOS 26.0, *) { content }
content else { content.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8) }
} else {
content
.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8)
}
}
}
// MARK: - Comparable Clamping
private extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
} }
} }
// MARK: - Preview
// MARK: - Badge Version Observer (observation-isolated)
/// Observes DialogRepository in its own scope, debounced badge refresh.
/// Body re-evaluates on any dialog mutation (observation tracking via `dialogs` read).
/// Debounces at 500ms to avoid CPU spikes from rapid mutations.
private struct BadgeVersionObserver: View { private struct BadgeVersionObserver: View {
var onVersionChanged: () -> Void var onVersionChanged: () -> Void
@State private var refreshTask: Task<Void, Never>? @State private var refreshTask: Task<Void, Never>?
@State private var lastUnread: Int = -1 @State private var lastUnread: Int = -1
var body: some View { var body: some View {
// O(n) reduce but only on THIS view's body (isolated scope).
// Not called during RosettaTabBar drag/animation.
let unread = DialogRepository.shared.dialogs.values let unread = DialogRepository.shared.dialogs.values
.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) } .reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) }
Color.clear Color.clear
.onChange(of: unread) { _, newValue in .onChange(of: unread) { _, v in
guard newValue != lastUnread else { return } guard v != lastUnread else { return }; lastUnread = v
lastUnread = newValue
// Debounce to avoid rapid refreshes during sync
refreshTask?.cancel() refreshTask?.cancel()
refreshTask = Task { @MainActor in refreshTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(200)) try? await Task.sleep(for: .milliseconds(200))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }; onVersionChanged()
onVersionChanged()
} }
} }
.onAppear { .onAppear { lastUnread = unread; onVersionChanged() }
lastUnread = unread
onVersionChanged()
}
}
}
#Preview {
ZStack(alignment: .bottom) {
Color.black.ignoresSafeArea()
RosettaTabBar(selectedTab: .chats)
} }
} }

View File

@@ -0,0 +1,91 @@
import SwiftUI
import Lottie
/// Telegram-parity tab bar icon using Lottie animations.
///
/// Behaviour matches `TabBarComponent.ItemComponent` in Telegram-iOS:
/// - Displays the **last frame** at rest (`startingPosition: .end`)
/// - Plays **once** from start to end when the tab becomes selected
/// - Tinted with a single color via `ColorValueProvider` (Telegram: `setOverlayColor`)
struct TabBarLottieIcon: UIViewRepresentable {
let animationName: String
let color: Color
/// Incremented each time the tab becomes selected triggers a play.
let playTrigger: Int
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator {
var lastTrigger: Int = 0
var currentName: String = ""
var lastColorHex: UInt64 = .max // cache to skip redundant setValueProvider
}
func makeUIView(context: Context) -> LottieAnimationView {
let view = LottieAnimationView()
view.contentMode = .scaleAspectFit
view.isUserInteractionEnabled = false
view.backgroundBehavior = .pauseAndRestore
// CRITICAL: allow SwiftUI .frame() to constrain the view.
// Without this, LottieAnimationView reports intrinsicContentSize 512×512
// (from the JSON canvas) and SwiftUI cannot shrink it.
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
view.setContentHuggingPriority(.required, for: .horizontal)
view.setContentHuggingPriority(.required, for: .vertical)
view.clipsToBounds = true
loadAnimation(into: view, context: context)
return view
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
if context.coordinator.currentName != animationName {
loadAnimation(into: uiView, context: context)
}
applyColorIfNeeded(uiView, coordinator: context.coordinator)
if context.coordinator.lastTrigger != playTrigger {
context.coordinator.lastTrigger = playTrigger
uiView.play(fromProgress: 0, toProgress: 1, loopMode: .playOnce)
}
}
private func loadAnimation(into view: LottieAnimationView, context: Context) {
if let cached = LottieAnimationCache.shared.animation(named: animationName) {
view.animation = cached
} else {
view.animation = LottieAnimation.named(animationName)
}
view.loopMode = .playOnce
view.currentProgress = 1.0
context.coordinator.currentName = animationName
context.coordinator.lastTrigger = playTrigger
context.coordinator.lastColorHex = .max // force color apply
applyColorIfNeeded(view, coordinator: context.coordinator)
}
private func applyColorIfNeeded(_ view: LottieAnimationView, coordinator: Coordinator) {
let hex = colorToHex(color)
guard hex != coordinator.lastColorHex else { return }
coordinator.lastColorHex = hex
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a)
let lottieColor = LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a))
view.setValueProvider(
ColorValueProvider(lottieColor),
keypath: AnimationKeypath(keypath: "**.Color")
)
}
private func colorToHex(_ c: Color) -> UInt64 {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
UIColor(c).getRed(&r, green: &g, blue: &b, alpha: &a)
let ri = UInt64(r * 255) << 24
let gi = UInt64(g * 255) << 16
let bi = UInt64(b * 255) << 8
let ai = UInt64(a * 255)
return ri | gi | bi | ai
}
}

View File

@@ -20,10 +20,11 @@ extension EnvironmentValues {
} }
} }
// MARK: - Telegram Glass (CABackdropLayer + CAFilter) // MARK: - Telegram Glass (CABackdropLayer + CAFilter + Mesh + Foreground Image)
// //
// Exact port of Telegram iOS LegacyGlassView + GlassBackgroundView foreground. // Exact port of Telegram iOS LegacyGlassView + GlassBackgroundView foreground.
// iOS < 26: CABackdropLayer with gaussianBlur radius 2.0 + dark foreground overlay. // iOS < 26: CABackdropLayer with gaussianBlur + colorMatrix + mesh displacement
// + generated foreground image with shadows, gradient border, blend modes.
// iOS 26+: native UIGlassEffect(style: .regular). // iOS 26+: native UIGlassEffect(style: .regular).
/// SwiftUI wrapper for Telegram-style glass background. /// SwiftUI wrapper for Telegram-style glass background.
@@ -99,11 +100,20 @@ final class TelegramGlassUIView: UIView {
// Layers // Layers
private var backdropLayer: CALayer? private var backdropLayer: CALayer?
private let clippingContainer = CALayer() private let clippingContainer = CALayer()
private let foregroundLayer = CALayer()
// Foreground + Shadow image views (legacy glass)
private var foregroundImageView: UIImageView?
private var shadowImageView: UIImageView?
// Track params for image regeneration
private var lastImageCornerRadius: CGFloat = -1
private var lastImageIsDark: Bool?
// iOS 26+ native glass // iOS 26+ native glass
private var nativeGlassView: UIVisualEffectView? private var nativeGlassView: UIVisualEffectView?
private static let shadowInset: CGFloat = 32.0
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
clipsToBounds = false clipsToBounds = false
@@ -143,40 +153,56 @@ final class TelegramGlassUIView: UIView {
nativeGlassView = glassView nativeGlassView = glassView
} }
// MARK: - iOS < 26 (CABackdropLayer Telegram LegacyGlassView) // MARK: - iOS < 26 (CABackdropLayer + ColorMatrix + Mesh Telegram parity)
private func setupLegacyGlass() { private func setupLegacyGlass() {
// Clipping container holds backdrop + foreground, clips to pill shape. // Shadow image view positioned behind glass with negative inset
// Border is added to main layer OUTSIDE the clip so it's fully visible. let shadow = UIImageView()
shadow.isUserInteractionEnabled = false
addSubview(shadow)
shadowImageView = shadow
// Clipping container holds backdrop, clips to pill shape.
// Telegram uses .circular cornerCurve for LegacyGlassView.
clippingContainer.masksToBounds = true clippingContainer.masksToBounds = true
clippingContainer.cornerCurve = .continuous clippingContainer.cornerCurve = .circular
layer.addSublayer(clippingContainer) layer.addSublayer(clippingContainer)
// 1. CABackdropLayer blurs content behind this view // 1. CABackdropLayer blurs + tones content behind this view
if let backdrop = Self.createBackdropLayer() { if let backdrop = BackdropLayerHelper.createBackdropLayer() {
backdrop.delegate = BackdropLayerDelegate.shared
backdrop.rasterizationScale = 1.0 backdrop.rasterizationScale = 1.0
Self.setBackdropScale(backdrop, scale: 1.0) BackdropLayerHelper.setScale(backdrop, scale: 1.0)
// gaussianBlur filter with radius 2.0 (Telegram .normal style) // Blur + Color Matrix filters (Telegram .normal style)
if let blurFilter = Self.makeBlurFilter() { if let blurFilter = CALayer.blurFilter(),
let colorMatrixFilter = CALayer.colorMatrixFilter() {
blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius") blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius")
backdrop.filters = [blurFilter]
var matrix: [Float32] = [
2.6705, -1.1087999, -0.1117, 0.0, 0.049999997,
-0.3295, 1.8914, -0.111899994, 0.0, 0.049999997,
-0.3297, -1.1084, 2.8881, 0.0, 0.049999997,
0.0, 0.0, 0.0, 1.0, 0.0
]
colorMatrixFilter.setValue(
NSValue(bytes: &matrix, objCType: "{CAColorMatrix=ffffffffffffffffffff}"),
forKey: "inputColorMatrix"
)
colorMatrixFilter.setValue(true as NSNumber, forKey: "inputBackdropAware")
backdrop.filters = [colorMatrixFilter, blurFilter]
} }
clippingContainer.addSublayer(backdrop) clippingContainer.addSublayer(backdrop)
self.backdropLayer = backdrop self.backdropLayer = backdrop
} }
// 2. Foreground adaptive semi-transparent fill (resolved in didMoveToWindow) // 2. Foreground image generated overlay with fill, shadows, gradient border
foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor let fg = UIImageView()
clippingContainer.addSublayer(foregroundLayer) fg.isUserInteractionEnabled = false
addSubview(fg)
// 3. Border on main layer via CALayer border properties. foregroundImageView = fg
// Using layer.borderWidth + cornerCurve ensures the border follows
// the same .continuous curve as the clipping container fill.
layer.borderWidth = 0.5
layer.borderColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
layer.cornerCurve = .continuous
} }
// MARK: - Layout // MARK: - Layout
@@ -202,7 +228,6 @@ final class TelegramGlassUIView: UIView {
glassView.layer.cornerRadius = radius glassView.layer.cornerRadius = radius
} else { } else {
clippingContainer.cornerRadius = radius clippingContainer.cornerRadius = radius
layer.cornerRadius = radius
} }
} }
@@ -213,7 +238,6 @@ final class TelegramGlassUIView: UIView {
let cornerRadius: CGFloat let cornerRadius: CGFloat
if let fixed = fixedCornerRadius { if let fixed = fixedCornerRadius {
// Cap at half-height to guarantee capsule shape when radius >= height/2.
cornerRadius = min(fixed, bounds.height / 2) cornerRadius = min(fixed, bounds.height / 2)
} else if isCircle { } else if isCircle {
cornerRadius = min(bounds.width, bounds.height) / 2 cornerRadius = min(bounds.width, bounds.height) / 2
@@ -223,116 +247,115 @@ final class TelegramGlassUIView: UIView {
if #available(iOS 26.0, *), let glassView = nativeGlassView { if #available(iOS 26.0, *), let glassView = nativeGlassView {
glassView.frame = bounds glassView.frame = bounds
// Use cornerRadius directly simpler and works in current iOS 26 betas.
// CAShapeLayer mask was unreliable with UIGlassEffect in some beta versions.
glassView.layer.cornerRadius = cornerRadius glassView.layer.cornerRadius = cornerRadius
return return
} }
// Legacy layout clippingContainer.masksToBounds clips all children, // Legacy layout
// so foregroundLayer needs no cornerRadius (avoids double-rounding artifacts).
clippingContainer.frame = bounds clippingContainer.frame = bounds
clippingContainer.cornerRadius = cornerRadius clippingContainer.cornerRadius = cornerRadius
backdropLayer?.frame = bounds backdropLayer?.frame = bounds
foregroundLayer.frame = bounds
// Border follows .continuous curve via main layer's cornerRadius. // Shadow image view extends beyond bounds
// clipsToBounds is false, so this only affects visual border not child clipping. let si = Self.shadowInset
layer.cornerRadius = cornerRadius shadowImageView?.frame = bounds.insetBy(dx: -si, dy: -si)
// Foreground image view same as shadow (image includes inset)
foregroundImageView?.frame = bounds.insetBy(dx: -si, dy: -si)
// Apply mesh displacement (iOS 17+, capable devices)
if #available(iOS 17.0, *), DeviceCapability.isGraphicallyCapable {
let size = CGSize(width: max(1.0, bounds.width), height: max(1.0, bounds.height))
let cr = min(min(size.width, size.height) * 0.5, cornerRadius)
let displacementMagnitudePoints: CGFloat = 20.0
let displacementMagnitudeU = displacementMagnitudePoints / size.width
let displacementMagnitudeV = displacementMagnitudePoints / size.height
let mesh = generateGlassMesh(
size: size,
cornerRadius: cr,
edgeDistance: min(12.0, cr),
displacementMagnitudeU: displacementMagnitudeU,
displacementMagnitudeV: displacementMagnitudeV,
cornerResolution: 12,
outerEdgeDistance: 2.0,
bezier: DisplacementBezier(
x1: 0.816137566137566,
y1: 0.20502645502645533,
x2: 0.5806878306878306,
y2: 0.873015873015873
)
)
if let meshValue = mesh.makeValue() {
backdropLayer?.setValue(meshValue, forKey: "meshTransform")
}
}
// Regenerate foreground/shadow images if needed
regenerateGlassImages(cornerRadius: cornerRadius)
}
// MARK: - Image Generation
private func regenerateGlassImages(cornerRadius: CGFloat) {
let isDark = traitCollection.userInterfaceStyle == .dark
// Skip if params unchanged
if cornerRadius == lastImageCornerRadius && isDark == lastImageIsDark {
return
}
lastImageCornerRadius = cornerRadius
lastImageIsDark = isDark
let inset = Self.shadowInset
// Fill color (Telegram parity)
let fillColor: UIColor
if isDark {
fillColor = UIColor(white: 1.0, alpha: 1.0).mixedWith(.black, alpha: 1.0 - 0.11).withAlphaComponent(0.85)
} else {
fillColor = UIColor(white: 1.0, alpha: 0.7)
}
// Foreground image
let fgImage = GlassImageGeneration.generateLegacyGlassImage(
size: CGSize(width: cornerRadius * 2, height: cornerRadius * 2),
inset: inset,
borderWidthFactor: 1.0,
isDark: isDark,
fillColor: fillColor
)
foregroundImageView?.image = fgImage
// Shadow image
shadowImageView?.image = GlassImageGeneration.generateShadowImage(cornerRadius: cornerRadius)
} }
// MARK: - Adaptive Colors for Legacy Glass // MARK: - Adaptive Colors for Legacy Glass
private func resolvedForegroundColor() -> CGColor {
let isDark = traitCollection.userInterfaceStyle == .dark
return isDark
? UIColor(white: 0.11, alpha: 0.85).cgColor
: UIColor(white: 0.95, alpha: 0.85).cgColor
}
private func resolvedBorderColor() -> CGColor {
let isDark = traitCollection.userInterfaceStyle == .dark
return isDark
? UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
: UIColor(white: 0.0, alpha: 0.08).cgColor
}
private func updateLegacyColors() {
guard nativeGlassView == nil else { return }
foregroundLayer.backgroundColor = resolvedForegroundColor()
layer.borderColor = resolvedBorderColor()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection) super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return } guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
updateLegacyColors() // Force image regeneration on theme change
lastImageIsDark = nil
setNeedsLayout()
} }
override func didMoveToWindow() { override func didMoveToWindow() {
super.didMoveToWindow() super.didMoveToWindow()
// Resolve colors once view is in a window and has valid traitCollection // Resolve images once view is in a window and has valid traitCollection
updateLegacyColors() if nativeGlassView == nil {
lastImageIsDark = nil
setNeedsLayout()
}
} }
// MARK: - Shadow (drawn as separate image Telegram parity) // MARK: - Shadow (static helper kept for external callers)
/// Call from parent to add Telegram-exact shadow. /// Call from parent to add Telegram-exact shadow.
/// Telegram: blur 40, color black 4%, offset (0,1), inset 32pt. /// Telegram: blur 40, color black 4%, offset (0,1), inset 32pt.
static func makeShadowImage(cornerRadius: CGFloat) -> UIImage? { static func makeShadowImage(cornerRadius: CGFloat) -> UIImage? {
let inset: CGFloat = 32 return GlassImageGeneration.generateShadowImage(cornerRadius: cornerRadius)
let innerInset: CGFloat = 0.5
let diameter = cornerRadius * 2
let totalSize = CGSize(width: inset * 2 + diameter, height: inset * 2 + diameter)
let image = UIGraphicsImageRenderer(size: totalSize).image { ctx in
let context = ctx.cgContext
context.clear(CGRect(origin: .zero, size: totalSize))
context.setFillColor(UIColor.black.cgColor)
context.setShadow(
offset: CGSize(width: 0, height: 1),
blur: 40.0,
color: UIColor(white: 0.0, alpha: 0.04).cgColor
)
let ellipseRect = CGRect(
x: inset + innerInset,
y: inset + innerInset,
width: totalSize.width - (inset + innerInset) * 2,
height: totalSize.height - (inset + innerInset) * 2
)
context.fillEllipse(in: ellipseRect)
// Punch out the center (shadow only, no fill)
context.setFillColor(UIColor.clear.cgColor)
context.setBlendMode(.copy)
context.fillEllipse(in: ellipseRect)
}
return image.stretchableImage(
withLeftCapWidth: Int(inset + cornerRadius),
topCapHeight: Int(inset + cornerRadius)
)
}
// MARK: - Private API Helpers (same approach as Telegram)
private static func createBackdropLayer() -> CALayer? {
let className = ["CA", "Backdrop", "Layer"].joined()
guard let cls = NSClassFromString(className) as? CALayer.Type else { return nil }
return cls.init()
}
private static func setBackdropScale(_ layer: CALayer, scale: Double) {
let sel = NSSelectorFromString("setScale:")
guard layer.responds(to: sel) else { return }
layer.perform(sel, with: NSNumber(value: scale))
}
private static func makeBlurFilter() -> NSObject? {
let className = ["CA", "Filter"].joined()
guard let cls = NSClassFromString(className) as? NSObject.Type else { return nil }
let sel = NSSelectorFromString("filterWithName:")
guard cls.responds(to: sel) else { return nil }
return cls.perform(sel, with: "gaussianBlur")?.takeUnretainedValue() as? NSObject
} }
} }

View File

@@ -448,7 +448,8 @@ struct AttachmentPanelView: View {
private var legacySelectionIndicator: some View { private var legacySelectionIndicator: some View {
let width = tabWidths[selectedTab] ?? 0 let width = tabWidths[selectedTab] ?? 0
let xOffset = tabOrigins[selectedTab] ?? 0 let xOffset = tabOrigins[selectedTab] ?? 0
return Capsule().fill(.thinMaterial) return Capsule().fill(.clear)
.background { TelegramGlassCapsule() }
.frame(width: max(0, width - 4)) .frame(width: max(0, width - 4))
.padding(.vertical, 4) .padding(.vertical, 4)
.offset(x: xOffset + 2) .offset(x: xOffset + 2)

View File

@@ -620,16 +620,16 @@ private extension ChatDetailView {
@ToolbarContentBuilder @ToolbarContentBuilder
var chatDetailToolbar: some ToolbarContent { var chatDetailToolbar: some ToolbarContent {
if isMultiSelectMode { if isMultiSelectMode {
// Selection mode toolbar (Telegram parity: Clear Chat | N Selected | Cancel) // Selection mode toolbar (Telegram parity: all 3 items in glass capsules)
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button { Button {
showClearChatConfirmation = true showClearChatConfirmation = true
} label: { } label: {
Text("Clear Chat") Text("Clear Chat")
.font(.system(size: 17, weight: .regular)) .font(.system(size: 15, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.padding(.horizontal, 12) .padding(.horizontal, 10)
.frame(height: 36) .frame(height: 32)
.background { .background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text)
} }
@@ -639,10 +639,10 @@ private extension ChatDetailView {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
Text("\(selectedMessageIds.count) Selected") Text("\(selectedMessageIds.count) Selected")
.font(.system(size: 17, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.padding(.horizontal, 16) .padding(.horizontal, 12)
.frame(height: 36) .frame(height: 32)
.background { .background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text) glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text)
} }
@@ -654,8 +654,13 @@ private extension ChatDetailView {
selectedMessageIds.removeAll() selectedMessageIds.removeAll()
} label: { } label: {
Text("Cancel") Text("Cancel")
.font(.system(size: 17, weight: .regular)) .font(.system(size: 15, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.padding(.horizontal, 10)
.frame(height: 32)
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text)
}
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -844,55 +849,55 @@ private extension ChatDetailView {
@ViewBuilder @ViewBuilder
private var selectionActionBar: some View { private var selectionActionBar: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Delete // Delete (Telegram: GlassButtonView + MessageSelectionTrash)
Button { Button {
deleteSelectedMessages() deleteSelectedMessages()
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
.font(.system(size: 20)) .font(.system(size: 19, weight: .regular))
.foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : .red) .foregroundStyle(RosettaColors.Adaptive.text)
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } .background { TelegramGlassCircle() }
.opacity(selectedMessageIds.isEmpty ? 0.5 : 1.0)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(selectedMessageIds.isEmpty) .disabled(selectedMessageIds.isEmpty)
Spacer() Spacer()
// Share // Share (Telegram: GlassButtonView + MessageSelectionAction)
Button { Button {
shareSelectedMessages() shareSelectedMessages()
} label: { } label: {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
.font(.system(size: 20)) .font(.system(size: 19, weight: .regular))
.foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } .background { TelegramGlassCircle() }
.opacity(selectedMessageIds.isEmpty ? 0.5 : 1.0)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(selectedMessageIds.isEmpty) .disabled(selectedMessageIds.isEmpty)
Spacer() Spacer()
// Forward // Forward (Telegram: GlassButtonView + MessageSelectionForward)
Button { Button {
forwardSelectedMessages() forwardSelectedMessages()
} label: { } label: {
Image(systemName: "arrowshape.turn.up.right") Image(systemName: "arrowshape.turn.up.right")
.font(.system(size: 20)) .font(.system(size: 19, weight: .regular))
.foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.background { glass(shape: .circle, strokeOpacity: 0.18, strokeColor: RosettaColors.Adaptive.text) } .background { TelegramGlassCircle() }
.opacity(selectedMessageIds.isEmpty ? 0.5 : 1.0)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(selectedMessageIds.isEmpty) .disabled(selectedMessageIds.isEmpty)
} }
.padding(.horizontal, 26) // Telegram: 8 base + 18 safe area inset .padding(.horizontal, 26)
.padding(.vertical, 12) .padding(.vertical, 12)
.padding(.bottom, 16) // safe area .padding(.bottom, 16) // safe area
.background {
glass(shape: .rounded(0), strokeOpacity: 0)
}
} }
private func deleteSelectedMessages() { private func deleteSelectedMessages() {

View File

@@ -229,7 +229,7 @@ final class NativeMessageCell: UICollectionViewCell {
private let selectionCheckContainer = UIView() private let selectionCheckContainer = UIView()
private let selectionCheckBorder = CAShapeLayer() private let selectionCheckBorder = CAShapeLayer()
private let selectionCheckFill = CAShapeLayer() private let selectionCheckFill = CAShapeLayer()
private let selectionCheckmarkView = UIImageView() private let selectionCheckmarkLayer = CAShapeLayer()
// Swipe-to-reply // Swipe-to-reply
private let replyCircleView = UIView() private let replyCircleView = UIView()
@@ -606,15 +606,16 @@ final class NativeMessageCell: UICollectionViewCell {
selectionCheckContainer.isHidden = true selectionCheckContainer.isHidden = true
selectionCheckContainer.isUserInteractionEnabled = false selectionCheckContainer.isUserInteractionEnabled = false
let checkPath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28)) // Telegram CheckNode: overlay style white border, shadow, CG checkmark
selectionCheckBorder.path = checkPath.cgPath let inset: CGFloat = 2.0 - (1.0 / UIScreen.main.scale) // Telegram: 2.0 - UIScreenPixel
let borderWidth: CGFloat = 1.0 + (1.0 / UIScreen.main.scale) // Telegram: 1.0 + UIScreenPixel
let borderRect = CGRect(x: 0, y: 0, width: 28, height: 28).insetBy(dx: inset, dy: inset)
let checkCirclePath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28))
selectionCheckBorder.path = UIBezierPath(ovalIn: borderRect).cgPath
selectionCheckBorder.fillColor = UIColor.clear.cgColor selectionCheckBorder.fillColor = UIColor.clear.cgColor
selectionCheckBorder.strokeColor = UIColor { traits in selectionCheckBorder.strokeColor = UIColor.white.cgColor // Telegram: pure white for overlay
traits.userInterfaceStyle == .dark selectionCheckBorder.lineWidth = borderWidth
? UIColor.white.withAlphaComponent(0.5)
: UIColor.black.withAlphaComponent(0.3)
}.cgColor
selectionCheckBorder.lineWidth = 1.5 // Telegram: 1.0 + UIScreenPixel
selectionCheckContainer.layer.addSublayer(selectionCheckBorder) selectionCheckContainer.layer.addSublayer(selectionCheckBorder)
// Telegram CheckNode overlay shadow // Telegram CheckNode overlay shadow
@@ -623,18 +624,30 @@ final class NativeMessageCell: UICollectionViewCell {
selectionCheckContainer.layer.shadowRadius = 2.5 selectionCheckContainer.layer.shadowRadius = 2.5
selectionCheckContainer.layer.shadowOffset = .zero selectionCheckContainer.layer.shadowOffset = .zero
selectionCheckFill.path = checkPath.cgPath selectionCheckFill.path = checkCirclePath.cgPath
selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6 selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6 Rosetta primary blue
selectionCheckFill.isHidden = true selectionCheckFill.isHidden = true
selectionCheckContainer.layer.addSublayer(selectionCheckFill) selectionCheckContainer.layer.addSublayer(selectionCheckFill)
let checkConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) // Telegram CheckNode: CG-drawn checkmark (1.5pt, round cap/join)
selectionCheckmarkView.image = UIImage(systemName: "checkmark", withConfiguration: checkConfig) let s = (28.0 - inset * 2) / 18.0 // Telegram scale factor
selectionCheckmarkView.tintColor = .white let cx = 14.0, cy = 14.0
selectionCheckmarkView.contentMode = .center let startX = cx - (4.0 - 0.3333) * s
selectionCheckmarkView.frame = CGRect(x: 0, y: 0, width: 28, height: 28) let startY = cy + 0.5 * s
selectionCheckmarkView.isHidden = true let checkmarkPath = UIBezierPath()
selectionCheckContainer.addSubview(selectionCheckmarkView) checkmarkPath.move(to: CGPoint(x: startX, y: startY))
checkmarkPath.addLine(to: CGPoint(x: startX + 2.5 * s, y: startY + 3.0 * s))
checkmarkPath.addLine(to: CGPoint(x: startX + 2.5 * s + 4.6667 * s, y: startY + 3.0 * s - 6.0 * s))
selectionCheckmarkLayer.path = checkmarkPath.cgPath
selectionCheckmarkLayer.strokeColor = UIColor.white.cgColor
selectionCheckmarkLayer.fillColor = UIColor.clear.cgColor
selectionCheckmarkLayer.lineWidth = 1.5
selectionCheckmarkLayer.lineCap = .round
selectionCheckmarkLayer.lineJoin = .round
selectionCheckmarkLayer.frame = CGRect(x: 0, y: 0, width: 28, height: 28)
selectionCheckmarkLayer.isHidden = true
selectionCheckContainer.layer.addSublayer(selectionCheckmarkLayer)
contentView.addSubview(selectionCheckContainer) contentView.addSubview(selectionCheckContainer)
@@ -2708,7 +2721,7 @@ final class NativeMessageCell: UICollectionViewCell {
// Selection: reset selected state on reuse, keep mode (same for all cells) // Selection: reset selected state on reuse, keep mode (same for all cells)
isMessageSelected = false isMessageSelected = false
selectionCheckFill.isHidden = true selectionCheckFill.isHidden = true
selectionCheckmarkView.isHidden = true selectionCheckmarkLayer.isHidden = true
} }
// MARK: - Multi-Select // MARK: - Multi-Select
@@ -2717,8 +2730,6 @@ final class NativeMessageCell: UICollectionViewCell {
guard isInSelectionMode != enabled else { return } guard isInSelectionMode != enabled else { return }
isInSelectionMode = enabled isInSelectionMode = enabled
let newOffset: CGFloat = enabled ? 42 : 0 let newOffset: CGFloat = enabled ? 42 : 0
let duration: TimeInterval = enabled ? 0.3 : 0.4
let damping: CGFloat = enabled ? 0.8 : 0.85
if animated { if animated {
selectionCheckContainer.isHidden = false selectionCheckContainer.isHidden = false
@@ -2727,11 +2738,11 @@ final class NativeMessageCell: UICollectionViewCell {
let slideFrom = enabled ? -42.0 : 0.0 let slideFrom = enabled ? -42.0 : 0.0
let slideTo = enabled ? 0.0 : -42.0 let slideTo = enabled ? 0.0 : -42.0
// Checkbox fade + slide // Telegram: 0.2s easeOut for checkbox fade + slide
let alphaAnim = CABasicAnimation(keyPath: "opacity") let alphaAnim = CABasicAnimation(keyPath: "opacity")
alphaAnim.fromValue = fromAlpha alphaAnim.fromValue = fromAlpha
alphaAnim.toValue = toAlpha alphaAnim.toValue = toAlpha
alphaAnim.duration = duration alphaAnim.duration = 0.2
alphaAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) alphaAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
alphaAnim.fillMode = .forwards alphaAnim.fillMode = .forwards
alphaAnim.isRemovedOnCompletion = false alphaAnim.isRemovedOnCompletion = false
@@ -2740,15 +2751,15 @@ final class NativeMessageCell: UICollectionViewCell {
let posAnim = CABasicAnimation(keyPath: "position.x") let posAnim = CABasicAnimation(keyPath: "position.x")
posAnim.fromValue = selectionCheckContainer.layer.position.x + slideFrom posAnim.fromValue = selectionCheckContainer.layer.position.x + slideFrom
posAnim.toValue = selectionCheckContainer.layer.position.x + slideTo posAnim.toValue = selectionCheckContainer.layer.position.x + slideTo
posAnim.duration = duration posAnim.duration = 0.2
posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide") selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide")
selectionCheckContainer.layer.opacity = toAlpha selectionCheckContainer.layer.opacity = toAlpha
// Content shift (spring animation, Telegram parity) // Telegram: 0.2s easeOut for content shift
selectionOffset = newOffset selectionOffset = newOffset
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) { UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {
self.setNeedsLayout() self.setNeedsLayout()
self.layoutIfNeeded() self.layoutIfNeeded()
} completion: { _ in } completion: { _ in
@@ -2771,7 +2782,7 @@ final class NativeMessageCell: UICollectionViewCell {
guard isMessageSelected != selected else { return } guard isMessageSelected != selected else { return }
isMessageSelected = selected isMessageSelected = selected
selectionCheckFill.isHidden = !selected selectionCheckFill.isHidden = !selected
selectionCheckmarkView.isHidden = !selected selectionCheckmarkLayer.isHidden = !selected
selectionCheckBorder.isHidden = selected selectionCheckBorder.isHidden = selected
if animated && selected { if animated && selected {
@@ -2782,7 +2793,7 @@ final class NativeMessageCell: UICollectionViewCell {
anim.duration = 0.21 anim.duration = 0.21
anim.timingFunction = CAMediaTimingFunction(name: .easeOut) anim.timingFunction = CAMediaTimingFunction(name: .easeOut)
selectionCheckFill.add(anim, forKey: "checkBounce") selectionCheckFill.add(anim, forKey: "checkBounce")
selectionCheckmarkView.layer.add(anim, forKey: "checkBounce") selectionCheckmarkLayer.add(anim, forKey: "checkBounce")
} else if animated && !selected { } else if animated && !selected {
// Telegram CheckNode: 2-stage scale 10.91 over 0.15s // Telegram CheckNode: 2-stage scale 10.91 over 0.15s
let anim = CAKeyframeAnimation(keyPath: "transform.scale") let anim = CAKeyframeAnimation(keyPath: "transform.scale")

View File

@@ -215,11 +215,10 @@ struct OpponentProfileView: View {
// MARK: - Shared Media Tab Bar (Telegram parity) // MARK: - Shared Media Tab Bar (Telegram parity)
private var tabActiveColor: Color { colorScheme == .dark ? .white : .black } private var tabActiveColor: Color { colorScheme == .dark ? .white : .black }
private var tabInactiveColor: Color { colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.4) } private var tabInactiveColor: Color { colorScheme == .dark ? Color(red: 0x98/255, green: 0x98/255, blue: 0x9E/255) : Color.black.opacity(0.4) }
private var tabIndicatorFill: Color { colorScheme == .dark ? Color.white.opacity(0.18) : Color.black.opacity(0.08) } private var tabIndicatorFill: Color { colorScheme == .dark ? Color.white.opacity(0.18) : Color.black.opacity(0.08) }
private var sharedMediaTabBar: some View { private var sharedMediaTabBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(PeerProfileTab.allCases, id: \.self) { tab in ForEach(PeerProfileTab.allCases, id: \.self) { tab in
Button { Button {
@@ -228,9 +227,9 @@ struct OpponentProfileView: View {
} }
} label: { } label: {
Text(tab.rawValue) Text(tab.rawValue)
.font(.system(size: 15, weight: .medium)) .font(.system(size: 14, weight: .medium))
.foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor) .foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor)
.padding(.horizontal, 14) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
.background { .background {
if selectedTab == tab { if selectedTab == tab {
@@ -245,8 +244,9 @@ struct OpponentProfileView: View {
} }
.padding(.horizontal, 3) .padding(.horizontal, 3)
.padding(.vertical, 3) .padding(.vertical, 3)
.background {
TelegramGlassCapsule()
} }
.background(Capsule().fill(telegramSectionFill))
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@@ -462,7 +462,7 @@ private struct IOS18ScrollTracker<Content: View>: View {
.onScrollGeometryChange(for: CGFloat.self) { $0.contentInsets.top } action: { _, v in topInset = v } .onScrollGeometryChange(for: CGFloat.self) { $0.contentInsets.top } action: { _, v in topInset = v }
.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y + $0.contentInsets.top } action: { _, v in .onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y + $0.contentInsets.top } action: { _, v in
if scrollPhase == .interacting { if scrollPhase == .interacting {
withAnimation(.snappy(duration: 0.2, extraBounce: 0)) { withAnimation(.spring(response: 0.35, dampingFraction: 0.86)) {
isLargeHeader = canExpand && (v < -10 || (isLargeHeader && v < 0)) isLargeHeader = canExpand && (v < -10 || (isLargeHeader && v < 0))
} }
} }

View File

@@ -74,11 +74,13 @@ struct PeerProfileHeaderView: View {
.scaledToFill() .scaledToFill()
} else { } else {
let pair = RosettaColors.avatarColors[avatarColorIndex % RosettaColors.avatarColors.count] let pair = RosettaColors.avatarColors[avatarColorIndex % RosettaColors.avatarColors.count]
let textColor: Color = colorScheme == .dark ? pair.text : pair.tint
ZStack { ZStack {
Rectangle().fill(pair.tint) Rectangle().fill(colorScheme == .dark ? Color(hex: 0x1A1B1E) : .white)
Rectangle().fill(pair.tint.opacity(colorScheme == .dark ? 0.15 : 0.10))
Text(avatarInitials) Text(avatarInitials)
.font(.system(size: isLargeHeader ? 120 : 36, weight: .medium)) .font(.system(size: isLargeHeader ? 120 : 38, weight: .bold, design: .rounded))
.foregroundStyle(pair.text) .foregroundStyle(textColor)
} }
} }
} }
@@ -86,38 +88,34 @@ struct PeerProfileHeaderView: View {
// MARK: - Navigation Bar Content (sticky + scale) // MARK: - Navigation Bar Content (sticky + scale)
private var navigationBarContent: some View { private var navigationBarContent: some View {
VStack(alignment: isLargeHeader ? .leading : .center, spacing: 4) { VStack(alignment: isLargeHeader ? .leading : .center, spacing: isLargeHeader ? 4 : 1) {
HStack(spacing: 5) { HStack(spacing: 5) {
Text(displayName) Text(displayName)
.font(.title2) .font(isLargeHeader ? .title2.weight(.semibold) : .system(size: 17, weight: .semibold))
.fontWeight(.semibold) .lineLimit(isLargeHeader ? 2 : 1)
.lineLimit(2)
if effectiveVerified > 0 { if effectiveVerified > 0 {
VerifiedBadge( VerifiedBadge(
verified: effectiveVerified, verified: effectiveVerified,
size: 18, size: isLargeHeader ? 18 : 15,
badgeTint: isLargeHeader ? .white : nil badgeTint: isLargeHeader ? .white : nil
) )
} }
} }
Text(subtitleText) Text(subtitleText)
.font(.callout) .font(isLargeHeader ? .callout : .system(size: 16))
.foregroundStyle(isLargeHeader ? .white.opacity(0.7) : .secondary) .foregroundStyle(isLargeHeader ? .white.opacity(0.7) : .secondary)
} }
.frame(maxWidth: .infinity, alignment: isLargeHeader ? .leading : .center) .frame(maxWidth: .infinity, alignment: isLargeHeader ? .leading : .center)
.visualEffect { content, proxy in .visualEffect { content, proxy in
let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY
let progress = max(min(minY / 50, 1), 0) let progress = max(min(minY / 50, 1), 0)
let scale = 0.7 + (0.3 * progress) let contentH = proxy.size.height
let scaledH = proxy.size.height * scale let navBarCenterY: CGFloat = -22
// Center title at nav bar vertical center when stuck let centeringOffset = navBarCenterY - contentH / 2
let navBarCenterY = topInset - 22
let centeringOffset = navBarCenterY - scaledH / 2
return content return content
.scaleEffect(scale, anchor: .top)
.offset(y: minY < 0 ? -minY + centeringOffset * (1 - progress) : 0) .offset(y: minY < 0 ? -minY + centeringOffset * (1 - progress) : 0)
} }
.background { navBarBackground } .background { navBarBackground }
@@ -130,32 +128,27 @@ struct PeerProfileHeaderView: View {
GeometryReader { geo in GeometryReader { geo in
let minY = geo.frame(in: .scrollView(axis: .vertical)).minY let minY = geo.frame(in: .scrollView(axis: .vertical)).minY
let opacity = 1.0 - max(min(minY / 50, 1), 0) let opacity = 1.0 - max(min(minY / 50, 1), 0)
let tint: Color = colorScheme == .dark ? .black : .white
ZStack { // Telegram parity: EdgeEffectView(content: #000000, blur: true, edgeSize: 60)
if #available(iOS 26, *) { // 89-stop smooth gradient from opaque black to transparent
Rectangle() Rectangle()
.fill(.clear) .fill(Color.black)
.glassEffect(.clear.tint(tint.opacity(0.8)), in: .rect)
.mask { .mask {
LinearGradient( LinearGradient(
colors: [.black, .black, .black, .black.opacity(0.5), .clear], stops: [
.init(color: .black, location: 0.0),
.init(color: .black, location: 0.45),
.init(color: .black.opacity(0.9), location: 0.55),
.init(color: .black.opacity(0.75), location: 0.65),
.init(color: .black.opacity(0.55), location: 0.75),
.init(color: .black.opacity(0.3), location: 0.85),
.init(color: .black.opacity(0.1), location: 0.93),
.init(color: .clear, location: 1.0),
],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
) )
} }
} else {
Rectangle()
.fill(tint)
.mask {
LinearGradient(
colors: [.black, .black, .black, .black.opacity(0.9), .black.opacity(0.4), .clear],
startPoint: .top,
endPoint: .bottom
)
}
}
}
.padding(-20) .padding(-20)
.padding(.bottom, -40) .padding(.bottom, -40)
.padding(.top, -topInset) .padding(.top, -topInset)
@@ -200,10 +193,8 @@ struct PeerProfileHeaderView: View {
.fill(telegramSectionFill) .fill(telegramSectionFill)
.opacity(isLargeHeader ? 0 : 1) .opacity(isLargeHeader ? 0 : 1)
RoundedRectangle(cornerRadius: 15, style: .continuous) TelegramGlassRoundedRect(cornerRadius: 15)
.fill(.ultraThinMaterial)
.opacity(isLargeHeader ? 0.8 : 0) .opacity(isLargeHeader ? 0.8 : 0)
.environment(\.colorScheme, .dark)
} }
} }
.contentShape(.rect) .contentShape(.rect)

View File

@@ -152,22 +152,10 @@ struct MainTabView: View {
.ignoresSafeArea() .ignoresSafeArea()
if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented { if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented {
RosettaTabBar( RosettaTabBarContainer(
selectedTab: selectedTab, selectedTab: selectedTab,
onTabSelected: { tab in onTabSelected: { tab in
selectedTab = tab selectedTab = tab
},
onSwipeStateChanged: { state in
if let state {
for tab in RosettaTab.interactionOrder {
activatedTabs.insert(tab)
}
dragFractionalIndex = state.fractionalIndex
} else {
withAnimation(.easeInOut(duration: 0.15)) {
dragFractionalIndex = nil
}
}
} }
) )
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
@@ -186,35 +174,34 @@ struct MainTabView: View {
CGFloat(selectedTab.interactionIndex) CGFloat(selectedTab.interactionIndex)
} }
// MARK: - Tab Pager (Telegram Parity)
//
// Telegram TabBarController animation:
// New tab: alpha 01 (0.1s) + scale 0.9981.0 (0.15s, delay 0.1s, spring)
// Old tab: just removed (no fade-out)
//
// zIndex ensures selected tab is always on top, so only the fade-IN
// is visible the old tab's opacity change is hidden underneath.
@ViewBuilder @ViewBuilder
private func tabPager(availableSize: CGSize) -> some View { private func tabPager(availableSize: CGSize) -> some View {
let width = max(1, availableSize.width) let width = max(1, availableSize.width)
ZStack { ZStack {
ForEach(RosettaTab.interactionOrder, id: \.self) { tab in ForEach(RosettaTab.interactionOrder, id: \.self) { tab in
let isSelected = tab == selectedTab
tabView(for: tab) tabView(for: tab)
.frame(width: width, height: availableSize.height) .frame(width: width, height: availableSize.height)
.opacity(tabOpacity(for: tab)) .zIndex(isSelected ? 1 : 0)
.environment(\.telegramGlassActive, tabOpacity(for: tab) > 0) .opacity(isSelected ? 1 : 0)
.environment(\.telegramGlassActive, isSelected)
.animation(.easeOut(duration: 0.12), value: selectedTab) .animation(.easeOut(duration: 0.12), value: selectedTab)
.allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil) .allowsHitTesting(isSelected)
} }
} }
.clipped() .clipped()
} }
private func tabOpacity(for tab: RosettaTab) -> Double {
if let frac = dragFractionalIndex {
// During drag: crossfade between adjacent tabs
let tabIndex = CGFloat(tab.interactionIndex)
let distance = abs(frac - tabIndex)
if distance >= 1 { return 0 }
return Double(1 - distance)
} else {
return tab == selectedTab ? 1 : 0
}
}
@ViewBuilder @ViewBuilder
private func tabView(for tab: RosettaTab) -> some View { private func tabView(for tab: RosettaTab) -> some View {
if activatedTabs.contains(tab) { if activatedTabs.contains(tab) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long