mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
790 lines
30 KiB
Swift
790 lines
30 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import SwiftSignalKit
|
|
import Postbox
|
|
import TelegramCore
|
|
import TelegramPresentationData
|
|
import TelegramUIPreferences
|
|
import AccountContext
|
|
import LegacyComponents
|
|
import AnimatedCountLabelNode
|
|
|
|
private let blue = UIColor(rgb: 0x0078ff)
|
|
private let lightBlue = UIColor(rgb: 0x59c7f8)
|
|
private let green = UIColor(rgb: 0x33c659)
|
|
private let activeBlue = UIColor(rgb: 0x00a0b9)
|
|
|
|
private class CallStatusBarBackgroundNode: ASDisplayNode {
|
|
private let foregroundView: UIView
|
|
private let foregroundGradientLayer: CAGradientLayer
|
|
private let maskCurveView: VoiceCurveView
|
|
|
|
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 speaking: Bool? = nil {
|
|
didSet {
|
|
if self.speaking != oldValue {
|
|
self.updateGradientColors()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateGradientColors() {
|
|
let initialColors = self.foregroundGradientLayer.colors
|
|
let targetColors: [CGColor]
|
|
if let speaking = self.speaking {
|
|
targetColors = speaking ? [green.cgColor, activeBlue.cgColor] : [blue.cgColor, lightBlue.cgColor]
|
|
} else {
|
|
targetColors = [connectingColor.cgColor, connectingColor.cgColor]
|
|
}
|
|
self.foregroundGradientLayer.colors = targetColors
|
|
self.foregroundGradientLayer.animate(from: initialColors as AnyObject, to: targetColors as AnyObject, keyPath: "colors", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
|
|
}
|
|
|
|
private let hierarchyTrackingNode: HierarchyTrackingNode
|
|
private var isCurrentlyInHierarchy = true
|
|
|
|
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()
|
|
}
|
|
|
|
private func setupGradientAnimations() {
|
|
return
|
|
if let _ = self.foregroundGradientLayer.animation(forKey: "movement") {
|
|
} else {
|
|
let previousValue = self.foregroundGradientLayer.startPoint
|
|
let newValue: CGPoint
|
|
if self.maskCurveView.presentationAudioLevel > 0.1 {
|
|
newValue = CGPoint(x: CGFloat.random(in: 1.0 ..< 1.3), y: 0.5)
|
|
} else {
|
|
newValue = CGPoint(x: CGFloat.random(in: 0.85 ..< 1.2), y: 0.5)
|
|
}
|
|
self.foregroundGradientLayer.startPoint = newValue
|
|
|
|
CATransaction.begin()
|
|
|
|
let animation = CABasicAnimation(keyPath: "endPoint")
|
|
animation.duration = Double.random(in: 0.8 ..< 1.4)
|
|
animation.fromValue = previousValue
|
|
animation.toValue = newValue
|
|
|
|
CATransaction.setCompletionBlock { [weak self] in
|
|
self?.setupGradientAnimations()
|
|
}
|
|
|
|
self.foregroundGradientLayer.add(animation, forKey: "movement")
|
|
CATransaction.commit()
|
|
}
|
|
}
|
|
|
|
func updateAnimations() {
|
|
if !isCurrentlyInHierarchy {
|
|
self.foregroundGradientLayer.removeAllAnimations()
|
|
self.maskCurveView.stopAnimating()
|
|
return
|
|
}
|
|
self.setupGradientAnimations()
|
|
self.maskCurveView.startAnimating()
|
|
}
|
|
}
|
|
|
|
public class CallStatusBarNodeImpl: CallStatusBarNode {
|
|
public enum Content {
|
|
case call(SharedAccountContext, Account, PresentationCall)
|
|
case groupCall(SharedAccountContext, Account, PresentationGroupCall)
|
|
}
|
|
|
|
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 var didSetupData = false
|
|
|
|
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 currentMembers: PresentationGroupCallMembers?
|
|
private var currentIsConnected = true
|
|
|
|
public override init() {
|
|
self.backgroundNode = CallStatusBarBackgroundNode()
|
|
self.titleNode = ImmediateTextNode()
|
|
self.subtitleNode = ImmediateAnimatedCountLabelNode()
|
|
self.subtitleNode.reverseAnimationDirection = true
|
|
self.speakerNode = ImmediateTextNode()
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.backgroundNode)
|
|
self.addSubnode(self.titleNode)
|
|
self.addSubnode(self.subtitleNode)
|
|
self.addSubnode(self.speakerNode)
|
|
}
|
|
|
|
deinit {
|
|
self.presentationDataDisposable.dispose()
|
|
self.audioLevelDisposable.dispose()
|
|
self.stateDisposable.dispose()
|
|
self.currentCallTimer?.invalidate()
|
|
}
|
|
|
|
public func update(content: Content) {
|
|
self.currentContent = content
|
|
self.update()
|
|
}
|
|
|
|
public override func update(size: CGSize) {
|
|
self.currentSize = size
|
|
self.update()
|
|
}
|
|
|
|
private func update() {
|
|
guard let size = self.currentSize, let content = self.currentContent else {
|
|
return
|
|
}
|
|
|
|
let wasEmpty = (self.titleNode.attributedText?.string ?? "").isEmpty
|
|
|
|
if !self.didSetupData {
|
|
self.didSetupData = true
|
|
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()
|
|
}
|
|
}))
|
|
self.stateDisposable.set(
|
|
(combineLatest(
|
|
account.postbox.peerView(id: call.peerId),
|
|
call.summaryState,
|
|
call.isMuted,
|
|
call.members
|
|
)
|
|
|> deliverOnMainQueue).start(next: { [weak self] view, state, isMuted, members in
|
|
if let strongSelf = self {
|
|
strongSelf.currentPeer = view.peers[view.peerId]
|
|
strongSelf.currentGroupCallState = state
|
|
strongSelf.currentMembers = members
|
|
|
|
var isMuted = isMuted
|
|
if let state = state, let muteState = state.callState.muteState {
|
|
if !muteState.canUnmute {
|
|
isMuted = true
|
|
}
|
|
}
|
|
strongSelf.currentIsMuted = isMuted
|
|
|
|
let currentIsConnected: Bool
|
|
if let state = state, case .connected = state.callState.networkState {
|
|
currentIsConnected = true
|
|
} else {
|
|
currentIsConnected = false
|
|
}
|
|
strongSelf.currentIsConnected = currentIsConnected
|
|
|
|
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), myAudioLevel, true))
|
|
}
|
|
effectiveLevel = audioLevels.map { $0.1 }.max() ?? 0.0
|
|
strongSelf.backgroundNode.audioLevel = effectiveLevel
|
|
}))
|
|
}
|
|
}
|
|
|
|
var title: String = ""
|
|
var speakerSubtitle: String = ""
|
|
|
|
let textFont = Font.regular(13.0)
|
|
let textColor = UIColor.white
|
|
var segments: [AnimatedCountLabelNode.Segment] = []
|
|
|
|
if let presentationData = self.presentationData {
|
|
if let currentPeer = self.currentPeer {
|
|
title = 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 members.speakingParticipants.contains(member.peer.id) {
|
|
speakingPeers.append(member.peer)
|
|
}
|
|
}
|
|
speakingPeer = speakingPeers.first
|
|
}
|
|
|
|
if let speakingPeer = speakingPeer {
|
|
speakerSubtitle = speakingPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
|
|
}
|
|
|
|
if let membersCount = membersCount {
|
|
var membersPart = presentationData.strings.VoiceChat_Status_Members(membersCount)
|
|
if let startIndex = membersPart.firstIndex(of: "["), let endIndex = membersPart.firstIndex(of: "]") {
|
|
membersPart.removeSubrange(startIndex ... endIndex)
|
|
}
|
|
|
|
let rawTextAndRanges = presentationData.strings.VoiceChat_Status_MembersFormat("\(membersCount)", membersPart)
|
|
|
|
let (rawText, ranges) = rawTextAndRanges
|
|
var textIndex = 0
|
|
var latestIndex = 0
|
|
for (index, range) in ranges {
|
|
var lowerSegmentIndex = range.lowerBound
|
|
if index != 0 {
|
|
lowerSegmentIndex = min(lowerSegmentIndex, latestIndex)
|
|
} else {
|
|
if latestIndex < range.lowerBound {
|
|
let part = String(rawText[rawText.index(rawText.startIndex, offsetBy: latestIndex) ..< rawText.index(rawText.startIndex, offsetBy: range.lowerBound)])
|
|
segments.append(.text(textIndex, NSAttributedString(string: part, font: textFont, textColor: textColor)))
|
|
textIndex += 1
|
|
}
|
|
}
|
|
latestIndex = range.upperBound
|
|
|
|
let part = String(rawText[rawText.index(rawText.startIndex, offsetBy: lowerSegmentIndex) ..< rawText.index(rawText.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 < rawText.count {
|
|
let part = String(rawText[rawText.index(rawText.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 self.subtitleNode.segments != segments && speakerSubtitle.isEmpty {
|
|
self.subtitleNode.segments = segments
|
|
}
|
|
|
|
let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)
|
|
alphaTransition.updateAlpha(node: self.subtitleNode, alpha: !speakerSubtitle.isEmpty ? 0.0 : 1.0)
|
|
alphaTransition.updateAlpha(node: self.speakerNode, alpha: !speakerSubtitle.isEmpty ? 1.0 : 0.0)
|
|
|
|
self.titleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(13.0), textColor: .white)
|
|
|
|
if !speakerSubtitle.isEmpty {
|
|
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: 160.0, height: size.height))
|
|
let subtitleSize = self.subtitleNode.updateLayout(size: CGSize(width: 160.0, height: size.height), animated: true)
|
|
let speakerSize = self.speakerNode.updateLayout(CGSize(width: 160.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 transition: ContainedViewLayoutTransition = wasEmpty ? .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 !speakerSubtitle.isEmpty {
|
|
self.speakerNode.frame = CGRect(origin: CGPoint(x: horizontalOrigin + titleSize.width + spacing, y: verticalOrigin + floor((contentHeight - speakerSize.height) / 2.0)), size: speakerSize)
|
|
}
|
|
|
|
self.backgroundNode.speaking = self.currentIsConnected ? !self.currentIsMuted : nil
|
|
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 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)
|
|
|
|
addSubview(bigCurve)
|
|
addSubview(mediumCurve)
|
|
addSubview(smallCurve)
|
|
|
|
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) {
|
|
smallCurve.setColor(color.withAlphaComponent(1.0))
|
|
mediumCurve.setColor(color.withAlphaComponent(0.55))
|
|
bigCurve.setColor(color.withAlphaComponent(0.35))
|
|
}
|
|
|
|
public func updateLevel(_ level: CGFloat) {
|
|
let normalizedLevel = min(1, max(level / maxLevel, 0))
|
|
|
|
smallCurve.updateSpeedLevel(to: normalizedLevel)
|
|
mediumCurve.updateSpeedLevel(to: normalizedLevel)
|
|
bigCurve.updateSpeedLevel(to: normalizedLevel)
|
|
|
|
audioLevel = normalizedLevel
|
|
}
|
|
|
|
public func startAnimating() {
|
|
guard !isAnimating else { return }
|
|
isAnimating = true
|
|
|
|
updateCurvesState()
|
|
|
|
displayLinkAnimator?.isPaused = false
|
|
}
|
|
|
|
public func stopAnimating() {
|
|
self.stopAnimating(duration: 0.15)
|
|
}
|
|
|
|
public func stopAnimating(duration: Double) {
|
|
guard isAnimating else { return }
|
|
isAnimating = false
|
|
|
|
updateCurvesState()
|
|
|
|
displayLinkAnimator?.isPaused = true
|
|
}
|
|
|
|
private func updateCurvesState() {
|
|
if isAnimating {
|
|
if smallCurve.frame.size != .zero {
|
|
smallCurve.startAnimating()
|
|
mediumCurve.startAnimating()
|
|
bigCurve.startAnimating()
|
|
}
|
|
} else {
|
|
smallCurve.stopAnimating()
|
|
mediumCurve.stopAnimating()
|
|
bigCurve.stopAnimating()
|
|
}
|
|
}
|
|
|
|
override public func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
smallCurve.frame = bounds
|
|
mediumCurve.frame = bounds
|
|
bigCurve.frame = bounds
|
|
|
|
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 = minOffset + (maxOffset - minOffset) * level
|
|
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
|
|
}()
|
|
|
|
private var transition: CGFloat = 0 {
|
|
didSet {
|
|
guard let currentPoints = currentPoints else { return }
|
|
|
|
shapeLayer.path = UIBezierPath.smoothCurve(through: currentPoints, length: bounds.width, smoothness: smoothness, curve: true).cgPath
|
|
}
|
|
}
|
|
|
|
override var frame: CGRect {
|
|
didSet {
|
|
if self.frame.size != oldValue.size {
|
|
self.fromPoints = nil
|
|
self.toPoints = nil
|
|
self.animateToNewShape()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var fromPoints: [CGPoint]?
|
|
private var toPoints: [CGPoint]?
|
|
|
|
private var currentPoints: [CGPoint]? {
|
|
guard let fromPoints = fromPoints, let toPoints = 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
|
|
)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
layer.addSublayer(shapeLayer)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func setColor(_ color: UIColor) {
|
|
shapeLayer.fillColor = color.cgColor
|
|
}
|
|
|
|
func updateSpeedLevel(to newSpeedLevel: CGFloat) {
|
|
speedLevel = max(speedLevel, newSpeedLevel)
|
|
|
|
// if abs(lastSpeedLevel - newSpeedLevel) > 0.45 {
|
|
// animateToNewShape()
|
|
// }
|
|
}
|
|
|
|
func startAnimating() {
|
|
animateToNewShape()
|
|
}
|
|
|
|
func stopAnimating() {
|
|
fromPoints = currentPoints
|
|
toPoints = nil
|
|
pop_removeAnimation(forKey: "curve")
|
|
}
|
|
|
|
private func animateToNewShape() {
|
|
if pop_animation(forKey: "curve") != nil {
|
|
fromPoints = currentPoints
|
|
toPoints = nil
|
|
pop_removeAnimation(forKey: "curve")
|
|
}
|
|
|
|
if fromPoints == nil {
|
|
fromPoints = generateNextCurve(for: bounds.size)
|
|
}
|
|
if toPoints == nil {
|
|
toPoints = generateNextCurve(for: bounds.size)
|
|
}
|
|
|
|
let animation = POPBasicAnimation()
|
|
animation.property = POPAnimatableProperty.property(withName: "curve.transition", initializer: { property in
|
|
property?.readBlock = { curveView, values in
|
|
guard let curveView = curveView as? CurveView, let values = values else { return }
|
|
|
|
values.pointee = curveView.transition
|
|
}
|
|
property?.writeBlock = { curveView, values in
|
|
guard let curveView = curveView as? CurveView, let values = values else { return }
|
|
|
|
curveView.transition = values.pointee
|
|
}
|
|
}) as? POPAnimatableProperty
|
|
animation.completionBlock = { [weak self] animation, finished in
|
|
if finished {
|
|
self?.fromPoints = self?.currentPoints
|
|
self?.toPoints = nil
|
|
self?.animateToNewShape()
|
|
}
|
|
}
|
|
animation.duration = CFTimeInterval(1 / (minSpeed + (maxSpeed - minSpeed) * speedLevel))
|
|
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
animation.fromValue = 0
|
|
animation.toValue = 1
|
|
pop_add(animation, forKey: "curve")
|
|
|
|
lastSpeedLevel = speedLevel
|
|
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)
|
|
shapeLayer.position = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0)
|
|
shapeLayer.bounds = self.bounds
|
|
CATransaction.commit()
|
|
}
|
|
}
|