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

275 lines
9.7 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) {
uiView.isFrozen = !context.environment.telegramGlassActive
uiView.fixedCornerRadius = cornerRadius
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()
private let borderLayer = CAShapeLayer()
// iOS 26+ native glass
private var nativeGlassView: UIVisualEffectView?
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = 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.layer.cornerCurve = .continuous
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 = .circular
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, NOT inside clipping container
borderLayer.fillColor = UIColor.clear.cgColor
borderLayer.strokeColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
borderLayer.lineWidth = 0.5
layer.addSublayer(borderLayer)
layer.cornerCurve = .circular
}
// MARK: - Layout
func updateGlass() {
setNeedsLayout()
}
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 = fixed
} 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
foregroundLayer.frame = bounds
foregroundLayer.cornerRadius = cornerRadius
let halfBorder = borderLayer.lineWidth / 2
let borderRect = bounds.insetBy(dx: halfBorder, dy: halfBorder)
let borderRadius = max(0, cornerRadius - halfBorder)
let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: borderRadius)
borderLayer.path = borderPath.cgPath
borderLayer.frame = bounds
}
// 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
}
}