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

328 lines
12 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)
//
// Exact port of Telegram iOS LegacyGlassView + GlassBackgroundView foreground.
// iOS < 26: CABackdropLayer with gaussianBlur radius 2.0 + dark foreground overlay.
// 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) {
#if DEBUG
let oldRadius = uiView.fixedCornerRadius ?? -1
let boundsH = uiView.bounds.height
print("🔲 GlassRoundedRect.updateUIView | radius \(Int(oldRadius))\(Int(cornerRadius)) boundsH=\(Int(boundsH))")
#endif
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()
private let foregroundLayer = CALayer()
// iOS 26+ native glass
private var nativeGlassView: UIVisualEffectView?
private var glassMaskLayer: CAShapeLayer?
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
// Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025)
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
let glassView = UIVisualEffectView(effect: effect)
glassView.clipsToBounds = true
glassView.layer.cornerCurve = .continuous
glassView.isUserInteractionEnabled = false
addSubview(glassView)
nativeGlassView = glassView
}
// MARK: - iOS < 26 (CABackdropLayer Telegram LegacyGlassView)
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.
clippingContainer.masksToBounds = true
clippingContainer.cornerCurve = .continuous
layer.addSublayer(clippingContainer)
// 1. CABackdropLayer blurs content behind this view
if let backdrop = Self.createBackdropLayer() {
backdrop.rasterizationScale = 1.0
Self.setBackdropScale(backdrop, scale: 1.0)
// gaussianBlur filter with radius 2.0 (Telegram .normal style)
if let blurFilter = Self.makeBlurFilter() {
blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius")
backdrop.filters = [blurFilter]
}
clippingContainer.addSublayer(backdrop)
self.backdropLayer = backdrop
}
// 2. Foreground dark semi-transparent fill
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
}
// 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 {
let mask: CAShapeLayer
if let existing = glassMaskLayer {
mask = existing
} else {
mask = CAShapeLayer()
glassMaskLayer = mask
glassView.layer.mask = mask
}
mask.path = UIBezierPath(
roundedRect: bounds,
cornerRadius: radius
).cgPath
} else {
clippingContainer.cornerRadius = radius
layer.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 {
// 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
} else {
cornerRadius = bounds.height / 2
}
if #available(iOS 26.0, *), let glassView = nativeGlassView {
glassView.frame = bounds
// UIGlassEffect ignores layer.cornerRadius changes after initial layout.
// Use CAShapeLayer mask guaranteed to clip the glass to any shape.
let mask: CAShapeLayer
if let existing = glassMaskLayer {
mask = existing
} else {
mask = CAShapeLayer()
glassMaskLayer = mask
glassView.layer.mask = mask
}
mask.path = UIBezierPath(
roundedRect: bounds,
cornerRadius: cornerRadius
).cgPath
return
}
// Legacy layout clippingContainer.masksToBounds clips all children,
// so foregroundLayer needs no cornerRadius (avoids double-rounding artifacts).
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
}
// MARK: - Shadow (drawn as separate image Telegram parity)
/// 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
}
}