import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import TelegramUIPreferences import AccountContext import AnimatedCountLabelNode import VoiceChatActionButton 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 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 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.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 textFont = Font.with(size: 13.0, design: .regular, weight: .regular, traits: [.monospacedNumbers]) private func update() { guard let size = self.currentSize, let content = self.currentContent else { return } let wasEmpty = (self.titleNode.attributedText?.string ?? "").isEmpty let setupDataForCall: AnyObject? switch content { case let .call(_, _, call): setupDataForCall = call case let .groupCall(_, _, call): setupDataForCall = call } 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 })) } } 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 alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) alphaTransition.updateAlpha(node: self.subtitleNode, alpha: displaySpeakerSubtitle ? 0.0 : 1.0) alphaTransition.updateAlpha(node: self.speakerNode, alpha: displaySpeakerSubtitle ? 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)) let totalWidth = titleSize.width + spacing + subtitleSize.width let horizontalOrigin: CGFloat = floor((size.width - totalWidth) / 2.0) let contentHeight: CGFloat = 24.0 let verticalOrigin: CGFloat = size.height - contentHeight 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 { self.speakerNode.frame = CGRect(origin: CGPoint(x: horizontalOrigin + titleSize.width + spacing, 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() } }