From 6b75d36548d3088ed1c8db73c9b55cf1a149b4e0 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Mon, 30 Nov 2020 03:38:41 +0400 Subject: [PATCH] Voice Chat UI improvements --- .../Sources/Node/ChatListItem.swift | 26 ++- .../Sources/HorizontalPeerItem.swift | 2 +- .../Sources/PeerOnlineMarkerNode.swift | 127 ++++++++++- .../Sources/SelectablePeerNode.swift | 2 +- .../Sources/CallStatusBarNode.swift | 172 +++++++++++++++ .../Sources/VoiceChatActionButton.swift | 206 ++++++++++++------ .../Resources/PresentationResourceKey.swift | 4 + .../PresentationResourcesChatList.swift | 13 +- 8 files changed, 470 insertions(+), 82 deletions(-) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 026f64da00..160256a2ed 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -442,6 +442,8 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { private var isHighlighted: Bool = false private var skipFadeout: Bool = false + private var onlineIsVoiceChat: Bool = false + override var canBeSelected: Bool { if self.selectableControlNode != nil || self.item?.editing == true { return false @@ -695,7 +697,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { transition.updateAlpha(layer: self.highlightedBackgroundNode.layer, alpha: highlightProgress) if let item = self.item { - self.onlineNode.setImage(PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .highlighted)) + self.onlineNode.setImage(PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .highlighted, voiceChat: self.onlineIsVoiceChat), color: nil) } } else { if self.highlightedBackgroundNode.supernode != nil { @@ -711,11 +713,11 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { if let item = self.item { let onlineIcon: UIImage? if item.index.pinningIndex != nil { - onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .pinned) + onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .pinned, voiceChat: self.onlineIsVoiceChat) } else { - onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .regular) + onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .regular, voiceChat: self.onlineIsVoiceChat) } - self.onlineNode.setImage(onlineIcon) + self.onlineNode.setImage(onlineIcon, color: nil) } } } @@ -1439,6 +1441,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { strongSelf.currentItemHeight = itemHeight strongSelf.cachedChatListText = chatListText strongSelf.cachedChatListSearchResult = chatListSearchResult + strongSelf.onlineIsVoiceChat = onlineIsVoiceChat strongSelf.contextContainer.frame = CGRect(origin: CGPoint(), size: layout.contentSize) @@ -1524,18 +1527,23 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let avatarFrame = CGRect(origin: CGPoint(x: leftInset - avatarLeftInset + editingOffset + 10.0 + revealOffset, y: floor((itemHeight - avatarDiameter) / 2.0)), size: CGSize(width: avatarDiameter, height: avatarDiameter)) transition.updateFrame(node: strongSelf.avatarNode, frame: avatarFrame) - let onlineFrame = CGRect(origin: CGPoint(x: avatarFrame.maxX - onlineLayout.width - 2.0, y: avatarFrame.maxY - onlineLayout.height - 2.0), size: onlineLayout) + let onlineFrame: CGRect + if onlineIsVoiceChat { + onlineFrame = CGRect(origin: CGPoint(x: avatarFrame.maxX - onlineLayout.width + 1.0 - UIScreenPixel, y: avatarFrame.maxY - onlineLayout.height + 1.0 - UIScreenPixel), size: onlineLayout) + } else { + onlineFrame = CGRect(origin: CGPoint(x: avatarFrame.maxX - onlineLayout.width - 2.0, y: avatarFrame.maxY - onlineLayout.height - 2.0), size: onlineLayout) + } transition.updateFrame(node: strongSelf.onlineNode, frame: onlineFrame) let onlineIcon: UIImage? if strongSelf.reallyHighlighted { - onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .highlighted) + onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .highlighted, voiceChat: onlineIsVoiceChat) } else if item.index.pinningIndex != nil { - onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .pinned) + onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .pinned, voiceChat: onlineIsVoiceChat) } else { - onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .regular) + onlineIcon = PresentationResourcesChatList.recentStatusOnlineIcon(item.presentationData.theme, state: .regular, voiceChat: onlineIsVoiceChat) } - strongSelf.onlineNode.setImage(onlineIcon) + strongSelf.onlineNode.setImage(onlineIcon, color: item.presentationData.theme.list.itemCheckColors.foregroundColor) let _ = measureApply() let _ = dateApply() diff --git a/submodules/HorizontalPeerItem/Sources/HorizontalPeerItem.swift b/submodules/HorizontalPeerItem/Sources/HorizontalPeerItem.swift index 982245c3b5..15625961bd 100644 --- a/submodules/HorizontalPeerItem/Sources/HorizontalPeerItem.swift +++ b/submodules/HorizontalPeerItem/Sources/HorizontalPeerItem.swift @@ -209,7 +209,7 @@ public final class HorizontalPeerItemNode: ListViewItemNode { strongSelf.badgeBackgroundNode.isHidden = true } - strongSelf.onlineNode.setImage(PresentationResourcesChatList.recentStatusOnlineIcon(item.theme, state: .regular)) + strongSelf.onlineNode.setImage(PresentationResourcesChatList.recentStatusOnlineIcon(item.theme, state: .regular), color: nil) strongSelf.onlineNode.frame = CGRect(x: itemLayout.size.width - onlineLayout.width - 18.0, y: itemLayout.size.height - onlineLayout.height - 18.0, width: onlineLayout.width, height: onlineLayout.height) let _ = badgeApply() diff --git a/submodules/PeerOnlineMarkerNode/Sources/PeerOnlineMarkerNode.swift b/submodules/PeerOnlineMarkerNode/Sources/PeerOnlineMarkerNode.swift index 1943185352..b407ee45d6 100644 --- a/submodules/PeerOnlineMarkerNode/Sources/PeerOnlineMarkerNode.swift +++ b/submodules/PeerOnlineMarkerNode/Sources/PeerOnlineMarkerNode.swift @@ -3,8 +3,106 @@ import UIKit import AsyncDisplayKit import Display +private final class VoiceChatIndicatorNode: ASDisplayNode { + private let leftLine: ASDisplayNode + private let centerLine: ASDisplayNode + private let rightLine: ASDisplayNode + + private var isCurrentlyInHierarchy = false + private var shouldBeAnimating = false + + var color: UIColor = UIColor(rgb: 0xffffff) { + didSet { + self.leftLine.backgroundColor = self.color + self.centerLine.backgroundColor = self.color + self.rightLine.backgroundColor = self.color + } + } + + override init() { + self.leftLine = ASDisplayNode() + self.leftLine.isLayerBacked = true + self.leftLine.cornerRadius = 1.0 + self.leftLine.frame = CGRect(x: 6.0, y: 6.0, width: 2.0, height: 10.0) + + self.centerLine = ASDisplayNode() + self.centerLine.isLayerBacked = true + self.centerLine.cornerRadius = 1.0 + self.centerLine.frame = CGRect(x: 10.0, y: 5.0, width: 2.0, height: 12.0) + + self.rightLine = ASDisplayNode() + self.rightLine.isLayerBacked = true + self.rightLine.cornerRadius = 1.0 + self.rightLine.frame = CGRect(x: 14.0, y: 6.0, width: 2.0, height: 10.0) + + super.init() + + self.isLayerBacked = true + + self.addSubnode(self.leftLine) + self.addSubnode(self.centerLine) + self.addSubnode(self.rightLine) + } + + override func didEnterHierarchy() { + super.didEnterHierarchy() + + self.isCurrentlyInHierarchy = true + self.updateAnimation() + } + + override func didExitHierarchy() { + super.didExitHierarchy() + + self.isCurrentlyInHierarchy = false + self.updateAnimation() + } + + private func updateAnimation() { + let shouldBeAnimating = self.isCurrentlyInHierarchy + if shouldBeAnimating != self.shouldBeAnimating { + self.shouldBeAnimating = shouldBeAnimating + if shouldBeAnimating { + let timingFunctions: [CAMediaTimingFunction] = (0 ..< 5).map { _ in CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) } + + let leftAnimation = CAKeyframeAnimation(keyPath: "bounds.size.height") + leftAnimation.timingFunctions = timingFunctions + leftAnimation.values = [NSNumber(value: 10.0), NSNumber(value: 4.0), NSNumber(value: 8.0), NSNumber(value: 4.0), NSNumber(value: 10.0)] + leftAnimation.repeatCount = Float.infinity + leftAnimation.duration = 2.0 + self.leftLine.layer.add(leftAnimation, forKey: "animation") + + let centerAnimation = CAKeyframeAnimation(keyPath: "bounds.size.height") + centerAnimation.timingFunctions = timingFunctions + centerAnimation.values = [NSNumber(value: 6.0), NSNumber(value: 10.0), NSNumber(value: 4.0), NSNumber(value: 12.0), NSNumber(value: 6.0)] + centerAnimation.repeatCount = Float.infinity + centerAnimation.duration = 2.0 + self.centerLine.layer.add(centerAnimation, forKey: "animation") + + let rightAnimation = CAKeyframeAnimation(keyPath: "bounds.size.height") + rightAnimation.timingFunctions = timingFunctions + rightAnimation.values = [NSNumber(value: 10.0), NSNumber(value: 4.0), NSNumber(value: 8.0), NSNumber(value: 4.0), NSNumber(value: 10.0)] + rightAnimation.repeatCount = Float.infinity + rightAnimation.duration = 2.0 + self.rightLine.layer.add(rightAnimation, forKey: "animation") + } else { + self.leftLine.layer.removeAnimation(forKey: "animation") + self.centerLine.layer.removeAnimation(forKey: "animation") + self.rightLine.layer.removeAnimation(forKey: "animation") + } + } + } +} + public final class PeerOnlineMarkerNode: ASDisplayNode { private let iconNode: ASImageNode + private var animationNode: VoiceChatIndicatorNode? + + private var color: UIColor = UIColor(rgb: 0xffffff) { + didSet { + self.animationNode?.color = self.color + } + } override public init() { self.iconNode = ASImageNode() @@ -20,16 +118,31 @@ public final class PeerOnlineMarkerNode: ASDisplayNode { self.addSubnode(self.iconNode) } - public func setImage(_ image: UIImage?) { + public func setImage(_ image: UIImage?, color: UIColor?) { self.iconNode.image = image + if let color = color { + self.color = color + } } public func asyncLayout() -> (Bool, Bool) -> (CGSize, (Bool) -> Void) { return { [weak self] online, isVoiceChat in - return (CGSize(width: 14.0, height: 14.0), { animated in + let size: CGFloat = isVoiceChat ? 22.0 : 14.0 + return (CGSize(width: size, height: size), { animated in if let strongSelf = self { - strongSelf.iconNode.frame = CGRect(x: 0.0, y: 0.0, width: 14.0, height: 14.0) + strongSelf.iconNode.frame = CGRect(x: 0.0, y: 0.0, width: size, height: size) + if isVoiceChat { + if let _ = strongSelf.animationNode { + } else { + let animationNode = VoiceChatIndicatorNode() + animationNode.color = strongSelf.color + animationNode.frame = strongSelf.iconNode.bounds + strongSelf.animationNode = animationNode + strongSelf.iconNode.addSubnode(animationNode) + } + } + if animated { let initialScale: CGFloat = strongSelf.iconNode.isHidden ? 0.0 : CGFloat((strongSelf.iconNode.value(forKeyPath: "layer.presentationLayer.transform.scale.x") as? NSNumber)?.floatValue ?? 1.0) let targetScale: CGFloat = online ? 1.0 : 0.0 @@ -37,10 +150,18 @@ public final class PeerOnlineMarkerNode: ASDisplayNode { strongSelf.iconNode.layer.animateScale(from: initialScale, to: targetScale, duration: 0.2, removeOnCompletion: false, completion: { [weak self] finished in if let strongSelf = self, finished { strongSelf.iconNode.isHidden = !online + + if let animationNode = strongSelf.animationNode, !isVoiceChat { + animationNode.removeFromSupernode() + } } }) } else { strongSelf.iconNode.isHidden = !online + + if let animationNode = strongSelf.animationNode, !isVoiceChat { + animationNode.removeFromSupernode() + } } } }) diff --git a/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift b/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift index b73a032eec..ba610aab9e 100644 --- a/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift +++ b/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift @@ -166,7 +166,7 @@ public final class SelectablePeerNode: ASDisplayNode { let (onlineSize, onlineApply) = onlineLayout(online, false) let _ = onlineApply(false) - self.onlineNode.setImage(PresentationResourcesChatList.recentStatusOnlineIcon(theme, state: .panel)) + self.onlineNode.setImage(PresentationResourcesChatList.recentStatusOnlineIcon(theme, state: .panel), color: nil) self.onlineNode.frame = CGRect(origin: CGPoint(), size: onlineSize) self.setNeedsLayout() diff --git a/submodules/TelegramCallsUI/Sources/CallStatusBarNode.swift b/submodules/TelegramCallsUI/Sources/CallStatusBarNode.swift index cdda012e86..d5fe07ef4f 100644 --- a/submodules/TelegramCallsUI/Sources/CallStatusBarNode.swift +++ b/submodules/TelegramCallsUI/Sources/CallStatusBarNode.swift @@ -25,6 +25,178 @@ private class CallStatusBarBackgroundNodeDrawingState: NSObject { } } +private final class Curve { + let pointsCount: Int + let smoothness: CGFloat + + let minRandomness: CGFloat + let maxRandomness: CGFloat + + let minSpeed: CGFloat + let maxSpeed: CGFloat + + let size: CGSize + var currentOffset: CGFloat = 1.0 + var minOffset: CGFloat = 0.0 + var maxOffset: CGFloat = 2.0 + let scaleSpeed: CGFloat + + private var speedLevel: CGFloat = 0.0 + private var lastSpeedLevel: CGFloat = 0.0 + + private var fromPoints: [CGPoint]? + private var toPoints: [CGPoint]? + + private var currentPoints: [CGPoint]? { + guard let fromPoints = self.fromPoints, let toPoints = self.toPoints else { return nil } + + return fromPoints.enumerated().map { offset, fromPoint in + let toPoint = toPoints[offset] + return CGPoint(x: fromPoint.x + (toPoint.x - fromPoint.x) * transition, y: fromPoint.y + (toPoint.y - fromPoint.y) * transition) + } + } + + var currentShape: UIBezierPath? + private var transition: CGFloat = 0 { + didSet { + if let currentPoints = self.currentPoints { + self.currentShape = UIBezierPath.smoothCurve(through: currentPoints, length: size.width, smoothness: smoothness) + } + } + } + + var level: CGFloat = 0.0 { + didSet { + self.currentOffset = self.minOffset + (self.maxOffset - self.minOffset) * self.level + } + } + + private var transitionArguments: (startTime: Double, duration: Double)? + + var loop: Bool = true { + didSet { + if let _ = transitionArguments { + } else { + self.animateToNewShape() + } + } + } + + init( + size: CGSize, + pointsCount: Int, + minRandomness: CGFloat, + maxRandomness: CGFloat, + minSpeed: CGFloat, + maxSpeed: CGFloat, + minOffset: CGFloat, + maxOffset: CGFloat, + scaleSpeed: CGFloat + ) { + self.size = size +// self.alpha = alpha + self.pointsCount = pointsCount + self.minRandomness = minRandomness + self.maxRandomness = maxRandomness + self.minSpeed = minSpeed + self.maxSpeed = maxSpeed + self.minOffset = minOffset + self.maxOffset = maxOffset + self.scaleSpeed = scaleSpeed + + let angle = (CGFloat.pi * 2) / CGFloat(pointsCount) + self.smoothness = ((4 / 3) * tan(angle / 4)) / sin(angle / 2) / 2 + + self.currentOffset = minOffset + + self.animateToNewShape() + } + + func updateSpeedLevel(to newSpeedLevel: CGFloat) { + self.speedLevel = max(self.speedLevel, newSpeedLevel) + + if abs(lastSpeedLevel - newSpeedLevel) > 0.3 { + animateToNewShape() + } + } + + private func animateToNewShape() { + if let _ = self.transitionArguments { + self.fromPoints = self.currentPoints + self.toPoints = nil + self.transition = 0.0 + self.transitionArguments = nil + } + + if self.fromPoints == nil { + self.fromPoints = generateNextCurve(for: self.size) + } + if self.toPoints == nil { + self.toPoints = generateNextCurve(for: self.size) + } + + let duration: Double = 1.0 / Double(minSpeed + (maxSpeed - minSpeed) * speedLevel) + self.transitionArguments = (CACurrentMediaTime(), duration) + + self.lastSpeedLevel = self.speedLevel + self.speedLevel = 0 + + self.updateAnimations() + } + + func updateAnimations() { + var animate = false + let timestamp = CACurrentMediaTime() + + if let (startTime, duration) = self.transitionArguments, duration > 0.0 { + self.transition = max(0.0, min(1.0, CGFloat((timestamp - startTime) / duration))) + if self.transition < 1.0 { + animate = true + } else { + if self.loop { + self.animateToNewShape() + } else { + self.fromPoints = self.currentPoints + self.toPoints = nil + self.transition = 0.0 + self.transitionArguments = nil + } + } + } + } + + private func generateNextCurve(for size: CGSize) -> [CGPoint] { + let randomness = minRandomness + (maxRandomness - minRandomness) * speedLevel + return blob(pointsCount: pointsCount, randomness: randomness).map { + return CGPoint(x: size.width / 2.0 + $0.x * CGFloat(size.width), y: size.height / 2.0 + $0.y * CGFloat(size.height)) + } + } + + private func blob(pointsCount: Int, randomness: CGFloat) -> [CGPoint] { + let angle = (CGFloat.pi * 2) / CGFloat(pointsCount) + + let rgen = { () -> CGFloat in + let accuracy: UInt32 = 1000 + let random = arc4random_uniform(accuracy) + return CGFloat(random) / CGFloat(accuracy) + } + let rangeStart: CGFloat = 1.0 / (1.0 + randomness / 10.0) + + let startAngle = angle * CGFloat(arc4random_uniform(100)) / CGFloat(100) + + let points = (0 ..< pointsCount).map { i -> CGPoint in + let randPointOffset = (rangeStart + CGFloat(rgen()) * (1 - rangeStart)) / 2 + let angleRandomness: CGFloat = angle * 0.1 + let randAngle = angle + angle * ((angleRandomness * CGFloat(arc4random_uniform(100)) / CGFloat(100)) - angleRandomness * 0.5) + let pointX = sin(startAngle + CGFloat(i) * randAngle) + let pointY = cos(startAngle + CGFloat(i) * randAngle) + return CGPoint(x: pointX * randPointOffset, y: pointY * randPointOffset) + } + + return points + } +} + private class CallStatusBarBackgroundNode: ASDisplayNode { var muted = true diff --git a/submodules/TelegramCallsUI/Sources/VoiceChatActionButton.swift b/submodules/TelegramCallsUI/Sources/VoiceChatActionButton.swift index 26247c71ba..fbacfd9f4c 100644 --- a/submodules/TelegramCallsUI/Sources/VoiceChatActionButton.swift +++ b/submodules/TelegramCallsUI/Sources/VoiceChatActionButton.swift @@ -40,19 +40,26 @@ private enum VoiceChatActionButtonBackgroundNodeType { case blob } +private protocol VoiceChatActionButtonBackgroundNodeContext { + var type: VoiceChatActionButtonBackgroundNodeType { get } + var frameInterval: Int { get } + var isAnimating: Bool { get } + + func updateAnimations() + func drawingState() -> VoiceChatActionButtonBackgroundNodeState +} + private protocol VoiceChatActionButtonBackgroundNodeState: NSObjectProtocol { var blueGradient: UIImage? { get set } var greenGradient: UIImage? { get set } - - var frameInterval: Int { get } - var isAnimating: Bool { get } - var type: VoiceChatActionButtonBackgroundNodeType { get } - func updateAnimations() } -private final class VoiceChatActionButtonBackgroundNodeConnectingState: NSObject, VoiceChatActionButtonBackgroundNodeState { +private final class VoiceChatActionButtonBackgroundNodeConnectingContext: VoiceChatActionButtonBackgroundNodeContext { var blueGradient: UIImage? - var greenGradient: UIImage? + + init(blueGradient: UIImage?) { + self.blueGradient = blueGradient + } var isAnimating: Bool { return true @@ -69,15 +76,21 @@ private final class VoiceChatActionButtonBackgroundNodeConnectingState: NSObject func updateAnimations() { } + func drawingState() -> VoiceChatActionButtonBackgroundNodeState { + return VoiceChatActionButtonBackgroundNodeConnectingState(blueGradient: self.blueGradient) + } +} + +private final class VoiceChatActionButtonBackgroundNodeConnectingState: NSObject, VoiceChatActionButtonBackgroundNodeState { + var blueGradient: UIImage? + var greenGradient: UIImage? + init(blueGradient: UIImage?) { self.blueGradient = blueGradient } } -private final class VoiceChatActionButtonBackgroundNodeDisabledState: NSObject, VoiceChatActionButtonBackgroundNodeState { - var blueGradient: UIImage? - var greenGradient: UIImage? - +private final class VoiceChatActionButtonBackgroundNodeDisabledContext: VoiceChatActionButtonBackgroundNodeContext { var isAnimating: Bool { return false } @@ -92,6 +105,15 @@ private final class VoiceChatActionButtonBackgroundNodeDisabledState: NSObject, func updateAnimations() { } + + func drawingState() -> VoiceChatActionButtonBackgroundNodeState { + return VoiceChatActionButtonBackgroundNodeDisabledState() + } +} + +private final class VoiceChatActionButtonBackgroundNodeDisabledState: NSObject, VoiceChatActionButtonBackgroundNodeState { + var blueGradient: UIImage? + var greenGradient: UIImage? } private final class Blob { @@ -305,7 +327,7 @@ private final class Blob { } } -private final class VoiceChatActionButtonBackgroundNodeBlobState: NSObject, VoiceChatActionButtonBackgroundNodeState { +private final class VoiceChatActionButtonBackgroundNodeBlobContext: VoiceChatActionButtonBackgroundNodeContext { var blueGradient: UIImage? var greenGradient: UIImage? @@ -321,13 +343,15 @@ private final class VoiceChatActionButtonBackgroundNodeBlobState: NSObject, Voic return .blob } - typealias BlobRange = (min: CGFloat, max: CGFloat) - let blobs: [Blob] - + let size: CGSize var active: Bool var activeTransitionArguments: (startTime: Double, duration: Double)? + typealias BlobRange = (min: CGFloat, max: CGFloat) + let blobs: [Blob] + init(size: CGSize, active: Bool, blueGradient: UIImage, greenGradient: UIImage) { + self.size = size self.active = active self.blueGradient = blueGradient self.greenGradient = greenGradient @@ -340,8 +364,8 @@ private final class VoiceChatActionButtonBackgroundNodeBlobState: NSObject, Voic self.blobs = [largeBlob, mediumBlob] } - - func update(with state: VoiceChatActionButtonBackgroundNodeBlobState) { + + func update(with state: VoiceChatActionButtonBackgroundNodeBlobContext) { if self.active != state.active { self.active = state.active @@ -364,14 +388,69 @@ private final class VoiceChatActionButtonBackgroundNodeBlobState: NSObject, Voic blob.updateAnimations() } } + + func drawingState() -> VoiceChatActionButtonBackgroundNodeState { + var blobs: [BlobDrawingState] = [] + for blob in self.blobs { + if let path = blob.currentShape?.copy() as? UIBezierPath { + blobs.append(BlobDrawingState(size: blob.size, path: path, scale: blob.currentScale, alpha: blob.alpha)) + } + } + return VoiceChatActionButtonBackgroundNodeBlobState(size: self.size, active: self.active, activeTransitionArguments: self.activeTransitionArguments, blueGradient: self.blueGradient, greenGradient: self.greenGradient, blobs: blobs) + } } -private final class VoiceChatActionButtonBackgroundNodeTransition { +private class BlobDrawingState: NSObject { + let size: CGSize + let path: UIBezierPath + let scale: CGFloat + let alpha: CGFloat + + init(size: CGSize, path: UIBezierPath, scale: CGFloat, alpha: CGFloat) { + self.size = size + self.path = path + self.scale = scale + self.alpha = alpha + } +} + +private final class VoiceChatActionButtonBackgroundNodeBlobState: NSObject, VoiceChatActionButtonBackgroundNodeState { + var blueGradient: UIImage? + var greenGradient: UIImage? + + let active: Bool + let activeTransitionArguments: (startTime: Double, duration: Double)? + + let blobs: [BlobDrawingState] + + init(size: CGSize, active: Bool, activeTransitionArguments: (startTime: Double, duration: Double)?, blueGradient: UIImage?, greenGradient: UIImage?, blobs: [BlobDrawingState]) { + self.active = active + self.activeTransitionArguments = activeTransitionArguments + self.blueGradient = blueGradient + self.greenGradient = greenGradient + self.blobs = blobs + } +} + +private final class VoiceChatActionButtonBackgroundNodeTransitionState: NSObject { + let startTime: Double + let transition: CGFloat + let previousState: VoiceChatActionButtonBackgroundNodeType + + init(startTime: Double, transition: CGFloat, previousState: VoiceChatActionButtonBackgroundNodeType) { + self.startTime = startTime + self.transition = transition + self.previousState = previousState + } +} + + +private final class VoiceChatActionButtonBackgroundNodeTransitionContext { let startTime: Double let duration: Double - let previousState: VoiceChatActionButtonBackgroundNodeState? + let previousState: VoiceChatActionButtonBackgroundNodeContext - init(startTime: Double, duration: Double, previousState: VoiceChatActionButtonBackgroundNodeState?) { + init(startTime: Double, duration: Double, previousState: VoiceChatActionButtonBackgroundNodeContext) { self.startTime = startTime self.duration = duration self.previousState = previousState @@ -384,15 +463,20 @@ private final class VoiceChatActionButtonBackgroundNodeTransition { return 0.0 } } + + func drawingTransitionState(time: Double) -> VoiceChatActionButtonBackgroundNodeTransitionState { + let transition = CGFloat(max(0.0, min(1.0, (time - startTime) / duration))) + return VoiceChatActionButtonBackgroundNodeTransitionState(startTime: self.startTime, transition: transition, previousState: previousState.type) + } } private class VoiceChatActionButtonBackgroundNodeDrawingState: NSObject { let timestamp: Double let state: VoiceChatActionButtonBackgroundNodeState let simplified: Bool - let transition: VoiceChatActionButtonBackgroundNodeTransition? + let transition: VoiceChatActionButtonBackgroundNodeTransitionState? - init(timestamp: Double, state: VoiceChatActionButtonBackgroundNodeState, simplified: Bool, transition: VoiceChatActionButtonBackgroundNodeTransition?) { + init(timestamp: Double, state: VoiceChatActionButtonBackgroundNodeState, simplified: Bool, transition: VoiceChatActionButtonBackgroundNodeTransitionState?) { self.timestamp = timestamp self.state = state self.simplified = simplified @@ -401,14 +485,14 @@ private class VoiceChatActionButtonBackgroundNodeDrawingState: NSObject { } private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { - private var state: VoiceChatActionButtonBackgroundNodeState + private var state: VoiceChatActionButtonBackgroundNodeContext private var hasState = false - private var transition: VoiceChatActionButtonBackgroundNodeTransition? + private var transition: VoiceChatActionButtonBackgroundNodeTransitionContext? private var simplified = false var audioLevel: CGFloat = 0.0 { didSet { - if let blobsState = self.state as? VoiceChatActionButtonBackgroundNodeBlobState { + if let blobsState = self.state as? VoiceChatActionButtonBackgroundNodeBlobContext { for blob in blobsState.blobs { blob.loop = audioLevel.isZero blob.updateSpeedLevel(to: self.audioLevel) @@ -421,7 +505,7 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { private var animator: ConstantDisplayLinkAnimator? override init() { - self.state = VoiceChatActionButtonBackgroundNodeConnectingState(blueGradient: nil) + self.state = VoiceChatActionButtonBackgroundNodeConnectingContext(blueGradient: nil) super.init() @@ -429,7 +513,7 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { self.displaysAsynchronously = true } - func update(state: VoiceChatActionButtonBackgroundNodeState, simplified: Bool, animated: Bool) { + func update(state: VoiceChatActionButtonBackgroundNodeContext, simplified: Bool, animated: Bool) { var animated = animated var hadState = true if !self.hasState { @@ -442,10 +526,10 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { if state.type != self.state.type || !hadState { if animated { - self.transition = VoiceChatActionButtonBackgroundNodeTransition(startTime: CACurrentMediaTime(), duration: 0.3, previousState: self.state) + self.transition = VoiceChatActionButtonBackgroundNodeTransitionContext(startTime: CACurrentMediaTime(), duration: 0.3, previousState: self.state) } self.state = state - } else if let blobState = self.state as? VoiceChatActionButtonBackgroundNodeBlobState, let nextState = state as? VoiceChatActionButtonBackgroundNodeBlobState { + } else if let blobState = self.state as? VoiceChatActionButtonBackgroundNodeBlobContext, let nextState = state as? VoiceChatActionButtonBackgroundNodeBlobContext { blobState.update(with: nextState) } @@ -457,7 +541,7 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { let timestamp = CACurrentMediaTime() self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + max(0.1, self.audioLevel) * 0.1 - if let blobsState = self.state as? VoiceChatActionButtonBackgroundNodeBlobState { + if let blobsState = self.state as? VoiceChatActionButtonBackgroundNodeBlobContext { for blob in blobsState.blobs { blob.level = self.presentationAudioLevel } @@ -496,14 +580,13 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { } override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { - return VoiceChatActionButtonBackgroundNodeDrawingState(timestamp: CACurrentMediaTime(), state: self.state, simplified: self.simplified, transition: self.transition) + let timestamp = CACurrentMediaTime() + return VoiceChatActionButtonBackgroundNodeDrawingState(timestamp: timestamp, state: self.state.drawingState(), simplified: self.simplified, transition: self.transition?.drawingTransitionState(time: timestamp)) } @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! -// let drawStart = CACurrentMediaTime() - if !isRasterizing { context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) @@ -529,8 +612,8 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { var appearanceProgress: CGFloat = 1.0 var glowScale: CGFloat = 0.75 - if let transition = parameters.transition, transition.previousState is VoiceChatActionButtonBackgroundNodeConnectingState { - appearanceProgress = transition.progress(time: parameters.timestamp) + if let transition = parameters.transition, transition.previousState == .connecting { + appearanceProgress = transition.transition } if let blobsState = parameters.state as? VoiceChatActionButtonBackgroundNodeBlobState { @@ -582,26 +665,25 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { if let blobsState = parameters.state as? VoiceChatActionButtonBackgroundNodeBlobState { for blob in blobsState.blobs { - if let path = blob.currentShape, let uiPath = path.copy() as? UIBezierPath { - let offset = (bounds.size.width - blob.size.width) / 2.0 - let toOrigin = CGAffineTransform(translationX: -bounds.size.width / 2.0 + offset, y: -bounds.size.height / 2.0 + offset) - let fromOrigin = CGAffineTransform(translationX: bounds.size.width / 2.0, y: bounds.size.height / 2.0) + let uiPath = blob.path + let offset = (bounds.size.width - blob.size.width) / 2.0 + let toOrigin = CGAffineTransform(translationX: -bounds.size.width / 2.0 + offset, y: -bounds.size.height / 2.0 + offset) + let fromOrigin = CGAffineTransform(translationX: bounds.size.width / 2.0, y: bounds.size.height / 2.0) - uiPath.apply(toOrigin) - uiPath.apply(CGAffineTransform(scaleX: blob.currentScale * appearanceProgress, y: blob.currentScale * appearanceProgress)) - uiPath.apply(fromOrigin) + uiPath.apply(toOrigin) + uiPath.apply(CGAffineTransform(scaleX: blob.scale * appearanceProgress, y: blob.scale * appearanceProgress)) + uiPath.apply(fromOrigin) - context.addPath(uiPath.cgPath) - context.clip() + context.addPath(uiPath.cgPath) + context.clip() - context.setAlpha(blob.alpha) + context.setAlpha(blob.alpha) - if parameters.simplified { - context.setFillColor(simpleColor.cgColor) - context.fill(bounds) - } else if let gradient = gradientImage?.cgImage { - context.draw(gradient, in: CGRect(origin: CGPoint(x: gradientCenter.x - gradientSize / 2.0, y: gradientCenter.y - gradientSize / 2.0), size: CGSize(width: gradientSize, height: gradientSize))) - } + if parameters.simplified { + context.setFillColor(simpleColor.cgColor) + context.fill(bounds) + } else if let gradient = gradientImage?.cgImage { + context.draw(gradient, in: CGRect(origin: CGPoint(x: gradientCenter.x - gradientSize / 2.0, y: gradientCenter.y - gradientSize / 2.0), size: CGSize(width: gradientSize, height: gradientSize))) } } } @@ -614,7 +696,7 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { var drawGradient = false let lineWidth = 3.0 + UIScreenPixel - if parameters.state is VoiceChatActionButtonBackgroundNodeConnectingState || parameters.transition?.previousState is VoiceChatActionButtonBackgroundNodeConnectingState { + if parameters.state is VoiceChatActionButtonBackgroundNodeConnectingState || parameters.transition?.previousState == .connecting { var globalAngle: CGFloat = CGFloat(parameters.timestamp.truncatingRemainder(dividingBy: Double.pi * 2.0)) globalAngle *= 4.0 globalAngle = CGFloat(globalAngle.truncatingRemainder(dividingBy: CGFloat.pi * 2.0)) @@ -627,7 +709,7 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { var skip = false var progress = CGFloat(1.0 + timestamp.remainder(dividingBy: 2.0)) if let transition = parameters.transition { - var transitionProgress = transition.progress(time: parameters.timestamp) + var transitionProgress = transition.transition if parameters.state is VoiceChatActionButtonBackgroundNodeBlobState { transitionProgress = min(1.0, transitionProgress / 0.5) progress = progress + (2.0 - progress) * transitionProgress @@ -635,7 +717,7 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { skip = true } } else if parameters.state is VoiceChatActionButtonBackgroundNodeDisabledState { - progress = progress + (1.0 - progress) * transition.progress(time: parameters.timestamp) + progress = progress + (1.0 - progress) * transition.transition if transitionProgress >= 1.0 { skip = true } @@ -668,8 +750,8 @@ private class VoiceChatActionButtonBackgroundNode: ASDisplayNode { path.addEllipse(in: buttonRect.insetBy(dx: -lineWidth / 2.0, dy: -lineWidth / 2.0)) context.addPath(path) context.clip() - if let transition = parameters.transition, transition.previousState is VoiceChatActionButtonBackgroundNodeConnectingState || transition.previousState is VoiceChatActionButtonBackgroundNodeDisabledState, transition.progress(time: parameters.timestamp) > 0.5 { - let progress = (transition.progress(time: parameters.timestamp) - 0.5) / 0.5 + if let transition = parameters.transition, transition.previousState == .connecting || transition.previousState == .disabled, transition.transition > 0.5 { + let progress = (transition.transition - 0.5) / 0.5 clearInside = progress } @@ -798,21 +880,21 @@ final class VoiceChatActionButton: HighlightTrackingButtonNode { var iconMuted = true var iconColor: UIColor = .white - var backgroundState: VoiceChatActionButtonBackgroundNodeState + var backgroundState: VoiceChatActionButtonBackgroundNodeContext switch state { case let .active(state): switch state { case .on: iconMuted = false - backgroundState = VoiceChatActionButtonBackgroundNodeBlobState(size: blobSize, active: true, blueGradient: self.blueGradient, greenGradient: self.greenGradient) + backgroundState = VoiceChatActionButtonBackgroundNodeBlobContext(size: blobSize, active: true, blueGradient: self.blueGradient, greenGradient: self.greenGradient) case .muted: - backgroundState = VoiceChatActionButtonBackgroundNodeBlobState(size: blobSize, active: false, blueGradient: self.blueGradient, greenGradient: self.greenGradient) + backgroundState = VoiceChatActionButtonBackgroundNodeBlobContext(size: blobSize, active: false, blueGradient: self.blueGradient, greenGradient: self.greenGradient) case .cantSpeak: iconColor = UIColor(rgb: 0xff3b30) - backgroundState = VoiceChatActionButtonBackgroundNodeDisabledState() + backgroundState = VoiceChatActionButtonBackgroundNodeDisabledContext() } case .connecting: - backgroundState = VoiceChatActionButtonBackgroundNodeConnectingState(blueGradient: self.blueGradient) + backgroundState = VoiceChatActionButtonBackgroundNodeConnectingContext(blueGradient: self.blueGradient) } self.backgroundNode.update(state: backgroundState, simplified: simplified, animated: true) @@ -871,7 +953,7 @@ final class VoiceChatActionButton: HighlightTrackingButtonNode { } } -private extension UIBezierPath { +extension UIBezierPath { static func smoothCurve(through points: [CGPoint], length: CGFloat, smoothness: CGFloat) -> UIBezierPath { var smoothPoints = [SmoothPoint]() for index in (0 ..< points.count) { diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift index bb48cabfda..0c77121d4f 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift @@ -80,6 +80,10 @@ public enum PresentationResourceKey: Int32 { case chatListRecentStatusOnlineHighlightedIcon case chatListRecentStatusOnlinePinnedIcon case chatListRecentStatusOnlinePanelIcon + case chatListRecentStatusVoiceChatIcon + case chatListRecentStatusVoiceChatHighlightedIcon + case chatListRecentStatusVoiceChatPinnedIcon + case chatListRecentStatusVoiceChatPanelIcon case chatTitleLockIcon case chatTitleMuteIcon diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift index 12aca8462b..312b50a237 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift @@ -135,20 +135,21 @@ public struct PresentationResourcesChatList { }) } - public static func recentStatusOnlineIcon(_ theme: PresentationTheme, state: RecentStatusOnlineIconState) -> UIImage? { + public static func recentStatusOnlineIcon(_ theme: PresentationTheme, state: RecentStatusOnlineIconState, voiceChat: Bool = false) -> UIImage? { let key: PresentationResourceKey switch state { case .regular: - key = PresentationResourceKey.chatListRecentStatusOnlineIcon + key = voiceChat ? PresentationResourceKey.chatListRecentStatusVoiceChatIcon : PresentationResourceKey.chatListRecentStatusOnlineIcon case .highlighted: - key = PresentationResourceKey.chatListRecentStatusOnlineHighlightedIcon + key = voiceChat ? PresentationResourceKey.chatListRecentStatusVoiceChatHighlightedIcon : PresentationResourceKey.chatListRecentStatusOnlineHighlightedIcon case .pinned: - key = PresentationResourceKey.chatListRecentStatusOnlinePinnedIcon + key = voiceChat ? PresentationResourceKey.chatListRecentStatusVoiceChatPinnedIcon : PresentationResourceKey.chatListRecentStatusOnlinePinnedIcon case .panel: - key = PresentationResourceKey.chatListRecentStatusOnlinePanelIcon + key = voiceChat ? PresentationResourceKey.chatListRecentStatusVoiceChatPanelIcon : PresentationResourceKey.chatListRecentStatusOnlinePanelIcon } return theme.image(key.rawValue, { theme in - return generateImage(CGSize(width: 14.0, height: 14.0), rotatedContext: { size, context in + let size: CGFloat = voiceChat ? 22.0 : 14.0 + return generateImage(CGSize(width: size, height: size), rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) switch state {