From 4b04a6c69ec1a5a22a92d94040e98433e3d5c74b Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Sat, 4 Oct 2025 00:44:30 +0800 Subject: [PATCH] Bounce experiment --- .../Display/Source/CAAnimationUtils.swift | 3 +- .../ContainedViewLayoutTransition.swift | 104 ++++++++++++++++++ submodules/Display/Source/UIKitUtils.swift | 2 +- .../Sources/ChatTextInputPanelNode.swift | 44 +++++++- .../Sources/GlassBackgroundComponent.swift | 27 +++-- .../Source/UIKitRuntimeUtils/UIKitUtils.h | 2 +- .../Source/UIKitRuntimeUtils/UIKitUtils.m | 2 +- 7 files changed, 165 insertions(+), 19 deletions(-) diff --git a/submodules/Display/Source/CAAnimationUtils.swift b/submodules/Display/Source/CAAnimationUtils.swift index 9add13d39d..4b6a2629a8 100644 --- a/submodules/Display/Source/CAAnimationUtils.swift +++ b/submodules/Display/Source/CAAnimationUtils.swift @@ -322,8 +322,9 @@ public extension CALayer { return animation } - func animateSpring(from: AnyObject, to: AnyObject, keyPath: String, duration: Double, delay: Double = 0.0, initialVelocity: CGFloat = 0.0, damping: CGFloat = 88.0, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { + func animateSpring(from: AnyObject, to: AnyObject, keyPath: String, duration: Double, delay: Double = 0.0, initialVelocity: CGFloat = 0.0, stiffness: CGFloat = 900.0, damping: CGFloat = 88.0, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { let animation = makeSpringBounceAnimation(keyPath, initialVelocity, damping) + animation.stiffness = stiffness animation.fromValue = from animation.toValue = to animation.isRemovedOnCompletion = removeOnCompletion diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index 47d54e90d8..d7ff761a96 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -138,6 +138,10 @@ private extension CALayer { } } +private func bounceParameters(duration: Double) -> (duration: Double, damping: CGFloat, stiffness: CGFloat) { + return (duration: duration * 1.25, damping: 88.0, stiffness: 750.0) +} + public extension ContainedViewLayoutTransition { func animation() -> CABasicAnimation? { switch self { @@ -469,6 +473,106 @@ public extension ContainedViewLayoutTransition { } } + func updatePositionSpring(layer: CALayer, position: CGPoint, completion: ((Bool) -> Void)? = nil) { + if layer.position.equalTo(position) { + completion?(true) + } else { + switch self { + case .immediate: + layer.removeAnimation(forKey: "position") + if let view = layer.delegate as? UIView { + view.center = position + } else { + layer.position = position + } + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + let _ = curve + let previousPosition = layer.position + if let view = layer.delegate as? UIView { + view.center = position + } else { + layer.position = position + } + let params = bounceParameters(duration: duration) + layer.animateSpring(from: NSValue(cgPoint: previousPosition), to: NSValue(cgPoint: position), keyPath: "position", duration: params.duration, stiffness: params.stiffness, damping: params.damping, completion: { flag in + if let completion { + completion(flag) + } + }) + } + } + } + + func updateScaleSpring(layer: CALayer, scale: CGFloat, completion: ((Bool) -> Void)? = nil) { + let t = layer.transform + let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) + if abs(CGFloat(currentScale) - scale) <= CGFloat(Float.ulpOfOne) { + completion?(true) + } else { + switch self { + case .immediate: + layer.removeAnimation(forKey: "transform.scale") + if let view = layer.delegate as? UIView { + view.transform = CGAffineTransformMakeScale(scale, scale) + } else { + layer.transform = CATransform3DMakeScale(scale, scale, 1.0) + } + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + let _ = curve + if let view = layer.delegate as? UIView { + view.transform = CGAffineTransformMakeScale(scale, scale) + } else { + layer.transform = CATransform3DMakeScale(scale, scale, 1.0) + } + let params = bounceParameters(duration: duration) + layer.animateSpring(from: currentScale as NSNumber, to: scale as NSNumber, keyPath: "transform.scale", duration: params.duration, stiffness: params.stiffness, damping: params.damping, completion: { flag in + if let completion { + completion(flag) + } + }) + } + } + } + + func updateBoundsSpring(layer: CALayer, bounds: CGRect, completion: ((Bool) -> Void)? = nil) { + if layer.bounds.equalTo(bounds) { + completion?(true) + } else { + switch self { + case .immediate: + layer.removeAnimation(forKey: "bounds") + if let view = layer.delegate as? UIView { + view.bounds = bounds + } else { + layer.bounds = bounds + } + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + let _ = curve + let previousBounds = layer.bounds + if let view = layer.delegate as? UIView { + view.bounds = bounds + } else { + layer.bounds = bounds + } + let params = bounceParameters(duration: duration) + layer.animateSpring(from: NSValue(cgRect: previousBounds), to: NSValue(cgRect: bounds), keyPath: "bounds", duration: params.duration, stiffness: params.stiffness, damping: params.damping, completion: { result in + if let completion = completion { + completion(result) + } + }) + } + } + } + func updateAnchorPoint(layer: CALayer, anchorPoint: CGPoint, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if layer.anchorPoint.equalTo(anchorPoint) && !force { completion?(true) diff --git a/submodules/Display/Source/UIKitUtils.swift b/submodules/Display/Source/UIKitUtils.swift index c3906905e4..13ad7ac7e9 100644 --- a/submodules/Display/Source/UIKitUtils.swift +++ b/submodules/Display/Source/UIKitUtils.swift @@ -12,7 +12,7 @@ public func makeSpringAnimation(_ keyPath: String, duration: Double) -> CABasicA return makeSpringAnimationImpl(keyPath, duration) } -public func makeSpringBounceAnimation(_ keyPath: String, _ initialVelocity: CGFloat, _ damping: CGFloat) -> CABasicAnimation { +public func makeSpringBounceAnimation(_ keyPath: String, _ initialVelocity: CGFloat, _ damping: CGFloat) -> CASpringAnimation { return makeSpringBounceAnimationImpl(keyPath, initialVelocity, damping) } diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 35c84819e5..0bca922882 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -288,6 +288,8 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg private var rightSlowModeInset: CGFloat = 0.0 private var currentTextInputBackgroundWidthOffset: CGFloat = 0.0 + private var enableBounceAnimations: Bool = false + public var displayAttachmentMenu: () -> Void = { } public var sendMessage: () -> Void = { } public var paste: (ChatTextInputPanelPasteData) -> Void = { _ in } @@ -320,6 +322,8 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg private let hapticFeedback = HapticFeedback() + private var currentInputHasText: Bool = false + public var inputTextState: ChatTextInputState { if let textInputNode = self.textInputNode { let selectionRange: Range = textInputNode.selectedRange.location ..< (textInputNode.selectedRange.location + textInputNode.selectedRange.length) @@ -636,6 +640,11 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg self.context = context + self.enableBounceAnimations = true + if let data = context.currentAppConfiguration.with({ $0 }).data, data["ios_killswitch_input_bounce"] != nil { + self.enableBounceAnimations = false + } + self.addSubnode(self.clippingNode) self.sendAsAvatarContainerNode.activated = { [weak self] gesture, _ in @@ -1416,6 +1425,16 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, attributedText.length != 0 { inputHasText = true } + let inputHadText = self.currentInputHasText + self.currentInputHasText = inputHasText + + var useBounceAnimation = inputHasText && !inputHadText + if accessoryPanel != nil || self.accessoryPanel != nil { + useBounceAnimation = false + } + if !self.enableBounceAnimations { + useBounceAnimation = false + } var hasMenuButton = false var menuButtonExpanded = false @@ -1957,7 +1976,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg } menuButtonTitleTransition.updateFrame(node: self.menuButtonTextNode, frame: CGRect(origin: CGPoint(x: 16.0, y: 11.0), size: menuTextSize)) transition.updateAlpha(node: self.menuButtonTextNode, alpha: menuButtonExpanded ? 1.0 : 0.0) - transition.updateFrame(node: self.menuButtonIconNode, frame: CGRect(x: 5.0, y: isSendAsButton ? 5.0 : (5.0 - UIScreenPixel), width: 30.0, height: 30.0)) + transition.updateFrame(node: self.menuButtonIconNode, frame: CGRect(x: 7.0, y: 7.0, width: 26.0, height: 26.0)) transition.updateFrame(node: self.sendAsAvatarButtonNode, frame: menuButtonFrame) transition.updateFrame(node: self.sendAsAvatarContainerNode, frame: CGRect(origin: CGPoint(), size: menuButtonFrame.size)) @@ -2384,7 +2403,11 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg transition.updateFrame(view: self.accessoryPanelContainer, frame: CGRect(origin: CGPoint(), size: textInputContainerBackgroundFrame.size)) transition.updateFrame(view: self.textInputContainerBackgroundView, frame: CGRect(origin: CGPoint(), size: textInputContainerBackgroundFrame.size)) - self.textInputContainerBackgroundView.update(size: textInputContainerBackgroundFrame.size, cornerRadius: floor(minimalInputHeight * 0.5), isDark: interfaceState.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: interfaceState.theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: ComponentTransition(transition)) + var textInputContainerBackgroundTransition = ComponentTransition(transition) + if useBounceAnimation, case let .animated(_, curve) = transition, case .spring = curve { + textInputContainerBackgroundTransition = textInputContainerBackgroundTransition.withUserData(GlassBackgroundView.TransitionFlagBounce()) + } + self.textInputContainerBackgroundView.update(size: textInputContainerBackgroundFrame.size, cornerRadius: floor(minimalInputHeight * 0.5), isDark: interfaceState.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: interfaceState.theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: textInputContainerBackgroundTransition) transition.updateFrame(layer: self.textInputBackgroundNode.layer, frame: textInputContainerBackgroundFrame) transition.updateAlpha(node: self.textInputBackgroundNode, alpha: audioRecordingItemsAlpha) @@ -2626,13 +2649,22 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg } var sendActionButtonsFrame = CGRect(origin: CGPoint(x: textInputContainerBackgroundFrame.maxX - sendActionButtonsSize.width, y: textInputContainerBackgroundFrame.maxY - sendActionButtonsSize.height), size: sendActionButtonsSize) + + let sendActionsScale: CGFloat if inputHasText || hasMediaDraft || hasForward { - transition.updateTransformScale(node: self.sendActionButtons, scale: CGPoint(x: 1.0, y: 1.0)) + sendActionsScale = 1.0 } else { + sendActionsScale = 0.001 sendActionButtonsFrame.origin.x += (sendActionButtonsSize.width - 3.0 * 2.0) * 0.5 - 3.0 - transition.updateTransformScale(node: self.sendActionButtons, scale: CGPoint(x: 0.001, y: 0.001)) } - transition.updatePosition(node: self.sendActionButtons, position: sendActionButtonsFrame.center) + + if useBounceAnimation, case let .animated(duration, curve) = transition, case .spring = curve { + ContainedViewLayoutTransition.animated(duration: duration, curve: curve).updateScaleSpring(layer: self.sendActionButtons.layer, scale: sendActionsScale) + ContainedViewLayoutTransition.animated(duration: duration, curve: curve).updatePositionSpring(layer: self.sendActionButtons.layer, position: sendActionButtonsFrame.center) + } else { + transition.updateTransformScale(node: self.sendActionButtons, scale: CGPoint(x: sendActionsScale, y: sendActionsScale)) + transition.updatePosition(node: self.sendActionButtons, position: sendActionButtonsFrame.center) + } transition.updateBounds(node: self.sendActionButtons, bounds: CGRect(origin: CGPoint(), size: sendActionButtonsFrame.size)) if let (rect, containerSize) = self.absoluteRect { self.sendActionButtons.updateAbsoluteRect(CGRect(x: rect.origin.x + sendActionButtonsFrame.origin.x, y: rect.origin.y + sendActionButtonsFrame.origin.y, width: sendActionButtonsFrame.width, height: sendActionButtonsFrame.height), within: containerSize, transition: transition) @@ -3806,7 +3838,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg if self.sendActionButtons.sendContainerNode.alpha.isZero && self.rightSlowModeInset.isZero { alphaTransition.updateAlpha(node: self.sendActionButtons.sendContainerNode, alpha: 1.0) blurTransitionIn.animateBlur(layer: self.sendActionButtons.sendContainerNode.layer, fromRadius: sendButtonBlurOut, toRadius: 0.0) - transition.animatePositionAdditive(layer: self.sendActionButtons.sendButton.imageNode.layer, offset: CGPoint(x: -18.0, y: 14.0)) + transition.animatePositionAdditive(layer: self.sendActionButtons.sendButton.imageNode.layer, offset: CGPoint(x: -22.0, y: 18.0)) if let sendButtonRadialStatusNode = self.sendActionButtons.sendButtonRadialStatusNode { alphaTransition.updateAlpha(node: sendButtonRadialStatusNode, alpha: 1.0) } diff --git a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift index 0ef6f84f20..e63eef4856 100644 --- a/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift +++ b/submodules/TelegramUI/Components/GlassBackgroundComponent/Sources/GlassBackgroundComponent.swift @@ -48,6 +48,11 @@ private final class ContentContainer: UIView { } public class GlassBackgroundView: UIView { + public final class TransitionFlagBounce { + public init() { + } + } + public protocol ContentView: UIView { var tintMask: UIView { get } } @@ -377,23 +382,27 @@ public class GlassBackgroundView: UIView { public func update(size: CGSize, cornerRadius: CGFloat, isDark: Bool, tintColor: TintColor, isInteractive: Bool = false, transition: ComponentTransition) { if let nativeContainerView = self.nativeContainerView, let nativeView = self.nativeView, nativeView.bounds.size != size { - //let previousFrame = nativeView.frame if transition.animation.isImmediate { nativeView.layer.cornerRadius = cornerRadius nativeView.frame = CGRect(origin: CGPoint(), size: size) nativeContainerView.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: max(size.height, 400.0))) } else { - /*transition.containedViewLayoutTransition.animateView { - nativeView.layer.cornerRadius = cornerRadius - nativeView.frame = CGRect(origin: CGPoint(), size: size) - nativeContainerView.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: max(size.height, 400.0))) - }*/ nativeView.layer.cornerRadius = cornerRadius - transition.setFrame(view: nativeView, frame: CGRect(origin: CGPoint(), size: size)) - transition.setFrame(view: nativeContainerView, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: max(size.height, 400.0)))) - //nativeView.layer.animateFrame(from: previousFrame, to: CGRect(origin: CGPoint(), size: size), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + let nativeFrame = CGRect(origin: CGPoint(), size: size) + let nativeContainerFrame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: max(size.height, 400.0))) + + if transition.userData(TransitionFlagBounce.self) != nil { + transition.containedViewLayoutTransition.updatePositionSpring(layer: nativeView.layer, position: nativeFrame.center) + transition.containedViewLayoutTransition.updateBoundsSpring(layer: nativeView.layer, bounds: CGRect(origin: CGPoint(), size: nativeFrame.size)) + + transition.containedViewLayoutTransition.updatePositionSpring(layer: nativeContainerView.layer, position: nativeContainerFrame.center) + transition.containedViewLayoutTransition.updateBoundsSpring(layer: nativeContainerView.layer, bounds: CGRect(origin: CGPoint(), size: nativeContainerFrame.size)) + } else { + transition.setFrame(view: nativeView, frame: nativeFrame) + transition.setFrame(view: nativeContainerView, frame: nativeContainerFrame) + } } } if let backgroundNode = self.backgroundNode { diff --git a/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.h b/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.h index a270bd765f..2efe921c88 100644 --- a/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.h +++ b/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.h @@ -5,7 +5,7 @@ double animationDurationFactorImpl(); CABasicAnimation * _Nonnull makeSpringAnimationImpl(NSString * _Nonnull keyPath, double duration); CABasicAnimation * _Nonnull make26SpringAnimationImpl(NSString * _Nonnull keyPath, double duration); -CABasicAnimation * _Nonnull makeSpringBounceAnimationImpl(NSString * _Nonnull keyPath, CGFloat initialVelocity, CGFloat damping); +CASpringAnimation * _Nonnull makeSpringBounceAnimationImpl(NSString * _Nonnull keyPath, CGFloat initialVelocity, CGFloat damping); CGFloat springAnimationValueAtImpl(CABasicAnimation * _Nonnull animation, CGFloat t); UIBlurEffect * _Nonnull makeCustomZoomBlurEffectImpl(bool isLight); diff --git a/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m b/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m index 0a4588ec7c..97de630895 100644 --- a/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m +++ b/submodules/UIKitRuntimeUtils/Source/UIKitRuntimeUtils/UIKitUtils.m @@ -83,7 +83,7 @@ CABasicAnimation * _Nonnull make26SpringAnimationImpl(NSString * _Nonnull keyPat return springAnimation; } -CABasicAnimation * _Nonnull makeSpringBounceAnimationImpl(NSString * _Nonnull keyPath, CGFloat initialVelocity, CGFloat damping) { +CASpringAnimation * _Nonnull makeSpringBounceAnimationImpl(NSString * _Nonnull keyPath, CGFloat initialVelocity, CGFloat damping) { CASpringAnimation *springAnimation = [CASpringAnimation animationWithKeyPath:keyPath]; springAnimation.mass = 5.0f; springAnimation.stiffness = 900.0f;