From 043e9380d5164a17932832e7d7c02c4884913ecf Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Wed, 25 Feb 2026 10:46:04 +0100 Subject: [PATCH 01/10] Bump version --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index 83ea3fd866..08c7c722f1 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "12.5", + "app": "12.5.1", "xcode": "26.2", "bazel": "8.4.2:45e9388abf21d1107e146ea366ad080eb93cb6a5f3a4a3b048f78de0bc3faffa", "macos": "26" From eac4e0321db013f75e495cafae6e05c3816bf324 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Thu, 26 Feb 2026 19:09:03 +0100 Subject: [PATCH 02/10] Unbump version --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index 08c7c722f1..83ea3fd866 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "12.5.1", + "app": "12.5", "xcode": "26.2", "bazel": "8.4.2:45e9388abf21d1107e146ea366ad080eb93cb6a5f3a4a3b048f78de0bc3faffa", "macos": "26" From 951bc1cec37256736f91d18631828cb0145de436 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 3 Mar 2026 11:40:08 +0100 Subject: [PATCH 03/10] Temp --- .../ContextControllerActionsStackNode.swift | 236 ++++-- ...tControllerExtractedPresentationNode.swift | 18 +- .../Components/LensTransition/BUILD | 1 + .../Sources/LensTransitionContainer.swift | 801 ++++++++++++++---- 4 files changed, 842 insertions(+), 214 deletions(-) diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift index 56949fc241..b5928ead73 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift @@ -1406,11 +1406,131 @@ private final class ItemSelectionRecognizer: UIGestureRecognizer { } } +private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitionContainerEffectView { + let glassView: UIVisualEffectView + + private var theme: PresentationTheme? + + override init(frame: CGRect) { + self.glassView = UIVisualEffectView() + + super.init(frame: frame) + + self.addSubview(self.glassView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(theme: PresentationTheme) { + self.theme = theme + if #available(iOS 26.0, *) { + let glassEffectValue: UIGlassEffect + if theme.overallDarkAppearance { + glassEffectValue = UIGlassEffect(style: .regular) + glassEffectValue.tintColor = UIColor(white: 1.0, alpha: 0.025) + } else { + glassEffectValue = UIGlassEffect(style: .regular) + glassEffectValue.tintColor = UIColor(white: 1.0, alpha: 0.1) + } + self.glassView.effect = glassEffectValue + } + } + + func updateSize(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { + transition.animateView { + self.glassView.bounds.size = size + self.glassView.center = CGPoint(x: size.width * 0.5, y: size.height * 0.5) + if #available(iOS 26.0, *) { + self.glassView.cornerConfiguration = .corners(radius: UICornerRadius(floatLiteral: cornerRadius)) + } + } + } + + func updateSize(duration: Double, keyframes: [CGSize]) { + guard keyframes.count >= 2 else { + if let last = keyframes.last { + self.bounds.size = last + self.glassView.bounds.size = last + self.glassView.center = CGPoint(x: last.width * 0.5, y: last.height * 0.5) + } + return + } + + // Start value + self.bounds.size = keyframes[0] + + let segmentCount = keyframes.count - 1 + let step = 1.0 / Double(segmentCount) + + var options: UIView.KeyframeAnimationOptions = [.calculationModeLinear] + options.insert(UIView.KeyframeAnimationOptions(rawValue: UIView.AnimationOptions.curveLinear.rawValue)) + UIView.animateKeyframes( + withDuration: duration, + delay: 0.0, + options: options, + animations: { + for i in 0..= 2 else { + if let last = keyframes.last { + self.glassView.cornerConfiguration = .corners(radius: UICornerRadius(floatLiteral: last)) + } + return + } + + // Start value + self.glassView.cornerConfiguration = .corners(radius: UICornerRadius(floatLiteral: keyframes[0])) + + let segmentCount = keyframes.count - 1 + let step = 1.0 / Double(segmentCount) + + var options: UIView.KeyframeAnimationOptions = [.calculationModeLinear] + options.insert(UIView.KeyframeAnimationOptions(rawValue: UIView.AnimationOptions.curveLinear.rawValue)) + UIView.animateKeyframes( + withDuration: duration, + delay: 0.0, + options: options, + animations: { + for i in 0 ..< segmentCount { + let nextValue = keyframes[i + 1] + UIView.addKeyframe( + withRelativeStartTime: Double(i) * step, + relativeDuration: step + ) { + self.glassView.cornerConfiguration = .corners(radius: UICornerRadius(floatLiteral: nextValue)) + } + } + }, + completion: nil + ) + } +} + public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, ContextControllerActionsStackNode { final class NavigationContainer: ASDisplayNode, ASGestureRecognizerDelegate { let backgroundContainer: GlassBackgroundContainerView let backgroundContainerInset: CGFloat - let backgroundView: GlassBackgroundView var sourceExtractableContainer: ContextExtractableContainer? let contentContainer: LensTransitionContainer @@ -1427,19 +1547,20 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context } override init() { - self.backgroundContainer = GlassBackgroundContainerView() + /*self.backgroundContainer = GlassBackgroundContainerView() self.backgroundView = GlassBackgroundView() - self.backgroundContainer.contentView.addSubview(self.backgroundView) + self.backgroundContainer.contentView.addSubview(self.backgroundView)*/ - self.contentContainer = LensTransitionContainer() - self.contentContainer.clipsToBounds = true - self.backgroundView.contentView.addSubview(self.contentContainer) + self.backgroundContainer = GlassBackgroundContainerView() + self.contentContainer = LensTransitionContainer(effectView: LensTransitionContainerEffectViewImpl(), sourceEffectView: LensTransitionContainerEffectViewImpl()) + //self.backgroundView.contentView.addSubview(self.contentContainer) self.backgroundContainerInset = 32.0 super.init() self.view.addSubview(self.backgroundContainer) + self.backgroundContainer.contentView.addSubview(self.contentContainer) let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] point in guard let strongSelf = self else { @@ -1493,69 +1614,44 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context } } - func animateIn(fromExtractableContainer extractableContainer: ContextExtractableContainer, transition: ComponentTransition) { + func animateIn(fromExtractableContainer extractableContainer: ContextExtractableContainer, fromRect: CGRect, transition: ComponentTransition) { let normalState = extractableContainer.normalState - let sourceSize = normalState.size + //let sourceSize = normalState.size let normalCornerRadius: CGFloat = normalState.cornerRadius let currentSize = self.contentContainer.bounds.size self.sourceExtractableContainer = extractableContainer - self.backgroundView.isHidden = true - self.backgroundContainer.contentView.addSubview(extractableContainer.extractableContentView) - for subview in extractableContainer.extractableContentView.subviews { - if let subview = subview as? GlassBackgroundView { - //TODO:release - subview.contentView.addSubview(self.contentContainer) - break - } - } - - self.contentContainer.frame = CGRect(origin: CGPoint(), size: sourceSize) - self.contentContainer.update(size: sourceSize, cornerRadius: min(sourceSize.width, sourceSize.height) * 0.5, state: .animatedOut, transition: .immediate) - - extractableContainer.extractableContentView.frame = CGRect(origin: CGPoint(x: (currentSize.width - sourceSize.width) * 0.5, y: (currentSize.height - sourceSize.height) * 0.5), size: sourceSize).offsetBy(dx: self.backgroundContainerInset, dy: self.backgroundContainerInset) - transition.setFrame(view: extractableContainer.extractableContentView, frame: CGRect(origin: CGPoint(x: self.backgroundContainerInset, y: self.backgroundContainerInset), size: currentSize)) - transition.setFrame(view: self.contentContainer, frame: CGRect(origin: CGPoint(), size: currentSize)) - self.contentContainer.update(size: currentSize, cornerRadius: 30.0, state: .animatedIn, transition: transition) - self.contentContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) - - extractableContainer.updateState(state: .extracted(size: sourceSize, cornerRadius: normalCornerRadius, state: .animatedOut), transition: .transition(.immediate), completion: nil) - let mappedTransition: ContextExtractableContainer.Transition - if case let .curve(duration, curve) = transition.animation, case let .bounce(stiffness, damping) = curve { - mappedTransition = .spring(duration: duration, stiffness: stiffness, damping: damping) - } else { - mappedTransition = .transition(transition.containedViewLayoutTransition) - } - extractableContainer.updateState(state: .extracted(size: currentSize, cornerRadius: 30.0, state: .animatedIn), transition: mappedTransition, completion: nil) + extractableContainer.extractableContentView.frame = fromRect + self.contentContainer.frame = CGRect(origin: CGPoint(), size: currentSize) + extractableContainer.extractableContentView.alpha = 0.0 + extractableContainer.extractableContentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15) + extractableContainer.isHidden = true + self.contentContainer.animateIn(fromRect: fromRect, toRect: CGRect(origin: CGPoint(), size: currentSize), fromCornerRadius: normalCornerRadius, toCornerRadius: 30.0) } - func animateOut(toExtractableContainer extractableContainer: ContextExtractableContainer, transition: ComponentTransition) { + func animateOut(toExtractableContainer extractableContainer: ContextExtractableContainer, toRect: CGRect, transition: ComponentTransition) { let normalState = extractableContainer.normalState - let normalSize = normalState.size let normalCornerRadius: CGFloat = normalState.cornerRadius - + let currentSize = self.contentContainer.bounds.size - - transition.setFrame(view: extractableContainer.extractableContentView, frame: CGRect(origin: CGPoint(x: self.backgroundContainerInset, y: self.backgroundContainerInset), size: normalSize).offsetBy(dx: (currentSize.width - normalSize.width) * 0.5, dy: (currentSize.height - normalSize.height) * 0.5)) - - transition.setFrame(view: self.contentContainer, frame: CGRect(origin: CGPoint(), size: normalSize)) - self.contentContainer.update(size: normalSize, cornerRadius: normalCornerRadius, state: .animatedOut, transition: transition) - - transition.setCornerRadius(layer: self.contentContainer.layer, cornerRadius: normalCornerRadius) - transition.setAlpha(view: self.contentContainer, alpha: 0.0) - - let mappedTransition: ContextExtractableContainer.Transition - if case let .curve(duration, curve) = transition.animation, case let .bounce(stiffness, damping) = curve { - mappedTransition = .spring(duration: duration, stiffness: stiffness, damping: damping) - } else { - mappedTransition = .transition(transition.containedViewLayoutTransition) - } - extractableContainer.updateState(state: .extracted(size: normalSize, cornerRadius: normalCornerRadius, state: .animatedOut), transition: mappedTransition, completion: nil) + + self.backgroundContainer.contentView.addSubview(extractableContainer.extractableContentView) + extractableContainer.extractableContentView.frame = toRect + extractableContainer.extractableContentView.alpha = 1.0 + extractableContainer.extractableContentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + + self.contentContainer.frame = CGRect(origin: CGPoint(), size: currentSize) + + self.contentContainer.animateOut(fromRect: CGRect(origin: CGPoint(), size: currentSize), toRect: toRect, fromCornerRadius: 30.0, toCornerRadius: normalCornerRadius) } func didAnimateOut(toExtractableContainer extractableContainer: ContextExtractableContainer) { + let normalState = extractableContainer.normalState + extractableContainer.extractableContentView.frame = CGRect(origin: CGPoint(), size: normalState.size) + extractableContainer.isHidden = false + extractableContainer.extractableContentView.alpha = 1.0 extractableContainer.addSubview(extractableContainer.extractableContentView) extractableContainer.updateState(state: .normal, transition: .transition(.immediate), completion: nil) } @@ -1563,23 +1659,33 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context func update(presentationData: PresentationData, presentation: Presentation, size: CGSize, transition: ContainedViewLayoutTransition) { let transition = ComponentTransition(transition) - transition.setFrame(view: self.contentContainer, frame: CGRect(origin: CGPoint(), size: size)) - transition.setCornerRadius(layer: self.contentContainer.layer, cornerRadius: min(30.0, size.height * 0.5)) + transition.setFrame(view: self.backgroundContainer, frame: CGRect(origin: CGPoint(), size: size)) + self.backgroundContainer.update(size: size, isDark: presentationData.theme.overallDarkAppearance, transition: transition) - let backgroundContainerFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: -self.backgroundContainerInset, dy: -self.backgroundContainerInset) + if let effectView = self.contentContainer.effectView as? LensTransitionContainerEffectViewImpl { + effectView.update(theme: presentationData.theme) + } + if let sourceEffectView = self.contentContainer.sourceEffectView as? LensTransitionContainerEffectViewImpl { + sourceEffectView.update(theme: presentationData.theme) + } + transition.setPosition(view: self.contentContainer, position: CGRect(origin: CGPoint(), size: size).center) + transition.setBounds(view: self.contentContainer, bounds: CGRect(origin: CGPoint(), size: size)) + self.contentContainer.update(size: size, cornerRadius: min(30.0, size.height * 0.5), transition: transition) - if self.backgroundContainer.bounds.size != backgroundContainerFrame.size { + //let backgroundContainerFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: -self.backgroundContainerInset, dy: -self.backgroundContainerInset) + + /*if self.backgroundContainer.bounds.size != backgroundContainerFrame.size { self.backgroundContainer.update(size: backgroundContainerFrame.size, isDark: presentationData.theme.overallDarkAppearance, transition: transition) transition.setFrame(view: self.backgroundContainer, frame: backgroundContainerFrame) } transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: self.backgroundContainerInset, y: self.backgroundContainerInset), size: size)) - self.backgroundView.update(size: size, cornerRadius: min(30.0, size.height * 0.5), isDark: presentationData.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: transition) + self.backgroundView.update(size: size, cornerRadius: min(30.0, size.height * 0.5), isDark: presentationData.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: transition)*/ - if let sourceExtractableContainer = self.sourceExtractableContainer { + /*if let sourceExtractableContainer = self.sourceExtractableContainer { transition.setFrame(view: sourceExtractableContainer.extractableContentView, frame: CGRect(origin: CGPoint(x: self.backgroundContainerInset, y: self.backgroundContainerInset), size: size)) sourceExtractableContainer.updateState(state: .extracted(size: size, cornerRadius: min(30.0, size.height * 0.5), state: .animatedIn), transition: .transition(transition.containedViewLayoutTransition), completion: nil) - } + }*/ } } @@ -2223,12 +2329,12 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context } } - func animateIn(fromExtractableContainer extractableContainer: ContextExtractableContainer, transition: ComponentTransition) { - self.navigationContainer.animateIn(fromExtractableContainer: extractableContainer, transition: transition) + func animateIn(fromExtractableContainer extractableContainer: ContextExtractableContainer, fromRect: CGRect, transition: ComponentTransition) { + self.navigationContainer.animateIn(fromExtractableContainer: extractableContainer, fromRect: fromRect, transition: transition) } - func animateOut(toExtractableContainer extractableContainer: ContextExtractableContainer, transition: ComponentTransition) { - self.navigationContainer.animateOut(toExtractableContainer: extractableContainer, transition: transition) + func animateOut(toExtractableContainer extractableContainer: ContextExtractableContainer, toRect: CGRect, transition: ComponentTransition) { + self.navigationContainer.animateOut(toExtractableContainer: extractableContainer, toRect: toRect, transition: transition) } func didAnimateOut(toExtractableContainer extractableContainer: ContextExtractableContainer) { diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift index 6257398596..4b7a18907c 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift @@ -1351,13 +1351,13 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } if let contextExtractableContainer { - let positionTransition = ComponentTransition(animation: .curve(duration: 0.35, curve: .bounce(stiffness: 900.0, damping: 95.0))) + //let positionTransition = ComponentTransition(animation: .curve(duration: 0.35, curve: .bounce(stiffness: 900.0, damping: 95.0))) let transition = ComponentTransition(animation: .curve(duration: 0.5, curve: .spring)) - positionTransition.animatePosition(layer: self.actionsContainerNode.layer, from: CGPoint( + /*positionTransition.animatePosition(layer: self.actionsContainerNode.layer, from: CGPoint( x: contextExtractableContainer.sourceRect.midX - self.actionsContainerNode.frame.midX, y: contextExtractableContainer.sourceRect.midY - self.actionsContainerNode.frame.midY - ), to: CGPoint(), additive: true) + ), to: CGPoint(), additive: true)*/ /*self.actionsContainerNode.layer.animateScale(from: 1.0, to: 1.2, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, completion: { [weak self] _ in guard let self else { return @@ -1365,7 +1365,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo self.actionsContainerNode.layer.animateScale(from: 1.2, to: 1.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) })*/ - self.actionsStackNode.animateIn(fromExtractableContainer: contextExtractableContainer.container, transition: transition) + self.actionsStackNode.animateIn(fromExtractableContainer: contextExtractableContainer.container, fromRect: contextExtractableContainer.sourceRect.offsetBy(dx: -self.actionsContainerNode.frame.minX, dy: -self.actionsContainerNode.frame.minY), transition: transition) } else { self.actionsContainerNode.layer.animateAlpha(from: 0.0, to: self.actionsContainerNode.alpha, duration: 0.05) self.actionsContainerNode.layer.animateSpring( @@ -1719,20 +1719,24 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo let contextExtractableContainerView = contextExtractableContainer.container - positionTransition.setPosition(view: self.actionsContainerNode.view, position: CGPoint(x: contextExtractableContainer.sourceRect.midX, y: contextExtractableContainer.sourceRect.midY), completion: { _ in + /*positionTransition.setPosition(view: self.actionsContainerNode.view, position: CGPoint(x: contextExtractableContainer.sourceRect.midX, y: contextExtractableContainer.sourceRect.midY), completion: { _ in if completeWithActionStack { restoreOverlayViews.forEach({ $0() }) completion() } - }) + })*/ positionTransition.attachAnimation(view: self.actionsContainerNode.view, id: "animateOut", completion: { [weak self, weak contextExtractableContainerView] _ in + if completeWithActionStack { + restoreOverlayViews.forEach({ $0() }) + completion() + } if let self, let contextExtractableContainerView { self.actionsStackNode.didAnimateOut(toExtractableContainer: contextExtractableContainerView) } }) - self.actionsStackNode.animateOut(toExtractableContainer: contextExtractableContainer.container, transition: transition) + self.actionsStackNode.animateOut(toExtractableContainer: contextExtractableContainer.container, toRect: contextExtractableContainer.sourceRect.offsetBy(dx: -self.actionsContainerNode.frame.minX, dy: -self.actionsContainerNode.frame.minY), transition: transition) } else { self.actionsContainerNode.layer.animateAlpha(from: self.actionsContainerNode.alpha, to: 0.0, duration: duration, removeOnCompletion: false) self.actionsContainerNode.layer.animate( diff --git a/submodules/TelegramUI/Components/LensTransition/BUILD b/submodules/TelegramUI/Components/LensTransition/BUILD index ade2df5c4e..5c22060a3f 100644 --- a/submodules/TelegramUI/Components/LensTransition/BUILD +++ b/submodules/TelegramUI/Components/LensTransition/BUILD @@ -12,6 +12,7 @@ swift_library( deps = [ "//submodules/Display", "//submodules/ComponentFlow", + "//submodules/TelegramUI/Components/GlassBackgroundComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift index 9b014cd029..7a6a7630dd 100644 --- a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift +++ b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import Display import ComponentFlow +import GlassBackgroundComponent @inline(__always) private func getMethod(object: NSObject, selector: String) -> T? { @@ -72,6 +73,12 @@ private func setFilterName(object: NSObject, name: String) { object.perform(NSSelectorFromString("setName:"), with: name) } +public protocol LensTransitionContainerEffectView: UIView { + func updateSize(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) + func updateSize(duration: Double, keyframes: [CGSize]) + func updateCornerRadius(duration: Double, keyframes: [CGFloat]) +} + private final class EmptyLayerDelegate: NSObject, CALayerDelegate { func action(for layer: CALayer, forKey event: String) -> CAAction? { return nullAction @@ -79,11 +86,10 @@ private final class EmptyLayerDelegate: NSObject, CALayerDelegate { } public final class LensTransitionContainer: UIView { - public enum State { - case animatedOut - case animatedIn - } - + public let effectView: LensTransitionContainerEffectView? + public let sourceEffectView: LensTransitionContainerEffectView? + private let containerView: UIView + public let contentsEffectView: UIView public let contentsView: UIView private let emptyLayerDelegate = EmptyLayerDelegate() @@ -92,9 +98,11 @@ public final class LensTransitionContainer: UIView { private let sdfLayer: CALayer? private let displacementEffect: NSObject? - private(set) var state: State = .animatedOut - - override public init(frame: CGRect) { + public init(effectView: LensTransitionContainerEffectView? = nil, sourceEffectView: LensTransitionContainerEffectView? = nil) { + self.containerView = UIView() + self.effectView = effectView + self.sourceEffectView = sourceEffectView + self.contentsEffectView = UIView() self.contentsView = UIView() if #available(iOS 26.0, *) { @@ -107,29 +115,32 @@ public final class LensTransitionContainer: UIView { self.displacementEffect = nil } - super.init(frame: frame) + super.init(frame: CGRect()) - self.clipsToBounds = true - self.addSubview(self.contentsView) - - //self.contentsView.backgroundColor = .blue + self.addSubview(self.containerView) + self.contentsView.clipsToBounds = true - let curvature: CGFloat = 1.0 + if let effectView = self.effectView { + self.containerView.addSubview(effectView) + } + + self.containerView.addSubview(self.contentsEffectView) + self.contentsEffectView.addSubview(self.contentsView) if let displacementEffect = self.displacementEffect { - displacementEffect.setValue(curvature, forKey: "curvature") + displacementEffect.setValue(1.0, forKey: "curvature") displacementEffect.setValue(0.0 as NSNumber, forKey: "angle") } if let sdfLayer = self.sdfLayer, let displacementEffect = self.displacementEffect { sdfLayer.name = "sdfLayer" - sdfLayer.setValue(3.0, forKey: "scale") + sdfLayer.setValue(UIScreenScale, forKey: "scale") sdfLayer.setValue(displacementEffect, forKey: "effect") sdfLayer.delegate = self.emptyLayerDelegate } if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { - sdfElementLayer.setValue(0.0 as NSNumber, forKey: "gradientOvalization") + sdfElementLayer.setValue(0.5 as NSNumber, forKey: "gradientOvalization") sdfElementLayer.isOpaque = true sdfElementLayer.allowsEdgeAntialiasing = true let sdfLayerDelegate = unsafeBitCast(sdfLayer, to: CALayerDelegate.self) @@ -138,11 +149,11 @@ public final class LensTransitionContainer: UIView { sdfLayer.addSublayer(sdfElementLayer) } } - - required public init?(coder: NSCoder) { + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.alpha.isZero { return nil @@ -152,7 +163,7 @@ public final class LensTransitionContainer: UIView { return result } } - + let result = self.contentsView.hitTest(point, with: event) if result != self.contentsView { return result @@ -160,12 +171,12 @@ public final class LensTransitionContainer: UIView { return nil } } - + private func setIsFilterActive(isFilterActive: Bool) { if isFilterActive { - if self.contentsView.layer.filters == nil { + if self.contentsEffectView.layer.filters == nil { if let sdfLayer = self.sdfLayer { - self.contentsView.layer.insertSublayer(sdfLayer, at: 0) + self.contentsEffectView.layer.insertSublayer(sdfLayer, at: 0) } if let displacementFilter = CALayer.displacementMap(), let blurFilter = CALayer.blur() { setFilterName(object: blurFilter, name: "gaussianBlur") @@ -174,146 +185,652 @@ public final class LensTransitionContainer: UIView { setFilterName(object: displacementFilter, name: "displacementMap") displacementFilter.setValue("sdfLayer", forKey: "inputSourceSublayerName") - self.contentsView.layer.filters = [ + self.contentsEffectView.layer.rasterizationScale = UIScreenScale + self.contentsEffectView.layer.filters = [ blurFilter, displacementFilter ] } } - } else if self.contentsView.layer.filters != nil { - self.contentsView.layer.filters = nil + } else if self.contentsEffectView.layer.filters != nil { + self.contentsEffectView.layer.filters = nil if let sdfLayer = self.sdfLayer { sdfLayer.removeFromSuperlayer() } } } - - public func update(size: CGSize, cornerRadius: CGFloat, state: State, transition: ComponentTransition) { - let previousState = self.state - self.state = state - transition.setFrame(view: self.contentsView, frame: CGRect(origin: CGPoint(), size: size)) + public func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat) { + self.setIsFilterActive(isFilterActive: true) - if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer, sdfLayer.bounds.size != size || previousState != state { - let previousSize = sdfLayer.bounds.size + let duration = 0.5 + let toSize = toRect.size - if previousState == .animatedOut && state == .animatedIn && !transition.animation.isImmediate && previousSize != .zero { - self.setIsFilterActive(isFilterActive: true) - self.animateIn(fromSize: previousSize, fromCornerRadius: sdfLayer.cornerRadius, toSize: size, toCornerRadius: cornerRadius, transition: transition) - } else { - transition.setFrame(layer: sdfLayer, frame: CGRect(origin: CGPoint(), size: size), completion: { [weak self] _ in - guard let self else { - return + if let sourceEffectView = self.sourceEffectView, !"".isEmpty { + self.insertSubview(sourceEffectView, at: 0) + sourceEffectView.frame = fromRect + sourceEffectView.updateSize(size: fromRect.size, cornerRadius: fromCornerRadius, transition: .immediate) + + let minSide = min(toSize.width, toSize.height) + let maxSide = max(toSize.width, toSize.height) + + let sizeKeyframes: [CGSize] = (0 ..< 30).map { i in + let t = CGFloat(i) / (30.0 - 1.0) + let scale = scaleEase(t) + let sideFraction = max(0.0, min(1.0, sideFractionEase(t))) + let side = (1.0 - sideFraction) * minSide + sideFraction * maxSide + let size: CGSize + if toSize.width > toSize.height { + size = CGSize(width: side, height: minSide) + } else { + size = CGSize(width: minSide, height: side) + } + return CGSize(width: size.width * scale, height: size.height * scale) + } + + let cornerRadiusKeyframes: [CGFloat] = (0 ..< 30).map { i in + let t = CGFloat(i) / (30.0 - 1.0) + let scale = scaleEase(t) + let fraction = max(0.0, min(1.0, radiusFractionEase(t))) + let radius = (1.0 - fraction) * (minSide * 0.5) + fraction * toCornerRadius + return radius * scale + } + + sourceEffectView.updateSize(duration: duration, keyframes: sizeKeyframes) + sourceEffectView.updateCornerRadius(duration: duration, keyframes: cornerRadiusKeyframes) + + let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) + let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) + + var options: UIView.KeyframeAnimationOptions = [.calculationModeLinear] + options.insert(UIView.KeyframeAnimationOptions(rawValue: UIView.AnimationOptions.curveLinear.rawValue)) + UIView.animateKeyframes( + withDuration: duration, + delay: 0.0, + options: options, + animations: { + let segmentCount = 29 + let step = 1.0 / Double(segmentCount) + for i in 0 ..< segmentCount { + let t = CGFloat(i + 1) / (30.0 - 1.0) + let fx = positionXFractionEase(t) + let fy = positionYFractionEase(t) + let center = CGPoint( + x: (1.0 - fx) * fromCenter.x + fx * toCenter.x, + y: (1.0 - fy) * fromCenter.y + fy * toCenter.y + ) + UIView.addKeyframe( + withRelativeStartTime: Double(i) * step, + relativeDuration: step + ) { + sourceEffectView.center = center + } } - self.setIsFilterActive(isFilterActive: false) - }) - transition.setCornerRadius(layer: sdfLayer, cornerRadius: cornerRadius) - transition.setFrame(layer: sdfElementLayer, frame: CGRect(origin: CGPoint(), size: size)) - transition.setCornerRadius(layer: sdfElementLayer, cornerRadius: cornerRadius) - transition.setCornerRadius(layer: self.layer, cornerRadius: cornerRadius) + }, + completion: { [weak sourceEffectView] finished in + if finished { + sourceEffectView?.removeFromSuperview() + } + } + ) + } - let phase3Height: CGFloat = 0.0 - let phase3Amount: CGFloat = -0.001 - let phase3Blur: CGFloat = 0.0 - self.contentsView.layer.setValue(phase3Height as NSNumber, forKeyPath: "sublayers.sdfLayer.effect.height") - self.contentsView.layer.setValue(phase3Amount as NSNumber, forKeyPath: "filters.displacementMap.inputAmount") - self.contentsView.layer.setValue(phase3Blur as NSNumber, forKeyPath: "filters.gaussianBlur.inputRadius") + do { + let keyframeAnimation = CAKeyframeAnimation(keyPath: "transform.scale") + keyframeAnimation.duration = duration * UIView.animationDurationFactor() + keyframeAnimation.values = (0 ..< 30).map { i in + let t = CGFloat(i) / (30.0 - 1.0) + return scaleEase(t) as NSNumber + } + keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + keyframeAnimation.isRemovedOnCompletion = true + keyframeAnimation.fillMode = .both + self.containerView.layer.add(keyframeAnimation, forKey: "transform.scale") + } + do { + let minSide = min(toSize.width, toSize.height) + let maxSide = max(toSize.width, toSize.height) + let sizes: [CGSize] = (0 ..< 30).map { i in + let t = CGFloat(i) / (30.0 - 1.0) + let fraction = max(0.0, min(1.0, sideFractionEase(t))) + let value = (1.0 - fraction) * minSide + fraction * maxSide + if toSize.width > toSize.height { + return CGSize(width: value, height: minSide) + } else { + return CGSize(width: minSide, height: value) + } + } + + let keyframeAnimation = CAKeyframeAnimation(keyPath: "bounds.size") + keyframeAnimation.duration = duration * UIView.animationDurationFactor() + keyframeAnimation.values = sizes.map { + NSValue(cgSize: $0) + } + keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + keyframeAnimation.isRemovedOnCompletion = true + keyframeAnimation.fillMode = .both + self.contentsView.layer.add(keyframeAnimation, forKey: "bounds.size") + self.contentsEffectView.layer.add(keyframeAnimation, forKey: "bounds.size") + + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.add(keyframeAnimation, forKey: "bounds.size") + sdfElementLayer.add(keyframeAnimation, forKey: "bounds.size") + } + + let positions: [CGPoint] = (0 ..< 30).map { i in + let t = CGFloat(i) / (30.0 - 1.0) + let fraction = max(0.0, min(1.0, sideFractionEase(t))) + let value = (1.0 - fraction) * minSide + fraction * maxSide + let size: CGSize + if toSize.width > toSize.height { + size = CGSize(width: value, height: minSide) + } else { + size = CGSize(width: minSide, height: value) + } + return CGPoint(x: size.width * 0.5, y: size.height * 0.5) + } + let positionAnimation = CAKeyframeAnimation(keyPath: "position") + positionAnimation.duration = duration * UIView.animationDurationFactor() + positionAnimation.values = positions.map { NSValue(cgPoint: $0) } + positionAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + positionAnimation.isRemovedOnCompletion = true + positionAnimation.fillMode = .both + self.contentsView.layer.add(positionAnimation, forKey: "position") + self.contentsEffectView.layer.add(positionAnimation, forKey: "position") + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.add(positionAnimation, forKey: "position") + sdfElementLayer.add(positionAnimation, forKey: "position") + } + + if let effectView = self.effectView { + effectView.layer.add(positionAnimation, forKey: "position") + effectView.updateSize(duration: duration, keyframes: sizes) } } - } + do { + let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) + let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) + let positions: [CGPoint] = (0..<30).map { i in + let t = CGFloat(i) / 29.0 + let fx = positionXFractionEase(t) + let fy = positionYFractionEase(t) + return CGPoint( + x: (1.0 - fx) * fromCenter.x + fx * toCenter.x, + y: (1.0 - fy) * fromCenter.y + fy * toCenter.y + ) + } - private func animateIn(fromSize: CGSize, fromCornerRadius: CGFloat, toSize: CGSize, toCornerRadius: CGFloat, transition: ComponentTransition) { - guard let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer else { - return + let keyframeAnimation = CAKeyframeAnimation(keyPath: "position") + keyframeAnimation.duration = duration * UIView.animationDurationFactor() + keyframeAnimation.values = positions.map { NSValue(cgPoint: $0) } + keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + keyframeAnimation.isRemovedOnCompletion = true + keyframeAnimation.fillMode = .both + self.containerView.layer.add(keyframeAnimation, forKey: "position") + } + do { + let minSide = min(toSize.width, toSize.height) + let radiusKeyframes = (0 ..< 30).map { i -> CGFloat in + let t = CGFloat(i) / (30.0 - 1.0) + let fraction = max(0.0, min(1.0, radiusFractionEase(t))) + let value = (1.0 - fraction) * (minSide * 0.5) + fraction * toCornerRadius + return value + } + let keyframeAnimation = CAKeyframeAnimation(keyPath: "cornerRadius") + keyframeAnimation.duration = duration * UIView.animationDurationFactor() + keyframeAnimation.values = radiusKeyframes.map { $0 as NSNumber } + keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + keyframeAnimation.isRemovedOnCompletion = true + keyframeAnimation.fillMode = .both + self.contentsView.layer.add(keyframeAnimation, forKey: "cornerRadius") + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.add(keyframeAnimation, forKey: "cornerRadius") + sdfElementLayer.add(keyframeAnimation, forKey: "cornerRadius") + } + self.effectView?.updateCornerRadius(duration: duration, keyframes: radiusKeyframes) + } + do { + self.contentsEffectView.layer.setValue(0.0 as NSNumber, forKeyPath: "filters.gaussianBlur.inputRadius") + self.contentsEffectView.layer.setValue(0.0 as NSNumber, forKeyPath: "sublayers.sdfLayer.effect.height") + self.contentsEffectView.layer.setValue(-0.001 as NSNumber, forKeyPath: "filters.displacementMap.inputAmount") + + let minSide = min(toSize.width, toSize.height) + let fromHeight: CGFloat = minSide * 0.33 + let toHeight: CGFloat = 0.001 + let effectHeightKeyframes = (0 ..< 30).map { i -> CGFloat in + let t = CGFloat(i) / (30.0 - 1.0) + let fraction = max(0.0, min(1.0, displacementFractionEase(t))) + let value = (1.0 - fraction) * fromHeight + fraction * toHeight + return value + } + + let heightKeyframeAnimation = CAKeyframeAnimation(keyPath: "sublayers.sdfLayer.effect.height") + heightKeyframeAnimation.duration = duration * UIView.animationDurationFactor() + heightKeyframeAnimation.values = effectHeightKeyframes.map { $0 as NSNumber } + heightKeyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + heightKeyframeAnimation.isRemovedOnCompletion = true + heightKeyframeAnimation.fillMode = .both + self.contentsEffectView.layer.add(heightKeyframeAnimation, forKey: "sublayers.sdfLayer.effect.height") + + let displacementKeyframeAnimation = CAKeyframeAnimation(keyPath: "filters.displacementMap.inputAmount") + displacementKeyframeAnimation.duration = duration * UIView.animationDurationFactor() + displacementKeyframeAnimation.values = effectHeightKeyframes.map { -$0 as NSNumber } + displacementKeyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + displacementKeyframeAnimation.isRemovedOnCompletion = true + displacementKeyframeAnimation.fillMode = .both + self.contentsEffectView.layer.add(displacementKeyframeAnimation, forKey: "filters.displacementMap.inputAmount") + + let blurKeyframes = (0 ..< 30).map { i -> CGFloat in + let t = CGFloat(i) / (30.0 - 1.0) + return blurEase(t) + } + let blurKeyframeAnimation = CAKeyframeAnimation(keyPath: "filters.gaussianBlur.inputRadius") + blurKeyframeAnimation.duration = duration * UIView.animationDurationFactor() + blurKeyframeAnimation.values = blurKeyframes.map { $0 as NSNumber } + blurKeyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + blurKeyframeAnimation.isRemovedOnCompletion = true + blurKeyframeAnimation.fillMode = .both + self.contentsEffectView.layer.add(blurKeyframeAnimation, forKey: "filters.gaussianBlur.inputRadius") + } + do { + let subScaleKeyframes = (0 ..< 30).map { i -> CGFloat in + let t = CGFloat(i) / (30.0 - 1.0) + return subScaleEase(t) + } + let keyframeAnimation = CAKeyframeAnimation(keyPath: "transform.scale") + keyframeAnimation.duration = duration * UIView.animationDurationFactor() + keyframeAnimation.values = subScaleKeyframes.map { $0 as NSNumber } + keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + keyframeAnimation.isRemovedOnCompletion = true + keyframeAnimation.fillMode = .both + self.contentsView.layer.add(keyframeAnimation, forKey: "transform.scale") } - let duration: Double - let transitionTimingFunction: String - if case let .curve(durationValue, _) = transition.animation { - duration = durationValue - transitionTimingFunction = kCAMediaTimingFunctionSpring - } else { - return - } - let firstPartDuration: Double = 0.35 - let timingFunction = CAMediaTimingFunctionName.easeInEaseOut.rawValue + self.contentsView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) - let phase1Height: CGFloat = fromCornerRadius * 0.5 - let phase1Amount: CGFloat = -phase1Height - let phase1Scale: CGFloat = 1.05 - - let phase2Height: CGFloat = min(toSize.width, toSize.height) / 3.3333 - let phase2Amount: CGFloat = -phase2Height - let phase2Blur: CGFloat = 4.0 - let phase2Scale: CGFloat = 1.05 - - let phase3Height: CGFloat = 0.0 - let phase3Amount: CGFloat = -0.001 - let phase3Blur: CGFloat = 0.0 - let phase3Scale: CGFloat = 1.0 - - let capsuleCornerRadius = min(toSize.width, toSize.height) * 0.5 - - let fromCenter = CGPoint(x: fromSize.width * 0.5, y: fromSize.height * 0.5) - let toCenter = CGPoint(x: toSize.width * 0.5, y: toSize.height * 0.5) - - // --- Phase 1: circle → capsule, glass phase1 → phase2 --- - let finalFrame = CGRect(origin: CGPoint(), size: toSize) - sdfLayer.frame = finalFrame - sdfElementLayer.frame = finalFrame - self.contentsView.center = CGPoint(x: finalFrame.midX, y: finalFrame.midY) - self.contentsView.layer.removeAllAnimations() - sdfLayer.cornerRadius = capsuleCornerRadius - sdfElementLayer.cornerRadius = capsuleCornerRadius - self.layer.cornerRadius = capsuleCornerRadius - self.contentsView.layer.setValue(phase2Height as NSNumber, forKeyPath: "sublayers.sdfLayer.effect.height") - self.contentsView.layer.setValue(phase2Amount as NSNumber, forKeyPath: "filters.displacementMap.inputAmount") - self.contentsView.layer.setValue(phase2Scale as NSNumber, forKeyPath: "transform.scale") - self.contentsView.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * firstPartDuration) - - let phase1Duration = duration * firstPartDuration - let phase2Duration = duration * (1.0 - firstPartDuration) - let phase2BlurDuration = phase2Duration * 0.2 - - let _ = phase2BlurDuration - let _ = phase3Blur - self.contentsView.layer.setValue(0.0 as NSNumber, forKeyPath: "filters.gaussianBlur.inputRadius") - self.contentsView.layer.animate(from: phase2Blur as NSNumber, to: phase3Blur as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, duration: phase2BlurDuration, delay: phase1Duration * 0.8) - - for layer in [sdfLayer, sdfElementLayer] { - layer.animate(from: NSValue(cgSize: fromSize), to: NSValue(cgSize: toSize), keyPath: "bounds.size", timingFunction: transitionTimingFunction, duration: duration) - layer.removeAnimation(forKey: "position") - layer.animate(from: fromCornerRadius as NSNumber, to: capsuleCornerRadius as NSNumber, keyPath: "cornerRadius", timingFunction: timingFunction, duration: phase1Duration) - } - - self.contentsView.layer.animate(from: NSValue(cgPoint: fromCenter), to: NSValue(cgPoint: toCenter), keyPath: "position", timingFunction: transitionTimingFunction, duration: duration) - - self.contentsView.layer.animate(from: phase1Height as NSNumber, to: phase2Height as NSNumber, keyPath: "sublayers.sdfLayer.effect.height", timingFunction: timingFunction, duration: phase1Duration) - self.contentsView.layer.animate(from: phase1Amount as NSNumber, to: phase2Amount as NSNumber, keyPath: "filters.displacementMap.inputAmount", timingFunction: timingFunction, duration: phase1Duration) - self.contentsView.layer.animate(from: phase1Scale as NSNumber, to: phase2Scale as NSNumber, keyPath: "transform.scale", timingFunction: timingFunction, duration: phase1Duration) - self.layer.animate(from: fromCornerRadius as NSNumber, to: capsuleCornerRadius as NSNumber, keyPath: "cornerRadius", timingFunction: timingFunction, duration: phase1Duration) - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + UIView.animationDurationFactor() * phase1Duration, execute: { [weak self] in + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + UIView.animationDurationFactor() * duration, execute: { [weak self] in guard let self else { return } - - // --- Phase 2: capsule → final cornerRadius, glass phase2 → phase3 --- - sdfLayer.cornerRadius = toCornerRadius - sdfElementLayer.cornerRadius = toCornerRadius - self.layer.cornerRadius = toCornerRadius - self.contentsView.layer.setValue(phase3Height as NSNumber, forKeyPath: "sublayers.sdfLayer.effect.height") - self.contentsView.layer.setValue(phase3Amount as NSNumber, forKeyPath: "filters.displacementMap.inputAmount") - self.contentsView.layer.setValue(phase3Scale as NSNumber, forKeyPath: "transform.scale") - - for layer in [sdfLayer, sdfElementLayer] { - layer.animate(from: capsuleCornerRadius as NSNumber, to: toCornerRadius as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: phase2Duration) - } - self.contentsView.layer.animate(from: phase2Height as NSNumber, to: phase3Height as NSNumber, keyPath: "sublayers.sdfLayer.effect.height", timingFunction: kCAMediaTimingFunctionSpring, duration: phase2Duration) - self.contentsView.layer.animate(from: phase2Amount as NSNumber, to: phase3Amount as NSNumber, keyPath: "filters.displacementMap.inputAmount", timingFunction: kCAMediaTimingFunctionSpring, duration: phase2Duration) - self.contentsView.layer.animate(from: phase2Scale as NSNumber, to: phase3Scale as NSNumber, keyPath: "transform.scale", timingFunction: kCAMediaTimingFunctionSpring, duration: phase2Duration) - self.layer.animate(from: capsuleCornerRadius as NSNumber, to: toCornerRadius as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: phase2Duration, completion: { [weak self] _ in - guard let self else { return } - self.setIsFilterActive(isFilterActive: false) - }) + self.setIsFilterActive(isFilterActive: false) }) } + + public func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat) { + self.setIsFilterActive(isFilterActive: true) + + let duration: Double = 0.15 + + if let sourceEffectView = self.sourceEffectView, !"".isEmpty { + self.insertSubview(sourceEffectView, at: 0) + sourceEffectView.frame = fromRect + sourceEffectView.updateSize(size: fromRect.size, cornerRadius: fromCornerRadius, transition: .immediate) + sourceEffectView.updateSize(size: toRect.size, cornerRadius: toCornerRadius, transition: .easeInOut(duration: duration)) + UIView.animate(withDuration: duration, delay: 0.0, options: .curveEaseInOut, animations: { + sourceEffectView.center = toRect.center + }) + } + + let fromSize = fromRect.size + //let toSize = toRect.size + let timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + + // Scale: 1.0 -> 0.097 + do { + let animation = CABasicAnimation(keyPath: "transform.scale") + animation.fromValue = 1.0 as NSNumber + animation.toValue = 0.097 as NSNumber + animation.duration = duration * UIView.animationDurationFactor() + animation.timingFunction = timingFunction + animation.isRemovedOnCompletion = false + animation.fillMode = .both + self.containerView.layer.add(animation, forKey: "transform.scale") + } + + // Bounds size: fromSize -> square(minSide) + do { + let minSide = min(fromSize.width, fromSize.height) + let toSquare = CGSize(width: minSide, height: minSide) + + let sizeAnimation = CABasicAnimation(keyPath: "bounds.size") + sizeAnimation.fromValue = NSValue(cgSize: fromSize) + sizeAnimation.toValue = NSValue(cgSize: toSquare) + sizeAnimation.duration = duration * UIView.animationDurationFactor() + sizeAnimation.timingFunction = timingFunction + sizeAnimation.isRemovedOnCompletion = false + sizeAnimation.fillMode = .both + self.contentsView.layer.add(sizeAnimation, forKey: "bounds.size") + self.contentsEffectView.layer.add(sizeAnimation, forKey: "bounds.size") + + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.add(sizeAnimation, forKey: "bounds.size") + sdfElementLayer.add(sizeAnimation, forKey: "bounds.size") + } + + // Position tracks the center of the shrinking bounds + let fromPosition = CGPoint(x: fromSize.width * 0.5, y: fromSize.height * 0.5) + let toPosition = CGPoint(x: minSide * 0.5, y: minSide * 0.5) + + let positionAnimation = CABasicAnimation(keyPath: "position") + positionAnimation.fromValue = NSValue(cgPoint: fromPosition) + positionAnimation.toValue = NSValue(cgPoint: toPosition) + positionAnimation.duration = duration * UIView.animationDurationFactor() + positionAnimation.timingFunction = timingFunction + positionAnimation.isRemovedOnCompletion = false + positionAnimation.fillMode = .both + self.contentsView.layer.add(positionAnimation, forKey: "position") + self.contentsEffectView.layer.add(positionAnimation, forKey: "position") + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.add(positionAnimation, forKey: "position") + sdfElementLayer.add(positionAnimation, forKey: "position") + } + + if let effectView = self.effectView { + effectView.layer.add(sizeAnimation, forKey: "bounds.size") + effectView.layer.add(positionAnimation, forKey: "position") + effectView.updateSize(size: toSquare, cornerRadius: minSide * 0.5, transition: .easeInOut(duration: duration)) + } + } + + // Container position: fromRect center -> toRect center + do { + let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) + let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) + + let animation = CABasicAnimation(keyPath: "position") + animation.fromValue = NSValue(cgPoint: fromCenter) + animation.toValue = NSValue(cgPoint: toCenter) + animation.duration = duration * UIView.animationDurationFactor() + animation.timingFunction = timingFunction + animation.isRemovedOnCompletion = false + animation.fillMode = .both + self.containerView.layer.add(animation, forKey: "position") + } + + // Corner radius: fromCornerRadius -> minSide * 0.5 (circle) + do { + let minSide = min(fromSize.width, fromSize.height) + let toRadius = minSide * 0.5 + + let animation = CABasicAnimation(keyPath: "cornerRadius") + animation.fromValue = fromCornerRadius as NSNumber + animation.toValue = toRadius as NSNumber + animation.duration = duration * UIView.animationDurationFactor() + animation.timingFunction = timingFunction + animation.isRemovedOnCompletion = false + animation.fillMode = .both + self.contentsView.layer.add(animation, forKey: "cornerRadius") + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.add(animation, forKey: "cornerRadius") + sdfElementLayer.add(animation, forKey: "cornerRadius") + } + self.effectView?.updateCornerRadius(duration: duration, keyframes: [fromCornerRadius, toRadius]) + } + + // Blur: ramp from 0 to peak (~3.0) + // No displacement animations + do { + self.contentsEffectView.layer.setValue(0.0 as NSNumber, forKeyPath: "filters.gaussianBlur.inputRadius") + self.contentsEffectView.layer.setValue(0.0 as NSNumber, forKeyPath: "sublayers.sdfLayer.effect.height") + self.contentsEffectView.layer.setValue(-0.001 as NSNumber, forKeyPath: "filters.displacementMap.inputAmount") + + let blurPeak: CGFloat = 3.0 + let animation = CABasicAnimation(keyPath: "filters.gaussianBlur.inputRadius") + animation.fromValue = 0.0 as NSNumber + animation.toValue = blurPeak as NSNumber + animation.duration = duration * UIView.animationDurationFactor() + animation.timingFunction = timingFunction + animation.isRemovedOnCompletion = false + animation.fillMode = .both + self.contentsEffectView.layer.add(animation, forKey: "filters.gaussianBlur.inputRadius") + } + + // SubScale: 1.0 -> 1.0 (no visible change, skip) + + // Alpha: 1.0 -> 0.0 over last 0.15s + self.contentsView.alpha = 0.0 + self.contentsView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, delay: duration - 0.15) + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + UIView.animationDurationFactor() * duration, execute: { [weak self] in + guard let self else { return } + self.setIsFilterActive(isFilterActive: false) + }) + } + + public func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { + transition.setBounds(view: self.containerView, bounds: CGRect(origin: CGPoint(), size: size)) + transition.setPosition(view: self.containerView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + transition.setBounds(view: self.contentsView, bounds: CGRect(origin: CGPoint(), size: size)) + transition.setPosition(view: self.contentsView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + transition.setCornerRadius(layer: self.contentsView.layer, cornerRadius: cornerRadius) + transition.setBounds(view: self.contentsEffectView, bounds: CGRect(origin: CGPoint(), size: size)) + transition.setPosition(view: self.contentsEffectView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + + if let effectView = self.effectView { + transition.setBounds(view: effectView, bounds: CGRect(origin: CGPoint(), size: size)) + transition.setPosition(view: effectView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + effectView.updateSize(size: size, cornerRadius: cornerRadius, transition: transition) + } + + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + transition.setFrame(layer: sdfLayer, frame: CGRect(origin: CGPoint(), size: size)) + transition.setFrame(layer: sdfElementLayer, frame: CGRect(origin: CGPoint(), size: size)) + sdfLayer.cornerRadius = cornerRadius + sdfElementLayer.cornerRadius = cornerRadius + } + } +} + +@inline(__always) +private func clamp01(_ x: Double) -> Double { max(0.0, min(1.0, x)) } + +private func scaleEase(_ uIn: Double) -> Double { + let u = clamp01(uIn) + + let endIndex = 29.0 + let n = u * endIndex + + let s0: Double = 0.09669952058569901 + let s1: Double = 1.0 + + let k: Double = 0.2047679706652983 + let w: Double = 0.15481658188988102 + let B: Double = 0.08646704068381172 + let n0: Double = -0.19300689982260073 + + @inline(__always) + func raw(_ n: Double) -> Double { + let t = n - n0 + if t <= 0.0 { return 0.0 } + return 1.0 - exp(-k * t) * (cos(w * t) + B * sin(w * t)) + } + + let base = raw(0.0) + let end = raw(endIndex) + let denom = end - base + if abs(denom) <= 1e-12 { + return s0 + } + + let frac = (raw(n) - base) / denom + + let s = s0 + (s1 - s0) * frac + return s +} + +private func sideFractionEase(_ uIn: Double) -> Double { + let u = clamp01(uIn) + + let endIndex = 29.0 + let n = u * endIndex + + let k = 0.4334891216702717 + let n0 = 0.8238404710496342 + + @inline(__always) + func g(_ n: Double) -> Double { + let t = n - n0 + if t <= 0.0 { return 0.0 } + return 1.0 - exp(-k * t) * (1.0 + k * t) + } + + let gEnd = 0.9999344552429187 + let eased = g(n) / gEnd + + return max(0.0, min(1.0, eased)) +} + +private func radiusFractionEase(_ uIn: Double) -> Double { + let u = clamp01(uIn) + + let endIndex = 29.0 + let n = u * endIndex + + let k = 0.5452042256694901 + let n0 = 8.025670446964643 + + @inline(__always) + func g(_ n: Double) -> Double { + let t = n - n0 + if t <= 0.0 { return 0.0 } + return 1.0 - exp(-k * t) * (1.0 + k * t) + } + + let gEnd = g(endIndex) + if gEnd <= 1e-12 { return 0.0 } + + let eased = g(n) / gEnd + return max(0.0, min(1.0, eased)) +} + +private func positionXFractionEase(_ uIn: Double) -> Double { + let u = clamp01(uIn) + + let endIndex = 29.0 + let n = u * endIndex + + let k = 0.4576441099336031 + let n0 = -1.1076590882287138 + + @inline(__always) + func raw(_ n: Double) -> Double { + let t = n - n0 + if t <= 0.0 { return 0.0 } + return 1.0 - exp(-k * t) * (1.0 + k * t) + } + + let base = raw(0.0) + + @inline(__always) + func g(_ n: Double) -> Double { + let v = raw(n) - base + return v > 0.0 ? v : 0.0 + } + + let gEnd = g(endIndex) + if gEnd <= 1e-12 { return 0.0 } + + let eased = g(n) / gEnd + return max(0.0, min(1.0, eased)) +} + +private func positionYFractionEase(_ uIn: Double) -> Double { + let u = clamp01(uIn) + + let endIndex = 29.0 + let n = u * endIndex + + let k = 0.7328940609652471 + let n0 = -0.11837294418417923 + + @inline(__always) + func raw(_ n: Double) -> Double { + let t = n - n0 + if t <= 0.0 { return 0.0 } + return 1.0 - exp(-k * t) + } + + let base = raw(0.0) + + @inline(__always) + func g(_ n: Double) -> Double { + let v = raw(n) - base + return v > 0.0 ? v : 0.0 + } + + let gEnd = g(endIndex) + if gEnd <= 1e-12 { return 0.0 } + + let eased = g(n) / gEnd + return max(0.0, min(1.0, eased)) +} + +private func displacementFractionEase(_ uIn: Double) -> Double { + let u = clamp01(uIn) + + let endIndex = 29.0 + let n = u * endIndex + + let k = 0.14743333600632425 + let w = 31.30115940141963 + let B = -3.3813807242203156 + let n0 = 0.1872224520792323 + + @inline(__always) + func raw(_ n: Double) -> Double { + let t = n - n0 + if t <= 0.0 { return 0.0 } + return 1.0 - exp(-k * t) * (cos(w * t) + B * sin(w * t)) + } + + let end = raw(endIndex) + if end <= 1e-12 { return 0.0 } + + let eased = raw(n) / end + return max(0.0, min(1.0, eased)) +} + +private func subScaleEase(_ uIn: Double) -> Double { + let u = clamp01(uIn) + + let endIndex = 29.0 + let n = u * endIndex + + let A = 0.02941789470493528 + let k = 0.18710512325378066 + let w = 0.1871386188061029 + let B = 36.12000805553303 + + @inline(__always) + func raw(_ n: Double) -> Double { + let e = exp(-k * n) + return 1.0 + A * e * (cos(w * n) + B * sin(w * n)) + } + + let end = raw(endIndex) + let shifted = raw(n) - (end - 1.0) + + return max(0.0, min(2.0, shifted)) +} + +private func blurEase(_ uIn: Double) -> Double { + let u = clamp01(uIn) + + let endIndex = 29.0 + let n = u * endIndex + + let A: Double = 0.40877086657583617 + let k: Double = 0.564 + let n0: Double = -1.4575 + let p: Double = 3.0 + + @inline(__always) + func raw(_ n: Double) -> Double { + let t = n - n0 + if t <= 0.0 { return 0.0 } + return A * pow(t, p) * exp(-k * t) + } + + let tail = raw(endIndex) + let v = raw(n) - tail + + return max(0.0, v) } From 354a4ee92e9002dc454b2f5325a2343e3fe3206b Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 3 Mar 2026 15:25:19 +0100 Subject: [PATCH 04/10] Temp --- .../ContextControllerActionsStackNode.swift | 130 +- ...tControllerExtractedPresentationNode.swift | 29 +- .../Sources/GlassBackgroundComponent.swift | 6 +- .../Sources/LensTransitionContainer.swift | 1698 +++++++++++++---- 4 files changed, 1398 insertions(+), 465 deletions(-) diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift index b5928ead73..400c6cab50 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift @@ -1408,15 +1408,20 @@ private final class ItemSelectionRecognizer: UIGestureRecognizer { private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitionContainerEffectView { let glassView: UIVisualEffectView + let contentView: UIView? private var theme: PresentationTheme? - override init(frame: CGRect) { + init(contentView: UIView?) { self.glassView = UIVisualEffectView() + self.contentView = contentView - super.init(frame: frame) + super.init(frame: CGRect()) self.addSubview(self.glassView) + if let contentView { + self.glassView.contentView.addSubview(contentView) + } } required init?(coder: NSCoder) { @@ -1429,10 +1434,10 @@ private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitio let glassEffectValue: UIGlassEffect if theme.overallDarkAppearance { glassEffectValue = UIGlassEffect(style: .regular) - glassEffectValue.tintColor = UIColor(white: 1.0, alpha: 0.025) + //glassEffectValue.tintColor = UIColor(white: 1.0, alpha: 0.025) } else { glassEffectValue = UIGlassEffect(style: .regular) - glassEffectValue.tintColor = UIColor(white: 1.0, alpha: 0.1) + //glassEffectValue.tintColor = UIColor(white: 1.0, alpha: 0.1) } self.glassView.effect = glassEffectValue } @@ -1448,6 +1453,12 @@ private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitio } } + func updateSize(size: CGSize, transition: ComponentTransition) { + transition.setBounds(view: self, bounds: CGRect(origin: .zero, size: size)) + transition.setBounds(view: self.glassView, bounds: CGRect(origin: .zero, size: size)) + transition.setPosition(view: self.glassView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + } + func updateSize(duration: Double, keyframes: [CGSize]) { guard keyframes.count >= 2 else { if let last = keyframes.last { @@ -1460,9 +1471,11 @@ private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitio // Start value self.bounds.size = keyframes[0] + self.glassView.bounds.size = keyframes[0] + self.glassView.center = CGPoint(x: keyframes[0].width * 0.5, y: keyframes[0].height * 0.5) let segmentCount = keyframes.count - 1 - let step = 1.0 / Double(segmentCount) + let relativeStep = 1.0 / Double(segmentCount) var options: UIView.KeyframeAnimationOptions = [.calculationModeLinear] options.insert(UIView.KeyframeAnimationOptions(rawValue: UIView.AnimationOptions.curveLinear.rawValue)) @@ -1473,9 +1486,11 @@ private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitio animations: { for i in 0..= 2 else { + if let last = keyframes.last { + self.center = last + } + return + } + + self.center = keyframes[0] + + let segmentCount = keyframes.count - 1 + let relativeStep = 1.0 / Double(segmentCount) + + var options: UIView.KeyframeAnimationOptions = [.calculationModeLinear] + options.insert(UIView.KeyframeAnimationOptions(rawValue: UIView.AnimationOptions.curveLinear.rawValue)) + UIView.animateKeyframes( + withDuration: duration, + delay: 0.0, + options: options, + animations: { + for i in 0 ..< segmentCount { + let nextPosition = keyframes[i + 1] + let relativeStartTime = Double(i) * relativeStep + let relativeDuration = (i == segmentCount - 1) ? (1.0 - relativeStartTime) : relativeStep + UIView.addKeyframe( + withRelativeStartTime: relativeStartTime, + relativeDuration: relativeDuration + ) { + self.center = nextPosition + } + } + }, + completion: nil + ) + } + + func updatePosition(position: CGPoint, transition: ComponentTransition) { + transition.setPosition(view: self, position: position) + } func updateCornerRadius(duration: Double, keyframes: [CGFloat]) { guard #available(iOS 26.0, *) else { @@ -1503,7 +1558,7 @@ private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitio self.glassView.cornerConfiguration = .corners(radius: UICornerRadius(floatLiteral: keyframes[0])) let segmentCount = keyframes.count - 1 - let step = 1.0 / Double(segmentCount) + let relativeStep = 1.0 / Double(segmentCount) var options: UIView.KeyframeAnimationOptions = [.calculationModeLinear] options.insert(UIView.KeyframeAnimationOptions(rawValue: UIView.AnimationOptions.curveLinear.rawValue)) @@ -1514,9 +1569,11 @@ private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitio animations: { for i in 0 ..< segmentCount { let nextValue = keyframes[i + 1] + let relativeStartTime = Double(i) * relativeStep + let relativeDuration = (i == segmentCount - 1) ? (1.0 - relativeStartTime) : relativeStep UIView.addKeyframe( - withRelativeStartTime: Double(i) * step, - relativeDuration: step + withRelativeStartTime: relativeStartTime, + relativeDuration: relativeDuration ) { self.glassView.cornerConfiguration = .corners(radius: UICornerRadius(floatLiteral: nextValue)) } @@ -1525,6 +1582,13 @@ private final class LensTransitionContainerEffectViewImpl: UIView, LensTransitio completion: nil ) } + + func setTransitionFraction(value: CGFloat, duration: Double) { + let fraction = max(0.0, min(1.0, value)) + let transition: ComponentTransition = duration == 0.0 ? .immediate : .easeInOut(duration: duration) + transition.setBlur(layer: self.glassView.contentView.layer, radius: (1.0 - fraction) * 4.0) + transition.setAlpha(view: self.glassView.contentView, alpha: fraction) + } } public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, ContextControllerActionsStackNode { @@ -1547,13 +1611,8 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context } override init() { - /*self.backgroundContainer = GlassBackgroundContainerView() - self.backgroundView = GlassBackgroundView() - self.backgroundContainer.contentView.addSubview(self.backgroundView)*/ - - self.backgroundContainer = GlassBackgroundContainerView() - self.contentContainer = LensTransitionContainer(effectView: LensTransitionContainerEffectViewImpl(), sourceEffectView: LensTransitionContainerEffectViewImpl()) - //self.backgroundView.contentView.addSubview(self.contentContainer) + self.backgroundContainer = GlassBackgroundContainerView(spacing: 20.0) + self.contentContainer = LensTransitionContainer(effectView: LensTransitionContainerEffectViewImpl(contentView: nil)) self.backgroundContainerInset = 32.0 @@ -1614,7 +1673,7 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context } } - func animateIn(fromExtractableContainer extractableContainer: ContextExtractableContainer, fromRect: CGRect, transition: ComponentTransition) { + func animateIn(fromExtractableContainer extractableContainer: ContextExtractableContainer, fromRect: CGRect, presentationData: PresentationData, transition: ComponentTransition) { let normalState = extractableContainer.normalState //let sourceSize = normalState.size let normalCornerRadius: CGFloat = normalState.cornerRadius @@ -1622,29 +1681,25 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context let currentSize = self.contentContainer.bounds.size self.sourceExtractableContainer = extractableContainer - self.backgroundContainer.contentView.addSubview(extractableContainer.extractableContentView) - extractableContainer.extractableContentView.frame = fromRect self.contentContainer.frame = CGRect(origin: CGPoint(), size: currentSize) - extractableContainer.extractableContentView.alpha = 0.0 - extractableContainer.extractableContentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15) - extractableContainer.isHidden = true - self.contentContainer.animateIn(fromRect: fromRect, toRect: CGRect(origin: CGPoint(), size: currentSize), fromCornerRadius: normalCornerRadius, toCornerRadius: 30.0) + + let sourceEffectView = LensTransitionContainerEffectViewImpl(contentView: extractableContainer.extractableContentView) + sourceEffectView.update(theme: presentationData.theme) + + self.contentContainer.animateIn(fromRect: fromRect, toRect: CGRect(origin: CGPoint(), size: currentSize), fromCornerRadius: normalCornerRadius, toCornerRadius: 30.0, isDark: presentationData.theme.overallDarkAppearance, sourceEffectView: sourceEffectView) } - func animateOut(toExtractableContainer extractableContainer: ContextExtractableContainer, toRect: CGRect, transition: ComponentTransition) { + func animateOut(toExtractableContainer extractableContainer: ContextExtractableContainer, toRect: CGRect, presentationData: PresentationData, transition: ComponentTransition) { let normalState = extractableContainer.normalState let normalCornerRadius: CGFloat = normalState.cornerRadius let currentSize = self.contentContainer.bounds.size - - self.backgroundContainer.contentView.addSubview(extractableContainer.extractableContentView) - extractableContainer.extractableContentView.frame = toRect - extractableContainer.extractableContentView.alpha = 1.0 - extractableContainer.extractableContentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - self.contentContainer.frame = CGRect(origin: CGPoint(), size: currentSize) + + let sourceEffectView = LensTransitionContainerEffectViewImpl(contentView: extractableContainer.extractableContentView) + sourceEffectView.update(theme: presentationData.theme) - self.contentContainer.animateOut(fromRect: CGRect(origin: CGPoint(), size: currentSize), toRect: toRect, fromCornerRadius: 30.0, toCornerRadius: normalCornerRadius) + self.contentContainer.animateOut(fromRect: CGRect(origin: CGPoint(), size: currentSize), toRect: toRect, fromCornerRadius: 30.0, toCornerRadius: normalCornerRadius, isDark: presentationData.theme.overallDarkAppearance, sourceEffectView: sourceEffectView) } func didAnimateOut(toExtractableContainer extractableContainer: ContextExtractableContainer) { @@ -1665,9 +1720,6 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context if let effectView = self.contentContainer.effectView as? LensTransitionContainerEffectViewImpl { effectView.update(theme: presentationData.theme) } - if let sourceEffectView = self.contentContainer.sourceEffectView as? LensTransitionContainerEffectViewImpl { - sourceEffectView.update(theme: presentationData.theme) - } transition.setPosition(view: self.contentContainer, position: CGRect(origin: CGPoint(), size: size).center) transition.setBounds(view: self.contentContainer, bounds: CGRect(origin: CGPoint(), size: size)) self.contentContainer.update(size: size, cornerRadius: min(30.0, size.height * 0.5), transition: transition) @@ -2329,12 +2381,12 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context } } - func animateIn(fromExtractableContainer extractableContainer: ContextExtractableContainer, fromRect: CGRect, transition: ComponentTransition) { - self.navigationContainer.animateIn(fromExtractableContainer: extractableContainer, fromRect: fromRect, transition: transition) + func animateIn(fromExtractableContainer extractableContainer: ContextExtractableContainer, fromRect: CGRect, presentationData: PresentationData, transition: ComponentTransition) { + self.navigationContainer.animateIn(fromExtractableContainer: extractableContainer, fromRect: fromRect, presentationData: presentationData, transition: transition) } - func animateOut(toExtractableContainer extractableContainer: ContextExtractableContainer, toRect: CGRect, transition: ComponentTransition) { - self.navigationContainer.animateOut(toExtractableContainer: extractableContainer, toRect: toRect, transition: transition) + func animateOut(toExtractableContainer extractableContainer: ContextExtractableContainer, toRect: CGRect, presentationData: PresentationData, transition: ComponentTransition) { + self.navigationContainer.animateOut(toExtractableContainer: extractableContainer, toRect: toRect, presentationData: presentationData, transition: transition) } func didAnimateOut(toExtractableContainer extractableContainer: ContextExtractableContainer) { diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift index 4b7a18907c..cb5328aed8 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift @@ -327,6 +327,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo self.contentRectDebugNode.backgroundColor = UIColor.red.withAlphaComponent(0.2) self.actionsContainerNode = ASDisplayNode() + self.actionsContainerNode.alpha = 0.0 self.actionsStackNode = ContextControllerActionsStackNodeImpl( context: self.context, getController: getController, @@ -816,7 +817,9 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo case let .reference(reference): if let transitionInfo = reference.transitionInfo() { if let referenceView = transitionInfo.referenceView as? ContextExtractableContainer { - contextExtractableContainer = (referenceView, convertFrame(transitionInfo.referenceView.bounds.inset(by: transitionInfo.insets), from: transitionInfo.referenceView, to: self.view)) + if #available(iOS 26.0, *) { + contextExtractableContainer = (referenceView, convertFrame(transitionInfo.referenceView.bounds.inset(by: transitionInfo.insets), from: transitionInfo.referenceView, to: self.view)) + } } contentRect = convertFrame(transitionInfo.referenceView.bounds.inset(by: transitionInfo.insets), from: transitionInfo.referenceView, to: self.view).insetBy(dx: -2.0, dy: 0.0) @@ -887,6 +890,14 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } } + if contextExtractableContainer != nil { + if stateTransition != nil { + self.actionsContainerNode.alpha = 1.0 + } + } else { + self.actionsContainerNode.alpha = 1.0 + } + var contentParentGlobalFrameOffsetX: CGFloat = 0.0 if case let .extracted(extracted) = self.source, extracted.adjustContentForSideInset { let contentSideInset: CGFloat = actionsSideInset + 6.0 @@ -1351,21 +1362,9 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } if let contextExtractableContainer { - //let positionTransition = ComponentTransition(animation: .curve(duration: 0.35, curve: .bounce(stiffness: 900.0, damping: 95.0))) let transition = ComponentTransition(animation: .curve(duration: 0.5, curve: .spring)) - /*positionTransition.animatePosition(layer: self.actionsContainerNode.layer, from: CGPoint( - x: contextExtractableContainer.sourceRect.midX - self.actionsContainerNode.frame.midX, - y: contextExtractableContainer.sourceRect.midY - self.actionsContainerNode.frame.midY - ), to: CGPoint(), additive: true)*/ - /*self.actionsContainerNode.layer.animateScale(from: 1.0, to: 1.2, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, completion: { [weak self] _ in - guard let self else { - return - } - self.actionsContainerNode.layer.animateScale(from: 1.2, to: 1.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) - })*/ - - self.actionsStackNode.animateIn(fromExtractableContainer: contextExtractableContainer.container, fromRect: contextExtractableContainer.sourceRect.offsetBy(dx: -self.actionsContainerNode.frame.minX, dy: -self.actionsContainerNode.frame.minY), transition: transition) + self.actionsStackNode.animateIn(fromExtractableContainer: contextExtractableContainer.container, fromRect: contextExtractableContainer.sourceRect.offsetBy(dx: -self.actionsContainerNode.frame.minX, dy: -self.actionsContainerNode.frame.minY), presentationData: presentationData, transition: transition) } else { self.actionsContainerNode.layer.animateAlpha(from: 0.0, to: self.actionsContainerNode.alpha, duration: 0.05) self.actionsContainerNode.layer.animateSpring( @@ -1736,7 +1735,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } }) - self.actionsStackNode.animateOut(toExtractableContainer: contextExtractableContainer.container, toRect: contextExtractableContainer.sourceRect.offsetBy(dx: -self.actionsContainerNode.frame.minX, dy: -self.actionsContainerNode.frame.minY), transition: transition) + self.actionsStackNode.animateOut(toExtractableContainer: contextExtractableContainer.container, toRect: contextExtractableContainer.sourceRect.offsetBy(dx: -self.actionsContainerNode.frame.minX, dy: -self.actionsContainerNode.frame.minY), presentationData: presentationData, transition: transition) } else { self.actionsContainerNode.layer.animateAlpha(from: self.actionsContainerNode.alpha, to: 0.0, duration: duration, removeOnCompletion: false) self.actionsContainerNode.layer.animate( diff --git a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift index dd4e114789..14b8a8d020 100644 --- a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift +++ b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift @@ -666,10 +666,10 @@ public final class GlassBackgroundContainerView: UIView { } } - public override init(frame: CGRect) { + public init(spacing: CGFloat = 7.0) { if #available(iOS 26.0, *), !GlassBackgroundView.useCustomGlassImpl { let effect = UIGlassContainerEffect() - effect.spacing = 7.0 + effect.spacing = spacing let nativeView = UIVisualEffectView(effect: effect) self.nativeView = nativeView @@ -684,7 +684,7 @@ public final class GlassBackgroundContainerView: UIView { self.legacyView = ContentView() } - super.init(frame: frame) + super.init(frame: CGRect()) if let nativeParamsView = self.nativeParamsView { self.addSubview(nativeParamsView) diff --git a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift index 7a6a7630dd..d7cfb74633 100644 --- a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift +++ b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift @@ -2,7 +2,8 @@ import Foundation import UIKit import Display import ComponentFlow -import GlassBackgroundComponent +import Display +import UIKitRuntimeUtils @inline(__always) private func getMethod(object: NSObject, selector: String) -> T? { @@ -69,123 +70,120 @@ private func createObject(className: String) -> NSObject? { } } +private func createFilter(name: String) -> NSObject? { + if let classValue = NSClassFromString("CAFilter") as AnyObject as? NSObject { + return classValue.perform(NSSelectorFromString("filterWithName:"), with: name).takeUnretainedValue() as? NSObject + } else { + return nil + } +} + private func setFilterName(object: NSObject, name: String) { object.perform(NSSelectorFromString("setName:"), with: name) } -public protocol LensTransitionContainerEffectView: UIView { - func updateSize(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) - func updateSize(duration: Double, keyframes: [CGSize]) - func updateCornerRadius(duration: Double, keyframes: [CGFloat]) -} - private final class EmptyLayerDelegate: NSObject, CALayerDelegate { func action(for layer: CALayer, forKey event: String) -> CAAction? { - return nullAction + return NSNull() } } -public final class LensTransitionContainer: UIView { - public let effectView: LensTransitionContainerEffectView? - public let sourceEffectView: LensTransitionContainerEffectView? +public protocol LensTransitionContainerEffectView: UIView { + func updateSize(duration: Double, keyframes: [CGSize]) + func updateSize(size: CGSize, transition: ComponentTransition) + func updatePosition(duration: Double, keyframes: [CGPoint]) + func updatePosition(position: CGPoint, transition: ComponentTransition) + func updateCornerRadius(duration: Double, keyframes: [CGFloat]) + func setTransitionFraction(value: CGFloat, duration: Double) +} + +public protocol LensTransitionContainerProtocol: UIView { + var effectView: LensTransitionContainerEffectView { get } + var contentsEffectView: UIView { get } + var contentsView: UIView { get } + + func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) + func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) + func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) +} + +@available(iOS 26.0, *) +final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol { + private let effectSettingsContainerView: EffectSettingsContainerView + public let effectView: LensTransitionContainerEffectView private let containerView: UIView public let contentsEffectView: UIView public let contentsView: UIView - + private let emptyLayerDelegate = EmptyLayerDelegate() - + private let sdfElementLayer: CALayer? private let sdfLayer: CALayer? private let displacementEffect: NSObject? - - public init(effectView: LensTransitionContainerEffectView? = nil, sourceEffectView: LensTransitionContainerEffectView? = nil) { + + init(effectView: LensTransitionContainerEffectView) { + self.effectSettingsContainerView = EffectSettingsContainerView() + self.containerView = UIView() self.effectView = effectView - self.sourceEffectView = sourceEffectView self.contentsEffectView = UIView() self.contentsView = UIView() - - if #available(iOS 26.0, *) { - self.sdfElementLayer = createObject(className: ("CAS" as NSString).appending("DFElementLayer") as String) as? CALayer - self.sdfLayer = createObject(className: ("CAS" as NSString).appending("DFLayer")) as? CALayer - self.displacementEffect = createObject(className: ("CAS" as NSString).appending("DFGlassDisplacementEffect")) - } else { - self.sdfElementLayer = nil - self.sdfLayer = nil - self.displacementEffect = nil - } - + + self.sdfElementLayer = createObject(className: "CASDFElementLayer") as? CALayer + self.sdfLayer = createObject(className: "CASDFLayer") as? CALayer + self.displacementEffect = createObject(className: "CASDFGlassDisplacementEffect") + super.init(frame: CGRect()) - - self.addSubview(self.containerView) + + self.addSubview(self.effectSettingsContainerView) + self.effectSettingsContainerView.addSubview(self.containerView) self.contentsView.clipsToBounds = true - - if let effectView = self.effectView { - self.containerView.addSubview(effectView) - } - + + self.containerView.addSubview(self.effectView) + self.containerView.addSubview(self.contentsEffectView) self.contentsEffectView.addSubview(self.contentsView) - + if let displacementEffect = self.displacementEffect { displacementEffect.setValue(1.0, forKey: "curvature") displacementEffect.setValue(0.0 as NSNumber, forKey: "angle") } - + if let sdfLayer = self.sdfLayer, let displacementEffect = self.displacementEffect { sdfLayer.name = "sdfLayer" - sdfLayer.setValue(UIScreenScale, forKey: "scale") + sdfLayer.setValue(3.0, forKey: "scale") sdfLayer.setValue(displacementEffect, forKey: "effect") sdfLayer.delegate = self.emptyLayerDelegate } - + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { sdfElementLayer.setValue(0.5 as NSNumber, forKey: "gradientOvalization") sdfElementLayer.isOpaque = true sdfElementLayer.allowsEdgeAntialiasing = true let sdfLayerDelegate = unsafeBitCast(sdfLayer, to: CALayerDelegate.self) sdfElementLayer.delegate = sdfLayerDelegate - sdfElementLayer.setValue(UIScreenScale, forKey: "scale") + sdfElementLayer.setValue(3.0, forKey: "scale") sdfLayer.addSublayer(sdfElementLayer) } + } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if self.alpha.isZero { - return nil - } - for view in self.contentsView.subviews.reversed() { - if let result = view.hitTest(self.convert(point, to: view), with: event), result.isUserInteractionEnabled { - return result - } - } - - let result = self.contentsView.hitTest(point, with: event) - if result != self.contentsView { - return result - } else { - return nil - } - } - private func setIsFilterActive(isFilterActive: Bool) { if isFilterActive { if self.contentsEffectView.layer.filters == nil { if let sdfLayer = self.sdfLayer { self.contentsEffectView.layer.insertSublayer(sdfLayer, at: 0) } - if let displacementFilter = CALayer.displacementMap(), let blurFilter = CALayer.blur() { + if let blurFilter = createFilter(name: "gaussianBlur"), let displacementFilter = createFilter(name: "displacementMap") { setFilterName(object: blurFilter, name: "gaussianBlur") - blurFilter.setValue(true, forKey: "inputNormalizeEdgesTransparent") - setFilterName(object: displacementFilter, name: "displacementMap") displacementFilter.setValue("sdfLayer", forKey: "inputSourceSublayerName") - - self.contentsEffectView.layer.rasterizationScale = UIScreenScale + + self.contentsEffectView.layer.rasterizationScale = 3.0 self.contentsEffectView.layer.filters = [ blurFilter, displacementFilter @@ -199,111 +197,664 @@ public final class LensTransitionContainer: UIView { } } } + + private func cancelAnimationsRecursively(layer: CALayer) { + layer.removeAllAnimations() + if let sublayers = layer.sublayers { + for sublayer in sublayers { + self.cancelAnimationsRecursively(layer: sublayer) + } + } + } + + private func cancelAnimationsRecursively(view: UIView) { + self.cancelAnimationsRecursively(layer: view.layer) + for subview in view.subviews { + self.cancelAnimationsRecursively(view: subview) + } + } + + private func cancelCurrentTransitionAnimations(sourceEffectView: LensTransitionContainerEffectView) { + self.cancelAnimationsRecursively(view: self.effectView) + self.cancelAnimationsRecursively(view: self.contentsEffectView) + self.cancelAnimationsRecursively(view: self.contentsView) + self.cancelAnimationsRecursively(view: self.containerView) + self.cancelAnimationsRecursively(view: sourceEffectView) + + if let sdfLayer = self.sdfLayer { + self.cancelAnimationsRecursively(layer: sdfLayer) + } + if let sdfElementLayer = self.sdfElementLayer { + self.cancelAnimationsRecursively(layer: sdfElementLayer) + } + } - public func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat) { - self.setIsFilterActive(isFilterActive: true) + private struct TransitionKeyframes { + let bakedSizes: [CGSize] + let bakedPositions: [CGPoint] + let localPositions: [CGPoint] + let sourcePositions: [CGPoint] + let radiusKeyframes: [CGFloat] + let containerPositions: [CGPoint] + let minSide: CGFloat + } - let duration = 0.5 + private func makeForwardTransitionKeyframes(fromRect: CGRect, toRect: CGRect, toCornerRadius: CGFloat) -> TransitionKeyframes { + let sourceMaxEdgeDistance: CGFloat = 20.0 + let sourceSuckDurationFraction: CGFloat = 0.9 + let sourceFinalInsideDistance: CGFloat = -8.0 + let sourceFinalInsideStartFraction: CGFloat = 0.65 + let sourceFullInsideInset: CGFloat = max(1.0, min(fromRect.width, fromRect.height) * 0.5) + let sampleCount = 30 + let sampleEndIndex = CGFloat(sampleCount - 1) let toSize = toRect.size - - if let sourceEffectView = self.sourceEffectView, !"".isEmpty { - self.insertSubview(sourceEffectView, at: 0) - sourceEffectView.frame = fromRect - sourceEffectView.updateSize(size: fromRect.size, cornerRadius: fromCornerRadius, transition: .immediate) - - let minSide = min(toSize.width, toSize.height) - let maxSide = max(toSize.width, toSize.height) - - let sizeKeyframes: [CGSize] = (0 ..< 30).map { i in - let t = CGFloat(i) / (30.0 - 1.0) - let scale = scaleEase(t) - let sideFraction = max(0.0, min(1.0, sideFractionEase(t))) - let side = (1.0 - sideFraction) * minSide + sideFraction * maxSide - let size: CGSize - if toSize.width > toSize.height { - size = CGSize(width: side, height: minSide) - } else { - size = CGSize(width: minSide, height: side) - } - return CGSize(width: size.width * scale, height: size.height * scale) + let toHalf = CGPoint(x: toSize.width * 0.5, y: toSize.height * 0.5) + let minSide = min(toSize.width, toSize.height) + let maxSide = max(toSize.width, toSize.height) + let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) + let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) + let opposingDirection = CGPoint(x: fromCenter.x - toCenter.x, y: fromCenter.y - toCenter.y) + + func clampSourcePositionToOriginalOpposingAxes(_ point: CGPoint) -> CGPoint { + var clamped = point + if opposingDirection.x > 0.0 { + clamped.x = min(clamped.x, fromCenter.x) + } else if opposingDirection.x < 0.0 { + clamped.x = max(clamped.x, fromCenter.x) } - - let cornerRadiusKeyframes: [CGFloat] = (0 ..< 30).map { i in - let t = CGFloat(i) / (30.0 - 1.0) - let scale = scaleEase(t) - let fraction = max(0.0, min(1.0, radiusFractionEase(t))) - let radius = (1.0 - fraction) * (minSide * 0.5) + fraction * toCornerRadius - return radius * scale + if opposingDirection.y > 0.0 { + clamped.y = min(clamped.y, fromCenter.y) + } else if opposingDirection.y < 0.0 { + clamped.y = max(clamped.y, fromCenter.y) } - - sourceEffectView.updateSize(duration: duration, keyframes: sizeKeyframes) - sourceEffectView.updateCornerRadius(duration: duration, keyframes: cornerRadiusKeyframes) - - let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) - let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) - - var options: UIView.KeyframeAnimationOptions = [.calculationModeLinear] - options.insert(UIView.KeyframeAnimationOptions(rawValue: UIView.AnimationOptions.curveLinear.rawValue)) - UIView.animateKeyframes( - withDuration: duration, - delay: 0.0, - options: options, - animations: { - let segmentCount = 29 - let step = 1.0 / Double(segmentCount) - for i in 0 ..< segmentCount { - let t = CGFloat(i + 1) / (30.0 - 1.0) - let fx = positionXFractionEase(t) - let fy = positionYFractionEase(t) - let center = CGPoint( - x: (1.0 - fx) * fromCenter.x + fx * toCenter.x, - y: (1.0 - fy) * fromCenter.y + fy * toCenter.y - ) - UIView.addKeyframe( - withRelativeStartTime: Double(i) * step, - relativeDuration: step - ) { - sourceEffectView.center = center - } - } - }, - completion: { [weak sourceEffectView] finished in - if finished { - sourceEffectView?.removeFromSuperview() - } - } + return clamped + } + + func isPointInsideRoundedRect(point: CGPoint, rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat) -> Bool { + let halfWidth = rectSize.width * 0.5 + let halfHeight = rectSize.height * 0.5 + if halfWidth <= 0.0 || halfHeight <= 0.0 { + return false + } + let radius = max(0.0, min(cornerRadius, min(halfWidth, halfHeight))) + + let localX = point.x - rectCenter.x + let localY = point.y - rectCenter.y + let absX = abs(localX) + let absY = abs(localY) + + if absX > halfWidth || absY > halfHeight { + return false + } + + if absX <= halfWidth - radius || absY <= halfHeight - radius { + return true + } + + let cornerCenter = CGPoint( + x: (halfWidth - radius) * (localX >= 0.0 ? 1.0 : -1.0), + y: (halfHeight - radius) * (localY >= 0.0 ? 1.0 : -1.0) ) + let dx = localX - cornerCenter.x + let dy = localY - cornerCenter.y + return dx * dx + dy * dy <= radius * radius } - - do { - let keyframeAnimation = CAKeyframeAnimation(keyPath: "transform.scale") - keyframeAnimation.duration = duration * UIView.animationDurationFactor() - keyframeAnimation.values = (0 ..< 30).map { i in - let t = CGFloat(i) / (30.0 - 1.0) - return scaleEase(t) as NSNumber + + func nearestBoundaryPointOnRoundedRect(point: CGPoint, rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat) -> CGPoint { + @inline(__always) + func clamp(_ value: CGFloat, _ lower: CGFloat, _ upper: CGFloat) -> CGFloat { + return max(lower, min(upper, value)) } - keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) - keyframeAnimation.isRemovedOnCompletion = true - keyframeAnimation.fillMode = .both - self.containerView.layer.add(keyframeAnimation, forKey: "transform.scale") - } - do { - let minSide = min(toSize.width, toSize.height) - let maxSide = max(toSize.width, toSize.height) - let sizes: [CGSize] = (0 ..< 30).map { i in - let t = CGFloat(i) / (30.0 - 1.0) - let fraction = max(0.0, min(1.0, sideFractionEase(t))) - let value = (1.0 - fraction) * minSide + fraction * maxSide - if toSize.width > toSize.height { - return CGSize(width: value, height: minSide) + + @inline(__always) + func normalizedAngle(_ angle: CGFloat) -> CGFloat { + let twoPi = CGFloat.pi * 2.0 + var value = angle.truncatingRemainder(dividingBy: twoPi) + if value < 0.0 { + value += twoPi + } + return value + } + + @inline(__always) + func clampedRangeValue(_ value: CGFloat, _ a: CGFloat, _ b: CGFloat) -> CGFloat { + if a <= b { + return clamp(value, a, b) } else { - return CGSize(width: minSide, height: value) + return (a + b) * 0.5 } } + + @inline(__always) + func dist2(_ a: CGPoint, _ b: CGPoint) -> CGFloat { + let dx = a.x - b.x + let dy = a.y - b.y + return dx * dx + dy * dy + } + + let halfWidth = rectSize.width * 0.5 + let halfHeight = rectSize.height * 0.5 + let maxRadius = min(halfWidth, halfHeight) + let radius = max(0.0, min(cornerRadius, maxRadius)) + + let localPoint = CGPoint(x: point.x - rectCenter.x, y: point.y - rectCenter.y) + + var candidates: [CGPoint] = [] + candidates.reserveCapacity(radius > 0.0 ? 8 : 4) + + let minX = -halfWidth + let maxX = halfWidth + let minY = -halfHeight + let maxY = halfHeight + + let topX = clampedRangeValue(localPoint.x, minX + radius, maxX - radius) + candidates.append(CGPoint(x: topX, y: minY)) + + let bottomX = clampedRangeValue(localPoint.x, minX + radius, maxX - radius) + candidates.append(CGPoint(x: bottomX, y: maxY)) + + let leftY = clampedRangeValue(localPoint.y, minY + radius, maxY - radius) + candidates.append(CGPoint(x: minX, y: leftY)) + + let rightY = clampedRangeValue(localPoint.y, minY + radius, maxY - radius) + candidates.append(CGPoint(x: maxX, y: rightY)) + + if radius > 0.0 { + typealias Arc = (center: CGPoint, start: CGFloat, end: CGFloat) + let arcs: [Arc] = [ + (CGPoint(x: minX + radius, y: minY + radius), .pi, .pi * 1.5), + (CGPoint(x: maxX - radius, y: minY + radius), .pi * 1.5, .pi * 2.0), + (CGPoint(x: maxX - radius, y: maxY - radius), 0.0, .pi * 0.5), + (CGPoint(x: minX + radius, y: maxY - radius), .pi * 0.5, .pi) + ] + + for arc in arcs { + let vx = localPoint.x - arc.center.x + let vy = localPoint.y - arc.center.y + let rawAngle: CGFloat + if abs(vx) <= 1e-6 && abs(vy) <= 1e-6 { + rawAngle = arc.start + } else { + rawAngle = normalizedAngle(atan2(vy, vx)) + } + var angle = rawAngle + if arc.end >= (.pi * 2.0) - 1e-6 && angle < arc.start { + angle += .pi * 2.0 + } + let clampedAngle = clamp(angle, arc.start, arc.end) + candidates.append( + CGPoint( + x: arc.center.x + cos(clampedAngle) * radius, + y: arc.center.y + sin(clampedAngle) * radius + ) + ) + } + } + + guard let nearestLocal = candidates.min(by: { dist2($0, localPoint) < dist2($1, localPoint) }) else { + return rectCenter + } + return CGPoint(x: rectCenter.x + nearestLocal.x, y: rectCenter.y + nearestLocal.y) + } + + var bakedSizes: [CGSize] = [] + var bakedPositions: [CGPoint] = [] + var localPositions: [CGPoint] = [] + var sourcePositions: [CGPoint] = [] + var radiusKeyframes: [CGFloat] = [] + var containerPositions: [CGPoint] = [] + var lockedSourceInsetDistance: CGFloat? + bakedSizes.reserveCapacity(sampleCount) + bakedPositions.reserveCapacity(sampleCount) + localPositions.reserveCapacity(sampleCount) + sourcePositions.reserveCapacity(sampleCount) + radiusKeyframes.reserveCapacity(sampleCount) + containerPositions.reserveCapacity(sampleCount) + + for i in 0 ..< sampleCount { + let t = sampleEndIndex > 0.0 ? CGFloat(i) / sampleEndIndex : 1.0 + let scale = CGFloat(scaleEase(Double(t))) + + let sideFraction = max(0.0, min(1.0, sideFractionEase(Double(t)))) + let sideValue = (1.0 - sideFraction) * minSide + sideFraction * maxSide + let baseSize: CGSize + if toSize.width > toSize.height { + baseSize = CGSize(width: sideValue, height: minSide) + } else { + baseSize = CGSize(width: minSide, height: sideValue) + } + let scaledSize = CGSize(width: baseSize.width * scale, height: baseSize.height * scale) + bakedSizes.append(scaledSize) + localPositions.append(CGPoint(x: scaledSize.width * 0.5, y: scaledSize.height * 0.5)) + let bakedPosition = CGPoint( + x: (1.0 - scale) * toHalf.x + scale * baseSize.width * 0.5, + y: (1.0 - scale) * toHalf.y + scale * baseSize.height * 0.5 + ) + bakedPositions.append(bakedPosition) + + let radiusFraction = max(0.0, min(1.0, radiusFractionEase(Double(t)))) + let baseRadius = (1.0 - radiusFraction) * (minSide * 0.5) + radiusFraction * toCornerRadius + let scaledCornerRadius = baseRadius * scale + radiusKeyframes.append(scaledCornerRadius) + + let positionFraction = springProgress(Double(t)) + let containerPosition = CGPoint( + x: (1.0 - positionFraction) * fromCenter.x + positionFraction * toCenter.x, + y: (1.0 - positionFraction) * fromCenter.y + positionFraction * toCenter.y + ) + containerPositions.append(containerPosition) + + let blobCenter = CGPoint( + x: containerPosition.x - toHalf.x + bakedPosition.x, + y: containerPosition.y - toHalf.y + bakedPosition.y + ) + + if scaledSize.width < fromRect.width || scaledSize.height < fromRect.height { + sourcePositions.append(fromCenter) + continue + } + + let nearestEdgePoint = nearestBoundaryPointOnRoundedRect( + point: fromCenter, + rectCenter: blobCenter, + rectSize: scaledSize, + cornerRadius: scaledCornerRadius + ) + + let rawRayX = fromCenter.x - nearestEdgePoint.x + let rawRayY = fromCenter.y - nearestEdgePoint.y + let rawRayLength = hypot(rawRayX, rawRayY) + var rayDirection: CGPoint + if rawRayLength > 1e-6 { + rayDirection = CGPoint(x: rawRayX / rawRayLength, y: rawRayY / rawRayLength) + } else { + let outwardX = nearestEdgePoint.x - blobCenter.x + let outwardY = nearestEdgePoint.y - blobCenter.y + let outwardLength = hypot(outwardX, outwardY) + if outwardLength > 1e-6 { + rayDirection = CGPoint(x: outwardX / outwardLength, y: outwardY / outwardLength) + } else { + rayDirection = CGPoint(x: 0.0, y: 0.0) + } + } + + let outwardProbe = CGPoint( + x: nearestEdgePoint.x + rayDirection.x, + y: nearestEdgePoint.y + rayDirection.y + ) + if isPointInsideRoundedRect( + point: outwardProbe, + rectCenter: blobCenter, + rectSize: scaledSize, + cornerRadius: scaledCornerRadius + ) { + rayDirection.x = -rayDirection.x + rayDirection.y = -rayDirection.y + } + + let normalizedSuckProgress = max(0.0, min(1.0, t / sourceSuckDurationFraction)) + let oneMinusSuckProgress = 1.0 - normalizedSuckProgress + let suckFraction = 1.0 - oneMinusSuckProgress * oneMinusSuckProgress * oneMinusSuckProgress + let animatedSourceInsetDistance = sourceMaxEdgeDistance - suckFraction * (sourceMaxEdgeDistance + sourceFullInsideInset) + let baseSourceInsetDistance = lockedSourceInsetDistance ?? animatedSourceInsetDistance + let normalizedFinalInsideProgress = max(0.0, min(1.0, (t - sourceFinalInsideStartFraction) / max(0.001, 1.0 - sourceFinalInsideStartFraction))) + let oneMinusFinalInsideProgress = 1.0 - normalizedFinalInsideProgress + let finalInsideEase = 1.0 - oneMinusFinalInsideProgress * oneMinusFinalInsideProgress * oneMinusFinalInsideProgress + let sourceInsetDistance = baseSourceInsetDistance + (sourceFinalInsideDistance - baseSourceInsetDistance) * finalInsideEase + let sourceHalfWidth = fromRect.width * 0.5 + let sourceHalfHeight = fromRect.height * 0.5 + let sourceHalfExtentAlongRay = abs(rayDirection.x) * sourceHalfWidth + abs(rayDirection.y) * sourceHalfHeight + let sourceCenterDistance = sourceInsetDistance + sourceHalfExtentAlongRay + let rawSourcePosition = CGPoint( + x: nearestEdgePoint.x + rayDirection.x * sourceCenterDistance, + y: nearestEdgePoint.y + rayDirection.y * sourceCenterDistance + ) + let sourcePosition = clampSourcePositionToOriginalOpposingAxes(rawSourcePosition) + sourcePositions.append(sourcePosition) + + if lockedSourceInsetDistance == nil { + let insetWidth = max(0.0, scaledSize.width - sourceFullInsideInset * 2.0) + let insetHeight = max(0.0, scaledSize.height - sourceFullInsideInset * 2.0) + let insetSize = CGSize(width: insetWidth, height: insetHeight) + let insetRadius = max(0.0, scaledCornerRadius - sourceFullInsideInset) + if sourceInsetDistance <= 0.0 && isPointInsideRoundedRect( + point: sourcePosition, + rectCenter: blobCenter, + rectSize: insetSize, + cornerRadius: insetRadius + ) { + lockedSourceInsetDistance = sourceInsetDistance + } + } + } + + return TransitionKeyframes( + bakedSizes: bakedSizes, + bakedPositions: bakedPositions, + localPositions: localPositions, + sourcePositions: sourcePositions, + radiusKeyframes: radiusKeyframes, + containerPositions: containerPositions, + minSide: minSide + ) + } + + public func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, duration: Double, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { + if isDark { + self.effectSettingsContainerView.lumaMin = 0.0 + self.effectSettingsContainerView.lumaMax = 0.15 + } else { + self.effectSettingsContainerView.lumaMin = 0.8 + self.effectSettingsContainerView.lumaMax = 0.801 + } + + self.cancelCurrentTransitionAnimations(sourceEffectView: sourceEffectView) + self.setIsFilterActive(isFilterActive: true) + sourceEffectView.setTransitionFraction(value: 1.0, duration: 0.0) + sourceEffectView.setTransitionFraction(value: 0.0, duration: 0.2) + let sourceMaxEdgeDistance: CGFloat = 20.0 + let sourceSuckDurationFraction: CGFloat = 0.9 + let sourceFinalInsideDistance: CGFloat = -8.0 + let sourceFinalInsideStartFraction: CGFloat = 0.65 + let sourceFullInsideInset: CGFloat = max(1.0, min(fromRect.width, fromRect.height) * 0.5) + let sampleCount = 30 + let sampleEndIndex = CGFloat(sampleCount - 1) + let toSize = toRect.size + let toHalf = CGPoint(x: toSize.width * 0.5, y: toSize.height * 0.5) + let minSide = min(toSize.width, toSize.height) + let maxSide = max(toSize.width, toSize.height) + let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) + let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) + let opposingDirection = CGPoint(x: fromCenter.x - toCenter.x, y: fromCenter.y - toCenter.y) + + func clampSourcePositionToOriginalOpposingAxes(_ point: CGPoint) -> CGPoint { + var clamped = point + if opposingDirection.x > 0.0 { + clamped.x = min(clamped.x, fromCenter.x) + } else if opposingDirection.x < 0.0 { + clamped.x = max(clamped.x, fromCenter.x) + } + if opposingDirection.y > 0.0 { + clamped.y = min(clamped.y, fromCenter.y) + } else if opposingDirection.y < 0.0 { + clamped.y = max(clamped.y, fromCenter.y) + } + return clamped + } + + func isPointInsideRoundedRect(point: CGPoint, rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat) -> Bool { + let halfWidth = rectSize.width * 0.5 + let halfHeight = rectSize.height * 0.5 + if halfWidth <= 0.0 || halfHeight <= 0.0 { + return false + } + let radius = max(0.0, min(cornerRadius, min(halfWidth, halfHeight))) + + let localX = point.x - rectCenter.x + let localY = point.y - rectCenter.y + let absX = abs(localX) + let absY = abs(localY) + + if absX > halfWidth || absY > halfHeight { + return false + } + + // Central body strips. + if absX <= halfWidth - radius || absY <= halfHeight - radius { + return true + } + + // Corner arc region. + let cornerCenter = CGPoint( + x: (halfWidth - radius) * (localX >= 0.0 ? 1.0 : -1.0), + y: (halfHeight - radius) * (localY >= 0.0 ? 1.0 : -1.0) + ) + let dx = localX - cornerCenter.x + let dy = localY - cornerCenter.y + return dx * dx + dy * dy <= radius * radius + } + + func nearestBoundaryPointOnRoundedRect(point: CGPoint, rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat) -> CGPoint { + @inline(__always) + func clamp(_ value: CGFloat, _ lower: CGFloat, _ upper: CGFloat) -> CGFloat { + return max(lower, min(upper, value)) + } + + @inline(__always) + func normalizedAngle(_ angle: CGFloat) -> CGFloat { + let twoPi = CGFloat.pi * 2.0 + var value = angle.truncatingRemainder(dividingBy: twoPi) + if value < 0.0 { + value += twoPi + } + return value + } + + @inline(__always) + func clampedRangeValue(_ value: CGFloat, _ a: CGFloat, _ b: CGFloat) -> CGFloat { + if a <= b { + return clamp(value, a, b) + } else { + return (a + b) * 0.5 + } + } + + @inline(__always) + func dist2(_ a: CGPoint, _ b: CGPoint) -> CGFloat { + let dx = a.x - b.x + let dy = a.y - b.y + return dx * dx + dy * dy + } + + let halfWidth = rectSize.width * 0.5 + let halfHeight = rectSize.height * 0.5 + let maxRadius = min(halfWidth, halfHeight) + let radius = max(0.0, min(cornerRadius, maxRadius)) + + let localPoint = CGPoint(x: point.x - rectCenter.x, y: point.y - rectCenter.y) + + var candidates: [CGPoint] = [] + candidates.reserveCapacity(radius > 0.0 ? 8 : 4) + + // Side segments in local coordinates. + let minX = -halfWidth + let maxX = halfWidth + let minY = -halfHeight + let maxY = halfHeight + + let topX = clampedRangeValue(localPoint.x, minX + radius, maxX - radius) + candidates.append(CGPoint(x: topX, y: minY)) + + let bottomX = clampedRangeValue(localPoint.x, minX + radius, maxX - radius) + candidates.append(CGPoint(x: bottomX, y: maxY)) + + let leftY = clampedRangeValue(localPoint.y, minY + radius, maxY - radius) + candidates.append(CGPoint(x: minX, y: leftY)) + + let rightY = clampedRangeValue(localPoint.y, minY + radius, maxY - radius) + candidates.append(CGPoint(x: maxX, y: rightY)) + + if radius > 0.0 { + typealias Arc = (center: CGPoint, start: CGFloat, end: CGFloat) + let arcs: [Arc] = [ + (CGPoint(x: minX + radius, y: minY + radius), .pi, .pi * 1.5), // TL + (CGPoint(x: maxX - radius, y: minY + radius), .pi * 1.5, .pi * 2.0), // TR + (CGPoint(x: maxX - radius, y: maxY - radius), 0.0, .pi * 0.5), // BR + (CGPoint(x: minX + radius, y: maxY - radius), .pi * 0.5, .pi) // BL + ] + + for arc in arcs { + let vx = localPoint.x - arc.center.x + let vy = localPoint.y - arc.center.y + let rawAngle: CGFloat + if abs(vx) <= 1e-6 && abs(vy) <= 1e-6 { + rawAngle = arc.start + } else { + rawAngle = normalizedAngle(atan2(vy, vx)) + } + var angle = rawAngle + if arc.end >= (.pi * 2.0) - 1e-6 && angle < arc.start { + angle += .pi * 2.0 + } + let clampedAngle = clamp(angle, arc.start, arc.end) + candidates.append( + CGPoint( + x: arc.center.x + cos(clampedAngle) * radius, + y: arc.center.y + sin(clampedAngle) * radius + ) + ) + } + } + + guard let nearestLocal = candidates.min(by: { dist2($0, localPoint) < dist2($1, localPoint) }) else { + return rectCenter + } + return CGPoint(x: rectCenter.x + nearestLocal.x, y: rectCenter.y + nearestLocal.y) + } + + var bakedSizes: [CGSize] = [] + var bakedPositions: [CGPoint] = [] + var localPositions: [CGPoint] = [] + var sourcePositions: [CGPoint] = [] + var radiusKeyframes: [CGFloat] = [] + var containerPositions: [CGPoint] = [] + var lockedSourceInsetDistance: CGFloat? + bakedSizes.reserveCapacity(sampleCount) + bakedPositions.reserveCapacity(sampleCount) + localPositions.reserveCapacity(sampleCount) + sourcePositions.reserveCapacity(sampleCount) + radiusKeyframes.reserveCapacity(sampleCount) + containerPositions.reserveCapacity(sampleCount) + + for i in 0 ..< sampleCount { + let t = sampleEndIndex > 0.0 ? CGFloat(i) / sampleEndIndex : 1.0 + let scale = CGFloat(scaleEase(Double(t))) + + let sideFraction = max(0.0, min(1.0, sideFractionEase(Double(t)))) + let sideValue = (1.0 - sideFraction) * minSide + sideFraction * maxSide + let baseSize: CGSize + if toSize.width > toSize.height { + baseSize = CGSize(width: sideValue, height: minSide) + } else { + baseSize = CGSize(width: minSide, height: sideValue) + } + let scaledSize = CGSize(width: baseSize.width * scale, height: baseSize.height * scale) + bakedSizes.append(scaledSize) + localPositions.append(CGPoint(x: scaledSize.width * 0.5, y: scaledSize.height * 0.5)) + let bakedPosition = CGPoint( + x: (1.0 - scale) * toHalf.x + scale * baseSize.width * 0.5, + y: (1.0 - scale) * toHalf.y + scale * baseSize.height * 0.5 + ) + bakedPositions.append(bakedPosition) + + let radiusFraction = max(0.0, min(1.0, radiusFractionEase(Double(t)))) + let baseRadius = (1.0 - radiusFraction) * (minSide * 0.5) + radiusFraction * toCornerRadius + let scaledCornerRadius = baseRadius * scale + radiusKeyframes.append(scaledCornerRadius) + + let positionFraction = springProgress(Double(t)) + let containerPosition = CGPoint( + x: (1.0 - positionFraction) * fromCenter.x + positionFraction * toCenter.x, + y: (1.0 - positionFraction) * fromCenter.y + positionFraction * toCenter.y + ) + containerPositions.append(containerPosition) + + let blobCenter = CGPoint( + x: containerPosition.x - toHalf.x + bakedPosition.x, + y: containerPosition.y - toHalf.y + bakedPosition.y + ) + + // Keep source fixed until the blob can fully contain it. + if scaledSize.width < fromRect.width || scaledSize.height < fromRect.height { + sourcePositions.append(fromCenter) + continue + } + + let nearestEdgePoint = nearestBoundaryPointOnRoundedRect( + point: fromCenter, + rectCenter: blobCenter, + rectSize: scaledSize, + cornerRadius: scaledCornerRadius + ) + + let rawRayX = fromCenter.x - nearestEdgePoint.x + let rawRayY = fromCenter.y - nearestEdgePoint.y + let rawRayLength = hypot(rawRayX, rawRayY) + var rayDirection: CGPoint + if rawRayLength > 1e-6 { + rayDirection = CGPoint(x: rawRayX / rawRayLength, y: rawRayY / rawRayLength) + } else { + let outwardX = nearestEdgePoint.x - blobCenter.x + let outwardY = nearestEdgePoint.y - blobCenter.y + let outwardLength = hypot(outwardX, outwardY) + if outwardLength > 1e-6 { + rayDirection = CGPoint(x: outwardX / outwardLength, y: outwardY / outwardLength) + } else { + rayDirection = CGPoint(x: 0.0, y: 0.0) + } + } + + // Ensure positive distances move outward from the blob. + let outwardProbe = CGPoint( + x: nearestEdgePoint.x + rayDirection.x, + y: nearestEdgePoint.y + rayDirection.y + ) + if isPointInsideRoundedRect( + point: outwardProbe, + rectCenter: blobCenter, + rectSize: scaledSize, + cornerRadius: scaledCornerRadius + ) { + rayDirection.x = -rayDirection.x + rayDirection.y = -rayDirection.y + } + + let normalizedSuckProgress = max(0.0, min(1.0, t / sourceSuckDurationFraction)) + let oneMinusSuckProgress = 1.0 - normalizedSuckProgress + let suckFraction = 1.0 - oneMinusSuckProgress * oneMinusSuckProgress * oneMinusSuckProgress + let animatedSourceInsetDistance = sourceMaxEdgeDistance - suckFraction * (sourceMaxEdgeDistance + sourceFullInsideInset) + let baseSourceInsetDistance = lockedSourceInsetDistance ?? animatedSourceInsetDistance + let normalizedFinalInsideProgress = max(0.0, min(1.0, (t - sourceFinalInsideStartFraction) / max(0.001, 1.0 - sourceFinalInsideStartFraction))) + let oneMinusFinalInsideProgress = 1.0 - normalizedFinalInsideProgress + let finalInsideEase = 1.0 - oneMinusFinalInsideProgress * oneMinusFinalInsideProgress * oneMinusFinalInsideProgress + let sourceInsetDistance = baseSourceInsetDistance + (sourceFinalInsideDistance - baseSourceInsetDistance) * finalInsideEase + let sourceHalfWidth = fromRect.width * 0.5 + let sourceHalfHeight = fromRect.height * 0.5 + let sourceHalfExtentAlongRay = abs(rayDirection.x) * sourceHalfWidth + abs(rayDirection.y) * sourceHalfHeight + let sourceCenterDistance = sourceInsetDistance + sourceHalfExtentAlongRay + let rawSourcePosition = CGPoint( + x: nearestEdgePoint.x + rayDirection.x * sourceCenterDistance, + y: nearestEdgePoint.y + rayDirection.y * sourceCenterDistance + ) + let sourcePosition = clampSourcePositionToOriginalOpposingAxes(rawSourcePosition) + sourcePositions.append(sourcePosition) + + if lockedSourceInsetDistance == nil { + let insetWidth = max(0.0, scaledSize.width - sourceFullInsideInset * 2.0) + let insetHeight = max(0.0, scaledSize.height - sourceFullInsideInset * 2.0) + let insetSize = CGSize(width: insetWidth, height: insetHeight) + let insetRadius = max(0.0, scaledCornerRadius - sourceFullInsideInset) + if sourceInsetDistance <= 0.0 && isPointInsideRoundedRect( + point: sourcePosition, + rectCenter: blobCenter, + rectSize: insetSize, + cornerRadius: insetRadius + ) { + lockedSourceInsetDistance = sourceInsetDistance + } + } + } + + self.effectSettingsContainerView.addSubview(sourceEffectView) + sourceEffectView.updateSize(size: fromRect.size, transition: .immediate) + sourceEffectView.frame = fromRect + sourceEffectView.updateCornerRadius(duration: 0.0, keyframes: [fromCornerRadius]) + sourceEffectView.updatePosition(duration: duration, keyframes: sourcePositions) + do { let keyframeAnimation = CAKeyframeAnimation(keyPath: "bounds.size") keyframeAnimation.duration = duration * UIView.animationDurationFactor() - keyframeAnimation.values = sizes.map { + keyframeAnimation.values = bakedSizes.map { NSValue(cgSize: $0) } keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) @@ -311,71 +862,45 @@ public final class LensTransitionContainer: UIView { keyframeAnimation.fillMode = .both self.contentsView.layer.add(keyframeAnimation, forKey: "bounds.size") self.contentsEffectView.layer.add(keyframeAnimation, forKey: "bounds.size") - + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { sdfLayer.add(keyframeAnimation, forKey: "bounds.size") sdfElementLayer.add(keyframeAnimation, forKey: "bounds.size") } - - let positions: [CGPoint] = (0 ..< 30).map { i in - let t = CGFloat(i) / (30.0 - 1.0) - let fraction = max(0.0, min(1.0, sideFractionEase(t))) - let value = (1.0 - fraction) * minSide + fraction * maxSide - let size: CGSize - if toSize.width > toSize.height { - size = CGSize(width: value, height: minSide) - } else { - size = CGSize(width: minSide, height: value) - } - return CGPoint(x: size.width * 0.5, y: size.height * 0.5) - } + let positionAnimation = CAKeyframeAnimation(keyPath: "position") positionAnimation.duration = duration * UIView.animationDurationFactor() - positionAnimation.values = positions.map { NSValue(cgPoint: $0) } + positionAnimation.values = localPositions.map { NSValue(cgPoint: $0) } positionAnimation.timingFunction = CAMediaTimingFunction(name: .linear) positionAnimation.isRemovedOnCompletion = true positionAnimation.fillMode = .both self.contentsView.layer.add(positionAnimation, forKey: "position") - self.contentsEffectView.layer.add(positionAnimation, forKey: "position") if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { sdfLayer.add(positionAnimation, forKey: "position") sdfElementLayer.add(positionAnimation, forKey: "position") } - - if let effectView = self.effectView { - effectView.layer.add(positionAnimation, forKey: "position") - effectView.updateSize(duration: duration, keyframes: sizes) - } + + let containerPositionAnimation = CAKeyframeAnimation(keyPath: "position") + containerPositionAnimation.duration = duration * UIView.animationDurationFactor() + containerPositionAnimation.values = bakedPositions.map { NSValue(cgPoint: $0) } + containerPositionAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + containerPositionAnimation.isRemovedOnCompletion = true + containerPositionAnimation.fillMode = .both + self.contentsEffectView.layer.add(containerPositionAnimation, forKey: "position") + + self.effectView.updateSize(duration: duration, keyframes: bakedSizes) + self.effectView.updatePosition(duration: duration, keyframes: bakedPositions) } do { - let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) - let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) - let positions: [CGPoint] = (0..<30).map { i in - let t = CGFloat(i) / 29.0 - let fx = positionXFractionEase(t) - let fy = positionYFractionEase(t) - return CGPoint( - x: (1.0 - fx) * fromCenter.x + fx * toCenter.x, - y: (1.0 - fy) * fromCenter.y + fy * toCenter.y - ) - } - let keyframeAnimation = CAKeyframeAnimation(keyPath: "position") keyframeAnimation.duration = duration * UIView.animationDurationFactor() - keyframeAnimation.values = positions.map { NSValue(cgPoint: $0) } + keyframeAnimation.values = containerPositions.map { NSValue(cgPoint: $0) } keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) keyframeAnimation.isRemovedOnCompletion = true keyframeAnimation.fillMode = .both self.containerView.layer.add(keyframeAnimation, forKey: "position") } do { - let minSide = min(toSize.width, toSize.height) - let radiusKeyframes = (0 ..< 30).map { i -> CGFloat in - let t = CGFloat(i) / (30.0 - 1.0) - let fraction = max(0.0, min(1.0, radiusFractionEase(t))) - let value = (1.0 - fraction) * (minSide * 0.5) + fraction * toCornerRadius - return value - } let keyframeAnimation = CAKeyframeAnimation(keyPath: "cornerRadius") keyframeAnimation.duration = duration * UIView.animationDurationFactor() keyframeAnimation.values = radiusKeyframes.map { $0 as NSNumber } @@ -387,23 +912,22 @@ public final class LensTransitionContainer: UIView { sdfLayer.add(keyframeAnimation, forKey: "cornerRadius") sdfElementLayer.add(keyframeAnimation, forKey: "cornerRadius") } - self.effectView?.updateCornerRadius(duration: duration, keyframes: radiusKeyframes) + self.effectView.updateCornerRadius(duration: duration, keyframes: radiusKeyframes) } do { self.contentsEffectView.layer.setValue(0.0 as NSNumber, forKeyPath: "filters.gaussianBlur.inputRadius") self.contentsEffectView.layer.setValue(0.0 as NSNumber, forKeyPath: "sublayers.sdfLayer.effect.height") self.contentsEffectView.layer.setValue(-0.001 as NSNumber, forKeyPath: "filters.displacementMap.inputAmount") - - let minSide = min(toSize.width, toSize.height) - let fromHeight: CGFloat = minSide * 0.33 + + let fromHeight: CGFloat = minSide * 0.25 let toHeight: CGFloat = 0.001 let effectHeightKeyframes = (0 ..< 30).map { i -> CGFloat in let t = CGFloat(i) / (30.0 - 1.0) - let fraction = max(0.0, min(1.0, displacementFractionEase(t))) + let fraction = CGFloat(max(0.0, min(1.0, displacementFractionEase(Double(t))))) let value = (1.0 - fraction) * fromHeight + fraction * toHeight return value } - + let heightKeyframeAnimation = CAKeyframeAnimation(keyPath: "sublayers.sdfLayer.effect.height") heightKeyframeAnimation.duration = duration * UIView.animationDurationFactor() heightKeyframeAnimation.values = effectHeightKeyframes.map { $0 as NSNumber } @@ -411,7 +935,7 @@ public final class LensTransitionContainer: UIView { heightKeyframeAnimation.isRemovedOnCompletion = true heightKeyframeAnimation.fillMode = .both self.contentsEffectView.layer.add(heightKeyframeAnimation, forKey: "sublayers.sdfLayer.effect.height") - + let displacementKeyframeAnimation = CAKeyframeAnimation(keyPath: "filters.displacementMap.inputAmount") displacementKeyframeAnimation.duration = duration * UIView.animationDurationFactor() displacementKeyframeAnimation.values = effectHeightKeyframes.map { -$0 as NSNumber } @@ -419,10 +943,10 @@ public final class LensTransitionContainer: UIView { displacementKeyframeAnimation.isRemovedOnCompletion = true displacementKeyframeAnimation.fillMode = .both self.contentsEffectView.layer.add(displacementKeyframeAnimation, forKey: "filters.displacementMap.inputAmount") - + let blurKeyframes = (0 ..< 30).map { i -> CGFloat in let t = CGFloat(i) / (30.0 - 1.0) - return blurEase(t) + return CGFloat(blurEase(Double(t))) } let blurKeyframeAnimation = CAKeyframeAnimation(keyPath: "filters.gaussianBlur.inputRadius") blurKeyframeAnimation.duration = duration * UIView.animationDurationFactor() @@ -435,202 +959,560 @@ public final class LensTransitionContainer: UIView { do { let subScaleKeyframes = (0 ..< 30).map { i -> CGFloat in let t = CGFloat(i) / (30.0 - 1.0) - return subScaleEase(t) + return CGFloat(subScaleEase(Double(t))) } - let keyframeAnimation = CAKeyframeAnimation(keyPath: "transform.scale") + let keyframeAnimation = CAKeyframeAnimation(keyPath: "sublayerTransform.scale") keyframeAnimation.duration = duration * UIView.animationDurationFactor() keyframeAnimation.values = subScaleKeyframes.map { $0 as NSNumber } keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) keyframeAnimation.isRemovedOnCompletion = true keyframeAnimation.fillMode = .both - self.contentsView.layer.add(keyframeAnimation, forKey: "transform.scale") + self.contentsView.layer.add(keyframeAnimation, forKey: "sublayerTransform.scale") } - + self.contentsView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + UIView.animationDurationFactor() * duration, execute: { [weak self] in + let cleanupDelay = max(duration, duration * UIView.animationDurationFactor()) + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + cleanupDelay, execute: { [weak self, weak sourceEffectView] in + sourceEffectView?.removeFromSuperview() guard let self else { return } self.setIsFilterActive(isFilterActive: false) }) } + + public func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { + self.animateIn( + fromRect: fromRect, + toRect: toRect, + fromCornerRadius: fromCornerRadius, + toCornerRadius: toCornerRadius, + duration: 0.5, + isDark: isDark, + sourceEffectView: sourceEffectView + ) + } - public func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat) { - self.setIsFilterActive(isFilterActive: true) + public func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, duration: Double, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { + if isDark { + self.effectSettingsContainerView.lumaMin = 0.0 + self.effectSettingsContainerView.lumaMax = 0.15 + } else { + self.effectSettingsContainerView.lumaMin = 0.8 + self.effectSettingsContainerView.lumaMax = 0.801 + } - let duration: Double = 0.15 + self.cancelCurrentTransitionAnimations(sourceEffectView: sourceEffectView) + self.setIsFilterActive(isFilterActive: true) + sourceEffectView.setTransitionFraction(value: 0.0, duration: 0.0) + sourceEffectView.setTransitionFraction(value: 1.0, duration: 0.3) - if let sourceEffectView = self.sourceEffectView, !"".isEmpty { - self.insertSubview(sourceEffectView, at: 0) - sourceEffectView.frame = fromRect - sourceEffectView.updateSize(size: fromRect.size, cornerRadius: fromCornerRadius, transition: .immediate) - sourceEffectView.updateSize(size: toRect.size, cornerRadius: toCornerRadius, transition: .easeInOut(duration: duration)) - UIView.animate(withDuration: duration, delay: 0.0, options: .curveEaseInOut, animations: { - sourceEffectView.center = toRect.center - }) + let sourceMaxEdgeDistance: CGFloat = 20.0 + let sourceEaseOutDurationFraction: CGFloat = 0.35 + let sourceCenterPullStartFraction: CGFloat + let sourceCenterPullDurationFraction: CGFloat + + let centerDistance = sqrt(pow(fromRect.midX - toRect.midX, 2.0) + pow(fromRect.midY - toRect.midY, 2.0)) + if centerDistance >= 100.0 { + sourceCenterPullStartFraction = 0.2 + sourceCenterPullDurationFraction = 0.3 + } else { + sourceCenterPullStartFraction = 0.0 + sourceCenterPullDurationFraction = 0.0 } - - let fromSize = fromRect.size - //let toSize = toRect.size - let timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - // Scale: 1.0 -> 0.097 - do { - let animation = CABasicAnimation(keyPath: "transform.scale") - animation.fromValue = 1.0 as NSNumber - animation.toValue = 0.097 as NSNumber - animation.duration = duration * UIView.animationDurationFactor() - animation.timingFunction = timingFunction - animation.isRemovedOnCompletion = false - animation.fillMode = .both - self.containerView.layer.add(animation, forKey: "transform.scale") + + let sampleCount = 36 + let sampleEndIndex = CGFloat(sampleCount - 1) + let collapsedCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) + let expandedCenter = CGPoint(x: toRect.midX, y: toRect.midY) + let expandedSize = toRect.size + let collapsedSize = fromRect.size + + @inline(__always) + func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat { + return (1.0 - t) * a + t * b } - - // Bounds size: fromSize -> square(minSide) - do { - let minSide = min(fromSize.width, fromSize.height) - let toSquare = CGSize(width: minSide, height: minSide) - - let sizeAnimation = CABasicAnimation(keyPath: "bounds.size") - sizeAnimation.fromValue = NSValue(cgSize: fromSize) - sizeAnimation.toValue = NSValue(cgSize: toSquare) - sizeAnimation.duration = duration * UIView.animationDurationFactor() - sizeAnimation.timingFunction = timingFunction - sizeAnimation.isRemovedOnCompletion = false - sizeAnimation.fillMode = .both - self.contentsView.layer.add(sizeAnimation, forKey: "bounds.size") - self.contentsEffectView.layer.add(sizeAnimation, forKey: "bounds.size") - - if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { - sdfLayer.add(sizeAnimation, forKey: "bounds.size") - sdfElementLayer.add(sizeAnimation, forKey: "bounds.size") + + @inline(__always) + func lerpPoint(_ a: CGPoint, _ b: CGPoint, _ t: CGFloat) -> CGPoint { + return CGPoint(x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t)) + } + + @inline(__always) + func normalized(_ point: CGPoint) -> CGPoint { + let length = hypot(point.x, point.y) + if length <= 1e-6 { + return CGPoint(x: 1.0, y: 0.0) } + return CGPoint(x: point.x / length, y: point.y / length) + } + + func boundaryPointOnRoundedRectRay(rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat, direction: CGPoint) -> CGPoint { + let halfWidth = rectSize.width * 0.5 + let halfHeight = rectSize.height * 0.5 + if halfWidth <= 0.0 || halfHeight <= 0.0 { + return rectCenter + } + + let radius = max(0.0, min(cornerRadius, min(halfWidth, halfHeight))) + let innerX = max(0.0, halfWidth - radius) + let innerY = max(0.0, halfHeight - radius) + + let dir = normalized(direction) + let dx = abs(dir.x) + let dy = abs(dir.y) + + @inline(__always) + func signedPoint(_ x: CGFloat, _ y: CGFloat, _ direction: CGPoint) -> CGPoint { + let sx: CGFloat = direction.x >= 0.0 ? 1.0 : -1.0 + let sy: CGFloat = direction.y >= 0.0 ? 1.0 : -1.0 + return CGPoint(x: x * sx, y: y * sy) + } + + if dx > 1e-6 { + let tVertical = halfWidth / dx + let yAtVertical = tVertical * dy + if yAtVertical <= innerY + 1e-6 { + let local = signedPoint(halfWidth, yAtVertical, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + } + + if dy > 1e-6 { + let tHorizontal = halfHeight / dy + let xAtHorizontal = tHorizontal * dx + if xAtHorizontal <= innerX + 1e-6 { + let local = signedPoint(xAtHorizontal, halfHeight, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + } + + if radius <= 1e-6 { + let tx = dx > 1e-6 ? (halfWidth / dx) : CGFloat.greatestFiniteMagnitude + let ty = dy > 1e-6 ? (halfHeight / dy) : CGFloat.greatestFiniteMagnitude + let t = min(tx, ty) + let local = signedPoint(dx * t, dy * t, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + + let a = dx * dx + dy * dy + let b = -2.0 * (dx * innerX + dy * innerY) + let c = innerX * innerX + innerY * innerY - radius * radius + let discriminant = max(0.0, b * b - 4.0 * a * c) + let sqrtDiscriminant = sqrt(discriminant) + let t1 = (-b - sqrtDiscriminant) / (2.0 * a) + let t2 = (-b + sqrtDiscriminant) / (2.0 * a) + // Use the farthest positive root: the near root may lie in the central body region, + // while the far root is the actual outward boundary crossing on the corner arc. + let tCorner = max(0.0, max(t1, t2)) + let cornerLocal = signedPoint(dx * tCorner, dy * tCorner, dir) + return CGPoint(x: rectCenter.x + cornerLocal.x, y: rectCenter.y + cornerLocal.y) + } + + func criticallyDampedProgress(_ uIn: Double, settleBy: Double) -> Double { + let u = clamp01(uIn) + let settle = max(0.1, settleBy) + let k = 4.0 / settle + + @inline(__always) + func raw(_ t: Double) -> Double { + return 1.0 - exp(-k * t) * (1.0 + k * t) + } + + let end = raw(1.0) + if end <= 1e-12 { + return u + } + return clamp01(raw(u) / end) + } + + var bakedSizes: [CGSize] = [] + var bakedPositions: [CGPoint] = [] + var localPositions: [CGPoint] = [] + var sourcePositions: [CGPoint] = [] + var radiusKeyframes: [CGFloat] = [] + var containerPositions: [CGPoint] = [] + bakedSizes.reserveCapacity(sampleCount) + bakedPositions.reserveCapacity(sampleCount) + localPositions.reserveCapacity(sampleCount) + sourcePositions.reserveCapacity(sampleCount) + radiusKeyframes.reserveCapacity(sampleCount) + containerPositions.reserveCapacity(sampleCount) + + let centerLineDirection = normalized(CGPoint(x: collapsedCenter.x - expandedCenter.x, y: collapsedCenter.y - expandedCenter.y)) + + for i in 0 ..< sampleCount { + let t = sampleEndIndex > 0.0 ? CGFloat(i) / sampleEndIndex : 1.0 + + let positionFraction = CGFloat(max(0.0, min(1.0, springProgress(Double(t), dampingRatio: 0.62, settleBy: 0.82)))) + let sizeFraction = CGFloat(criticallyDampedProgress(Double(t), settleBy: 0.28)) + let radiusFraction = CGFloat(criticallyDampedProgress(Double(t), settleBy: 0.34)) + + let size = CGSize( + width: max(1.0, lerp(expandedSize.width, collapsedSize.width, sizeFraction)), + height: max(1.0, lerp(expandedSize.height, collapsedSize.height, sizeFraction)) + ) + bakedSizes.append(size) + localPositions.append(CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + bakedPositions.append(expandedCenter) + + let rawRadius = lerp(toCornerRadius, fromCornerRadius, radiusFraction) + let radius = max(0.0, min(rawRadius, min(size.width, size.height) * 0.5)) + radiusKeyframes.append(radius) + + let centerLinePosition = lerpPoint(expandedCenter, collapsedCenter, positionFraction) + containerPositions.append(centerLinePosition) + let sourceBoundaryPoint = boundaryPointOnRoundedRectRay( + rectCenter: centerLinePosition, + rectSize: size, + cornerRadius: radius, + direction: centerLineDirection + ) + let outwardDirection = normalized( + CGPoint( + x: sourceBoundaryPoint.x - centerLinePosition.x, + y: sourceBoundaryPoint.y - centerLinePosition.y + ) + ) + let normalizedSourceProgress = max(0.0, min(1.0, t / sourceEaseOutDurationFraction)) + let oneMinusSourceProgress = 1.0 - normalizedSourceProgress + let sourceEaseOut = 1.0 - oneMinusSourceProgress * oneMinusSourceProgress * oneMinusSourceProgress + let sourceEdgeOffset = lerp(-sourceMaxEdgeDistance, sourceMaxEdgeDistance, sourceEaseOut) + let sourceEdgePosition = CGPoint( + x: sourceBoundaryPoint.x + outwardDirection.x * sourceEdgeOffset, + y: sourceBoundaryPoint.y + outwardDirection.y * sourceEdgeOffset + ) + let normalizedCenterPullProgress = max(0.0, min(1.0, (t - sourceCenterPullStartFraction) / max(0.001, sourceCenterPullDurationFraction))) + let oneMinusCenterPull = 1.0 - normalizedCenterPullProgress + let centerPullEase = 1.0 - oneMinusCenterPull * oneMinusCenterPull * oneMinusCenterPull + sourcePositions.append(lerpPoint(sourceEdgePosition, centerLinePosition, centerPullEase)) + } - // Position tracks the center of the shrinking bounds - let fromPosition = CGPoint(x: fromSize.width * 0.5, y: fromSize.height * 0.5) - let toPosition = CGPoint(x: minSide * 0.5, y: minSide * 0.5) + if let finalContainerPosition = containerPositions.last { + self.containerView.center = finalContainerPosition + } + if let finalSize = bakedSizes.last { + self.contentsView.bounds = CGRect(origin: CGPoint(), size: finalSize) + self.contentsEffectView.bounds = CGRect(origin: CGPoint(), size: finalSize) + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.bounds = CGRect(origin: CGPoint(), size: finalSize) + sdfElementLayer.bounds = CGRect(origin: CGPoint(), size: finalSize) + } + } + if let finalLocalPosition = localPositions.last { + self.contentsView.layer.position = finalLocalPosition + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.position = finalLocalPosition + sdfElementLayer.position = finalLocalPosition + } + } + if let finalBakedPosition = bakedPositions.last { + self.contentsEffectView.layer.position = finalBakedPosition + } + if let finalRadius = radiusKeyframes.last { + self.contentsView.layer.cornerRadius = finalRadius + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.cornerRadius = finalRadius + sdfElementLayer.cornerRadius = finalRadius + } + } - let positionAnimation = CABasicAnimation(keyPath: "position") - positionAnimation.fromValue = NSValue(cgPoint: fromPosition) - positionAnimation.toValue = NSValue(cgPoint: toPosition) + self.effectSettingsContainerView.addSubview(sourceEffectView) + sourceEffectView.updateSize(duration: 0.0, keyframes: [fromRect.size]) + sourceEffectView.frame = fromRect + sourceEffectView.updateCornerRadius(duration: 0.0, keyframes: [fromCornerRadius]) + sourceEffectView.updatePosition(duration: duration, keyframes: sourcePositions) + do { + let keyframeAnimation = CAKeyframeAnimation(keyPath: "bounds.size") + keyframeAnimation.duration = duration * UIView.animationDurationFactor() + keyframeAnimation.values = bakedSizes.map { + NSValue(cgSize: $0) + } + keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + keyframeAnimation.isRemovedOnCompletion = true + keyframeAnimation.fillMode = .both + self.contentsView.layer.add(keyframeAnimation, forKey: "bounds.size") + self.contentsEffectView.layer.add(keyframeAnimation, forKey: "bounds.size") + + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { + sdfLayer.add(keyframeAnimation, forKey: "bounds.size") + sdfElementLayer.add(keyframeAnimation, forKey: "bounds.size") + } + + let positionAnimation = CAKeyframeAnimation(keyPath: "position") positionAnimation.duration = duration * UIView.animationDurationFactor() - positionAnimation.timingFunction = timingFunction - positionAnimation.isRemovedOnCompletion = false + positionAnimation.values = localPositions.map { NSValue(cgPoint: $0) } + positionAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + positionAnimation.isRemovedOnCompletion = true positionAnimation.fillMode = .both self.contentsView.layer.add(positionAnimation, forKey: "position") - self.contentsEffectView.layer.add(positionAnimation, forKey: "position") if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { sdfLayer.add(positionAnimation, forKey: "position") sdfElementLayer.add(positionAnimation, forKey: "position") } - - if let effectView = self.effectView { - effectView.layer.add(sizeAnimation, forKey: "bounds.size") - effectView.layer.add(positionAnimation, forKey: "position") - effectView.updateSize(size: toSquare, cornerRadius: minSide * 0.5, transition: .easeInOut(duration: duration)) - } + + let containerPositionAnimation = CAKeyframeAnimation(keyPath: "position") + containerPositionAnimation.duration = duration * UIView.animationDurationFactor() + containerPositionAnimation.values = bakedPositions.map { NSValue(cgPoint: $0) } + containerPositionAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + containerPositionAnimation.isRemovedOnCompletion = true + containerPositionAnimation.fillMode = .both + self.contentsEffectView.layer.add(containerPositionAnimation, forKey: "position") + + self.effectView.updateSize(duration: duration, keyframes: bakedSizes) + self.effectView.updatePosition(duration: duration, keyframes: bakedPositions) } - - // Container position: fromRect center -> toRect center do { - let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) - let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) - - let animation = CABasicAnimation(keyPath: "position") - animation.fromValue = NSValue(cgPoint: fromCenter) - animation.toValue = NSValue(cgPoint: toCenter) - animation.duration = duration * UIView.animationDurationFactor() - animation.timingFunction = timingFunction - animation.isRemovedOnCompletion = false - animation.fillMode = .both - self.containerView.layer.add(animation, forKey: "position") + let keyframeAnimation = CAKeyframeAnimation(keyPath: "position") + keyframeAnimation.duration = duration * UIView.animationDurationFactor() + keyframeAnimation.values = containerPositions.map { NSValue(cgPoint: $0) } + keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + keyframeAnimation.isRemovedOnCompletion = true + keyframeAnimation.fillMode = .both + self.containerView.layer.add(keyframeAnimation, forKey: "position") } - - // Corner radius: fromCornerRadius -> minSide * 0.5 (circle) do { - let minSide = min(fromSize.width, fromSize.height) - let toRadius = minSide * 0.5 - - let animation = CABasicAnimation(keyPath: "cornerRadius") - animation.fromValue = fromCornerRadius as NSNumber - animation.toValue = toRadius as NSNumber - animation.duration = duration * UIView.animationDurationFactor() - animation.timingFunction = timingFunction - animation.isRemovedOnCompletion = false - animation.fillMode = .both - self.contentsView.layer.add(animation, forKey: "cornerRadius") + let keyframeAnimation = CAKeyframeAnimation(keyPath: "cornerRadius") + keyframeAnimation.duration = duration * UIView.animationDurationFactor() + keyframeAnimation.values = radiusKeyframes.map { $0 as NSNumber } + keyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + keyframeAnimation.isRemovedOnCompletion = true + keyframeAnimation.fillMode = .both + self.contentsView.layer.add(keyframeAnimation, forKey: "cornerRadius") if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { - sdfLayer.add(animation, forKey: "cornerRadius") - sdfElementLayer.add(animation, forKey: "cornerRadius") + sdfLayer.add(keyframeAnimation, forKey: "cornerRadius") + sdfElementLayer.add(keyframeAnimation, forKey: "cornerRadius") } - self.effectView?.updateCornerRadius(duration: duration, keyframes: [fromCornerRadius, toRadius]) + self.effectView.updateCornerRadius(duration: duration, keyframes: radiusKeyframes) } - - // Blur: ramp from 0 to peak (~3.0) - // No displacement animations do { self.contentsEffectView.layer.setValue(0.0 as NSNumber, forKeyPath: "filters.gaussianBlur.inputRadius") - self.contentsEffectView.layer.setValue(0.0 as NSNumber, forKeyPath: "sublayers.sdfLayer.effect.height") - self.contentsEffectView.layer.setValue(-0.001 as NSNumber, forKeyPath: "filters.displacementMap.inputAmount") - - let blurPeak: CGFloat = 3.0 - let animation = CABasicAnimation(keyPath: "filters.gaussianBlur.inputRadius") - animation.fromValue = 0.0 as NSNumber - animation.toValue = blurPeak as NSNumber - animation.duration = duration * UIView.animationDurationFactor() - animation.timingFunction = timingFunction - animation.isRemovedOnCompletion = false - animation.fillMode = .both - self.contentsEffectView.layer.add(animation, forKey: "filters.gaussianBlur.inputRadius") + let blurKeyframes = (0 ..< 30).map { i -> CGFloat in + let t = CGFloat(i) / (30.0 - 1.0) + return CGFloat(blurEase(Double(t))) + } + let blurKeyframeAnimation = CAKeyframeAnimation(keyPath: "filters.gaussianBlur.inputRadius") + blurKeyframeAnimation.duration = duration * UIView.animationDurationFactor() + blurKeyframeAnimation.values = blurKeyframes.map { $0 as NSNumber } + blurKeyframeAnimation.timingFunction = CAMediaTimingFunction(name: .linear) + blurKeyframeAnimation.isRemovedOnCompletion = false + blurKeyframeAnimation.fillMode = .both + self.contentsEffectView.layer.add(blurKeyframeAnimation, forKey: "filters.gaussianBlur.inputRadius") } - - // SubScale: 1.0 -> 1.0 (no visible change, skip) - - // Alpha: 1.0 -> 0.0 over last 0.15s + self.contentsView.alpha = 0.0 - self.contentsView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, delay: duration - 0.15) + self.contentsView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + UIView.animationDurationFactor() * duration, execute: { [weak self] in + let cleanupDelay = max(duration, duration * UIView.animationDurationFactor()) + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + cleanupDelay, execute: { [weak self] in guard let self else { return } self.setIsFilterActive(isFilterActive: false) }) } - + + public func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { + // Telegram call sites pass expanded->collapsed, while the keyframed implementation + // expects collapsed->expanded. Adapt arguments here to preserve standalone parity. + self.animateOut( + fromRect: toRect, + toRect: fromRect, + fromCornerRadius: toCornerRadius, + toCornerRadius: fromCornerRadius, + duration: 0.5, + isDark: isDark, + sourceEffectView: sourceEffectView + ) + } + public func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { - transition.setBounds(view: self.containerView, bounds: CGRect(origin: CGPoint(), size: size)) - transition.setPosition(view: self.containerView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - transition.setBounds(view: self.contentsView, bounds: CGRect(origin: CGPoint(), size: size)) - transition.setPosition(view: self.contentsView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + let bounds = CGRect(origin: .zero, size: size) + let center = CGPoint(x: size.width * 0.5, y: size.height * 0.5) + + transition.setBounds(view: self.containerView, bounds: bounds) + transition.setPosition(view: self.containerView, position: center) + + transition.setBounds(view: self.contentsView, bounds: bounds) + transition.setPosition(view: self.contentsView, position: center) transition.setCornerRadius(layer: self.contentsView.layer, cornerRadius: cornerRadius) - transition.setBounds(view: self.contentsEffectView, bounds: CGRect(origin: CGPoint(), size: size)) - transition.setPosition(view: self.contentsEffectView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - - if let effectView = self.effectView { - transition.setBounds(view: effectView, bounds: CGRect(origin: CGPoint(), size: size)) - transition.setPosition(view: effectView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - effectView.updateSize(size: size, cornerRadius: cornerRadius, transition: transition) - } - + + transition.setBounds(view: self.contentsEffectView, bounds: bounds) + transition.setPosition(view: self.contentsEffectView, position: center) + + self.effectView.updateSize(size: size, transition: transition) + self.effectView.updatePosition(position: center, transition: transition) + self.effectView.updateCornerRadius(duration: 0.0, keyframes: [cornerRadius]) + if let sdfLayer = self.sdfLayer, let sdfElementLayer = self.sdfElementLayer { - transition.setFrame(layer: sdfLayer, frame: CGRect(origin: CGPoint(), size: size)) - transition.setFrame(layer: sdfElementLayer, frame: CGRect(origin: CGPoint(), size: size)) - sdfLayer.cornerRadius = cornerRadius - sdfElementLayer.cornerRadius = cornerRadius + transition.setFrame(layer: sdfLayer, frame: bounds) + transition.setFrame(layer: sdfElementLayer, frame: bounds) + transition.setCornerRadius(layer: sdfLayer, cornerRadius: cornerRadius) + transition.setCornerRadius(layer: sdfElementLayer, cornerRadius: cornerRadius) } } } -@inline(__always) -private func clamp01(_ x: Double) -> Double { max(0.0, min(1.0, x)) } +private final class LensTransitionContainerFallbackImpl: UIView, LensTransitionContainerProtocol { + let effectView: LensTransitionContainerEffectView + private let containerView: UIView + let contentsEffectView: UIView + let contentsView: UIView + + init(effectView: LensTransitionContainerEffectView) { + self.effectView = effectView + self.containerView = UIView() + self.contentsEffectView = UIView() + self.contentsView = UIView() + + super.init(frame: .zero) + + self.addSubview(self.containerView) + self.containerView.addSubview(self.effectView) + self.containerView.addSubview(self.contentsEffectView) + self.contentsEffectView.addSubview(self.contentsView) + self.contentsView.clipsToBounds = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { + self.update(size: fromRect.size, cornerRadius: fromCornerRadius, transition: .immediate) + UIView.animate(withDuration: 0.5, delay: 0.0, options: [.curveEaseInOut], animations: { + self.containerView.center = CGPoint(x: toRect.midX, y: toRect.midY) + self.update(size: toRect.size, cornerRadius: toCornerRadius, transition: .immediate) + }) + } + + func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { + self.update(size: fromRect.size, cornerRadius: fromCornerRadius, transition: .immediate) + UIView.animate(withDuration: 0.5, delay: 0.0, options: [.curveEaseInOut], animations: { + self.containerView.center = CGPoint(x: toRect.midX, y: toRect.midY) + self.update(size: toRect.size, cornerRadius: toCornerRadius, transition: .immediate) + }) + } + + func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { + transition.setBounds(view: self.containerView, bounds: CGRect(origin: .zero, size: size)) + transition.setPosition(view: self.containerView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + + transition.setBounds(view: self.contentsEffectView, bounds: CGRect(origin: .zero, size: size)) + transition.setPosition(view: self.contentsEffectView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + + transition.setBounds(view: self.contentsView, bounds: CGRect(origin: .zero, size: size)) + transition.setPosition(view: self.contentsView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + transition.setCornerRadius(layer: self.contentsView.layer, cornerRadius: cornerRadius) + + transition.setBounds(view: self.effectView, bounds: CGRect(origin: .zero, size: size)) + transition.setPosition(view: self.effectView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + + self.effectView.updateCornerRadius(duration: 0.0, keyframes: [cornerRadius]) + } +} -private func scaleEase(_ uIn: Double) -> Double { +public final class LensTransitionContainer: UIView { + private let impl: (UIView & LensTransitionContainerProtocol) + + public var effectView: LensTransitionContainerEffectView { + return self.impl.effectView + } + + public var contentsEffectView: UIView { + return self.impl.contentsEffectView + } + + public var contentsView: UIView { + return self.impl.contentsView + } + + public init(effectView: LensTransitionContainerEffectView) { + if #available(iOS 26.0, *) { + self.impl = LensTransitionContainerImpl(effectView: effectView) + } else { + self.impl = LensTransitionContainerFallbackImpl(effectView: effectView) + } + super.init(frame: .zero) + + self.addSubview(self.impl) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func layoutSubviews() { + super.layoutSubviews() + self.impl.frame = self.bounds + } + + public func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { + self.impl.animateIn(fromRect: fromRect, toRect: toRect, fromCornerRadius: fromCornerRadius, toCornerRadius: toCornerRadius, isDark: isDark, sourceEffectView: sourceEffectView) + } + + public func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { + self.impl.animateOut(fromRect: fromRect, toRect: toRect, fromCornerRadius: fromCornerRadius, toCornerRadius: toCornerRadius, isDark: isDark, sourceEffectView: sourceEffectView) + } + + public func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { + self.impl.update(size: size, cornerRadius: cornerRadius, transition: transition) + } +} + +@inline(__always) +func clamp01(_ x: Double) -> Double { max(0.0, min(1.0, x)) } + +// Normalized underdamped spring progress. +// - `uIn`: normalized time in [0, 1] +// - `dampingRatio`: 0..<1 (higher = less overshoot) +// - `settleBy`: target normalized time where motion is mostly settled +func springProgress(_ uIn: Double, dampingRatio: Double = 0.55, settleBy: Double = 0.5) -> Double { + let u = clamp01(uIn) + let z = max(0.05, min(0.99, dampingRatio)) + let settle = max(0.1, settleBy) + + // Choose a normalized natural frequency that approximately settles by `settle`. + let wn = 4.0 / (z * settle) + let wd = wn * sqrt(1.0 - z * z) + let c = z / sqrt(1.0 - z * z) + + @inline(__always) + func raw(_ t: Double) -> Double { + let e = exp(-z * wn * t) + return 1.0 - e * (cos(wd * t) + c * sin(wd * t)) + } + + // Normalize to hit exact endpoints in [0, 1]. + let start = raw(0.0) + let end = raw(1.0) + let denom = end - start + if abs(denom) <= 1e-12 { + return u + } + return (raw(u) - start) / denom +} + +// scale easing fitted to steps 0...29 of your `scale:` series. +// +// We fit the *normalized fraction*: +// frac[i] = (scale[i] - scale[0]) / (scale[29] - scale[0]) +// using an underdamped step response: +// raw(n) = 1 - exp(-k t) * (cos(w t) + B sin(w t)), t = n - n0 +// +// Then we baseline-shift and normalize so: +// frac(0) == 0 and frac(29) == 1 exactly, +// and finally un-normalize back into the scale domain. +// +// `u` in [0,1] maps to steps 0...29. +func scaleEase(_ uIn: Double) -> Double { let u = clamp01(uIn) let endIndex = 29.0 let n = u * endIndex + // Original endpoints for steps 0 and 29 let s0: Double = 0.09669952058569901 - let s1: Double = 1.0 + let s1: Double = 1.0 // step 29 + // Fitted parameters (least-squares over steps 0...29) for the normalized fraction. let k: Double = 0.2047679706652983 let w: Double = 0.15481658188988102 let B: Double = 0.08646704068381172 @@ -643,6 +1525,7 @@ private func scaleEase(_ uIn: Double) -> Double { return 1.0 - exp(-k * t) * (cos(w * t) + B * sin(w * t)) } + // Baseline shift (forces frac(0) == 0 even if n0 < 0) let base = raw(0.0) let end = raw(endIndex) let denom = end - base @@ -652,16 +1535,25 @@ private func scaleEase(_ uIn: Double) -> Double { let frac = (raw(n) - base) / denom + // Un-normalize back to scale. (Allows slight overshoot, like your data.) let s = s0 + (s1 - s0) * frac return s } -private func sideFractionEase(_ uIn: Double) -> Double { +/// Side transition fraction easing fitted to your sideFraction series. +/// Normalization: +/// - step 0 -> 0.0 +/// - step 29 -> 1.0 +/// +/// `u` in [0,1] maps to steps 0...29. +func sideFractionEase(_ uIn: Double) -> Double { let u = clamp01(uIn) + // Map u -> sample index n in [0,29] let endIndex = 29.0 let n = u * endIndex + // Critically damped step: g(t) = 1 - exp(-k t) * (1 + k t), with delay t = n - n0 let k = 0.4334891216702717 let n0 = 0.8238404710496342 @@ -672,18 +1564,34 @@ private func sideFractionEase(_ uIn: Double) -> Double { return 1.0 - exp(-k * t) * (1.0 + k * t) } + // Baked normalization anchor: g(29) let gEnd = 0.9999344552429187 let eased = g(n) / gEnd return max(0.0, min(1.0, eased)) } -private func radiusFractionEase(_ uIn: Double) -> Double { +// Radius transition fraction easing fitted to your series. +/// Normalization: +/// - step 0 -> 0.0 +/// - step 17 -> 1.0 +/// +/// `u` is normalized time [0,1] mapped to steps 0...17. +/// After the transition, you typically clamp to 1.0 in your animation code. +/// Radius transition fraction easing fitted to your radiusFraction series. +/// Normalization: +/// - step 0 -> 0.0 +/// - step 29 -> 1.0 +/// +/// `u` in [0,1] maps to steps 0...29. +func radiusFractionEase(_ uIn: Double) -> Double { let u = clamp01(uIn) + // Map u -> sample index n in [0,29] let endIndex = 29.0 let n = u * endIndex + // Baked fit parameters for: g(t) = 1 - exp(-k t) * (1 + k t), with delay t = n - n0 let k = 0.5452042256694901 let n0 = 8.025670446964643 @@ -694,6 +1602,7 @@ private func radiusFractionEase(_ uIn: Double) -> Double { return 1.0 - exp(-k * t) * (1.0 + k * t) } + // Normalize so g(29) == 1.0 let gEnd = g(endIndex) if gEnd <= 1e-12 { return 0.0 } @@ -701,74 +1610,21 @@ private func radiusFractionEase(_ uIn: Double) -> Double { return max(0.0, min(1.0, eased)) } -private func positionXFractionEase(_ uIn: Double) -> Double { - let u = clamp01(uIn) - - let endIndex = 29.0 - let n = u * endIndex - - let k = 0.4576441099336031 - let n0 = -1.1076590882287138 - - @inline(__always) - func raw(_ n: Double) -> Double { - let t = n - n0 - if t <= 0.0 { return 0.0 } - return 1.0 - exp(-k * t) * (1.0 + k * t) - } - - let base = raw(0.0) - - @inline(__always) - func g(_ n: Double) -> Double { - let v = raw(n) - base - return v > 0.0 ? v : 0.0 - } - - let gEnd = g(endIndex) - if gEnd <= 1e-12 { return 0.0 } - - let eased = g(n) / gEnd - return max(0.0, min(1.0, eased)) -} - -private func positionYFractionEase(_ uIn: Double) -> Double { - let u = clamp01(uIn) - - let endIndex = 29.0 - let n = u * endIndex - - let k = 0.7328940609652471 - let n0 = -0.11837294418417923 - - @inline(__always) - func raw(_ n: Double) -> Double { - let t = n - n0 - if t <= 0.0 { return 0.0 } - return 1.0 - exp(-k * t) - } - - let base = raw(0.0) - - @inline(__always) - func g(_ n: Double) -> Double { - let v = raw(n) - base - return v > 0.0 ? v : 0.0 - } - - let gEnd = g(endIndex) - if gEnd <= 1e-12 { return 0.0 } - - let eased = g(n) / gEnd - return max(0.0, min(1.0, eased)) -} - -private func displacementFractionEase(_ uIn: Double) -> Double { +// displacementFraction transition easing fitted to steps 0...29. +// Normalization: +// - step 0 -> 0.0 +// - step 29 -> 1.0 +// +// `u` in [0,1] maps to steps 0...29. +func displacementFractionEase(_ uIn: Double) -> Double { let u = clamp01(uIn) + // Map u -> sample index n in [0,29] let endIndex = 29.0 let n = u * endIndex + // Fitted parameters (least-squares over steps 0...29) for: + // raw(n) = 1 - exp(-k t) * (cos(w t) + B sin(w t)), t = max(0, n - n0) let k = 0.14743333600632425 let w = 31.30115940141963 let B = -3.3813807242203156 @@ -781,6 +1637,7 @@ private func displacementFractionEase(_ uIn: Double) -> Double { return 1.0 - exp(-k * t) * (cos(w * t) + B * sin(w * t)) } + // Normalize so raw(29) == 1.0 (and raw(0) == 0.0 due to t<=0 clamp) let end = raw(endIndex) if end <= 1e-12 { return 0.0 } @@ -788,12 +1645,23 @@ private func displacementFractionEase(_ uIn: Double) -> Double { return max(0.0, min(1.0, eased)) } -private func subScaleEase(_ uIn: Double) -> Double { +// subScale easing fitted to steps 0...29. +// +// Model (damped oscillation about 1.0): +// raw(n) = 1 + A * exp(-k n) * (cos(w n) + B sin(w n)) +// +// Post-adjustment: +// shift so raw(29) becomes exactly 1.0 (end-normalized like your other fits). +// +// `u` in [0,1] maps to steps 0...29. +func subScaleEase(_ uIn: Double) -> Double { let u = clamp01(uIn) + // Map u -> sample index n in [0,29] let endIndex = 29.0 let n = u * endIndex + // Fitted parameters (least-squares over steps 0...29): let A = 0.02941789470493528 let k = 0.18710512325378066 let w = 0.1871386188061029 @@ -805,18 +1673,31 @@ private func subScaleEase(_ uIn: Double) -> Double { return 1.0 + A * e * (cos(w * n) + B * sin(w * n)) } + // Shift so the curve lands exactly on 1.0 at the endIndex. let end = raw(endIndex) let shifted = raw(n) - (end - 1.0) + // Optional safety clamp (keeps extreme numerical outliers in check) return max(0.0, min(2.0, shifted)) } -private func blurEase(_ uIn: Double) -> Double { +// blur pulse fitted to steps 0...29. +// +// Model (gamma-shaped pulse): +// raw(n) = A * t^p * exp(-k * t), t = max(0, n - n0) +// +// Post-adjustment: +// shift so raw(29) becomes exactly 0.0 (since your series is 0 long before that). +// +// `u` in [0,1] maps to steps 0...29. +func blurEase(_ uIn: Double) -> Double { let u = clamp01(uIn) let endIndex = 29.0 let n = u * endIndex + // Fitted parameters (least-squares over steps 0...29) for p = 3: + // raw(n) = A * t^3 * exp(-k t), t = max(0, n - n0) let A: Double = 0.40877086657583617 let k: Double = 0.564 let n0: Double = -1.4575 @@ -829,6 +1710,7 @@ private func blurEase(_ uIn: Double) -> Double { return A * pow(t, p) * exp(-k * t) } + // Force the tail to land at 0.0 at endIndex. let tail = raw(endIndex) let v = raw(n) - tail From dc6827def2e31cf42b5287640bfb07c71d54e36b Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 3 Mar 2026 15:44:33 +0100 Subject: [PATCH 05/10] Fix --- .../Sources/LensTransitionContainer.swift | 213 +++++++++--------- 1 file changed, 102 insertions(+), 111 deletions(-) diff --git a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift index d7cfb74633..daaa424549 100644 --- a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift +++ b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift @@ -253,22 +253,11 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let maxSide = max(toSize.width, toSize.height) let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) - let opposingDirection = CGPoint(x: fromCenter.x - toCenter.x, y: fromCenter.y - toCenter.y) - - func clampSourcePositionToOriginalOpposingAxes(_ point: CGPoint) -> CGPoint { - var clamped = point - if opposingDirection.x > 0.0 { - clamped.x = min(clamped.x, fromCenter.x) - } else if opposingDirection.x < 0.0 { - clamped.x = max(clamped.x, fromCenter.x) - } - if opposingDirection.y > 0.0 { - clamped.y = min(clamped.y, fromCenter.y) - } else if opposingDirection.y < 0.0 { - clamped.y = max(clamped.y, fromCenter.y) - } - return clamped - } + let centerLineDelta = CGPoint(x: fromCenter.x - toCenter.x, y: fromCenter.y - toCenter.y) + let centerLineLength = hypot(centerLineDelta.x, centerLineDelta.y) + let centerLineDirection: CGPoint = centerLineLength > 1e-6 ? CGPoint(x: centerLineDelta.x / centerLineLength, y: centerLineDelta.y / centerLineLength) : CGPoint(x: 1.0, y: 0.0) + let centerLineMinDistance: CGFloat = -centerLineLength + let centerLineMaxDistance: CGFloat = 0.0 func isPointInsideRoundedRect(point: CGPoint, rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat) -> Bool { let halfWidth = rectSize.width * 0.5 @@ -454,44 +443,17 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol continue } + let lineDirection = centerLineDirection let nearestEdgePoint = nearestBoundaryPointOnRoundedRect( - point: fromCenter, + point: CGPoint( + x: blobCenter.x + lineDirection.x * 10000.0, + y: blobCenter.y + lineDirection.y * 10000.0 + ), rectCenter: blobCenter, rectSize: scaledSize, cornerRadius: scaledCornerRadius ) - let rawRayX = fromCenter.x - nearestEdgePoint.x - let rawRayY = fromCenter.y - nearestEdgePoint.y - let rawRayLength = hypot(rawRayX, rawRayY) - var rayDirection: CGPoint - if rawRayLength > 1e-6 { - rayDirection = CGPoint(x: rawRayX / rawRayLength, y: rawRayY / rawRayLength) - } else { - let outwardX = nearestEdgePoint.x - blobCenter.x - let outwardY = nearestEdgePoint.y - blobCenter.y - let outwardLength = hypot(outwardX, outwardY) - if outwardLength > 1e-6 { - rayDirection = CGPoint(x: outwardX / outwardLength, y: outwardY / outwardLength) - } else { - rayDirection = CGPoint(x: 0.0, y: 0.0) - } - } - - let outwardProbe = CGPoint( - x: nearestEdgePoint.x + rayDirection.x, - y: nearestEdgePoint.y + rayDirection.y - ) - if isPointInsideRoundedRect( - point: outwardProbe, - rectCenter: blobCenter, - rectSize: scaledSize, - cornerRadius: scaledCornerRadius - ) { - rayDirection.x = -rayDirection.x - rayDirection.y = -rayDirection.y - } - let normalizedSuckProgress = max(0.0, min(1.0, t / sourceSuckDurationFraction)) let oneMinusSuckProgress = 1.0 - normalizedSuckProgress let suckFraction = 1.0 - oneMinusSuckProgress * oneMinusSuckProgress * oneMinusSuckProgress @@ -503,14 +465,48 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let sourceInsetDistance = baseSourceInsetDistance + (sourceFinalInsideDistance - baseSourceInsetDistance) * finalInsideEase let sourceHalfWidth = fromRect.width * 0.5 let sourceHalfHeight = fromRect.height * 0.5 - let sourceHalfExtentAlongRay = abs(rayDirection.x) * sourceHalfWidth + abs(rayDirection.y) * sourceHalfHeight + let sourceHalfExtentAlongRay = abs(lineDirection.x) * sourceHalfWidth + abs(lineDirection.y) * sourceHalfHeight let sourceCenterDistance = sourceInsetDistance + sourceHalfExtentAlongRay - let rawSourcePosition = CGPoint( - x: nearestEdgePoint.x + rayDirection.x * sourceCenterDistance, - y: nearestEdgePoint.y + rayDirection.y * sourceCenterDistance + let sourcePosition = CGPoint( + x: nearestEdgePoint.x + lineDirection.x * sourceCenterDistance, + y: nearestEdgePoint.y + lineDirection.y * sourceCenterDistance ) - let sourcePosition = clampSourcePositionToOriginalOpposingAxes(rawSourcePosition) - sourcePositions.append(sourcePosition) + let centerLineDistance = (sourcePosition.x - fromCenter.x) * centerLineDirection.x + (sourcePosition.y - fromCenter.y) * centerLineDirection.y + let clampedCenterLineDistance = max(centerLineMinDistance, min(centerLineMaxDistance, centerLineDistance)) + var projectedSourcePosition = CGPoint( + x: fromCenter.x + centerLineDirection.x * clampedCenterLineDistance, + y: fromCenter.y + centerLineDirection.y * clampedCenterLineDistance + ) + let sourceNearestPoint = CGPoint( + x: projectedSourcePosition.x - centerLineDirection.x * sourceHalfExtentAlongRay, + y: projectedSourcePosition.y - centerLineDirection.y * sourceHalfExtentAlongRay + ) + let sourceNearestBoundaryPoint = nearestBoundaryPointOnRoundedRect( + point: sourceNearestPoint, + rectCenter: blobCenter, + rectSize: scaledSize, + cornerRadius: scaledCornerRadius + ) + let sourceNearestDistance = hypot( + sourceNearestPoint.x - sourceNearestBoundaryPoint.x, + sourceNearestPoint.y - sourceNearestBoundaryPoint.y + ) + let sourceNearestSignedDistance: CGFloat = isPointInsideRoundedRect( + point: sourceNearestPoint, + rectCenter: blobCenter, + rectSize: scaledSize, + cornerRadius: scaledCornerRadius + ) ? -sourceNearestDistance : sourceNearestDistance + let centerCorrection = sourceNearestSignedDistance - sourceInsetDistance + if abs(centerCorrection) > 0.01 { + let correctedCenterLineDistance = centerLineDistance - centerCorrection + let clampedCorrectedCenterLineDistance = max(centerLineMinDistance, min(centerLineMaxDistance, correctedCenterLineDistance)) + projectedSourcePosition = CGPoint( + x: fromCenter.x + centerLineDirection.x * clampedCorrectedCenterLineDistance, + y: fromCenter.y + centerLineDirection.y * clampedCorrectedCenterLineDistance + ) + } + sourcePositions.append(projectedSourcePosition) if lockedSourceInsetDistance == nil { let insetWidth = max(0.0, scaledSize.width - sourceFullInsideInset * 2.0) @@ -518,7 +514,7 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let insetSize = CGSize(width: insetWidth, height: insetHeight) let insetRadius = max(0.0, scaledCornerRadius - sourceFullInsideInset) if sourceInsetDistance <= 0.0 && isPointInsideRoundedRect( - point: sourcePosition, + point: projectedSourcePosition, rectCenter: blobCenter, rectSize: insetSize, cornerRadius: insetRadius @@ -566,22 +562,11 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let maxSide = max(toSize.width, toSize.height) let fromCenter = CGPoint(x: fromRect.midX, y: fromRect.midY) let toCenter = CGPoint(x: toRect.midX, y: toRect.midY) - let opposingDirection = CGPoint(x: fromCenter.x - toCenter.x, y: fromCenter.y - toCenter.y) - - func clampSourcePositionToOriginalOpposingAxes(_ point: CGPoint) -> CGPoint { - var clamped = point - if opposingDirection.x > 0.0 { - clamped.x = min(clamped.x, fromCenter.x) - } else if opposingDirection.x < 0.0 { - clamped.x = max(clamped.x, fromCenter.x) - } - if opposingDirection.y > 0.0 { - clamped.y = min(clamped.y, fromCenter.y) - } else if opposingDirection.y < 0.0 { - clamped.y = max(clamped.y, fromCenter.y) - } - return clamped - } + let centerLineDelta = CGPoint(x: fromCenter.x - toCenter.x, y: fromCenter.y - toCenter.y) + let centerLineLength = hypot(centerLineDelta.x, centerLineDelta.y) + let centerLineDirection: CGPoint = centerLineLength > 1e-6 ? CGPoint(x: centerLineDelta.x / centerLineLength, y: centerLineDelta.y / centerLineLength) : CGPoint(x: 1.0, y: 0.0) + let centerLineMinDistance: CGFloat = -centerLineLength + let centerLineMaxDistance: CGFloat = 0.0 func isPointInsideRoundedRect(point: CGPoint, rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat) -> Bool { let halfWidth = rectSize.width * 0.5 @@ -771,45 +756,17 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol continue } + let lineDirection = centerLineDirection let nearestEdgePoint = nearestBoundaryPointOnRoundedRect( - point: fromCenter, + point: CGPoint( + x: blobCenter.x + lineDirection.x * 10000.0, + y: blobCenter.y + lineDirection.y * 10000.0 + ), rectCenter: blobCenter, rectSize: scaledSize, cornerRadius: scaledCornerRadius ) - let rawRayX = fromCenter.x - nearestEdgePoint.x - let rawRayY = fromCenter.y - nearestEdgePoint.y - let rawRayLength = hypot(rawRayX, rawRayY) - var rayDirection: CGPoint - if rawRayLength > 1e-6 { - rayDirection = CGPoint(x: rawRayX / rawRayLength, y: rawRayY / rawRayLength) - } else { - let outwardX = nearestEdgePoint.x - blobCenter.x - let outwardY = nearestEdgePoint.y - blobCenter.y - let outwardLength = hypot(outwardX, outwardY) - if outwardLength > 1e-6 { - rayDirection = CGPoint(x: outwardX / outwardLength, y: outwardY / outwardLength) - } else { - rayDirection = CGPoint(x: 0.0, y: 0.0) - } - } - - // Ensure positive distances move outward from the blob. - let outwardProbe = CGPoint( - x: nearestEdgePoint.x + rayDirection.x, - y: nearestEdgePoint.y + rayDirection.y - ) - if isPointInsideRoundedRect( - point: outwardProbe, - rectCenter: blobCenter, - rectSize: scaledSize, - cornerRadius: scaledCornerRadius - ) { - rayDirection.x = -rayDirection.x - rayDirection.y = -rayDirection.y - } - let normalizedSuckProgress = max(0.0, min(1.0, t / sourceSuckDurationFraction)) let oneMinusSuckProgress = 1.0 - normalizedSuckProgress let suckFraction = 1.0 - oneMinusSuckProgress * oneMinusSuckProgress * oneMinusSuckProgress @@ -821,14 +778,48 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let sourceInsetDistance = baseSourceInsetDistance + (sourceFinalInsideDistance - baseSourceInsetDistance) * finalInsideEase let sourceHalfWidth = fromRect.width * 0.5 let sourceHalfHeight = fromRect.height * 0.5 - let sourceHalfExtentAlongRay = abs(rayDirection.x) * sourceHalfWidth + abs(rayDirection.y) * sourceHalfHeight + let sourceHalfExtentAlongRay = abs(lineDirection.x) * sourceHalfWidth + abs(lineDirection.y) * sourceHalfHeight let sourceCenterDistance = sourceInsetDistance + sourceHalfExtentAlongRay - let rawSourcePosition = CGPoint( - x: nearestEdgePoint.x + rayDirection.x * sourceCenterDistance, - y: nearestEdgePoint.y + rayDirection.y * sourceCenterDistance + let sourcePosition = CGPoint( + x: nearestEdgePoint.x + lineDirection.x * sourceCenterDistance, + y: nearestEdgePoint.y + lineDirection.y * sourceCenterDistance ) - let sourcePosition = clampSourcePositionToOriginalOpposingAxes(rawSourcePosition) - sourcePositions.append(sourcePosition) + let centerLineDistance = (sourcePosition.x - fromCenter.x) * centerLineDirection.x + (sourcePosition.y - fromCenter.y) * centerLineDirection.y + let clampedCenterLineDistance = max(centerLineMinDistance, min(centerLineMaxDistance, centerLineDistance)) + var projectedSourcePosition = CGPoint( + x: fromCenter.x + centerLineDirection.x * clampedCenterLineDistance, + y: fromCenter.y + centerLineDirection.y * clampedCenterLineDistance + ) + let sourceNearestPoint = CGPoint( + x: projectedSourcePosition.x - centerLineDirection.x * sourceHalfExtentAlongRay, + y: projectedSourcePosition.y - centerLineDirection.y * sourceHalfExtentAlongRay + ) + let sourceNearestBoundaryPoint = nearestBoundaryPointOnRoundedRect( + point: sourceNearestPoint, + rectCenter: blobCenter, + rectSize: scaledSize, + cornerRadius: scaledCornerRadius + ) + let sourceNearestDistance = hypot( + sourceNearestPoint.x - sourceNearestBoundaryPoint.x, + sourceNearestPoint.y - sourceNearestBoundaryPoint.y + ) + let sourceNearestSignedDistance: CGFloat = isPointInsideRoundedRect( + point: sourceNearestPoint, + rectCenter: blobCenter, + rectSize: scaledSize, + cornerRadius: scaledCornerRadius + ) ? -sourceNearestDistance : sourceNearestDistance + let centerCorrection = sourceNearestSignedDistance - sourceInsetDistance + if abs(centerCorrection) > 0.01 { + let correctedCenterLineDistance = centerLineDistance - centerCorrection + let clampedCorrectedCenterLineDistance = max(centerLineMinDistance, min(centerLineMaxDistance, correctedCenterLineDistance)) + projectedSourcePosition = CGPoint( + x: fromCenter.x + centerLineDirection.x * clampedCorrectedCenterLineDistance, + y: fromCenter.y + centerLineDirection.y * clampedCorrectedCenterLineDistance + ) + } + sourcePositions.append(projectedSourcePosition) if lockedSourceInsetDistance == nil { let insetWidth = max(0.0, scaledSize.width - sourceFullInsideInset * 2.0) @@ -836,7 +827,7 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let insetSize = CGSize(width: insetWidth, height: insetHeight) let insetRadius = max(0.0, scaledCornerRadius - sourceFullInsideInset) if sourceInsetDistance <= 0.0 && isPointInsideRoundedRect( - point: sourcePosition, + point: projectedSourcePosition, rectCenter: blobCenter, rectSize: insetSize, cornerRadius: insetRadius From 3ca012d610d19c83cd4fa28e423c50aa6f368b76 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 3 Mar 2026 16:30:31 +0100 Subject: [PATCH 06/10] Adjust --- .../Sources/ContextControllerActionsStackNode.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift index 400c6cab50..e568c17d0e 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift @@ -1611,7 +1611,7 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context } override init() { - self.backgroundContainer = GlassBackgroundContainerView(spacing: 20.0) + self.backgroundContainer = GlassBackgroundContainerView(spacing: 28.0) self.contentContainer = LensTransitionContainer(effectView: LensTransitionContainerEffectViewImpl(contentView: nil)) self.backgroundContainerInset = 32.0 From bb23c6f653ac3fbd9f84cda8957da1822f41a6af Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Fri, 6 Mar 2026 18:13:48 +0100 Subject: [PATCH 07/10] Various improvements --- Telegram/BUILD | 2 +- .../ChatPresentationInterfaceState.swift | 26 +- submodules/Display/Source/UIKitUtils.swift | 4 + .../Sources/TGVideoMessageControls.m | 6 + .../Sources/TGVideoMessageTrimView.m | 6 + submodules/MtProtoKit/Sources/MTRsa.m | 6 + .../ContextControllerActionsStackNode.swift | 2 +- ...tControllerExtractedPresentationNode.swift | 4 +- .../Sources/GlassBackgroundComponent.swift | 60 +++ .../Sources/LegacyGlassView.swift | 27 +- .../Sources/TouchEffect.swift | 352 +++++++++++++++++ .../HeaderPanelContainerComponent.swift | 18 +- .../Sources/LensTransitionContainer.swift | 372 +++++++++++------- .../Chat/ChatControllerLoadDisplayNode.swift | 11 +- .../Sources/ChatControllerNode.swift | 91 ++++- .../ChatInterfaceTitlePanelNodes.swift | 12 +- .../Source/UIKitRuntimeUtils/UIKitUtils.h | 1 + .../Source/UIKitRuntimeUtils/UIKitUtils.m | 4 + 18 files changed, 839 insertions(+), 165 deletions(-) create mode 100644 submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/TouchEffect.swift diff --git a/Telegram/BUILD b/Telegram/BUILD index 7eff2fc281..15f6fe2c5f 100644 --- a/Telegram/BUILD +++ b/Telegram/BUILD @@ -1813,7 +1813,7 @@ swift_library( ios_ui_test_suite( name = "iOSAppUITestSuite", bundle_id = "org.telegram.Telegram-iOS-uitests", - minimum_os_version = "13.0", + minimum_os_version = minimum_os_version, runners = [ ":iPhone-17__26.2", ], diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift index 9b188621af..2d7884fce7 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift @@ -438,11 +438,31 @@ public final class ChatPresentationInterfaceState: Equatable { } public struct PersistentPeerData: Codable, Equatable { - public var topicListPanelLocation: Bool + public enum TopicListPanelLocation: Int32 { + case top = 0 + case side = 1 + case bottom = 2 + } - public init(topicListPanelLocation: Bool) { + private enum CodingKeys: String, CodingKey { + case topicListPanelLocation + } + + public var topicListPanelLocation: TopicListPanelLocation + + public init(topicListPanelLocation: TopicListPanelLocation) { self.topicListPanelLocation = topicListPanelLocation } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.topicListPanelLocation = TopicListPanelLocation(rawValue: try container.decode(Int32.self, forKey: .topicListPanelLocation)) ?? .top + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Int32(self.topicListPanelLocation.rawValue), forKey: .topicListPanelLocation) + } } public final class RemovePaidMessageFeeData: Equatable { @@ -660,7 +680,7 @@ public final class ChatPresentationInterfaceState: Equatable { self.alwaysShowGiftButton = false self.disallowedGifts = nil self.persistentData = PersistentPeerData( - topicListPanelLocation: false + topicListPanelLocation: .top ) self.removePaidMessageFeeData = nil self.viewForumAsMessages = false diff --git a/submodules/Display/Source/UIKitUtils.swift b/submodules/Display/Source/UIKitUtils.swift index 9c438f28ac..b6586e45bc 100644 --- a/submodules/Display/Source/UIKitUtils.swift +++ b/submodules/Display/Source/UIKitUtils.swift @@ -911,6 +911,10 @@ public extension CALayer { static func displacementMap() -> NSObject? { return makeDisplacementMapFilter() } + + static func colorMatrix() -> NSObject? { + return makeColorMatrixFilter() + } } public extension CALayer { diff --git a/submodules/LegacyComponents/Sources/TGVideoMessageControls.m b/submodules/LegacyComponents/Sources/TGVideoMessageControls.m index b6a6ed8a2e..3acabeee90 100644 --- a/submodules/LegacyComponents/Sources/TGVideoMessageControls.m +++ b/submodules/LegacyComponents/Sources/TGVideoMessageControls.m @@ -362,8 +362,11 @@ static CGRect viewFrame(UIView *view) _deleteButton = [[TGModernButton alloc] initWithFrame:CGRectMake(0.0f, (self.bounds.size.height - 45.0f) / 2.0f, 45.0f, 45.0f)]; [_deleteButton setImage:deleteImage forState:UIControlStateNormal]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" _deleteButton.adjustsImageWhenDisabled = false; _deleteButton.adjustsImageWhenHighlighted = false; +#pragma clang diagnostic pop [_deleteButton addTarget:self action:@selector(deleteButtonPressed) forControlEvents:UIControlEventTouchUpInside]; if (!_forStory) { [self addSubview:_deleteButton]; @@ -379,7 +382,10 @@ static CGRect viewFrame(UIView *view) _sendButton.alpha = 0.0f; _sendButton.exclusiveTouch = true; [_sendButton setImage:_assets.sendImage forState:UIControlStateNormal]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" _sendButton.adjustsImageWhenHighlighted = false; +#pragma clang diagnostic pop [_sendButton addTarget:self action:@selector(sendButtonPressed) forControlEvents:UIControlEventTouchUpInside]; _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(doneButtonLongPressed:)]; diff --git a/submodules/LegacyComponents/Sources/TGVideoMessageTrimView.m b/submodules/LegacyComponents/Sources/TGVideoMessageTrimView.m index 0b008e18dc..d3c22ad4b5 100644 --- a/submodules/LegacyComponents/Sources/TGVideoMessageTrimView.m +++ b/submodules/LegacyComponents/Sources/TGVideoMessageTrimView.m @@ -34,14 +34,20 @@ _leftSegmentView = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, 16, height)]; _leftSegmentView.exclusiveTouch = true; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" _leftSegmentView.adjustsImageWhenHighlighted = false; +#pragma clang diagnostic pop [_leftSegmentView setBackgroundImage:TGComponentsImageNamed(@"VideoMessageLeftHandle") forState:UIControlStateNormal]; _leftSegmentView.hitTestEdgeInsets = UIEdgeInsetsMake(-5, -25, -5, -10); [self addSubview:_leftSegmentView]; _rightSegmentView = [[UIButton alloc] initWithFrame:CGRectMake(0, 0, 16, height)]; _rightSegmentView.exclusiveTouch = true; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" _rightSegmentView.adjustsImageWhenHighlighted = false; +#pragma clang diagnostic pop [_rightSegmentView setBackgroundImage:TGComponentsImageNamed(@"VideoMessageRightHandle") forState:UIControlStateNormal]; _rightSegmentView.hitTestEdgeInsets = UIEdgeInsetsMake(-5, -10, -5, -25); [self addSubview:_rightSegmentView]; diff --git a/submodules/MtProtoKit/Sources/MTRsa.m b/submodules/MtProtoKit/Sources/MTRsa.m index 92edd5bf33..c7e9707016 100644 --- a/submodules/MtProtoKit/Sources/MTRsa.m +++ b/submodules/MtProtoKit/Sources/MTRsa.m @@ -287,6 +287,8 @@ static NSData *base64_decode(NSString *str) { size_t outlen = block_size; OSStatus status = noErr; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" status = SecKeyEncrypt(keyRef, kSecPaddingNone, srcbuf + idx, @@ -294,6 +296,7 @@ static NSData *base64_decode(NSString *str) { outbuf, &outlen ); +#pragma clang diagnostic pop if (status != 0) { NSLog(@"SecKeyEncrypt fail. Error Code: %d", (int)status); ret = nil; @@ -343,6 +346,8 @@ static NSData *base64_decode(NSString *str) { size_t outlen = block_size; OSStatus status = noErr; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" status = SecKeyDecrypt(keyRef, kSecPaddingNone, srcbuf + idx, @@ -350,6 +355,7 @@ static NSData *base64_decode(NSString *str) { outbuf, &outlen ); +#pragma clang diagnostic pop if (status != 0) { NSLog(@"SecKeyEncrypt fail. Error Code: %d", (int)status); ret = nil; diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift index e568c17d0e..ddec57771f 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerActionsStackNode.swift @@ -1722,7 +1722,7 @@ public final class ContextControllerActionsStackNodeImpl: ASDisplayNode, Context } transition.setPosition(view: self.contentContainer, position: CGRect(origin: CGPoint(), size: size).center) transition.setBounds(view: self.contentContainer, bounds: CGRect(origin: CGPoint(), size: size)) - self.contentContainer.update(size: size, cornerRadius: min(30.0, size.height * 0.5), transition: transition) + self.contentContainer.update(size: size, cornerRadius: min(30.0, size.height * 0.5), isDark: presentationData.theme.overallDarkAppearance, transition: transition) //let backgroundContainerFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: -self.backgroundContainerInset, dy: -self.backgroundContainerInset) diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift index cb5328aed8..19e231b579 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift @@ -818,7 +818,9 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo if let transitionInfo = reference.transitionInfo() { if let referenceView = transitionInfo.referenceView as? ContextExtractableContainer { if #available(iOS 26.0, *) { - contextExtractableContainer = (referenceView, convertFrame(transitionInfo.referenceView.bounds.inset(by: transitionInfo.insets), from: transitionInfo.referenceView, to: self.view)) + if transitionInfo.referenceView.bounds.width == transitionInfo.referenceView.bounds.height { + contextExtractableContainer = (referenceView, convertFrame(transitionInfo.referenceView.bounds.inset(by: transitionInfo.insets), from: transitionInfo.referenceView, to: self.view)) + } } } diff --git a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift index 14b8a8d020..68bfbc9b92 100644 --- a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift +++ b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift @@ -310,6 +310,8 @@ public class GlassBackgroundView: UIView { } private let legacyView: LegacyGlassView? + private let legacyHighlightContainerView: UIView? + private var glassHighlightRecognizer: GlassHighlightGestureRecognizer? private let nativeView: UIVisualEffectView? private let nativeViewClippingContext: ClippingShapeContext? @@ -339,6 +341,7 @@ public class GlassBackgroundView: UIView { public override init(frame: CGRect) { if #available(iOS 26.0, *), !GlassBackgroundView.useCustomGlassImpl { self.legacyView = nil + self.legacyHighlightContainerView = nil let glassEffect = UIGlassEffect(style: .regular) glassEffect.isInteractive = false @@ -355,6 +358,10 @@ public class GlassBackgroundView: UIView { self.shadowView = nil } else { self.legacyView = LegacyGlassView(frame: CGRect()) + let legacyHighlightContainerView = UIView() + legacyHighlightContainerView.isUserInteractionEnabled = false + legacyHighlightContainerView.clipsToBounds = true + self.legacyHighlightContainerView = legacyHighlightContainerView self.nativeView = nil self.nativeViewClippingContext = nil self.nativeParamsView = nil @@ -384,18 +391,29 @@ public class GlassBackgroundView: UIView { } if let legacyView = self.legacyView { self.addSubview(legacyView) + let glassHighlightRecognizer = GlassHighlightGestureRecognizer(target: self, action: #selector(self.onHighlightGesture(_:))) + glassHighlightRecognizer.highlightContainerView = self.legacyHighlightContainerView + self.glassHighlightRecognizer = glassHighlightRecognizer + self.addGestureRecognizer(glassHighlightRecognizer) + glassHighlightRecognizer.isEnabled = false } if let foregroundView = self.foregroundView { self.addSubview(foregroundView) foregroundView.mask = self.maskContainerView } self.addSubview(self.contentContainer) + if let legacyHighlightContainerView = self.legacyHighlightContainerView { + self.addSubview(legacyHighlightContainerView) + } } required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func onHighlightGesture(_ recognizer: GlassHighlightGestureRecognizer) { + } + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.isUserInteractionEnabled { return nil @@ -421,6 +439,10 @@ public class GlassBackgroundView: UIView { public func update(size: CGSize, cornerRadius: CGFloat, isDark: Bool, tintColor: TintColor, isInteractive: Bool = false, isVisible: Bool = true, transition: ComponentTransition) { let shape: Shape = .roundedRect(cornerRadius: cornerRadius) + if let glassHighlightRecognizer = self.glassHighlightRecognizer { + glassHighlightRecognizer.isEnabled = isInteractive + } + if let nativeView = self.nativeView, let nativeViewClippingContext = self.nativeViewClippingContext, (nativeView.bounds.size != size || nativeViewClippingContext.shape != shape) { nativeViewClippingContext.update(shape: shape, size: size, transition: transition) @@ -455,6 +477,16 @@ public class GlassBackgroundView: UIView { } transition.setFrame(view: legacyView, frame: CGRect(origin: CGPoint(), size: size)) transition.setAlpha(view: legacyView, alpha: isVisible ? 1.0 : 0.0) + + transition.setPosition(view: self.contentView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + transition.setBounds(view: self.contentView, bounds: CGRect(origin: CGPoint(), size: size)) + } + if let legacyHighlightContainerView = self.legacyHighlightContainerView { + transition.setFrame(view: legacyHighlightContainerView, frame: CGRect(origin: CGPoint(), size: size)) + switch shape { + case let .roundedRect(cornerRadius): + transition.setCornerRadius(layer: legacyHighlightContainerView.layer, cornerRadius: cornerRadius) + } } let shadowInset: CGFloat = 32.0 @@ -548,6 +580,9 @@ public class GlassBackgroundView: UIView { } } foregroundView.image = GlassBackgroundView.generateLegacyGlassImage(size: CGSize(width: outerCornerRadius * 2.0, height: outerCornerRadius * 2.0), inset: shadowInset, borderWidthFactor: borderWidthFactor, isDark: isDark, fillColor: fillColor) + #if DEBUG + //foregroundView.image = nil + #endif transition.setAlpha(view: foregroundView, alpha: isVisible ? 1.0 : 0.0) } else { if let nativeParamsView = self.nativeParamsView, let nativeView = self.nativeView { @@ -717,6 +752,31 @@ public final class GlassBackgroundContainerView: UIView { } for view in self.contentView.subviews.reversed() { if let result = view.hitTest(self.convert(point, to: view), with: event), result.isUserInteractionEnabled { + + #if DEBUG + func findMatrix(layer: CALayer) -> AnyObject? { + for filter in layer.filters ?? [] { + if "\(filter)".contains("vibrantColorMatrix") { + return filter as AnyObject + } + } + + for sublayer in layer.sublayers ?? [] { + if let result = findMatrix(layer: sublayer) { + return result + } + } + return nil + } + + /*if let filter = findMatrix(layer: self.layer) as? NSObject { + var matrix: [Float32] = .init(repeating: 0, count: 20) + let matrixValues = filter.value(forKey: "inputColorMatrix") as! NSValue + matrixValues.getValue(&matrix, size: 4 * 20) + assert(true) + }*/ + #endif + return result } } diff --git a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift index 4a855e53dc..38790fa641 100644 --- a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift +++ b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift @@ -138,21 +138,40 @@ final class LegacyGlassView: UIView { } if previousParams?.style != style { - if let blurFilter = CALayer.blur() { + if let blurFilter = CALayer.blur(), let colorMatrixFilter = CALayer.colorMatrix() { switch style { case .clear: - blurFilter.setValue(6.0 as NSNumber, forKey: "inputRadius") + if DeviceMetrics.performance.isGraphicallyCapable { + blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius") + } else { + blurFilter.setValue(6.0 as NSNumber, forKey: "inputRadius") + } case .normal: blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius") } - backdropLayer.filters = [blurFilter] + + 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") + + switch style { + case .clear: + backdropLayer.filters = [blurFilter] + case .normal: + backdropLayer.filters = [colorMatrixFilter, blurFilter] + } } } transition.setCornerRadius(layer: self.layer, cornerRadius: cornerRadius) transition.setFrame(layer: backdropLayer, frame: CGRect(origin: CGPoint(), size: size)) - if !"".isEmpty { + if DeviceMetrics.performance.isGraphicallyCapable { let size = CGSize(width: max(1.0, size.width), height: max(1.0, size.height)) let cornerRadius = min(min(size.width, size.height) * 0.5, cornerRadius) let displacementMagnitudePoints: CGFloat = 20.0 diff --git a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/TouchEffect.swift b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/TouchEffect.swift new file mode 100644 index 0000000000..643b4086aa --- /dev/null +++ b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/TouchEffect.swift @@ -0,0 +1,352 @@ +import Foundation +import UIKit +import Display + +final class GlassHighlightGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate { + var highlightContainerView: UIView? + + private var touchEffect: TouchEffect? + private var initialTouchLocation: CGPoint? + weak var touchEffectView: UIView? + var parameters = TouchEffect.Parameters() { + didSet { + self.touchEffect?.setParameters(self.parameters, animated: false) + } + } + + override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + + self.delegate = self + self.cancelsTouchesInView = false + self.delaysTouchesBegan = false + self.delaysTouchesEnded = false + self.requiresExclusiveTouchType = false + } + + override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } + + override func reset() { + if let touchEffect = self.touchEffect { + touchEffect.setIsTracking(false) + } + + self.touchEffect = nil + self.initialTouchLocation = nil + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + if let view = self.touchEffectView ?? self.view, let touch = touches.first { + let touchLocation = touch.location(in: view) + let touchEffect = TouchEffect(view: view, highlightContainerView: self.highlightContainerView) + touchEffect.setParameters(self.parameters, animated: false) + if let highlightContainerView = self.highlightContainerView { + touchEffect.setTouchLocation(touch.location(in: highlightContainerView), animated: false) + } + touchEffect.setStretchVector(.zero, animated: false) + self.touchEffect = touchEffect + self.initialTouchLocation = touchLocation + touchEffect.setIsTracking(true) + } + } + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + if let touchEffect = self.touchEffect { + touchEffect.setIsTracking(false) + } + self.touchEffect = nil + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + if let touchEffect = self.touchEffect { + touchEffect.setIsTracking(false) + } + self.touchEffect = nil + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + guard let touchEffect = self.touchEffect, + let view = self.touchEffectView ?? self.view, + let touch = touches.first, + let initialTouchLocation = self.initialTouchLocation else { + return + } + let touchLocation = touch.location(in: view) + if let highlightContainerView = self.highlightContainerView { + touchEffect.setTouchLocation(touch.location(in: highlightContainerView), animated: false) + } + touchEffect.setStretchVector( + CGPoint( + x: touchLocation.x - initialTouchLocation.x, + y: touchLocation.y - initialTouchLocation.y + ), + animated: false + ) + } +} + +final class TouchEffect { + private struct State: Equatable { + var isTracking: Bool + var stretchVector: CGPoint + var touchLocation: CGPoint? + } + + struct SpringParameters { + var mass: CGFloat + var stiffness: CGFloat + var damping: CGFloat + var initialVelocity: CGFloat + } + + struct Parameters { + var liftOn = SpringParameters( + mass: 1.36, + stiffness: 568.0, + damping: 39.7, + initialVelocity: 0.0 + ) + var liftOff = SpringParameters( + mass: 2.0, + stiffness: 460.0, + damping: 21.8, + initialVelocity: 0.0 + ) + var pressedSizeIncrease: CGFloat = 20.0 + } + + private weak var view: UIView? + private weak var highlightContainerView: UIView? + private let radialHighlightLayer: CAGradientLayer = { + let layer = CAGradientLayer() + layer.type = .radial + + let baseGradientAlpha: CGFloat = 0.5 + let numSteps = 8 + let firstStep = 1 + let firstLocation = 0.5 + let colors = (0 ..< numSteps).map { i -> UIColor in + if i < firstStep { + return UIColor(white: 1.0, alpha: 1.0) + } else { + let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1) + let value: CGFloat = 1.0 - bezierPoint(0.42, 0.0, 0.58, 1.0, step) + return UIColor(white: 1.0, alpha: baseGradientAlpha * value) + } + } + let locations = (0 ..< numSteps).map { i -> CGFloat in + if i < firstStep { + return 0.0 + } else { + let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1) + return (firstLocation + (1.0 - firstLocation) * step) + } + } + + layer.colors = colors.map(\.cgColor) + layer.locations = locations.map { $0 as NSNumber } + layer.startPoint = CGPoint(x: 0.5, y: 0.5) + layer.endPoint = CGPoint(x: 1.0, y: 1.0) + layer.opacity = 0.0 + layer.actions = [ + "position": NSNull(), + "bounds": NSNull(), + "opacity": NSNull() + ] + return layer + }() + private var state = State(isTracking: false, stretchVector: .zero, touchLocation: nil) + private var appliedState: State? + + var parameters = Parameters() + + init(view: UIView, highlightContainerView: UIView?) { + self.view = view + self.highlightContainerView = highlightContainerView + + if let highlightContainerView { + highlightContainerView.layer.addSublayer(self.radialHighlightLayer) + } + } + + deinit { + self.radialHighlightLayer.removeFromSuperlayer() + } + + private func currentTransform(for state: State, view: UIView) -> CATransform3D { + let referenceView = self.highlightContainerView ?? view + let viewWidth = max(1.0, referenceView.bounds.width) + let viewHeight = max(1.0, referenceView.bounds.height) + let aspectRatio = viewWidth / viewHeight + + let baseScaleX: CGFloat + let baseScaleY: CGFloat + if state.isTracking { + if viewWidth < viewHeight { + baseScaleY = 1.0 + self.parameters.pressedSizeIncrease / viewHeight + baseScaleX = baseScaleY + } else { + baseScaleX = 1.0 + self.parameters.pressedSizeIncrease / viewWidth + baseScaleY = baseScaleX + } + } else { + baseScaleX = 1.0 + baseScaleY = 1.0 + } + + guard state.isTracking else { + return CATransform3DScale(CATransform3DIdentity, baseScaleX, baseScaleY, 1.0) + } + + let stretchVector = state.stretchVector + let adjustedX = stretchVector.x / aspectRatio + let length = sqrt(pow(adjustedX, 2) + pow(stretchVector.y, 2)) + + guard length != 0.0 else { + return CATransform3DScale(CATransform3DIdentity, baseScaleX, baseScaleY, 1.0) + } + + let normal = CGPoint( + x: adjustedX / length, + y: stretchVector.y / length + ) + let k: CGFloat = -1.0 / ((length / viewHeight) / (5.0 * aspectRatio) + 1.0) + 1.0 + let additionalMaxScale = (viewHeight + 16.0 / aspectRatio) / viewHeight - 1.0 + let t = additionalMaxScale * k * aspectRatio + let maxOffset: CGFloat = 24.0 + + if abs(normal.x) > abs(normal.y) { + let diff = abs(normal.x) - abs(normal.y) + var transform = CATransform3DIdentity + transform.m11 = baseScaleX * (1.0 + t * diff) + transform.m22 = baseScaleY * (1.0 / (1.0 + t * diff)) + transform.m41 = normal.x * maxOffset * k + transform.m42 = normal.y * maxOffset * k + return transform + } else { + let diff = abs(normal.y) - abs(normal.x) + var transform = CATransform3DIdentity + transform.m11 = baseScaleX * (1.0 / (1.0 + t * diff)) + transform.m22 = baseScaleY * (1.0 + t * diff) + transform.m41 = normal.x * maxOffset * k + transform.m42 = normal.y * maxOffset * k + return transform + } + } + + private func currentSpringParameters(from previousState: State?, to state: State) -> SpringParameters { + guard let previousState, previousState != state else { + return state.isTracking ? self.parameters.liftOn : self.parameters.liftOff + } + if !previousState.isTracking && state.isTracking { + return self.parameters.liftOn + } else { + return self.parameters.liftOff + } + } + + private func updateRadialHighlight(animated: Bool) { + guard self.highlightContainerView != nil else { + return + } + + let baseAlpha: Float = 0.1 + let targetOpacity: Float = self.state.isTracking ? baseAlpha : 0.0 + let size = CGSize(width: 300.0, height: 300.0) + if let touchLocation = self.state.touchLocation { + self.radialHighlightLayer.bounds = CGRect(origin: CGPoint(), size: size) + self.radialHighlightLayer.position = touchLocation + } + + if animated { + let animation = CABasicAnimation(keyPath: "opacity") + animation.fromValue = self.radialHighlightLayer.presentation()?.opacity ?? self.radialHighlightLayer.opacity + self.radialHighlightLayer.opacity = targetOpacity + animation.toValue = targetOpacity + animation.duration = self.state.isTracking ? 0.12 : 0.22 + animation.timingFunction = CAMediaTimingFunction(name: self.state.isTracking ? .easeOut : .easeInEaseOut) + self.radialHighlightLayer.add(animation, forKey: "opacity") + } else { + self.radialHighlightLayer.opacity = targetOpacity + } + } + + func applyCurrentTransform(animated: Bool = true) { + guard let view = self.view else { + return + } + + let targetTransform = self.currentTransform(for: self.state, view: view) + + if !animated { + view.layer.removeAnimation(forKey: "sublayerTransform") + view.layer.sublayerTransform = targetTransform + self.updateRadialHighlight(animated: false) + self.appliedState = self.state + return + } + + let springParameters = self.currentSpringParameters(from: self.appliedState, to: self.state) + let animation = CASpringAnimation(keyPath: "sublayerTransform") + animation.fromValue = NSValue(caTransform3D: view.layer.presentation()?.sublayerTransform ?? view.layer.sublayerTransform) + animation.toValue = NSValue(caTransform3D: targetTransform) + animation.mass = springParameters.mass + animation.stiffness = springParameters.stiffness + animation.damping = springParameters.damping + animation.initialVelocity = springParameters.initialVelocity + animation.duration = animation.settlingDuration + animation.fillMode = .both + animation.isRemovedOnCompletion = false + + view.layer.sublayerTransform = targetTransform + view.layer.add(animation, forKey: "sublayerTransform") + self.updateRadialHighlight(animated: true) + self.appliedState = self.state + } + + func setParameters(_ parameters: Parameters, animated: Bool = false) { + self.parameters = parameters + self.applyCurrentTransform(animated: animated) + } + + func setIsTracking(_ value: Bool, animated: Bool = true) { + let nextState = State( + isTracking: value, + stretchVector: value ? self.state.stretchVector : .zero, + touchLocation: value ? self.state.touchLocation : self.state.touchLocation + ) + guard self.state != nextState else { + return + } + self.state = nextState + self.applyCurrentTransform(animated: animated) + } + + func setTouchLocation(_ touchLocation: CGPoint, animated: Bool = false) { + let nextState = State(isTracking: self.state.isTracking, stretchVector: self.state.stretchVector, touchLocation: touchLocation) + guard self.state != nextState else { + return + } + self.state = nextState + self.applyCurrentTransform(animated: animated) + } + + func setStretchVector(_ stretchVector: CGPoint, animated: Bool = false) { + let nextState = State(isTracking: self.state.isTracking, stretchVector: stretchVector, touchLocation: self.state.touchLocation) + guard self.state != nextState else { + return + } + self.state = nextState + self.applyCurrentTransform(animated: animated) + } +} diff --git a/submodules/TelegramUI/Components/HeaderPanelContainerComponent/Sources/HeaderPanelContainerComponent.swift b/submodules/TelegramUI/Components/HeaderPanelContainerComponent/Sources/HeaderPanelContainerComponent.swift index a009898553..94f7c0ca30 100644 --- a/submodules/TelegramUI/Components/HeaderPanelContainerComponent/Sources/HeaderPanelContainerComponent.swift +++ b/submodules/TelegramUI/Components/HeaderPanelContainerComponent/Sources/HeaderPanelContainerComponent.swift @@ -251,13 +251,27 @@ public final class HeaderPanelContainerComponent: Component { self.backgroundContainer.update(size: CGSize(width: backgroundSize.width + 32.0 * 2.0, height: backgroundSize.height + 32.0 * 2.0), isDark: component.theme.overallDarkAppearance, transition: transition) let backgroundFrame = CGRect(origin: CGPoint(x: 32.0 + sideInset, y: 32.0), size: CGSize(width: size.width - sideInset * 2.0, height: backgroundSize.height)) + + var panelCount = 0 + if component.tabs != nil { + panelCount += 1 + } + panelCount += component.panels.count + var cornerRadius: CGFloat = 0.0 + if panelCount == 1 { + cornerRadius = backgroundFrame.height * 0.5 + } else { + cornerRadius = 20.0 + } + transition.setFrame(view: self.backgroundView, frame: backgroundFrame) - self.backgroundView.update(size: backgroundFrame.size, cornerRadius: 20.0, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: component.preferClearGlass ? .clear : .panel), isInteractive: true, transition: transition) + self.backgroundView.update(size: backgroundFrame.size, cornerRadius: cornerRadius, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: component.preferClearGlass ? .clear : .panel), isInteractive: true, transition: transition) transition.setAlpha(view: self.backgroundContainer, alpha: (component.tabs != nil || !component.panels.isEmpty) ? 1.0 : 0.0) transition.setFrame(view: self.contentContainer, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) - self.contentContainer.layer.cornerRadius = 20.0 + + transition.setCornerRadius(layer: self.contentContainer.layer, cornerRadius: min(cornerRadius, backgroundFrame.height * 0.5)) return size } diff --git a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift index daaa424549..564d8d235e 100644 --- a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift +++ b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift @@ -4,6 +4,7 @@ import Display import ComponentFlow import Display import UIKitRuntimeUtils +import GlassBackgroundComponent @inline(__always) private func getMethod(object: NSObject, selector: String) -> T? { @@ -98,13 +99,11 @@ public protocol LensTransitionContainerEffectView: UIView { } public protocol LensTransitionContainerProtocol: UIView { - var effectView: LensTransitionContainerEffectView { get } - var contentsEffectView: UIView { get } var contentsView: UIView { get } func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) - func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) + func update(size: CGSize, cornerRadius: CGFloat, isDark: Bool, transition: ComponentTransition) } @available(iOS 26.0, *) @@ -228,7 +227,7 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol self.cancelAnimationsRecursively(layer: sdfElementLayer) } } - + private struct TransitionKeyframes { let bakedSizes: [CGSize] let bakedPositions: [CGPoint] @@ -242,7 +241,7 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol private func makeForwardTransitionKeyframes(fromRect: CGRect, toRect: CGRect, toCornerRadius: CGFloat) -> TransitionKeyframes { let sourceMaxEdgeDistance: CGFloat = 20.0 let sourceSuckDurationFraction: CGFloat = 0.9 - let sourceFinalInsideDistance: CGFloat = -8.0 + let sourceFinalFurthestInsideDistance: CGFloat = -8.0 let sourceFinalInsideStartFraction: CGFloat = 0.65 let sourceFullInsideInset: CGFloat = max(1.0, min(fromRect.width, fromRect.height) * 0.5) let sampleCount = 30 @@ -257,7 +256,6 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let centerLineLength = hypot(centerLineDelta.x, centerLineDelta.y) let centerLineDirection: CGPoint = centerLineLength > 1e-6 ? CGPoint(x: centerLineDelta.x / centerLineLength, y: centerLineDelta.y / centerLineLength) : CGPoint(x: 1.0, y: 0.0) let centerLineMinDistance: CGFloat = -centerLineLength - let centerLineMaxDistance: CGFloat = 0.0 func isPointInsideRoundedRect(point: CGPoint, rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat) -> Bool { let halfWidth = rectSize.width * 0.5 @@ -386,6 +384,72 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol return CGPoint(x: rectCenter.x + nearestLocal.x, y: rectCenter.y + nearestLocal.y) } + func boundaryPointOnRoundedRectRay(rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat, direction: CGPoint) -> CGPoint { + let halfWidth = rectSize.width * 0.5 + let halfHeight = rectSize.height * 0.5 + if halfWidth <= 0.0 || halfHeight <= 0.0 { + return rectCenter + } + + let radius = max(0.0, min(cornerRadius, min(halfWidth, halfHeight))) + let innerX = max(0.0, halfWidth - radius) + let innerY = max(0.0, halfHeight - radius) + + let dirLength = hypot(direction.x, direction.y) + let dir: CGPoint + if dirLength > 1e-6 { + dir = CGPoint(x: direction.x / dirLength, y: direction.y / dirLength) + } else { + dir = CGPoint(x: 1.0, y: 0.0) + } + let dx = abs(dir.x) + let dy = abs(dir.y) + + @inline(__always) + func signedPoint(_ x: CGFloat, _ y: CGFloat, _ direction: CGPoint) -> CGPoint { + let sx: CGFloat = direction.x >= 0.0 ? 1.0 : -1.0 + let sy: CGFloat = direction.y >= 0.0 ? 1.0 : -1.0 + return CGPoint(x: x * sx, y: y * sy) + } + + if dx > 1e-6 { + let tVertical = halfWidth / dx + let yAtVertical = tVertical * dy + if yAtVertical <= innerY + 1e-6 { + let local = signedPoint(halfWidth, yAtVertical, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + } + + if dy > 1e-6 { + let tHorizontal = halfHeight / dy + let xAtHorizontal = tHorizontal * dx + if xAtHorizontal <= innerX + 1e-6 { + let local = signedPoint(xAtHorizontal, halfHeight, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + } + + if radius <= 1e-6 { + let tx = dx > 1e-6 ? (halfWidth / dx) : CGFloat.greatestFiniteMagnitude + let ty = dy > 1e-6 ? (halfHeight / dy) : CGFloat.greatestFiniteMagnitude + let t = min(tx, ty) + let local = signedPoint(dx * t, dy * t, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + + let a = dx * dx + dy * dy + let b = -2.0 * (dx * innerX + dy * innerY) + let c = innerX * innerX + innerY * innerY - radius * radius + let discriminant = max(0.0, b * b - 4.0 * a * c) + let sqrtDiscriminant = sqrt(discriminant) + let t1 = (-b - sqrtDiscriminant) / (2.0 * a) + let t2 = (-b + sqrtDiscriminant) / (2.0 * a) + let tCorner = max(0.0, max(t1, t2)) + let cornerLocal = signedPoint(dx * tCorner, dy * tCorner, dir) + return CGPoint(x: rectCenter.x + cornerLocal.x, y: rectCenter.y + cornerLocal.y) + } + var bakedSizes: [CGSize] = [] var bakedPositions: [CGPoint] = [] var localPositions: [CGPoint] = [] @@ -444,14 +508,11 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol } let lineDirection = centerLineDirection - let nearestEdgePoint = nearestBoundaryPointOnRoundedRect( - point: CGPoint( - x: blobCenter.x + lineDirection.x * 10000.0, - y: blobCenter.y + lineDirection.y * 10000.0 - ), + let nearestEdgePoint = boundaryPointOnRoundedRectRay( rectCenter: blobCenter, rectSize: scaledSize, - cornerRadius: scaledCornerRadius + cornerRadius: scaledCornerRadius, + direction: lineDirection ) let normalizedSuckProgress = max(0.0, min(1.0, t / sourceSuckDurationFraction)) @@ -462,17 +523,20 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let normalizedFinalInsideProgress = max(0.0, min(1.0, (t - sourceFinalInsideStartFraction) / max(0.001, 1.0 - sourceFinalInsideStartFraction))) let oneMinusFinalInsideProgress = 1.0 - normalizedFinalInsideProgress let finalInsideEase = 1.0 - oneMinusFinalInsideProgress * oneMinusFinalInsideProgress * oneMinusFinalInsideProgress - let sourceInsetDistance = baseSourceInsetDistance + (sourceFinalInsideDistance - baseSourceInsetDistance) * finalInsideEase let sourceHalfWidth = fromRect.width * 0.5 let sourceHalfHeight = fromRect.height * 0.5 let sourceHalfExtentAlongRay = abs(lineDirection.x) * sourceHalfWidth + abs(lineDirection.y) * sourceHalfHeight + let sourceFinalInsideDistance = sourceFinalFurthestInsideDistance - sourceHalfExtentAlongRay * 2.0 + let sourceInsetDistance = baseSourceInsetDistance + (sourceFinalInsideDistance - baseSourceInsetDistance) * finalInsideEase let sourceCenterDistance = sourceInsetDistance + sourceHalfExtentAlongRay let sourcePosition = CGPoint( x: nearestEdgePoint.x + lineDirection.x * sourceCenterDistance, y: nearestEdgePoint.y + lineDirection.y * sourceCenterDistance ) let centerLineDistance = (sourcePosition.x - fromCenter.x) * centerLineDirection.x + (sourcePosition.y - fromCenter.y) * centerLineDirection.y - let clampedCenterLineDistance = max(centerLineMinDistance, min(centerLineMaxDistance, centerLineDistance)) + let centerLineOutwardAllowance = max(0.0, centerLineDistance) * finalInsideEase + let centerLineUpperBound = sourceInsetDistance < 0.0 ? centerLineOutwardAllowance : 0.0 + let clampedCenterLineDistance = max(centerLineMinDistance, min(centerLineUpperBound, centerLineDistance)) var projectedSourcePosition = CGPoint( x: fromCenter.x + centerLineDirection.x * clampedCenterLineDistance, y: fromCenter.y + centerLineDirection.y * clampedCenterLineDistance @@ -481,26 +545,19 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol x: projectedSourcePosition.x - centerLineDirection.x * sourceHalfExtentAlongRay, y: projectedSourcePosition.y - centerLineDirection.y * sourceHalfExtentAlongRay ) - let sourceNearestBoundaryPoint = nearestBoundaryPointOnRoundedRect( - point: sourceNearestPoint, + let sourceNearestBoundaryPoint = boundaryPointOnRoundedRectRay( rectCenter: blobCenter, rectSize: scaledSize, - cornerRadius: scaledCornerRadius + cornerRadius: scaledCornerRadius, + direction: lineDirection ) - let sourceNearestDistance = hypot( - sourceNearestPoint.x - sourceNearestBoundaryPoint.x, - sourceNearestPoint.y - sourceNearestBoundaryPoint.y - ) - let sourceNearestSignedDistance: CGFloat = isPointInsideRoundedRect( - point: sourceNearestPoint, - rectCenter: blobCenter, - rectSize: scaledSize, - cornerRadius: scaledCornerRadius - ) ? -sourceNearestDistance : sourceNearestDistance + let sourceNearestSignedDistance: CGFloat = + (sourceNearestPoint.x - sourceNearestBoundaryPoint.x) * lineDirection.x + + (sourceNearestPoint.y - sourceNearestBoundaryPoint.y) * lineDirection.y let centerCorrection = sourceNearestSignedDistance - sourceInsetDistance if abs(centerCorrection) > 0.01 { let correctedCenterLineDistance = centerLineDistance - centerCorrection - let clampedCorrectedCenterLineDistance = max(centerLineMinDistance, min(centerLineMaxDistance, correctedCenterLineDistance)) + let clampedCorrectedCenterLineDistance = max(centerLineMinDistance, min(centerLineUpperBound, correctedCenterLineDistance)) projectedSourcePosition = CGPoint( x: fromCenter.x + centerLineDirection.x * clampedCorrectedCenterLineDistance, y: fromCenter.y + centerLineDirection.y * clampedCorrectedCenterLineDistance @@ -551,7 +608,7 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let sourceMaxEdgeDistance: CGFloat = 20.0 let sourceSuckDurationFraction: CGFloat = 0.9 - let sourceFinalInsideDistance: CGFloat = -8.0 + let sourceFinalFurthestInsideDistance: CGFloat = -8.0 let sourceFinalInsideStartFraction: CGFloat = 0.65 let sourceFullInsideInset: CGFloat = max(1.0, min(fromRect.width, fromRect.height) * 0.5) let sampleCount = 30 @@ -566,7 +623,6 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let centerLineLength = hypot(centerLineDelta.x, centerLineDelta.y) let centerLineDirection: CGPoint = centerLineLength > 1e-6 ? CGPoint(x: centerLineDelta.x / centerLineLength, y: centerLineDelta.y / centerLineLength) : CGPoint(x: 1.0, y: 0.0) let centerLineMinDistance: CGFloat = -centerLineLength - let centerLineMaxDistance: CGFloat = 0.0 func isPointInsideRoundedRect(point: CGPoint, rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat) -> Bool { let halfWidth = rectSize.width * 0.5 @@ -698,6 +754,72 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol return CGPoint(x: rectCenter.x + nearestLocal.x, y: rectCenter.y + nearestLocal.y) } + func boundaryPointOnRoundedRectRay(rectCenter: CGPoint, rectSize: CGSize, cornerRadius: CGFloat, direction: CGPoint) -> CGPoint { + let halfWidth = rectSize.width * 0.5 + let halfHeight = rectSize.height * 0.5 + if halfWidth <= 0.0 || halfHeight <= 0.0 { + return rectCenter + } + + let radius = max(0.0, min(cornerRadius, min(halfWidth, halfHeight))) + let innerX = max(0.0, halfWidth - radius) + let innerY = max(0.0, halfHeight - radius) + + let dirLength = hypot(direction.x, direction.y) + let dir: CGPoint + if dirLength > 1e-6 { + dir = CGPoint(x: direction.x / dirLength, y: direction.y / dirLength) + } else { + dir = CGPoint(x: 1.0, y: 0.0) + } + let dx = abs(dir.x) + let dy = abs(dir.y) + + @inline(__always) + func signedPoint(_ x: CGFloat, _ y: CGFloat, _ direction: CGPoint) -> CGPoint { + let sx: CGFloat = direction.x >= 0.0 ? 1.0 : -1.0 + let sy: CGFloat = direction.y >= 0.0 ? 1.0 : -1.0 + return CGPoint(x: x * sx, y: y * sy) + } + + if dx > 1e-6 { + let tVertical = halfWidth / dx + let yAtVertical = tVertical * dy + if yAtVertical <= innerY + 1e-6 { + let local = signedPoint(halfWidth, yAtVertical, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + } + + if dy > 1e-6 { + let tHorizontal = halfHeight / dy + let xAtHorizontal = tHorizontal * dx + if xAtHorizontal <= innerX + 1e-6 { + let local = signedPoint(xAtHorizontal, halfHeight, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + } + + if radius <= 1e-6 { + let tx = dx > 1e-6 ? (halfWidth / dx) : CGFloat.greatestFiniteMagnitude + let ty = dy > 1e-6 ? (halfHeight / dy) : CGFloat.greatestFiniteMagnitude + let t = min(tx, ty) + let local = signedPoint(dx * t, dy * t, dir) + return CGPoint(x: rectCenter.x + local.x, y: rectCenter.y + local.y) + } + + let a = dx * dx + dy * dy + let b = -2.0 * (dx * innerX + dy * innerY) + let c = innerX * innerX + innerY * innerY - radius * radius + let discriminant = max(0.0, b * b - 4.0 * a * c) + let sqrtDiscriminant = sqrt(discriminant) + let t1 = (-b - sqrtDiscriminant) / (2.0 * a) + let t2 = (-b + sqrtDiscriminant) / (2.0 * a) + let tCorner = max(0.0, max(t1, t2)) + let cornerLocal = signedPoint(dx * tCorner, dy * tCorner, dir) + return CGPoint(x: rectCenter.x + cornerLocal.x, y: rectCenter.y + cornerLocal.y) + } + var bakedSizes: [CGSize] = [] var bakedPositions: [CGPoint] = [] var localPositions: [CGPoint] = [] @@ -757,14 +879,11 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol } let lineDirection = centerLineDirection - let nearestEdgePoint = nearestBoundaryPointOnRoundedRect( - point: CGPoint( - x: blobCenter.x + lineDirection.x * 10000.0, - y: blobCenter.y + lineDirection.y * 10000.0 - ), + let nearestEdgePoint = boundaryPointOnRoundedRectRay( rectCenter: blobCenter, rectSize: scaledSize, - cornerRadius: scaledCornerRadius + cornerRadius: scaledCornerRadius, + direction: lineDirection ) let normalizedSuckProgress = max(0.0, min(1.0, t / sourceSuckDurationFraction)) @@ -775,17 +894,20 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol let normalizedFinalInsideProgress = max(0.0, min(1.0, (t - sourceFinalInsideStartFraction) / max(0.001, 1.0 - sourceFinalInsideStartFraction))) let oneMinusFinalInsideProgress = 1.0 - normalizedFinalInsideProgress let finalInsideEase = 1.0 - oneMinusFinalInsideProgress * oneMinusFinalInsideProgress * oneMinusFinalInsideProgress - let sourceInsetDistance = baseSourceInsetDistance + (sourceFinalInsideDistance - baseSourceInsetDistance) * finalInsideEase let sourceHalfWidth = fromRect.width * 0.5 let sourceHalfHeight = fromRect.height * 0.5 let sourceHalfExtentAlongRay = abs(lineDirection.x) * sourceHalfWidth + abs(lineDirection.y) * sourceHalfHeight + let sourceFinalInsideDistance = sourceFinalFurthestInsideDistance - sourceHalfExtentAlongRay * 2.0 + let sourceInsetDistance = baseSourceInsetDistance + (sourceFinalInsideDistance - baseSourceInsetDistance) * finalInsideEase let sourceCenterDistance = sourceInsetDistance + sourceHalfExtentAlongRay let sourcePosition = CGPoint( x: nearestEdgePoint.x + lineDirection.x * sourceCenterDistance, y: nearestEdgePoint.y + lineDirection.y * sourceCenterDistance ) let centerLineDistance = (sourcePosition.x - fromCenter.x) * centerLineDirection.x + (sourcePosition.y - fromCenter.y) * centerLineDirection.y - let clampedCenterLineDistance = max(centerLineMinDistance, min(centerLineMaxDistance, centerLineDistance)) + let centerLineOutwardAllowance = max(0.0, centerLineDistance) * finalInsideEase + let centerLineUpperBound = sourceInsetDistance < 0.0 ? centerLineOutwardAllowance : 0.0 + let clampedCenterLineDistance = max(centerLineMinDistance, min(centerLineUpperBound, centerLineDistance)) var projectedSourcePosition = CGPoint( x: fromCenter.x + centerLineDirection.x * clampedCenterLineDistance, y: fromCenter.y + centerLineDirection.y * clampedCenterLineDistance @@ -794,26 +916,19 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol x: projectedSourcePosition.x - centerLineDirection.x * sourceHalfExtentAlongRay, y: projectedSourcePosition.y - centerLineDirection.y * sourceHalfExtentAlongRay ) - let sourceNearestBoundaryPoint = nearestBoundaryPointOnRoundedRect( - point: sourceNearestPoint, + let sourceNearestBoundaryPoint = boundaryPointOnRoundedRectRay( rectCenter: blobCenter, rectSize: scaledSize, - cornerRadius: scaledCornerRadius + cornerRadius: scaledCornerRadius, + direction: lineDirection ) - let sourceNearestDistance = hypot( - sourceNearestPoint.x - sourceNearestBoundaryPoint.x, - sourceNearestPoint.y - sourceNearestBoundaryPoint.y - ) - let sourceNearestSignedDistance: CGFloat = isPointInsideRoundedRect( - point: sourceNearestPoint, - rectCenter: blobCenter, - rectSize: scaledSize, - cornerRadius: scaledCornerRadius - ) ? -sourceNearestDistance : sourceNearestDistance + let sourceNearestSignedDistance: CGFloat = + (sourceNearestPoint.x - sourceNearestBoundaryPoint.x) * lineDirection.x + + (sourceNearestPoint.y - sourceNearestBoundaryPoint.y) * lineDirection.y let centerCorrection = sourceNearestSignedDistance - sourceInsetDistance if abs(centerCorrection) > 0.01 { let correctedCenterLineDistance = centerLineDistance - centerCorrection - let clampedCorrectedCenterLineDistance = max(centerLineMinDistance, min(centerLineMaxDistance, correctedCenterLineDistance)) + let clampedCorrectedCenterLineDistance = max(centerLineMinDistance, min(centerLineUpperBound, correctedCenterLineDistance)) projectedSourcePosition = CGPoint( x: fromCenter.x + centerLineDirection.x * clampedCorrectedCenterLineDistance, y: fromCenter.y + centerLineDirection.y * clampedCorrectedCenterLineDistance @@ -996,20 +1111,13 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol self.setIsFilterActive(isFilterActive: true) sourceEffectView.setTransitionFraction(value: 0.0, duration: 0.0) sourceEffectView.setTransitionFraction(value: 1.0, duration: 0.3) + //sourceEffectView.backgroundColor = .blue let sourceMaxEdgeDistance: CGFloat = 20.0 - let sourceEaseOutDurationFraction: CGFloat = 0.35 - let sourceCenterPullStartFraction: CGFloat - let sourceCenterPullDurationFraction: CGFloat - - let centerDistance = sqrt(pow(fromRect.midX - toRect.midX, 2.0) + pow(fromRect.midY - toRect.midY, 2.0)) - if centerDistance >= 100.0 { - sourceCenterPullStartFraction = 0.2 - sourceCenterPullDurationFraction = 0.3 - } else { - sourceCenterPullStartFraction = 0.0 - sourceCenterPullDurationFraction = 0.0 - } + let sourceSuckDurationFraction: CGFloat = 0.9 + let sourceFinalFurthestInsideDistance: CGFloat = -8.0 + let sourceFinalInsideStartFraction: CGFloat = 0.65 + let sourceFullInsideInset: CGFloat = max(1.0, min(fromRect.width, fromRect.height) * 0.5) let sampleCount = 36 let sampleEndIndex = CGFloat(sampleCount - 1) @@ -1158,24 +1266,47 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol cornerRadius: radius, direction: centerLineDirection ) - let outwardDirection = normalized( - CGPoint( - x: sourceBoundaryPoint.x - centerLinePosition.x, - y: sourceBoundaryPoint.y - centerLinePosition.y - ) - ) - let normalizedSourceProgress = max(0.0, min(1.0, t / sourceEaseOutDurationFraction)) - let oneMinusSourceProgress = 1.0 - normalizedSourceProgress - let sourceEaseOut = 1.0 - oneMinusSourceProgress * oneMinusSourceProgress * oneMinusSourceProgress - let sourceEdgeOffset = lerp(-sourceMaxEdgeDistance, sourceMaxEdgeDistance, sourceEaseOut) - let sourceEdgePosition = CGPoint( - x: sourceBoundaryPoint.x + outwardDirection.x * sourceEdgeOffset, - y: sourceBoundaryPoint.y + outwardDirection.y * sourceEdgeOffset - ) - let normalizedCenterPullProgress = max(0.0, min(1.0, (t - sourceCenterPullStartFraction) / max(0.001, sourceCenterPullDurationFraction))) - let oneMinusCenterPull = 1.0 - normalizedCenterPullProgress + let lineDirection = centerLineDirection + let sourceHalfWidth = fromRect.width * 0.5 + let sourceHalfHeight = fromRect.height * 0.5 + let sourceHalfExtentAlongRay = abs(lineDirection.x) * sourceHalfWidth + abs(lineDirection.y) * sourceHalfHeight + let reverseT = 1.0 - t + let normalizedSuckProgress = max(0.0, min(1.0, reverseT / sourceSuckDurationFraction)) + let oneMinusSuckProgress = 1.0 - normalizedSuckProgress + let suckFraction = 1.0 - oneMinusSuckProgress * oneMinusSuckProgress * oneMinusSuckProgress + let reverseAnimatedInsetDistance = sourceMaxEdgeDistance - suckFraction * (sourceMaxEdgeDistance + sourceFullInsideInset) + let normalizedFinalInsideProgress = max(0.0, min(1.0, (reverseT - sourceFinalInsideStartFraction) / max(0.001, 1.0 - sourceFinalInsideStartFraction))) + let oneMinusFinalInsideProgress = 1.0 - normalizedFinalInsideProgress + let finalInsideEase = 1.0 - oneMinusFinalInsideProgress * oneMinusFinalInsideProgress * oneMinusFinalInsideProgress + let sourceFinalInsideDistance = sourceFinalFurthestInsideDistance - sourceHalfExtentAlongRay * 2.0 + let reverseInsetDistance = reverseAnimatedInsetDistance + (sourceFinalInsideDistance - reverseAnimatedInsetDistance) * finalInsideEase + let oneMinusCenterPull = 1.0 - t let centerPullEase = 1.0 - oneMinusCenterPull * oneMinusCenterPull * oneMinusCenterPull - sourcePositions.append(lerpPoint(sourceEdgePosition, centerLinePosition, centerPullEase)) + let sourceBoundaryDistanceFromCenter = + (sourceBoundaryPoint.x - centerLinePosition.x) * lineDirection.x + + (sourceBoundaryPoint.y - centerLinePosition.y) * lineDirection.y + let sourceCenterInsetDistance = -(sourceBoundaryDistanceFromCenter + sourceHalfExtentAlongRay) + let sourceInsetDistance = lerp(reverseInsetDistance, sourceCenterInsetDistance, centerPullEase) + let sourceCenterDistance = sourceInsetDistance + sourceHalfExtentAlongRay + var projectedSourcePosition = CGPoint( + x: sourceBoundaryPoint.x + lineDirection.x * sourceCenterDistance, + y: sourceBoundaryPoint.y + lineDirection.y * sourceCenterDistance + ) + let sourceNearestPoint = CGPoint( + x: projectedSourcePosition.x - lineDirection.x * sourceHalfExtentAlongRay, + y: projectedSourcePosition.y - lineDirection.y * sourceHalfExtentAlongRay + ) + let sourceNearestSignedDistance = + (sourceNearestPoint.x - sourceBoundaryPoint.x) * lineDirection.x + + (sourceNearestPoint.y - sourceBoundaryPoint.y) * lineDirection.y + if sourceNearestSignedDistance > sourceMaxEdgeDistance { + let centerCorrection = sourceNearestSignedDistance - sourceMaxEdgeDistance + projectedSourcePosition = CGPoint( + x: projectedSourcePosition.x - lineDirection.x * centerCorrection, + y: projectedSourcePosition.y - lineDirection.y * centerCorrection + ) + } + sourcePositions.append(projectedSourcePosition) } if let finalContainerPosition = containerPositions.last { @@ -1314,7 +1445,7 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol ) } - public func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { + public func update(size: CGSize, cornerRadius: CGFloat, isDark: Bool, transition: ComponentTransition) { let bounds = CGRect(origin: .zero, size: size) let center = CGPoint(x: size.width * 0.5, y: size.height * 0.5) @@ -1342,24 +1473,18 @@ final class LensTransitionContainerImpl: UIView, LensTransitionContainerProtocol } private final class LensTransitionContainerFallbackImpl: UIView, LensTransitionContainerProtocol { - let effectView: LensTransitionContainerEffectView - private let containerView: UIView - let contentsEffectView: UIView - let contentsView: UIView + private let backgroundView: GlassBackgroundView - init(effectView: LensTransitionContainerEffectView) { - self.effectView = effectView - self.containerView = UIView() - self.contentsEffectView = UIView() - self.contentsView = UIView() + public var contentsView: UIView { + return self.backgroundView.contentView + } + + override init(frame: CGRect) { + self.backgroundView = GlassBackgroundView() - super.init(frame: .zero) + super.init(frame: frame) - self.addSubview(self.containerView) - self.containerView.addSubview(self.effectView) - self.containerView.addSubview(self.contentsEffectView) - self.contentsEffectView.addSubview(self.contentsView) - self.contentsView.clipsToBounds = true + self.addSubview(self.backgroundView) } required init?(coder: NSCoder) { @@ -1367,59 +1492,42 @@ private final class LensTransitionContainerFallbackImpl: UIView, LensTransitionC } func animateIn(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { - self.update(size: fromRect.size, cornerRadius: fromCornerRadius, transition: .immediate) - UIView.animate(withDuration: 0.5, delay: 0.0, options: [.curveEaseInOut], animations: { - self.containerView.center = CGPoint(x: toRect.midX, y: toRect.midY) - self.update(size: toRect.size, cornerRadius: toCornerRadius, transition: .immediate) - }) } func animateOut(fromRect: CGRect, toRect: CGRect, fromCornerRadius: CGFloat, toCornerRadius: CGFloat, isDark: Bool, sourceEffectView: LensTransitionContainerEffectView) { - self.update(size: fromRect.size, cornerRadius: fromCornerRadius, transition: .immediate) - UIView.animate(withDuration: 0.5, delay: 0.0, options: [.curveEaseInOut], animations: { - self.containerView.center = CGPoint(x: toRect.midX, y: toRect.midY) - self.update(size: toRect.size, cornerRadius: toCornerRadius, transition: .immediate) - }) } - func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { - transition.setBounds(view: self.containerView, bounds: CGRect(origin: .zero, size: size)) - transition.setPosition(view: self.containerView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - - transition.setBounds(view: self.contentsEffectView, bounds: CGRect(origin: .zero, size: size)) - transition.setPosition(view: self.contentsEffectView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - - transition.setBounds(view: self.contentsView, bounds: CGRect(origin: .zero, size: size)) - transition.setPosition(view: self.contentsView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - transition.setCornerRadius(layer: self.contentsView.layer, cornerRadius: cornerRadius) - - transition.setBounds(view: self.effectView, bounds: CGRect(origin: .zero, size: size)) - transition.setPosition(view: self.effectView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) - - self.effectView.updateCornerRadius(duration: 0.0, keyframes: [cornerRadius]) + func update(size: CGSize, cornerRadius: CGFloat, isDark: Bool, transition: ComponentTransition) { + transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: .zero, size: size)) + transition.setPosition(view: self.backgroundView, position: CGPoint(x: size.width * 0.5, y: size.height * 0.5)) + self.backgroundView.update(size: size, cornerRadius: cornerRadius, isDark: isDark, tintColor: .init(kind: .panel), transition: transition) } } public final class LensTransitionContainer: UIView { private let impl: (UIView & LensTransitionContainerProtocol) - public var effectView: LensTransitionContainerEffectView { - return self.impl.effectView - } - - public var contentsEffectView: UIView { - return self.impl.contentsEffectView + public var effectView: UIView? { + if #available(iOS 26.0, *) { + if let impl = self.impl as? LensTransitionContainerImpl { + return impl.effectView + } else { + return nil + } + } else { + return nil + } } public var contentsView: UIView { - return self.impl.contentsView + return impl.contentsView } public init(effectView: LensTransitionContainerEffectView) { if #available(iOS 26.0, *) { self.impl = LensTransitionContainerImpl(effectView: effectView) } else { - self.impl = LensTransitionContainerFallbackImpl(effectView: effectView) + self.impl = LensTransitionContainerFallbackImpl(frame: CGRect()) } super.init(frame: .zero) @@ -1443,8 +1551,8 @@ public final class LensTransitionContainer: UIView { self.impl.animateOut(fromRect: fromRect, toRect: toRect, fromCornerRadius: fromCornerRadius, toCornerRadius: toCornerRadius, isDark: isDark, sourceEffectView: sourceEffectView) } - public func update(size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { - self.impl.update(size: size, cornerRadius: cornerRadius, transition: transition) + public func update(size: CGSize, cornerRadius: CGFloat, isDark: Bool, transition: ComponentTransition) { + self.impl.update(size: size, cornerRadius: cornerRadius, isDark: isDark, transition: transition) } } diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index c9c6c6bf17..6e84242fbf 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -662,7 +662,7 @@ extension ChatControllerImpl { guard let self else { return } - if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum, self.presentationInterfaceState.persistentData.topicListPanelLocation == true, self.presentationInterfaceState.chatLocation.threadId != nil { + if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum, self.presentationInterfaceState.persistentData.topicListPanelLocation == .side, self.presentationInterfaceState.chatLocation.threadId != nil { self.updateChatLocationThread(threadId: nil, animationDirection: .left) } else { if self.attemptNavigation({ [weak self] in @@ -4540,7 +4540,14 @@ extension ChatControllerImpl { } self.updateChatPresentationInterfaceState(animated: true, interactive: true, { presentationInterfaceState in var persistentData = presentationInterfaceState.persistentData - persistentData.topicListPanelLocation = !persistentData.topicListPanelLocation + switch persistentData.topicListPanelLocation { + case .top: + persistentData.topicListPanelLocation = .side + case .side: + persistentData.topicListPanelLocation = .bottom + case .bottom: + persistentData.topicListPanelLocation = .top + } return presentationInterfaceState.updatedPersistentData(persistentData) }) }, updateDisplayHistoryFilterAsList: { [weak self] displayAsList in diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 17cb88c55c..6904f6162b 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -249,6 +249,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { private var floatingTopicsPanelContainer: ChatControllerTitlePanelNodeContainer private var floatingTopicsPanel: (view: ComponentView, component: ChatFloatingTopicsPanel)? private var headerPanelsView: ComponentView? + private var footerPanelsView: ComponentView? private var topBackgroundEdgeEffectNode: WallpaperEdgeEffectNode? private var bottomBackgroundEdgeEffectNode: WallpaperEdgeEffectNode? @@ -762,6 +763,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.usePlainInputSeparator = false self.plainInputSeparatorAlpha = nil } + self.inputPanelBackgroundNode.isUserInteractionEnabled = false self.navigateButtons = ChatHistoryNavigationButtons(theme: self.chatPresentationInterfaceState.theme, preferClearGlass: self.chatPresentationInterfaceState.preferredGlassType == .clear, dateTimeFormat: self.chatPresentationInterfaceState.dateTimeFormat, backgroundNode: self.backgroundNode, isChatRotated: historyNodeRotated) self.navigateButtons.accessibilityElementsHidden = true @@ -1347,13 +1349,19 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.containerLayoutAndNavigationBarHeight = (layout, navigationBarHeight) var headerPanels: [HeaderPanelContainerComponent.Panel] = [] + var footerPanels: [HeaderPanelContainerComponent.Panel] = [] - if let headerTopicsPanel = headerTopicsPanelForChatPresentationInterfaceState(self.chatPresentationInterfaceState, context: self.context, controllerInteraction: self.controllerInteraction, interfaceInteraction: self.interfaceInteraction, force: false) { - headerPanels.append(HeaderPanelContainerComponent.Panel( + if let headerTopicsPanel = headerTopicsPanelForChatPresentationInterfaceState(self.chatPresentationInterfaceState, context: self.context, controllerInteraction: self.controllerInteraction, interfaceInteraction: self.interfaceInteraction, force: false) { + let panel = HeaderPanelContainerComponent.Panel( key: "topics", orderIndex: 0, component: headerTopicsPanel - )) + ) + if self.chatPresentationInterfaceState.persistentData.topicListPanelLocation == .top { + headerPanels.append(panel) + } else { + footerPanels.append(panel) + } } if let mediaPlayback = self.controller?.globalControlPanelsContextState?.mediaPlayback { headerPanels.append(HeaderPanelContainerComponent.Panel( @@ -2232,6 +2240,59 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } } + var containerInsets = insets + if let dismissAsOverlayLayout = self.dismissAsOverlayLayout { + if let inputNodeHeightAndOverflow = inputNodeHeightAndOverflow { + containerInsets = dismissAsOverlayLayout.insets(options: []) + containerInsets.bottom = max(inputNodeHeightAndOverflow.0 + inputNodeHeightAndOverflow.1, insets.bottom) + } else { + containerInsets = dismissAsOverlayLayout.insets(options: [.input]) + } + } + + var footerPanelsSize: CGSize? + if !footerPanels.isEmpty { + let footerPanelsView: ComponentView + var footerPanelsTransition = ComponentTransition(transition) + if let current = self.footerPanelsView { + footerPanelsView = current + } else { + footerPanelsTransition = footerPanelsTransition.withAnimation(.none) + footerPanelsView = ComponentView() + self.footerPanelsView = footerPanelsView + } + + var footerPanelsWidth = layout.size.width - layout.safeInsets.left - layout.safeInsets.right + 16.0 + if containerInsets.bottom <= 32.0 { + footerPanelsWidth -= 36.0 + } + + let footerPanelsSizeValue = footerPanelsView.update( + transition: footerPanelsTransition, + component: AnyComponent(HeaderPanelContainerComponent( + theme: self.chatPresentationInterfaceState.theme, + preferClearGlass: self.chatPresentationInterfaceState.preferredGlassType == .clear, + tabs: nil, + panels: footerPanels + )), + environment: {}, + containerSize: CGSize(width: footerPanelsWidth, height: layout.size.height) + ) + footerPanelsSize = footerPanelsSizeValue + floatingTopicsPanelInsets.bottom += footerPanelsSizeValue.height + } else if let footerPanelsView = self.footerPanelsView { + self.footerPanelsView = nil + if let footerPanelsComponentView = footerPanelsView.view { + transition.updateAlpha(layer: footerPanelsComponentView.layer, alpha: 0.0, completion: { [weak footerPanelsComponentView] _ in + footerPanelsComponentView?.removeFromSuperview() + }) + } + } + + if let footerPanelsSize { + inputPanelsHeight += 12.0 + footerPanelsSize.height + } + if self.dismissedAsOverlay { inputPanelsHeight = 0.0 } @@ -2308,16 +2369,6 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { transition.updateFrame(node: scrollContainerNode, frame: CGRect(origin: CGPoint(), size: layout.size)) } - var containerInsets = insets - if let dismissAsOverlayLayout = self.dismissAsOverlayLayout { - if let inputNodeHeightAndOverflow = inputNodeHeightAndOverflow { - containerInsets = dismissAsOverlayLayout.insets(options: []) - containerInsets.bottom = max(inputNodeHeightAndOverflow.0 + inputNodeHeightAndOverflow.1, insets.bottom) - } else { - containerInsets = dismissAsOverlayLayout.insets(options: [.input]) - } - } - let visibleAreaInset = UIEdgeInsets(top: containerInsets.top, left: 0.0, bottom: containerInsets.bottom + inputPanelsHeight + 8.0 + 8.0, right: 0.0) self.visibleAreaInset = visibleAreaInset @@ -2567,6 +2618,17 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { sidePanelTopInset += headerPanelsSize.height + 2.0 } + if let footerPanelsComponentView = self.footerPanelsView?.view, let footerPanelsSize { + let footerPanelsFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - footerPanelsSize.width) * 0.5), y: layout.size.height - (containerInsets.bottom + inputPanelsHeight + 8.0)), size: footerPanelsSize) + var footerPanelsTransition = ComponentTransition(transition) + if footerPanelsComponentView.superview == nil { + footerPanelsTransition.animateAlpha(view: footerPanelsComponentView, from: 0.0, to: 1.0) + footerPanelsTransition = footerPanelsTransition.withAnimation(.none) + self.floatingTopicsPanelContainer.view.addSubview(footerPanelsComponentView) + } + footerPanelsTransition.setFrame(view: footerPanelsComponentView, frame: footerPanelsFrame) + } + let floatingTopicsPanelContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 0.0, height: layout.size.height)) transition.updateFrame(node: self.floatingTopicsPanelContainer, frame: floatingTopicsPanelContainerFrame) if let floatingTopicsPanel = self.floatingTopicsPanel { @@ -5076,6 +5138,9 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { if let headerPanelsComponentView = self.headerPanelsView?.view as? HeaderPanelContainerComponent.View, let topicsPanelView = headerPanelsComponentView.panel(forKey: AnyHashable("topics")) as? ChatTopicsHeaderPanelComponent.View { leftIndex = topicsPanelView.topicIndex(threadId: fromLocation) rightIndex = topicsPanelView.topicIndex(threadId: toLocation) + } else if let footerPanelsComponentView = self.footerPanelsView?.view as? HeaderPanelContainerComponent.View, let topicsPanelView = footerPanelsComponentView.panel(forKey: AnyHashable("topics")) as? ChatTopicsHeaderPanelComponent.View { + leftIndex = topicsPanelView.topicIndex(threadId: fromLocation) + rightIndex = topicsPanelView.topicIndex(threadId: toLocation) } } guard let leftIndex, let rightIndex else { diff --git a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift index c8602ccf47..315d8dbb5c 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift @@ -252,7 +252,7 @@ func headerTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInterf } if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = chatPresentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect), chatPresentationInterfaceState.search == nil { - let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation + let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation == .side if !topicListDisplayModeOnTheSide { return AnyComponent(ChatTopicsHeaderPanelComponent( context: context, @@ -282,7 +282,7 @@ func headerTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInterf if !chatPresentationInterfaceState.viewForumAsMessages { return nil } - let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation + let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation == .side if !topicListDisplayModeOnTheSide { return AnyComponent(ChatTopicsHeaderPanelComponent( context: context, @@ -314,7 +314,7 @@ func headerTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInterf return nil } } - let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation + let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation == .side if !topicListDisplayModeOnTheSide { return AnyComponent(ChatTopicsHeaderPanelComponent( context: context, @@ -367,7 +367,7 @@ func floatingTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInte } if let channel = chatPresentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = chatPresentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.manageDirect), chatPresentationInterfaceState.search == nil { - let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation + let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation == .side if topicListDisplayModeOnTheSide { return ChatFloatingTopicsPanel( context: context, @@ -399,7 +399,7 @@ func floatingTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInte if !chatPresentationInterfaceState.viewForumAsMessages { return nil } - let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation + let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation == .side if topicListDisplayModeOnTheSide { return ChatFloatingTopicsPanel( context: context, @@ -433,7 +433,7 @@ func floatingTopicsPanelForChatPresentationInterfaceState(_ chatPresentationInte return nil } } - let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation + let topicListDisplayModeOnTheSide = chatPresentationInterfaceState.persistentData.topicListPanelLocation == .side if topicListDisplayModeOnTheSide { return ChatFloatingTopicsPanel( context: context, diff --git a/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.h b/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.h index 01bac22f2f..66022262e5 100644 --- a/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.h +++ b/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.h @@ -34,6 +34,7 @@ NSObject * _Nullable makeLuminanceToAlphaFilter(); NSObject * _Nullable makeColorInvertFilter(); NSObject * _Nullable makeMonochromeFilter(); NSObject * _Nullable makeDisplacementMapFilter(); +NSObject * _Nullable makeColorMatrixFilter(); void setLayerDisableScreenshots(CALayer * _Nonnull layer, bool disableScreenshots); bool getLayerDisableScreenshots(CALayer * _Nonnull layer); diff --git a/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m b/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m index ffa8b0149f..3191bf1b3f 100644 --- a/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m +++ b/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m @@ -290,6 +290,10 @@ NSObject * _Nullable makeDisplacementMapFilter() { } } +NSObject * _Nullable makeColorMatrixFilter() { + return [(id)NSClassFromString(@"CAFilter") filterWithName:@"colorMatrix"]; +} + static const void *layerDisableScreenshotsKey = &layerDisableScreenshotsKey; void setLayerDisableScreenshots(CALayer * _Nonnull layer, bool disableScreenshots) { From c06e1b2eb4897f654432a98406516c22b54dd71b Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 6 Mar 2026 22:59:40 +0100 Subject: [PATCH 08/10] Bump version --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index 08c7c722f1..a8d1b05222 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "12.5.1", + "app": "12.5.2", "xcode": "26.2", "bazel": "8.4.2:45e9388abf21d1107e146ea366ad080eb93cb6a5f3a4a3b048f78de0bc3faffa", "macos": "26" From c84563bd867768e73a781770fdbe67f74641d74e Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Sat, 7 Mar 2026 02:43:53 +0100 Subject: [PATCH 09/10] Bump version --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index a8d1b05222..c10d79500f 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "12.5.2", + "app": "12.6", "xcode": "26.2", "bazel": "8.4.2:45e9388abf21d1107e146ea366ad080eb93cb6a5f3a4a3b048f78de0bc3faffa", "macos": "26" From ab724751066b7df1137958fadd9b8a83b4c359c6 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Sat, 7 Mar 2026 15:33:36 +0100 Subject: [PATCH 10/10] Fix --- .../GlassBackgroundComponent/Sources/LegacyGlassView.swift | 4 ++-- .../LensTransition/Sources/LensTransitionContainer.swift | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift index 38790fa641..b73133d2b2 100644 --- a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift +++ b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/LegacyGlassView.swift @@ -141,7 +141,7 @@ final class LegacyGlassView: UIView { if let blurFilter = CALayer.blur(), let colorMatrixFilter = CALayer.colorMatrix() { switch style { case .clear: - if DeviceMetrics.performance.isGraphicallyCapable { + if #available(iOS 17.0, *), DeviceMetrics.performance.isGraphicallyCapable { blurFilter.setValue(2.0 as NSNumber, forKey: "inputRadius") } else { blurFilter.setValue(6.0 as NSNumber, forKey: "inputRadius") @@ -171,7 +171,7 @@ final class LegacyGlassView: UIView { transition.setCornerRadius(layer: self.layer, cornerRadius: cornerRadius) transition.setFrame(layer: backdropLayer, frame: CGRect(origin: CGPoint(), size: size)) - if DeviceMetrics.performance.isGraphicallyCapable { + if #available(iOS 17.0, *), DeviceMetrics.performance.isGraphicallyCapable { let size = CGSize(width: max(1.0, size.width), height: max(1.0, size.height)) let cornerRadius = min(min(size.width, size.height) * 0.5, cornerRadius) let displacementMagnitudePoints: CGFloat = 20.0 diff --git a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift index 564d8d235e..6d364b774e 100644 --- a/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift +++ b/submodules/TelegramUI/Components/LensTransition/Sources/LensTransitionContainer.swift @@ -1554,6 +1554,10 @@ public final class LensTransitionContainer: UIView { public func update(size: CGSize, cornerRadius: CGFloat, isDark: Bool, transition: ComponentTransition) { self.impl.update(size: size, cornerRadius: cornerRadius, isDark: isDark, transition: transition) } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return self.contentsView.hitTest(point, with: event) + } } @inline(__always)