362 lines
13 KiB
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)
|
|
}
|
|
}
|