328 lines
12 KiB
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
|
|
}
|
|
}
|