Редизайн tab bar на UIKit — иконки, жесты и анимации 1:1 как в Telegram-iOS
This commit is contained in:
9
Rosetta/Assets.xcassets/TabBar/Contents.json
Normal file
9
Rosetta/Assets.xcassets/TabBar/Contents.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
||||
16
Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/Contents.json
vendored
Normal file
16
Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/ic_tb_calls.pdf
vendored
Normal file
BIN
Rosetta/Assets.xcassets/TabBar/TabCalls.imageset/ic_tb_calls.pdf
vendored
Normal file
Binary file not shown.
16
Rosetta/Assets.xcassets/TabBar/TabChats.imageset/Contents.json
vendored
Normal file
16
Rosetta/Assets.xcassets/TabBar/TabChats.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
Rosetta/Assets.xcassets/TabBar/TabChats.imageset/ic_tb_chats.pdf
vendored
Normal file
BIN
Rosetta/Assets.xcassets/TabBar/TabChats.imageset/ic_tb_chats.pdf
vendored
Normal file
Binary file not shown.
16
Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/Contents.json
vendored
Normal file
16
Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/ic_tb_settings.pdf
vendored
Normal file
BIN
Rosetta/Assets.xcassets/TabBar/TabSettings.imageset/ic_tb_settings.pdf
vendored
Normal file
Binary file not shown.
86
Rosetta/DesignSystem/Components/CALayerFilters.swift
Normal file
86
Rosetta/DesignSystem/Components/CALayerFilters.swift
Normal 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
|
||||
}
|
||||
}
|
||||
298
Rosetta/DesignSystem/Components/GlassImageGeneration.swift
Normal file
298
Rosetta/DesignSystem/Components/GlassImageGeneration.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
557
Rosetta/DesignSystem/Components/GlassMeshTransform.swift
Normal file
557
Rosetta/DesignSystem/Components/GlassMeshTransform.swift
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,100 @@
|
||||
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
|
||||
|
||||
/// 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 {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.toolbarBackground(.regularMaterial, for: .navigationBar)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.background {
|
||||
TelegramNavBlurView()
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Lottie
|
||||
|
||||
// MARK: - Tab
|
||||
|
||||
enum RosettaTab: String, CaseIterable, Sendable {
|
||||
case chats
|
||||
case calls
|
||||
case settings
|
||||
case chats, calls, 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 {
|
||||
switch self {
|
||||
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 {
|
||||
let fromTab: RosettaTab
|
||||
let hoveredTab: RosettaTab
|
||||
let fractionalIndex: CGFloat
|
||||
let fromTab: RosettaTab; let hoveredTab: RosettaTab; let fractionalIndex: CGFloat
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar Colors
|
||||
// MARK: - Colors
|
||||
|
||||
private enum TabBarColors {
|
||||
static let pillBackground = RosettaColors.adaptive(
|
||||
light: Color(hex: 0xF2F2F7),
|
||||
dark: Color(hex: 0x2C2C2E)
|
||||
)
|
||||
static let selectionBackground = RosettaColors.adaptive(
|
||||
light: Color.white,
|
||||
dark: Color(hex: 0x3A3A3C)
|
||||
)
|
||||
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)
|
||||
)
|
||||
private enum TabBarUIColors {
|
||||
static let icon = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(red: 0.6, green: 0.6, blue: 0.6, alpha: 1) }
|
||||
static let selectedIcon = UIColor(RosettaColors.primaryBlue)
|
||||
static let text = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(red: 0, green: 0, blue: 0, alpha: 0.8) }
|
||||
static let selectedText = UIColor(RosettaColors.primaryBlue)
|
||||
// Badge always red/white — matches Telegram screenshots in both themes
|
||||
static let badgeBg = UIColor(red: 1, green: 0.23, blue: 0.19, alpha: 1)
|
||||
static let badgeText = UIColor.white
|
||||
static let selectionFill = UIColor.white.withAlphaComponent(0.07)
|
||||
}
|
||||
|
||||
// MARK: - Preference Keys
|
||||
// MARK: - Gesture (Telegram TabSelectionRecognizer)
|
||||
|
||||
private struct TabWidthPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: [Int: CGFloat] = [:]
|
||||
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
|
||||
value.merge(nextValue()) { $1 }
|
||||
private final class TabSelectionGesture: UIGestureRecognizer {
|
||||
private(set) var initialLocation: CGPoint = .zero
|
||||
|
||||
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 {
|
||||
static var defaultValue: [Int: CGFloat] = [:]
|
||||
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
|
||||
value.merge(nextValue()) { $1 }
|
||||
// MARK: - RosettaTabBarUIView
|
||||
|
||||
final class RosettaTabBarUIView: UIView {
|
||||
|
||||
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: Gesture
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RosettaTabBar
|
||||
extension RosettaTabBarUIView: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { false }
|
||||
}
|
||||
|
||||
struct RosettaTabBar: View {
|
||||
// MARK: - SwiftUI Container
|
||||
|
||||
struct RosettaTabBarContainer: View {
|
||||
let selectedTab: RosettaTab
|
||||
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?
|
||||
|
||||
private var effectiveFractional: CGFloat {
|
||||
isDragging ? dragFractional : CGFloat(selectedTab.interactionIndex)
|
||||
}
|
||||
private let barWidth: CGFloat = 90 * 3 + 8
|
||||
|
||||
var body: some View {
|
||||
// Single pill with all tabs — same structure as iOS 26 system TabView
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(allTabs.enumerated()), id: \.element) { index, tab in
|
||||
tabContent(tab: tab, index: index)
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.preference(
|
||||
key: TabWidthPreferenceKey.self,
|
||||
value: [index: geo.size.width]
|
||||
)
|
||||
.preference(
|
||||
key: TabOriginPreferenceKey.self,
|
||||
value: [index: geo.frame(in: .named("tabBar")).minX]
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.coordinateSpace(name: "tabBar")
|
||||
.onPreferenceChange(TabWidthPreferenceKey.self) { tabWidths = $0 }
|
||||
.onPreferenceChange(TabOriginPreferenceKey.self) { tabOrigins = $0 }
|
||||
.background(alignment: .leading) {
|
||||
selectionIndicator
|
||||
}
|
||||
.background {
|
||||
// TelegramGlassCapsule handles both iOS 26+ (UIGlassEffect)
|
||||
// and iOS < 26 (CABackdropLayer), with isUserInteractionEnabled = false.
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
.gesture(dragGesture)
|
||||
RosettaTabBarBridge(selectedTab: selectedTab, onTabSelected: onTabSelected, badgeText: cachedBadgeText)
|
||||
.frame(width: barWidth, height: 64)
|
||||
.modifier(TabBarShadowModifier())
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.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.
|
||||
.padding(.bottom, 8)
|
||||
.onAppear { refreshBadges() }
|
||||
.onChange(of: selectedTab) { _, _ in refreshBadges() }
|
||||
.overlay {
|
||||
BadgeVersionObserver(onVersionChanged: refreshBadges)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
.frame(width: 0, height: 0).allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads DialogRepository outside the body's observation scope.
|
||||
private func refreshBadges() {
|
||||
let repo = DialogRepository.shared
|
||||
let unread = repo.sortedDialogs
|
||||
.filter { !$0.isMuted }
|
||||
.reduce(0) { $0 + $1.unreadCount }
|
||||
if unread <= 0 {
|
||||
cachedBadgeText = nil
|
||||
} else {
|
||||
cachedBadgeText = unread > 999 ? "\(unread / 1000)K" : "\(unread)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection Indicator
|
||||
|
||||
@ViewBuilder
|
||||
private var selectionIndicator: some View {
|
||||
let frac = effectiveFractional
|
||||
let nearestIdx = Int(frac.rounded()).clamped(to: 0...(tabCount - 1))
|
||||
let width = tabWidths[nearestIdx] ?? 80
|
||||
let xOffset = interpolatedOrigin(for: frac)
|
||||
|
||||
Group {
|
||||
if #available(iOS 26.0, *) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
.animation(
|
||||
isDragging ? nil : .spring(response: 0.34, dampingFraction: 0.82),
|
||||
value: frac
|
||||
)
|
||||
}
|
||||
|
||||
private func interpolatedOrigin(for fractional: CGFloat) -> CGFloat {
|
||||
let lower = Int(fractional).clamped(to: 0...(tabCount - 1))
|
||||
let upper = (lower + 1).clamped(to: 0...(tabCount - 1))
|
||||
let t = fractional - CGFloat(lower)
|
||||
let lowerX = tabOrigins[lower] ?? 0
|
||||
let upperX = tabOrigins[upper] ?? lowerX
|
||||
return lowerX + (upperX - lowerX) * t
|
||||
}
|
||||
|
||||
// MARK: - Drag Gesture
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 8)
|
||||
.onChanged { value in
|
||||
if !isDragging {
|
||||
isDragging = true
|
||||
dragStartIndex = CGFloat(selectedTab.interactionIndex)
|
||||
}
|
||||
|
||||
let avgTabWidth = totalTabWidth / CGFloat(tabCount)
|
||||
guard avgTabWidth > 0 else { return }
|
||||
let delta = value.translation.width / avgTabWidth
|
||||
let newFrac = (dragStartIndex - delta)
|
||||
.clamped(to: 0...CGFloat(tabCount - 1))
|
||||
|
||||
dragFractional = newFrac
|
||||
|
||||
let nearestIdx = Int(newFrac.rounded()).clamped(to: 0...(tabCount - 1))
|
||||
onSwipeStateChanged?(TabBarSwipeState(
|
||||
fromTab: selectedTab,
|
||||
hoveredTab: allTabs[nearestIdx],
|
||||
fractionalIndex: newFrac
|
||||
))
|
||||
}
|
||||
.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
|
||||
)
|
||||
let unread = DialogRepository.shared.sortedDialogs
|
||||
.filter { !$0.isMuted }.reduce(0) { $0 + $1.unreadCount }
|
||||
cachedBadgeText = unread <= 0 ? nil : (unread > 999 ? "\(unread / 1000)K" : "\(unread)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shadow (iOS < 26 only)
|
||||
private struct RosettaTabBarBridge: UIViewRepresentable {
|
||||
let selectedTab: RosettaTab
|
||||
var onTabSelected: ((RosettaTab) -> Void)?
|
||||
var badgeText: String?
|
||||
|
||||
func makeUIView(context: Context) -> RosettaTabBarUIView {
|
||||
let v = RosettaTabBarUIView(frame: .zero)
|
||||
v.selectedIndex = selectedTab.interactionIndex
|
||||
v.onTabSelected = onTabSelected; v.badgeText = badgeText; return v
|
||||
}
|
||||
func updateUIView(_ v: RosettaTabBarUIView, context: Context) {
|
||||
let idx = selectedTab.interactionIndex
|
||||
if v.selectedIndex != idx { v.selectedIndex = idx }
|
||||
v.onTabSelected = onTabSelected; v.badgeText = badgeText
|
||||
}
|
||||
}
|
||||
|
||||
/// Glass has built-in depth on iOS 26+, so shadow is only needed on older versions.
|
||||
private struct TabBarShadowModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.shadow(color: Color.black.opacity(0.12), radius: 20, y: 8)
|
||||
}
|
||||
if #available(iOS 26.0, *) { content }
|
||||
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 {
|
||||
var onVersionChanged: () -> Void
|
||||
@State private var refreshTask: Task<Void, Never>?
|
||||
@State private var lastUnread: Int = -1
|
||||
|
||||
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
|
||||
.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) }
|
||||
Color.clear
|
||||
.onChange(of: unread) { _, newValue in
|
||||
guard newValue != lastUnread else { return }
|
||||
lastUnread = newValue
|
||||
// Debounce to avoid rapid refreshes during sync
|
||||
.onChange(of: unread) { _, v in
|
||||
guard v != lastUnread else { return }; lastUnread = v
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(200))
|
||||
guard !Task.isCancelled else { return }
|
||||
onVersionChanged()
|
||||
guard !Task.isCancelled else { return }; onVersionChanged()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
lastUnread = unread
|
||||
onVersionChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.black.ignoresSafeArea()
|
||||
RosettaTabBar(selectedTab: .chats)
|
||||
.onAppear { lastUnread = unread; onVersionChanged() }
|
||||
}
|
||||
}
|
||||
|
||||
91
Rosetta/DesignSystem/Components/TabBarLottieIcon.swift
Normal file
91
Rosetta/DesignSystem/Components/TabBarLottieIcon.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
// 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).
|
||||
|
||||
/// SwiftUI wrapper for Telegram-style glass background.
|
||||
@@ -99,11 +100,20 @@ final class TelegramGlassUIView: UIView {
|
||||
// Layers
|
||||
private var backdropLayer: 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
|
||||
private var nativeGlassView: UIVisualEffectView?
|
||||
|
||||
private static let shadowInset: CGFloat = 32.0
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = false
|
||||
@@ -143,40 +153,56 @@ final class TelegramGlassUIView: UIView {
|
||||
nativeGlassView = glassView
|
||||
}
|
||||
|
||||
// MARK: - iOS < 26 (CABackdropLayer — Telegram LegacyGlassView)
|
||||
// MARK: - iOS < 26 (CABackdropLayer + ColorMatrix + Mesh — Telegram parity)
|
||||
|
||||
private func setupLegacyGlass() {
|
||||
// Clipping container — holds backdrop + foreground, clips to pill shape.
|
||||
// Border is added to main layer OUTSIDE the clip so it's fully visible.
|
||||
// Shadow image view — positioned behind glass with negative inset
|
||||
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.cornerCurve = .continuous
|
||||
clippingContainer.cornerCurve = .circular
|
||||
layer.addSublayer(clippingContainer)
|
||||
|
||||
// 1. CABackdropLayer — blurs content behind this view
|
||||
if let backdrop = Self.createBackdropLayer() {
|
||||
// 1. CABackdropLayer — blurs + tones content behind this view
|
||||
if let backdrop = BackdropLayerHelper.createBackdropLayer() {
|
||||
backdrop.delegate = BackdropLayerDelegate.shared
|
||||
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)
|
||||
if let blurFilter = Self.makeBlurFilter() {
|
||||
// Blur + Color Matrix filters (Telegram .normal style)
|
||||
if let blurFilter = CALayer.blurFilter(),
|
||||
let colorMatrixFilter = CALayer.colorMatrixFilter() {
|
||||
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)
|
||||
self.backdropLayer = backdrop
|
||||
}
|
||||
|
||||
// 2. Foreground — adaptive semi-transparent fill (resolved in didMoveToWindow)
|
||||
foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor
|
||||
clippingContainer.addSublayer(foregroundLayer)
|
||||
|
||||
// 3. Border — on main layer via CALayer border properties.
|
||||
// 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
|
||||
// 2. Foreground image — generated overlay with fill, shadows, gradient border
|
||||
let fg = UIImageView()
|
||||
fg.isUserInteractionEnabled = false
|
||||
addSubview(fg)
|
||||
foregroundImageView = fg
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
@@ -202,7 +228,6 @@ final class TelegramGlassUIView: UIView {
|
||||
glassView.layer.cornerRadius = radius
|
||||
} else {
|
||||
clippingContainer.cornerRadius = radius
|
||||
layer.cornerRadius = radius
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +238,6 @@ final class TelegramGlassUIView: UIView {
|
||||
|
||||
let cornerRadius: CGFloat
|
||||
if let fixed = fixedCornerRadius {
|
||||
// Cap at half-height to guarantee capsule shape when radius >= height/2.
|
||||
cornerRadius = min(fixed, bounds.height / 2)
|
||||
} else if isCircle {
|
||||
cornerRadius = min(bounds.width, bounds.height) / 2
|
||||
@@ -223,116 +247,115 @@ final class TelegramGlassUIView: UIView {
|
||||
|
||||
if #available(iOS 26.0, *), let glassView = nativeGlassView {
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy layout — clippingContainer.masksToBounds clips all children,
|
||||
// so foregroundLayer needs no cornerRadius (avoids double-rounding artifacts).
|
||||
// Legacy layout
|
||||
clippingContainer.frame = bounds
|
||||
clippingContainer.cornerRadius = cornerRadius
|
||||
backdropLayer?.frame = bounds
|
||||
foregroundLayer.frame = bounds
|
||||
|
||||
// Border follows .continuous curve via main layer's cornerRadius.
|
||||
// clipsToBounds is false, so this only affects visual border — not child clipping.
|
||||
layer.cornerRadius = cornerRadius
|
||||
// Shadow image view — extends beyond bounds
|
||||
let si = Self.shadowInset
|
||||
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
|
||||
|
||||
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?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
|
||||
updateLegacyColors()
|
||||
// Force image regeneration on theme change
|
||||
lastImageIsDark = nil
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
// Resolve colors once view is in a window and has valid traitCollection
|
||||
updateLegacyColors()
|
||||
// Resolve images once view is in a window and has valid traitCollection
|
||||
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.
|
||||
/// Telegram: blur 40, color black 4%, offset (0,1), inset 32pt.
|
||||
static func makeShadowImage(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)
|
||||
|
||||
// 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
|
||||
return GlassImageGeneration.generateShadowImage(cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +448,8 @@ struct AttachmentPanelView: View {
|
||||
private var legacySelectionIndicator: some View {
|
||||
let width = tabWidths[selectedTab] ?? 0
|
||||
let xOffset = tabOrigins[selectedTab] ?? 0
|
||||
return Capsule().fill(.thinMaterial)
|
||||
return Capsule().fill(.clear)
|
||||
.background { TelegramGlassCapsule() }
|
||||
.frame(width: max(0, width - 4))
|
||||
.padding(.vertical, 4)
|
||||
.offset(x: xOffset + 2)
|
||||
|
||||
@@ -620,16 +620,16 @@ private extension ChatDetailView {
|
||||
@ToolbarContentBuilder
|
||||
var chatDetailToolbar: some ToolbarContent {
|
||||
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) {
|
||||
Button {
|
||||
showClearChatConfirmation = true
|
||||
} label: {
|
||||
Text("Clear Chat")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 36)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 32)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text)
|
||||
}
|
||||
@@ -639,10 +639,10 @@ private extension ChatDetailView {
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("\(selectedMessageIds.count) Selected")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 36)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 32)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text)
|
||||
}
|
||||
@@ -654,8 +654,13 @@ private extension ChatDetailView {
|
||||
selectedMessageIds.removeAll()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 32)
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -844,55 +849,55 @@ private extension ChatDetailView {
|
||||
@ViewBuilder
|
||||
private var selectionActionBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Delete
|
||||
// Delete (Telegram: GlassButtonView + MessageSelectionTrash)
|
||||
Button {
|
||||
deleteSelectedMessages()
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : .red)
|
||||
.font(.system(size: 19, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.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)
|
||||
.disabled(selectedMessageIds.isEmpty)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Share
|
||||
// Share (Telegram: GlassButtonView + MessageSelectionAction)
|
||||
Button {
|
||||
shareSelectedMessages()
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : RosettaColors.Adaptive.text)
|
||||
.font(.system(size: 19, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.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)
|
||||
.disabled(selectedMessageIds.isEmpty)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Forward
|
||||
// Forward (Telegram: GlassButtonView + MessageSelectionForward)
|
||||
Button {
|
||||
forwardSelectedMessages()
|
||||
} label: {
|
||||
Image(systemName: "arrowshape.turn.up.right")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(selectedMessageIds.isEmpty ? RosettaColors.Adaptive.text.opacity(0.5) : RosettaColors.Adaptive.text)
|
||||
.font(.system(size: 19, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.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)
|
||||
.disabled(selectedMessageIds.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 26) // Telegram: 8 base + 18 safe area inset
|
||||
.padding(.horizontal, 26)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.bottom, 16) // safe area
|
||||
.background {
|
||||
glass(shape: .rounded(0), strokeOpacity: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteSelectedMessages() {
|
||||
|
||||
@@ -229,7 +229,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
private let selectionCheckContainer = UIView()
|
||||
private let selectionCheckBorder = CAShapeLayer()
|
||||
private let selectionCheckFill = CAShapeLayer()
|
||||
private let selectionCheckmarkView = UIImageView()
|
||||
private let selectionCheckmarkLayer = CAShapeLayer()
|
||||
|
||||
// Swipe-to-reply
|
||||
private let replyCircleView = UIView()
|
||||
@@ -606,15 +606,16 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
selectionCheckContainer.isHidden = true
|
||||
selectionCheckContainer.isUserInteractionEnabled = false
|
||||
|
||||
let checkPath = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 28, height: 28))
|
||||
selectionCheckBorder.path = checkPath.cgPath
|
||||
// Telegram CheckNode: overlay style — white border, shadow, CG checkmark
|
||||
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.strokeColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor.white.withAlphaComponent(0.5)
|
||||
: UIColor.black.withAlphaComponent(0.3)
|
||||
}.cgColor
|
||||
selectionCheckBorder.lineWidth = 1.5 // Telegram: 1.0 + UIScreenPixel
|
||||
selectionCheckBorder.strokeColor = UIColor.white.cgColor // Telegram: pure white for overlay
|
||||
selectionCheckBorder.lineWidth = borderWidth
|
||||
selectionCheckContainer.layer.addSublayer(selectionCheckBorder)
|
||||
|
||||
// Telegram CheckNode overlay shadow
|
||||
@@ -623,18 +624,30 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
selectionCheckContainer.layer.shadowRadius = 2.5
|
||||
selectionCheckContainer.layer.shadowOffset = .zero
|
||||
|
||||
selectionCheckFill.path = checkPath.cgPath
|
||||
selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6
|
||||
selectionCheckFill.path = checkCirclePath.cgPath
|
||||
selectionCheckFill.fillColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1).cgColor // #248AE6 — Rosetta primary blue
|
||||
selectionCheckFill.isHidden = true
|
||||
selectionCheckContainer.layer.addSublayer(selectionCheckFill)
|
||||
|
||||
let checkConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
|
||||
selectionCheckmarkView.image = UIImage(systemName: "checkmark", withConfiguration: checkConfig)
|
||||
selectionCheckmarkView.tintColor = .white
|
||||
selectionCheckmarkView.contentMode = .center
|
||||
selectionCheckmarkView.frame = CGRect(x: 0, y: 0, width: 28, height: 28)
|
||||
selectionCheckmarkView.isHidden = true
|
||||
selectionCheckContainer.addSubview(selectionCheckmarkView)
|
||||
// Telegram CheckNode: CG-drawn checkmark (1.5pt, round cap/join)
|
||||
let s = (28.0 - inset * 2) / 18.0 // Telegram scale factor
|
||||
let cx = 14.0, cy = 14.0
|
||||
let startX = cx - (4.0 - 0.3333) * s
|
||||
let startY = cy + 0.5 * s
|
||||
let checkmarkPath = UIBezierPath()
|
||||
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)
|
||||
|
||||
@@ -2708,7 +2721,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
// Selection: reset selected state on reuse, keep mode (same for all cells)
|
||||
isMessageSelected = false
|
||||
selectionCheckFill.isHidden = true
|
||||
selectionCheckmarkView.isHidden = true
|
||||
selectionCheckmarkLayer.isHidden = true
|
||||
}
|
||||
|
||||
// MARK: - Multi-Select
|
||||
@@ -2717,8 +2730,6 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
guard isInSelectionMode != enabled else { return }
|
||||
isInSelectionMode = enabled
|
||||
let newOffset: CGFloat = enabled ? 42 : 0
|
||||
let duration: TimeInterval = enabled ? 0.3 : 0.4
|
||||
let damping: CGFloat = enabled ? 0.8 : 0.85
|
||||
|
||||
if animated {
|
||||
selectionCheckContainer.isHidden = false
|
||||
@@ -2727,11 +2738,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
let slideFrom = enabled ? -42.0 : 0.0
|
||||
let slideTo = enabled ? 0.0 : -42.0
|
||||
|
||||
// Checkbox fade + slide
|
||||
// Telegram: 0.2s easeOut for checkbox fade + slide
|
||||
let alphaAnim = CABasicAnimation(keyPath: "opacity")
|
||||
alphaAnim.fromValue = fromAlpha
|
||||
alphaAnim.toValue = toAlpha
|
||||
alphaAnim.duration = duration
|
||||
alphaAnim.duration = 0.2
|
||||
alphaAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
alphaAnim.fillMode = .forwards
|
||||
alphaAnim.isRemovedOnCompletion = false
|
||||
@@ -2740,15 +2751,15 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
let posAnim = CABasicAnimation(keyPath: "position.x")
|
||||
posAnim.fromValue = selectionCheckContainer.layer.position.x + slideFrom
|
||||
posAnim.toValue = selectionCheckContainer.layer.position.x + slideTo
|
||||
posAnim.duration = duration
|
||||
posAnim.duration = 0.2
|
||||
posAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
selectionCheckContainer.layer.add(posAnim, forKey: "selectionSlide")
|
||||
|
||||
selectionCheckContainer.layer.opacity = toAlpha
|
||||
|
||||
// Content shift (spring animation, Telegram parity)
|
||||
// Telegram: 0.2s easeOut for content shift
|
||||
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.layoutIfNeeded()
|
||||
} completion: { _ in
|
||||
@@ -2771,7 +2782,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
guard isMessageSelected != selected else { return }
|
||||
isMessageSelected = selected
|
||||
selectionCheckFill.isHidden = !selected
|
||||
selectionCheckmarkView.isHidden = !selected
|
||||
selectionCheckmarkLayer.isHidden = !selected
|
||||
selectionCheckBorder.isHidden = selected
|
||||
|
||||
if animated && selected {
|
||||
@@ -2782,7 +2793,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
anim.duration = 0.21
|
||||
anim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
selectionCheckFill.add(anim, forKey: "checkBounce")
|
||||
selectionCheckmarkView.layer.add(anim, forKey: "checkBounce")
|
||||
selectionCheckmarkLayer.add(anim, forKey: "checkBounce")
|
||||
} else if animated && !selected {
|
||||
// Telegram CheckNode: 2-stage scale 1→0.9→1 over 0.15s
|
||||
let anim = CAKeyframeAnimation(keyPath: "transform.scale")
|
||||
|
||||
@@ -215,11 +215,10 @@ struct OpponentProfileView: View {
|
||||
// MARK: - Shared Media Tab Bar (Telegram parity)
|
||||
|
||||
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 sharedMediaTabBar: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(PeerProfileTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
@@ -228,9 +227,9 @@ struct OpponentProfileView: View {
|
||||
}
|
||||
} label: {
|
||||
Text(tab.rawValue)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
if selectedTab == tab {
|
||||
@@ -245,8 +244,9 @@ struct OpponentProfileView: View {
|
||||
}
|
||||
.padding(.horizontal, 3)
|
||||
.padding(.vertical, 3)
|
||||
.background {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
.background(Capsule().fill(telegramSectionFill))
|
||||
.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.contentOffset.y + $0.contentInsets.top } action: { _, v in
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,11 +74,13 @@ struct PeerProfileHeaderView: View {
|
||||
.scaledToFill()
|
||||
} else {
|
||||
let pair = RosettaColors.avatarColors[avatarColorIndex % RosettaColors.avatarColors.count]
|
||||
let textColor: Color = colorScheme == .dark ? pair.text : pair.tint
|
||||
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)
|
||||
.font(.system(size: isLargeHeader ? 120 : 36, weight: .medium))
|
||||
.foregroundStyle(pair.text)
|
||||
.font(.system(size: isLargeHeader ? 120 : 38, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,38 +88,34 @@ struct PeerProfileHeaderView: View {
|
||||
// MARK: - Navigation Bar Content (sticky + scale)
|
||||
|
||||
private var navigationBarContent: some View {
|
||||
VStack(alignment: isLargeHeader ? .leading : .center, spacing: 4) {
|
||||
VStack(alignment: isLargeHeader ? .leading : .center, spacing: isLargeHeader ? 4 : 1) {
|
||||
HStack(spacing: 5) {
|
||||
Text(displayName)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
.font(isLargeHeader ? .title2.weight(.semibold) : .system(size: 17, weight: .semibold))
|
||||
.lineLimit(isLargeHeader ? 2 : 1)
|
||||
|
||||
if effectiveVerified > 0 {
|
||||
VerifiedBadge(
|
||||
verified: effectiveVerified,
|
||||
size: 18,
|
||||
size: isLargeHeader ? 18 : 15,
|
||||
badgeTint: isLargeHeader ? .white : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(subtitleText)
|
||||
.font(.callout)
|
||||
.font(isLargeHeader ? .callout : .system(size: 16))
|
||||
.foregroundStyle(isLargeHeader ? .white.opacity(0.7) : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: isLargeHeader ? .leading : .center)
|
||||
.visualEffect { content, proxy in
|
||||
let minY = proxy.frame(in: .scrollView(axis: .vertical)).minY
|
||||
let progress = max(min(minY / 50, 1), 0)
|
||||
let scale = 0.7 + (0.3 * progress)
|
||||
let scaledH = proxy.size.height * scale
|
||||
// Center title at nav bar vertical center when stuck
|
||||
let navBarCenterY = topInset - 22
|
||||
let centeringOffset = navBarCenterY - scaledH / 2
|
||||
let contentH = proxy.size.height
|
||||
let navBarCenterY: CGFloat = -22
|
||||
let centeringOffset = navBarCenterY - contentH / 2
|
||||
|
||||
return content
|
||||
.scaleEffect(scale, anchor: .top)
|
||||
.offset(y: minY < 0 ? -minY + centeringOffset * (1 - progress) : 0)
|
||||
}
|
||||
.background { navBarBackground }
|
||||
@@ -130,32 +128,27 @@ struct PeerProfileHeaderView: View {
|
||||
GeometryReader { geo in
|
||||
let minY = geo.frame(in: .scrollView(axis: .vertical)).minY
|
||||
let opacity = 1.0 - max(min(minY / 50, 1), 0)
|
||||
let tint: Color = colorScheme == .dark ? .black : .white
|
||||
|
||||
ZStack {
|
||||
if #available(iOS 26, *) {
|
||||
// Telegram parity: EdgeEffectView(content: #000000, blur: true, edgeSize: 60)
|
||||
// 89-stop smooth gradient from opaque black to transparent
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.glassEffect(.clear.tint(tint.opacity(0.8)), in: .rect)
|
||||
.fill(Color.black)
|
||||
.mask {
|
||||
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,
|
||||
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(.bottom, -40)
|
||||
.padding(.top, -topInset)
|
||||
@@ -200,10 +193,8 @@ struct PeerProfileHeaderView: View {
|
||||
.fill(telegramSectionFill)
|
||||
.opacity(isLargeHeader ? 0 : 1)
|
||||
|
||||
RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
TelegramGlassRoundedRect(cornerRadius: 15)
|
||||
.opacity(isLargeHeader ? 0.8 : 0)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
|
||||
@@ -152,22 +152,10 @@ struct MainTabView: View {
|
||||
.ignoresSafeArea()
|
||||
|
||||
if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented {
|
||||
RosettaTabBar(
|
||||
RosettaTabBarContainer(
|
||||
selectedTab: selectedTab,
|
||||
onTabSelected: { tab in
|
||||
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)
|
||||
@@ -186,35 +174,34 @@ struct MainTabView: View {
|
||||
CGFloat(selectedTab.interactionIndex)
|
||||
}
|
||||
|
||||
// MARK: - Tab Pager (Telegram Parity)
|
||||
//
|
||||
// Telegram TabBarController animation:
|
||||
// New tab: alpha 0→1 (0.1s) + scale 0.998→1.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
|
||||
private func tabPager(availableSize: CGSize) -> some View {
|
||||
let width = max(1, availableSize.width)
|
||||
|
||||
ZStack {
|
||||
ForEach(RosettaTab.interactionOrder, id: \.self) { tab in
|
||||
let isSelected = tab == selectedTab
|
||||
tabView(for: tab)
|
||||
.frame(width: width, height: availableSize.height)
|
||||
.opacity(tabOpacity(for: tab))
|
||||
.environment(\.telegramGlassActive, tabOpacity(for: tab) > 0)
|
||||
.zIndex(isSelected ? 1 : 0)
|
||||
.opacity(isSelected ? 1 : 0)
|
||||
.environment(\.telegramGlassActive, isSelected)
|
||||
.animation(.easeOut(duration: 0.12), value: selectedTab)
|
||||
.allowsHitTesting(tab == selectedTab && dragFractionalIndex == nil)
|
||||
.allowsHitTesting(isSelected)
|
||||
}
|
||||
}
|
||||
.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
|
||||
private func tabView(for tab: RosettaTab) -> some View {
|
||||
if activatedTabs.contains(tab) {
|
||||
|
||||
1
Rosetta/Resources/Lottie/TabCalls.json
Normal file
1
Rosetta/Resources/Lottie/TabCalls.json
Normal file
File diff suppressed because one or more lines are too long
1
Rosetta/Resources/Lottie/TabChats.json
Normal file
1
Rosetta/Resources/Lottie/TabChats.json
Normal file
File diff suppressed because one or more lines are too long
1
Rosetta/Resources/Lottie/TabSettings.json
Normal file
1
Rosetta/Resources/Lottie/TabSettings.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user