import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import TelegramUIPreferences import AccountContext import AnimatedCountLabelNode import VoiceChatActionButton import ComponentFlow import ReactionSelectionNode private let blue = UIColor(rgb: 0x007fff) private let lightBlue = UIColor(rgb: 0x00affe) private let green = UIColor(rgb: 0x33c659) private let activeBlue = UIColor(rgb: 0x00a0b9) private let purple = UIColor(rgb: 0x3252ef) private let pink = UIColor(rgb: 0xef436c) private let latePurple = UIColor(rgb: 0xaa56a6) private let latePink = UIColor(rgb: 0xef476f) private class CallStatusBarBackgroundNode: ASDisplayNode { enum State { case connecting case cantSpeak case late case active case speaking } private let foregroundView: UIView private let foregroundGradientLayer: CAGradientLayer private let maskCurveView: VoiceCurveView private let initialTimestamp = CACurrentMediaTime() var audioLevel: Float = 0.0 { didSet { self.maskCurveView.updateLevel(CGFloat(audioLevel)) } } var connectingColor: UIColor = UIColor(rgb: 0xb6b6bb) { didSet { if self.connectingColor.rgb != oldValue.rgb { self.updateGradientColors() } } } var state: State = .connecting { didSet { if self.state != oldValue { self.updateGradientColors() } } } private func updateGradientColors() { let initialColors = self.foregroundGradientLayer.colors let targetColors: [CGColor] switch self.state { case .connecting: targetColors = [connectingColor.cgColor, connectingColor.cgColor] case .active: targetColors = [blue.cgColor, lightBlue.cgColor] case .speaking: targetColors = [green.cgColor, activeBlue.cgColor] case .cantSpeak: targetColors = [purple.cgColor, pink.cgColor] case .late: targetColors = [latePurple.cgColor, latePink.cgColor] } if CACurrentMediaTime() - self.initialTimestamp > 0.1 { self.foregroundGradientLayer.colors = targetColors self.foregroundGradientLayer.animate(from: initialColors as AnyObject, to: targetColors as AnyObject, keyPath: "colors", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3) } else { CATransaction.begin() CATransaction.setDisableActions(true) self.foregroundGradientLayer.colors = targetColors CATransaction.commit() } } private let hierarchyTrackingNode: HierarchyTrackingNode private var isCurrentlyInHierarchy = true var animationsEnabled: Bool = false { didSet { self.updateAnimations() if !self.animationsEnabled { self.maskCurveView.disableCurves() } } } override init() { self.foregroundView = UIView() self.foregroundGradientLayer = CAGradientLayer() self.maskCurveView = VoiceCurveView(frame: CGRect(), maxLevel: 1.5, smallCurveRange: (0.0, 0.0), mediumCurveRange: (0.1, 0.55), bigCurveRange: (0.1, 1.0)) self.maskCurveView.setColor(UIColor(rgb: 0xffffff)) var updateInHierarchy: ((Bool) -> Void)? self.hierarchyTrackingNode = HierarchyTrackingNode({ value in updateInHierarchy?(value) }) super.init() self.addSubnode(self.hierarchyTrackingNode) self.foregroundGradientLayer.colors = [blue.cgColor, lightBlue.cgColor] self.foregroundGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) self.foregroundGradientLayer.endPoint = CGPoint(x: 2.0, y: 0.5) self.foregroundView.mask = self.maskCurveView self.isOpaque = false self.updateAnimations() updateInHierarchy = { [weak self] value in if let strongSelf = self { strongSelf.isCurrentlyInHierarchy = value strongSelf.updateAnimations() } } } override func didLoad() { super.didLoad() self.view.addSubview(self.foregroundView) self.foregroundView.layer.addSublayer(self.foregroundGradientLayer) } override func layout() { super.layout() CATransaction.begin() CATransaction.setDisableActions(true) if self.maskCurveView.frame != self.bounds { self.foregroundView.frame = self.bounds self.foregroundGradientLayer.frame = self.bounds self.maskCurveView.frame = self.bounds } CATransaction.commit() } func updateAnimations() { if !self.isCurrentlyInHierarchy || !self.animationsEnabled { self.foregroundGradientLayer.removeAllAnimations() self.maskCurveView.stopAnimating() } else { self.maskCurveView.startAnimating() } } } public class CallStatusBarNodeImpl: CallStatusBarNode { public enum Content: Equatable { case call(SharedAccountContext, Account, PresentationCall) case groupCall(SharedAccountContext, Account, PresentationGroupCall) var sharedContext: SharedAccountContext { switch self { case let .call(sharedContext, _, _), let .groupCall(sharedContext, _, _): return sharedContext } } public static func ==(lhs: Content, rhs: Content) -> Bool { switch lhs { case let .call(sharedContext, account, call): if case let .call(rhsSharedContext, rhsAccount, rhsCall) = rhs, sharedContext === rhsSharedContext, account === rhsAccount, call === rhsCall { return true } else { return false } case let .groupCall(sharedContext, account, groupCall): if case let .groupCall(rhsSharedContext, rhsAccount, rhsGroupCall) = rhs, sharedContext === rhsSharedContext, account === rhsAccount, groupCall === rhsGroupCall { return true } else { return false } } } } private let backgroundNode: CallStatusBarBackgroundNode private let titleNode: ImmediateTextNode private let subtitleNode: ImmediateAnimatedCountLabelNode private let speakerNode: ImmediateTextNode private var messageView: ComponentView? private let audioLevelDisposable = MetaDisposable() private let stateDisposable = MetaDisposable() private weak var didSetupDataForCall: AnyObject? private var currentSize: CGSize? private var currentContent: Content? private var presentationData: PresentationData? private let presentationDataDisposable = MetaDisposable() private var currentPeer: Peer? private var currentCallTimer: SwiftSignalKit.Timer? private var currentCallState: PresentationCallState? private var currentGroupCallState: PresentationGroupCallSummaryState? private var currentIsMuted = true private var currentCantSpeak = false private var currentScheduleTimestamp: Int32? private var currentMembers: PresentationGroupCallMembers? private var currentIsConnected = true private var reactionItems: [ReactionItem]? private var messagesState: GroupCallMessagesContext.State? private let messagesStateDisposable = MetaDisposable() private var currentMessageId: GroupCallMessagesContext.Message.Id? private let hierarchyTrackingNode: HierarchyTrackingNode private var isCurrentlyInHierarchy = true public override init() { self.backgroundNode = CallStatusBarBackgroundNode() self.titleNode = ImmediateTextNode() self.subtitleNode = ImmediateAnimatedCountLabelNode() self.subtitleNode.reverseAnimationDirection = true self.speakerNode = ImmediateTextNode() var updateInHierarchy: ((Bool) -> Void)? self.hierarchyTrackingNode = HierarchyTrackingNode({ value in updateInHierarchy?(value) }) super.init() self.addSubnode(self.hierarchyTrackingNode) self.addSubnode(self.backgroundNode) self.addSubnode(self.titleNode) self.addSubnode(self.subtitleNode) self.addSubnode(self.speakerNode) updateInHierarchy = { [weak self] value in if let strongSelf = self { strongSelf.isCurrentlyInHierarchy = value if value { strongSelf.update() } } } } deinit { self.presentationDataDisposable.dispose() self.audioLevelDisposable.dispose() self.stateDisposable.dispose() self.messagesStateDisposable.dispose() self.currentCallTimer?.invalidate() } public func update(content: Content) { if self.currentContent != content { self.currentContent = content self.backgroundNode.animationsEnabled = content.sharedContext.energyUsageSettings.fullTranslucency if self.isCurrentlyInHierarchy { self.update() } } } public override func update(size: CGSize) { self.currentSize = size self.update() } private let callTextFont = Font.with(size: 13.0, design: .regular, weight: .regular, traits: [.monospacedNumbers]) private let groupCallTextFont = Font.with(size: 13.0, design: .regular, weight: .regular, traits: []) private func update() { guard let size = self.currentSize, let content = self.currentContent else { return } let wasEmpty = (self.titleNode.attributedText?.string ?? "").isEmpty let textFont: UIFont let setupDataForCall: AnyObject? switch content { case let .call(_, _, call): setupDataForCall = call textFont = callTextFont case let .groupCall(_, _, call): setupDataForCall = call textFont = groupCallTextFont } if self.didSetupDataForCall !== setupDataForCall { self.didSetupDataForCall = setupDataForCall switch content { case let .call(sharedContext, account, call): self.presentationData = sharedContext.currentPresentationData.with { $0 } self.stateDisposable.set( (combineLatest( account.postbox.loadedPeerWithId(call.peerId), call.state, call.isMuted ) |> deliverOnMainQueue).start(next: { [weak self] peer, state, isMuted in if let strongSelf = self { strongSelf.currentPeer = peer strongSelf.currentCallState = state strongSelf.currentIsMuted = isMuted let currentIsConnected: Bool switch state.state { case .active, .terminating, .terminated: currentIsConnected = true default: currentIsConnected = false } strongSelf.currentIsConnected = currentIsConnected strongSelf.update() } })) self.audioLevelDisposable.set((call.audioLevel |> deliverOnMainQueue).start(next: { [weak self] audioLevel in guard let strongSelf = self else { return } strongSelf.backgroundNode.audioLevel = audioLevel })) case let .groupCall(sharedContext, account, call): self.presentationData = sharedContext.currentPresentationData.with { $0 } self.presentationDataDisposable.set((sharedContext.presentationData |> deliverOnMainQueue).start(next: { [weak self] presentationData in if let strongSelf = self { strongSelf.presentationData = presentationData strongSelf.update() } })) let callPeerView: Signal if let peerId = call.peerId { callPeerView = account.postbox.peerView(id: peerId) |> map(Optional.init) } else { callPeerView = .single(nil) } self.stateDisposable.set( (combineLatest( callPeerView, call.summaryState, call.isMuted, call.members ) |> deliverOnMainQueue).start(next: { [weak self] view, state, isMuted, members in if let strongSelf = self { if let view { strongSelf.currentPeer = view.peers[view.peerId] } else { strongSelf.currentPeer = nil } strongSelf.currentGroupCallState = state strongSelf.currentMembers = members var isMuted = isMuted var cantSpeak = false if let state = state, let muteState = state.callState.muteState { if !muteState.canUnmute { isMuted = true cantSpeak = true } } if state?.callState.scheduleTimestamp != nil { cantSpeak = true } strongSelf.currentIsMuted = isMuted strongSelf.currentCantSpeak = cantSpeak strongSelf.currentScheduleTimestamp = state?.callState.scheduleTimestamp let currentIsConnected: Bool if let state = state, case .connected = state.callState.networkState { currentIsConnected = true } else if state?.callState.scheduleTimestamp != nil { currentIsConnected = true } else { currentIsConnected = false } strongSelf.currentIsConnected = currentIsConnected if strongSelf.isCurrentlyInHierarchy { strongSelf.update() } } })) self.audioLevelDisposable.set((combineLatest(call.myAudioLevel, .single([]) |> then(call.audioLevels)) |> deliverOnMainQueue).start(next: { [weak self] myAudioLevel, audioLevels in guard let strongSelf = self else { return } var effectiveLevel: Float = 0.0 var audioLevels = audioLevels if !strongSelf.currentIsMuted { audioLevels.append((PeerId(0), 0, myAudioLevel, true)) } effectiveLevel = audioLevels.map { $0.2 }.max() ?? 0.0 strongSelf.backgroundNode.audioLevel = effectiveLevel })) if let groupCall = call as? PresentationGroupCallImpl { let _ = (allowedStoryReactions(account: account) |> deliverOnMainQueue).start(next: { [weak self] reactionItems in self?.reactionItems = reactionItems }) self.messagesStateDisposable.set((groupCall.messagesState |> deliverOnMainQueue).start(next: { [weak self] messagesState in guard let self else { return } if self.messagesState != messagesState { self.messagesState = messagesState if self.isCurrentlyInHierarchy { self.update() } } })) } } } var title: String = "" var speakerSubtitle: String = "" let textColor = UIColor.white var segments: [AnimatedCountLabelNode.Segment] = [] var displaySpeakerSubtitle = false var isLate = false if let presentationData = self.presentationData { if let voiceChatTitle = self.currentGroupCallState?.info?.title, !voiceChatTitle.isEmpty { title = voiceChatTitle } else if let currentPeer = self.currentPeer { title = EnginePeer(currentPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) } var membersCount: Int32? if let groupCallState = self.currentGroupCallState { membersCount = Int32(max(1, groupCallState.participantCount)) } else if let content = self.currentContent, case .groupCall = content { membersCount = 1 } var speakingPeer: Peer? if let members = currentMembers { var speakingPeers: [Peer] = [] for member in members.participants { if let memberPeer = member.peer, members.speakingParticipants.contains(memberPeer.id) { speakingPeers.append(memberPeer._asPeer()) } } speakingPeer = speakingPeers.first } if let speakingPeer = speakingPeer { speakerSubtitle = EnginePeer(speakingPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) } displaySpeakerSubtitle = speakerSubtitle != title && !speakerSubtitle.isEmpty var requiresTimer = false if let scheduleTime = self.currentGroupCallState?.info?.scheduleTimestamp { requiresTimer = true let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) let elapsedTime = scheduleTime - currentTime let timerText: String if elapsedTime >= 86400 { timerText = presentationData.strings.VoiceChat_StatusStartsIn(scheduledTimeIntervalString(strings: presentationData.strings, value: elapsedTime)).string } else if elapsedTime < 0 { isLate = true timerText = presentationData.strings.VoiceChat_StatusLateBy(textForTimeout(value: abs(elapsedTime))).string } else { timerText = presentationData.strings.VoiceChat_StatusStartsIn(textForTimeout(value: elapsedTime)).string } segments.append(.text(0, NSAttributedString(string: timerText, font: textFont, textColor: textColor))) } else if let membersCount = membersCount { var membersPart = presentationData.strings.VoiceChat_Status_Members(membersCount) if membersPart.contains("[") && membersPart.contains("]") { if let startIndex = membersPart.firstIndex(of: "["), let endIndex = membersPart.firstIndex(of: "]") { membersPart.removeSubrange(startIndex ... endIndex) } } else { membersPart = membersPart.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) } let rawTextAndRanges = presentationData.strings.VoiceChat_Status_MembersFormat("\(membersCount)", membersPart) var textIndex = 0 var latestIndex = 0 for rangeItem in rawTextAndRanges.ranges { let index = rangeItem.index let range = rangeItem.range var lowerSegmentIndex = range.lowerBound if index != 0 { lowerSegmentIndex = min(lowerSegmentIndex, latestIndex) } else { if latestIndex < range.lowerBound { let part = String(rawTextAndRanges.string[rawTextAndRanges.string.index(rawTextAndRanges.string.startIndex, offsetBy: latestIndex) ..< rawTextAndRanges.string.index(rawTextAndRanges.string.startIndex, offsetBy: range.lowerBound)]) segments.append(.text(textIndex, NSAttributedString(string: part, font: textFont, textColor: textColor))) textIndex += 1 } } latestIndex = range.upperBound let part = String(rawTextAndRanges.string[rawTextAndRanges.string.index(rawTextAndRanges.string.startIndex, offsetBy: lowerSegmentIndex) ..< rawTextAndRanges.string.index(rawTextAndRanges.string.startIndex, offsetBy: range.upperBound)]) if index == 0 { segments.append(.number(Int(membersCount), NSAttributedString(string: part, font: textFont, textColor: textColor))) } else { segments.append(.text(textIndex, NSAttributedString(string: part, font: textFont, textColor: textColor))) textIndex += 1 } } if latestIndex < rawTextAndRanges.string.count { let part = String(rawTextAndRanges.string[rawTextAndRanges.string.index(rawTextAndRanges.string.startIndex, offsetBy: latestIndex)...]) segments.append(.text(textIndex, NSAttributedString(string: part, font: textFont, textColor: textColor))) textIndex += 1 } } let sourceColor = presentationData.theme.chatList.unreadBadgeInactiveBackgroundColor let color: UIColor if sourceColor.alpha < 1.0 { color = presentationData.theme.chatList.unreadBadgeInactiveBackgroundColor.mixedWith(sourceColor.withAlphaComponent(1.0), alpha: sourceColor.alpha) } else { color = sourceColor } self.backgroundNode.connectingColor = color if requiresTimer { if self.currentCallTimer == nil { let timer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in self?.update() }, queue: Queue.mainQueue()) timer.start() self.currentCallTimer = timer } } else if let currentCallTimer = self.currentCallTimer { self.currentCallTimer = nil currentCallTimer.invalidate() } } if self.subtitleNode.segments != segments && !displaySpeakerSubtitle { self.subtitleNode.segments = segments } let contentHeight: CGFloat = 24.0 let verticalOrigin: CGFloat = size.height - contentHeight var isDisplayingMessage = false let componentTransition: ComponentTransition = .easeInOut(duration: 0.25) if case let .groupCall(_, account, call) = self.currentContent, let message = self.messagesState?.messages.last(where: { $0.author?.id != account.peerId }), let author = message.author, let groupCall = call as? PresentationGroupCallImpl { if self.currentMessageId != message.id { self.currentMessageId = message.id if let messageView = self.messageView?.view { self.messageView = nil componentTransition.setAlpha(view: messageView, alpha: 0.0, completion: { _ in messageView.removeFromSuperview() }) } } let messageView: ComponentView if let current = self.messageView { messageView = current } else { messageView = ComponentView() self.messageView = messageView } let messageSize = messageView.update( transition: .immediate, component: AnyComponent( MessageItemComponent( context: groupCall.accountContext, icon: .peer(author), style: .status, text: message.text, entities: message.entities, availableReactions: self.reactionItems, openPeer: nil ) ), environment: {}, containerSize: CGSize(width: size.width - 140.0, height: contentHeight) ) if let view = messageView.view { if view.superview == nil { view.transform = CGAffineTransformMakeScale(1.0, -1.0) self.view.addSubview(view) componentTransition.animateAlpha(view: view, from: 0.0, to: 1.0) } view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - messageSize.width) / 2.0), y: verticalOrigin + floor((contentHeight - messageSize.height) / 2.0) + 1.0), size: messageSize) } isDisplayingMessage = true } else if let messageView = self.messageView?.view { self.messageView = nil componentTransition.setAlpha(view: messageView, alpha: 0.0, completion: { _ in messageView.removeFromSuperview() }) } let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) alphaTransition.updateAlpha(node: self.titleNode, alpha: isDisplayingMessage ? 0.0 : 1.0) alphaTransition.updateAlpha(node: self.subtitleNode, alpha: displaySpeakerSubtitle || isDisplayingMessage ? 0.0 : 1.0) alphaTransition.updateAlpha(node: self.speakerNode, alpha: displaySpeakerSubtitle && !isDisplayingMessage ? 1.0 : 0.0) self.titleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(13.0), textColor: .white) if displaySpeakerSubtitle { self.speakerNode.attributedText = NSAttributedString(string: speakerSubtitle, font: Font.regular(13.0), textColor: .white) } let spacing: CGFloat = 5.0 let titleSize = self.titleNode.updateLayout(CGSize(width: 150.0, height: size.height)) let subtitleSize = self.subtitleNode.updateLayout(size: CGSize(width: 150.0, height: size.height), animated: true) let speakerSize = self.speakerNode.updateLayout(CGSize(width: 150.0, height: size.height)) var totalWidth = titleSize.width if totalWidth > 0.0 { totalWidth += spacing } totalWidth += subtitleSize.width let horizontalOrigin: CGFloat = floor((size.width - totalWidth) / 2.0) let sizeChanged = self.titleNode.frame.size.width != titleSize.width let transition: ContainedViewLayoutTransition = wasEmpty || sizeChanged ? .immediate : .animated(duration: 0.2, curve: .easeInOut) transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: horizontalOrigin, y: verticalOrigin + floor((contentHeight - titleSize.height) / 2.0)), size: titleSize)) transition.updateFrame(node: self.subtitleNode, frame: CGRect(origin: CGPoint(x: horizontalOrigin + titleSize.width + spacing, y: verticalOrigin + floor((contentHeight - subtitleSize.height) / 2.0)), size: subtitleSize)) if displaySpeakerSubtitle { let speakerOriginX: CGFloat = title.isEmpty ? floor((size.width - speakerSize.width) / 2.0) : horizontalOrigin + titleSize.width + spacing self.speakerNode.frame = CGRect(origin: CGPoint(x: speakerOriginX, y: verticalOrigin + floor((contentHeight - speakerSize.height) / 2.0)), size: speakerSize) } let state: CallStatusBarBackgroundNode.State if self.currentIsConnected { if self.currentCantSpeak { state = isLate ? .late : .cantSpeak } else if self.currentIsMuted { state = .active } else { state = .speaking } } else { state = .connecting } self.backgroundNode.state = state self.backgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height + 18.0)) } } private final class VoiceCurveView: UIView { private let smallCurve: CurveView private let mediumCurve: CurveView private let bigCurve: CurveView private var solidView: UIView? private let maxLevel: CGFloat private var displayLinkAnimator: ConstantDisplayLinkAnimator? private var audioLevel: CGFloat = 0.0 var presentationAudioLevel: CGFloat = 0.0 private(set) var isAnimating = false public typealias CurveRange = (min: CGFloat, max: CGFloat) public init( frame: CGRect, maxLevel: CGFloat, smallCurveRange: CurveRange, mediumCurveRange: CurveRange, bigCurveRange: CurveRange ) { self.maxLevel = maxLevel self.smallCurve = CurveView( pointsCount: 8, minRandomness: 1, maxRandomness: 1.3, minSpeed: 0.9, maxSpeed: 3.2, minOffset: smallCurveRange.min, maxOffset: smallCurveRange.max ) self.mediumCurve = CurveView( pointsCount: 8, minRandomness: 1.2, maxRandomness: 1.5, minSpeed: 1.0, maxSpeed: 4.4, minOffset: mediumCurveRange.min, maxOffset: mediumCurveRange.max ) self.bigCurve = CurveView( pointsCount: 8, minRandomness: 1.2, maxRandomness: 1.7, minSpeed: 1.0, maxSpeed: 5.8, minOffset: bigCurveRange.min, maxOffset: bigCurveRange.max ) super.init(frame: frame) self.addSubview(self.bigCurve) self.addSubview(self.mediumCurve) self.addSubview(self.smallCurve) self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in guard let strongSelf = self else { return } strongSelf.presentationAudioLevel = strongSelf.presentationAudioLevel * 0.9 + strongSelf.audioLevel * 0.1 strongSelf.smallCurve.level = strongSelf.presentationAudioLevel strongSelf.mediumCurve.level = strongSelf.presentationAudioLevel strongSelf.bigCurve.level = strongSelf.presentationAudioLevel } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func setColor(_ color: UIColor) { self.smallCurve.setColor(color.withAlphaComponent(1.0)) self.mediumCurve.setColor(color.withAlphaComponent(0.55)) self.bigCurve.setColor(color.withAlphaComponent(0.35)) } public func updateLevel(_ level: CGFloat) { let normalizedLevel = min(1, max(level / self.maxLevel, 0)) self.smallCurve.updateSpeedLevel(to: normalizedLevel) self.mediumCurve.updateSpeedLevel(to: normalizedLevel) self.bigCurve.updateSpeedLevel(to: normalizedLevel) self.audioLevel = normalizedLevel } public func startAnimating() { guard !self.isAnimating else { return } self.isAnimating = true if let solidView = self.solidView { solidView.removeFromSuperview() self.solidView = nil } self.updateCurvesState() self.displayLinkAnimator?.isPaused = false } public func stopAnimating() { self.stopAnimating(duration: 0.15) } public func stopAnimating(duration: Double) { guard self.isAnimating else { return } self.isAnimating = false self.updateCurvesState() self.displayLinkAnimator?.isPaused = true } func disableCurves() { self.smallCurve.isHidden = true self.mediumCurve.isHidden = true self.bigCurve.isHidden = true let view = UIView(frame: .zero) view.backgroundColor = .white self.addSubview(view) self.solidView = view } private func updateCurvesState() { if self.isAnimating { if self.smallCurve.frame.size != .zero { self.smallCurve.startAnimating() self.mediumCurve.startAnimating() self.bigCurve.startAnimating() } } else { self.smallCurve.stopAnimating() self.mediumCurve.stopAnimating() self.bigCurve.stopAnimating() } } override public func layoutSubviews() { super.layoutSubviews() self.smallCurve.frame = self.bounds self.mediumCurve.frame = self.bounds self.bigCurve.frame = self.bounds self.solidView?.frame = CGRect(origin: .zero, size: CGSize(width: self.bounds.width, height: self.bounds.height - 18.0)) self.updateCurvesState() } } final class CurveView: UIView { let pointsCount: Int let smoothness: CGFloat let minRandomness: CGFloat let maxRandomness: CGFloat let minSpeed: CGFloat let maxSpeed: CGFloat let minOffset: CGFloat let maxOffset: CGFloat var level: CGFloat = 0 { didSet { guard self.minOffset > 0.0 else { return } CATransaction.begin() CATransaction.setDisableActions(true) let lv = self.minOffset + (self.maxOffset - self.minOffset) * self.level self.shapeLayer.transform = CATransform3DMakeTranslation(0.0, lv * 16.0, 0.0) CATransaction.commit() } } private var speedLevel: CGFloat = 0 private var lastSpeedLevel: CGFloat = 0 private let shapeLayer: CAShapeLayer = { let layer = CAShapeLayer() layer.strokeColor = nil return layer }() override var frame: CGRect { didSet { if self.frame.size != oldValue.size { self.shapeLayer.path = nil self.animateToNewShape() } } } init( pointsCount: Int, minRandomness: CGFloat, maxRandomness: CGFloat, minSpeed: CGFloat, maxSpeed: CGFloat, minOffset: CGFloat, maxOffset: CGFloat ) { self.pointsCount = pointsCount self.minRandomness = minRandomness self.maxRandomness = maxRandomness self.minSpeed = minSpeed self.maxSpeed = maxSpeed self.minOffset = minOffset self.maxOffset = maxOffset self.smoothness = 0.35 super.init(frame: .zero) self.layer.addSublayer(self.shapeLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setColor(_ color: UIColor) { self.shapeLayer.fillColor = color.cgColor } func updateSpeedLevel(to newSpeedLevel: CGFloat) { self.speedLevel = max(self.speedLevel, newSpeedLevel) } func startAnimating() { self.animateToNewShape() } func stopAnimating() { self.shapeLayer.removeAnimation(forKey: "path") } private func animateToNewShape() { if self.shapeLayer.path == nil { let points = self.generateNextCurve(for: self.bounds.size) self.shapeLayer.path = UIBezierPath.smoothCurve(through: points, length: bounds.width, smoothness: self.smoothness, curve: true).cgPath } let nextPoints = self.generateNextCurve(for: self.bounds.size) let nextPath = UIBezierPath.smoothCurve(through: nextPoints, length: bounds.width, smoothness: self.smoothness, curve: true).cgPath let animation = CABasicAnimation(keyPath: "path") let previousPath = self.shapeLayer.path self.shapeLayer.path = nextPath animation.duration = CFTimeInterval(1 / (self.minSpeed + (self.maxSpeed - self.minSpeed) * self.speedLevel)) animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) animation.fromValue = previousPath animation.toValue = nextPath animation.isRemovedOnCompletion = false animation.fillMode = .forwards animation.completion = { [weak self] finished in if finished { self?.animateToNewShape() } } self.shapeLayer.add(animation, forKey: "path") self.lastSpeedLevel = self.speedLevel self.speedLevel = 0 } private func generateNextCurve(for size: CGSize) -> [CGPoint] { let randomness = minRandomness + (maxRandomness - minRandomness) * speedLevel return curve(pointsCount: pointsCount, randomness: randomness).map { return CGPoint(x: $0.x * CGFloat(size.width), y: size.height - 18.0 + $0.y * 12.0) } } private func curve(pointsCount: Int, randomness: CGFloat) -> [CGPoint] { let segment = 1.0 / CGFloat(pointsCount - 1) 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 points = (0 ..< pointsCount).map { i -> CGPoint in let randPointOffset = (rangeStart + CGFloat(rgen()) * (1 - rangeStart)) / 2 let segmentRandomness: CGFloat = randomness let pointX: CGFloat let pointY: CGFloat let randomXDelta: CGFloat if i == 0 { pointX = 0.0 pointY = 0.0 randomXDelta = 0.0 } else if i == pointsCount - 1 { pointX = 1.0 pointY = 0.0 randomXDelta = 0.0 } else { pointX = segment * CGFloat(i) pointY = ((segmentRandomness * CGFloat(arc4random_uniform(100)) / CGFloat(100)) - segmentRandomness * 0.5) * randPointOffset randomXDelta = segment - segment * randPointOffset } return CGPoint(x: pointX + randomXDelta, y: pointY) } return points } override func layoutSubviews() { super.layoutSubviews() CATransaction.begin() CATransaction.setDisableActions(true) self.shapeLayer.position = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0) self.shapeLayer.bounds = self.bounds CATransaction.commit() } }