Files
mobile-ios/Rosetta/DesignSystem/Components/TelegramGlassView.swift

362 lines
13 KiB
Swift

import SwiftUI
import UIKit
// MARK: - Environment Key: Glass Active
/// When false, TelegramGlassUIView removes its CABackdropLayer from the
/// layer tree, stopping real-time blur (zero GPU cost). When true, the
/// backdrop layer is re-inserted and blur resumes.
///
/// Usage: set `.environment(\.telegramGlassActive, false)` on views whose
/// glass effects should be frozen (e.g. hidden tabs in a ZStack pager).
private struct TelegramGlassActiveKey: EnvironmentKey {
static let defaultValue: Bool = true
}
extension EnvironmentValues {
var telegramGlassActive: Bool {
get { self[TelegramGlassActiveKey.self] }
set { self[TelegramGlassActiveKey.self] = newValue }
}
}
// MARK: - Telegram Glass (CABackdropLayer + CAFilter + Mesh + Foreground Image)
//
// Exact port of Telegram iOS LegacyGlassView + GlassBackgroundView foreground.
// 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.
/// Capsule shape with proper corner radius.
struct TelegramGlassCapsule: UIViewRepresentable {
func makeUIView(context: Context) -> TelegramGlassUIView {
let view = TelegramGlassUIView(frame: .zero)
view.backgroundColor = .clear
return view
}
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
uiView.isFrozen = !context.environment.telegramGlassActive
uiView.updateGlass()
}
}
/// SwiftUI wrapper for Telegram-style glass circle.
struct TelegramGlassCircle: UIViewRepresentable {
func makeUIView(context: Context) -> TelegramGlassUIView {
let view = TelegramGlassUIView(frame: .zero)
view.backgroundColor = .clear
view.isCircle = true
return view
}
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
uiView.isFrozen = !context.environment.telegramGlassActive
uiView.updateGlass()
}
}
/// SwiftUI wrapper for Telegram-style glass with custom corner radius.
struct TelegramGlassRoundedRect: UIViewRepresentable {
let cornerRadius: CGFloat
func makeUIView(context: Context) -> TelegramGlassUIView {
let view = TelegramGlassUIView(frame: .zero)
view.backgroundColor = .clear
view.fixedCornerRadius = cornerRadius
return view
}
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
uiView.isFrozen = !context.environment.telegramGlassActive
uiView.fixedCornerRadius = cornerRadius
uiView.applyCornerRadius()
uiView.updateGlass()
}
}
// MARK: - UIKit Implementation
final class TelegramGlassUIView: UIView {
var isCircle = false
/// When set, overrides auto-calculated corner radius (height/2 for capsule, min/2 for circle).
var fixedCornerRadius: CGFloat?
/// When true, the CABackdropLayer is removed from the layer tree,
/// stopping real-time blur capture. Set to true for views inside
/// hidden tabs to eliminate GPU work from invisible glass effects.
var isFrozen: Bool = false {
didSet {
guard isFrozen != oldValue else { return }
if isFrozen {
backdropLayer?.removeFromSuperlayer()
} else if let backdrop = backdropLayer, backdrop.superlayer == nil {
clippingContainer.insertSublayer(backdrop, at: 0)
}
}
}
// Layers
private var backdropLayer: CALayer?
private let clippingContainer = 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
// CRITICAL: disable user interaction so glass background never intercepts
// touches from SwiftUI Buttons that use this view as .background.
isUserInteractionEnabled = false
if #available(iOS 26.0, *) {
setupNativeGlass()
} else {
setupLegacyGlass()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - iOS 26+ (native UIGlassEffect)
@available(iOS 26.0, *)
private func setupNativeGlass() {
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = false
effect.tintColor = UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 1.0, alpha: 0.025)
: UIColor(white: 0.0, alpha: 0.025)
}
let glassView = UIVisualEffectView(effect: effect)
glassView.clipsToBounds = true
glassView.layer.cornerCurve = .continuous
// Set initial corner radius will be updated in layoutSubviews.
glassView.layer.cornerRadius = 22
glassView.isUserInteractionEnabled = false
addSubview(glassView)
nativeGlassView = glassView
}
// MARK: - iOS < 26 (CABackdropLayer + ColorMatrix + Mesh Telegram parity)
private func setupLegacyGlass() {
// 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 = .circular
layer.addSublayer(clippingContainer)
// 1. CABackdropLayer blurs + tones content behind this view
if let backdrop = BackdropLayerHelper.createBackdropLayer() {
backdrop.delegate = BackdropLayerDelegate.shared
backdrop.rasterizationScale = 1.0
BackdropLayerHelper.setScale(backdrop, scale: 1.0)
// Blur + Color Matrix filters (Telegram .normal style)
if let blurFilter = CALayer.blurFilter(),
let colorMatrixFilter = CALayer.colorMatrixFilter() {
blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius")
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 image generated overlay with fill, shadows, gradient border
let fg = UIImageView()
fg.isUserInteractionEnabled = false
addSubview(fg)
foregroundImageView = fg
}
// MARK: - Layout
func updateGlass() {
setNeedsLayout()
}
/// Directly applies current fixedCornerRadius to all layers without waiting for layout.
/// Call when cornerRadius changes but bounds may not ensures immediate visual update.
func applyCornerRadius() {
let bounds = bounds
guard bounds.width > 0, bounds.height > 0 else { return }
let radius: CGFloat
if let fixed = fixedCornerRadius {
radius = min(fixed, bounds.height / 2)
} else if isCircle {
radius = min(bounds.width, bounds.height) / 2
} else {
radius = bounds.height / 2
}
if #available(iOS 26.0, *), let glassView = nativeGlassView {
glassView.layer.cornerRadius = radius
} else {
clippingContainer.cornerRadius = radius
}
}
override func layoutSubviews() {
super.layoutSubviews()
let bounds = bounds
guard bounds.width > 0, bounds.height > 0 else { return }
let cornerRadius: CGFloat
if let fixed = fixedCornerRadius {
cornerRadius = min(fixed, bounds.height / 2)
} else if isCircle {
cornerRadius = min(bounds.width, bounds.height) / 2
} else {
cornerRadius = bounds.height / 2
}
if #available(iOS 26.0, *), let glassView = nativeGlassView {
glassView.frame = bounds
glassView.layer.cornerRadius = cornerRadius
return
}
// Legacy layout
clippingContainer.frame = bounds
clippingContainer.cornerRadius = cornerRadius
backdropLayer?.frame = bounds
// 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
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
// Force image regeneration on theme change
lastImageIsDark = nil
setNeedsLayout()
}
override func didMoveToWindow() {
super.didMoveToWindow()
// Resolve images once view is in a window and has valid traitCollection
if nativeGlassView == nil {
lastImageIsDark = nil
setNeedsLayout()
}
}
// 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? {
return GlassImageGeneration.generateShadowImage(cornerRadius: cornerRadius)
}
}