149 lines
4.7 KiB
Swift
149 lines
4.7 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
|
||
/// Telegram-parity Dynamic Island blur effect.
|
||
/// Replicates DynamicIslandBlurNode.swift from Telegram iOS:
|
||
/// - UIVisualEffectView(.dark) with UIViewPropertyAnimator for progressive blur
|
||
/// - Black fade overlay with alpha formula
|
||
/// - Radial gradient for edge feathering
|
||
struct DynamicIslandBlurView: UIViewRepresentable {
|
||
let progress: CGFloat
|
||
|
||
func makeUIView(context: Context) -> DynamicIslandBlurUIView {
|
||
DynamicIslandBlurUIView()
|
||
}
|
||
|
||
func updateUIView(_ uiView: DynamicIslandBlurUIView, context: Context) {
|
||
uiView.update(progress)
|
||
}
|
||
}
|
||
|
||
final class DynamicIslandBlurUIView: UIView {
|
||
private var effectView: UIVisualEffectView?
|
||
private let fadeView = UIView()
|
||
private let gradientView = UIImageView()
|
||
private var animator: UIViewPropertyAnimator?
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
setup()
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
super.init(coder: coder)
|
||
setup()
|
||
}
|
||
|
||
private func setup() {
|
||
isUserInteractionEnabled = false
|
||
clipsToBounds = true
|
||
|
||
// Blur effect view (Telegram: effectView with nil initial effect)
|
||
let effectView = UIVisualEffectView(effect: nil)
|
||
effectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||
self.effectView = effectView
|
||
addSubview(effectView)
|
||
|
||
// Radial gradient (Telegram: 100×100, center offset +38, radius 90)
|
||
gradientView.image = Self.makeGradientImage()
|
||
gradientView.contentMode = .center
|
||
gradientView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin]
|
||
addSubview(gradientView)
|
||
|
||
// Fade overlay (Telegram: black, alpha driven by formula)
|
||
fadeView.backgroundColor = .black
|
||
fadeView.alpha = 0
|
||
fadeView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||
addSubview(fadeView)
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
effectView?.frame = bounds
|
||
fadeView.frame = bounds
|
||
|
||
let gradientSize = CGSize(width: 100, height: 100)
|
||
gradientView.frame = CGRect(
|
||
x: (bounds.width - gradientSize.width) / 2,
|
||
y: 0,
|
||
width: gradientSize.width,
|
||
height: gradientSize.height
|
||
)
|
||
}
|
||
|
||
func update(_ value: CGFloat) {
|
||
// Telegram formula: fadeAlpha = min(1.0, max(0.0, -0.25 + value * 1.55))
|
||
let fadeAlpha = min(1.0, max(0.0, -0.25 + value * 1.55))
|
||
|
||
if value > 0.0 {
|
||
var adjustedValue = value
|
||
let prepared = prepare()
|
||
if adjustedValue > 0.99 && prepared {
|
||
adjustedValue = 0.99
|
||
}
|
||
// Telegram formula: fractionComplete = max(0.0, -0.1 + value * 1.1)
|
||
animator?.fractionComplete = max(0.0, -0.1 + adjustedValue * 1.1)
|
||
} else {
|
||
animator?.stopAnimation(true)
|
||
animator = nil
|
||
effectView?.effect = nil
|
||
}
|
||
|
||
fadeView.alpha = fadeAlpha
|
||
}
|
||
|
||
private func prepare() -> Bool {
|
||
guard animator == nil else { return false }
|
||
|
||
let anim = UIViewPropertyAnimator(duration: 1.0, curve: .linear)
|
||
animator = anim
|
||
effectView?.effect = nil
|
||
anim.addAnimations { [weak self] in
|
||
self?.effectView?.effect = UIBlurEffect(style: .dark)
|
||
}
|
||
return true
|
||
}
|
||
|
||
deinit {
|
||
animator?.stopAnimation(true)
|
||
}
|
||
|
||
// Telegram: radial gradient 100×100, center (50, 88), radius 90
|
||
// Colors: transparent → transparent (0.87) → black (1.0)
|
||
private static func makeGradientImage() -> UIImage? {
|
||
let size = CGSize(width: 100, height: 100)
|
||
UIGraphicsBeginImageContextWithOptions(size, false, 0)
|
||
guard let ctx = UIGraphicsGetCurrentContext() else { return nil }
|
||
|
||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||
var locations: [CGFloat] = [0.0, 0.87, 1.0]
|
||
let colors: [CGColor] = [
|
||
UIColor(white: 0, alpha: 0).cgColor,
|
||
UIColor(white: 0, alpha: 0).cgColor,
|
||
UIColor(white: 0, alpha: 1).cgColor,
|
||
]
|
||
guard let gradient = CGGradient(
|
||
colorsSpace: colorSpace,
|
||
colors: colors as CFArray,
|
||
locations: &locations
|
||
) else {
|
||
UIGraphicsEndImageContext()
|
||
return nil
|
||
}
|
||
|
||
let center = CGPoint(x: size.width / 2, y: size.height / 2 + 38)
|
||
ctx.drawRadialGradient(
|
||
gradient,
|
||
startCenter: center,
|
||
startRadius: 0,
|
||
endCenter: center,
|
||
endRadius: 90,
|
||
options: .drawsAfterEndLocation
|
||
)
|
||
|
||
let image = UIGraphicsGetImageFromCurrentImageContext()
|
||
UIGraphicsEndImageContext()
|
||
return image
|
||
}
|
||
}
|