如何在 swift 中制作圆形音频可视化工具?

How to make Circular audio visualizer in swift?

我想做一个这样的可视化工具Circular visualizer,点击绿旗看动画

在我的项目中,首先我画了一个圆,我计算了圆上的点来绘制可视化条,我旋转视图使条看起来像圆。我使用 StreamingKit 来播放现场广播。 StreamingKit 提供以分贝为单位的实时音频功率。然后我为可视化栏设置动画。但是当我旋转视图时,高度和宽度会根据我旋转的角度而变化。但是边界值没有改变(我知道框架取决于 superViews)。

audioSpectrom Class

class audioSpectrom: UIView {
    let animateDuration = 0.15
    let visualizerColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
    var barsNumber = 0
    let barWidth = 4 // width of bar
    let radius: CGFloat = 40

    var radians = [CGFloat]()
    var barPoints = [CGPoint]()
    
    private var rectArray = [CustomView]()
    private var waveFormArray = [Int]()
    private var initialBarHeight: CGFloat = 0.0

    private let mainLayer: CALayer = CALayer()
    
    // draw circle
    var midViewX: CGFloat!
    var midViewY: CGFloat!
    var circlePath = UIBezierPath()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
    
    convenience init() {
        self.init(frame: CGRect.zero)
        setupView()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }
    
    private func setupView() {
        self.layer.addSublayer(mainLayer)
        barsNumber = 10
    }
    
    override func layoutSubviews() {
        mainLayer.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
        drawVisualizer()
    }
    
    //-----------------------------------------------------------------
    // MARK: - Drawing Section
    //-----------------------------------------------------------------
    
    func drawVisualizer() {
        midViewX = self.mainLayer.frame.midX
        midViewY = self.mainLayer.frame.midY
        
        // Draw Circle
        let arcCenter = CGPoint(x: midViewX, y: midViewY)
        let circlePath = UIBezierPath(arcCenter: arcCenter, radius: radius, startAngle: 0, endAngle: CGFloat(Double.pi * 2), clockwise: true)
        let circleShapeLayer = CAShapeLayer()
        
        circleShapeLayer.path = circlePath.cgPath
        circleShapeLayer.fillColor = UIColor.blue.cgColor
        circleShapeLayer.strokeColor = UIColor.clear.cgColor
        circleShapeLayer.lineWidth = 1.0
        mainLayer.addSublayer(circleShapeLayer)
        
        // Draw Bars
        rectArray = [CustomView]()
        
        for i in 0..<barsNumber {
            let angle = ((360 / barsNumber) * i) - 90
            let point = calculatePoints(angle: angle, radius: radius)
            let radian = angle.degreesToRadians
            radians.append(radian)
            barPoints.append(point)
            
            let rectangle = CustomView(frame: CGRect(x: barPoints[i].x, y: barPoints[i].y, width: CGFloat(barWidth), height: CGFloat(barWidth)))
            
            initialBarHeight = CGFloat(self.barWidth)
            
            rectangle.setAnchorPoint(anchorPoint: CGPoint.zero)
            let rotationAngle = (CGFloat(( 360/barsNumber) * i)).degreesToRadians + 180.degreesToRadians
            rectangle.transform = CGAffineTransform(rotationAngle: rotationAngle)
            
            rectangle.backgroundColor = visualizerColor
            rectangle.layer.cornerRadius = CGFloat(rectangle.bounds.width / 2)
            rectangle.tag = i
            self.addSubview(rectangle)
            rectArray.append(rectangle)
            
            var values = [5, 10, 15, 10, 5, 1]
            waveFormArray = [Int]()
            var j: Int = 0
            for _ in 0..<barsNumber {
                waveFormArray.append(values[j])
                j += 1
                if j == values.count {
                    j = 0
                }
            }
        }
    }
    
    //-----------------------------------------------------------------
    // MARK: - Animation Section
    //-----------------------------------------------------------------
    
    func animateAudioVisualizerWithChannel(level0: Float, level1: Float ) {
        DispatchQueue.main.async {
            UIView.animateKeyframes(withDuration: self.animateDuration, delay: 0, options: .beginFromCurrentState, animations: {
                for i in 0..<self.barsNumber {
                    let channelValue: Int = Int(arc4random_uniform(2))
                    
                    let wavePeak: Int = Int(arc4random_uniform(UInt32(self.waveFormArray[i])))
                    let barView = self.rectArray[i] as? CustomView
                    
                    
                    guard var barFrame = barView?.frame else { return }

                    // calculate the bar height
                    let barH = (self.frame.height / 2 ) - self.radius

                    // scale the value to 40, input value of this func range from 0-60, 60 is low and 0 is high. Then calculate the height by minimise the scaled height from bar height.
                    let scaled0 = (CGFloat(level0) * barH) / 60
                    let scaled1 = (CGFloat(level1) * barH) / 60
                    let calc0 = barH - scaled0
                    let calc1 = barH - scaled1

                    if channelValue == 0 {
                        barFrame.size.height = calc0
                    } else {
                        barFrame.size.height = calc1
                    }
                    
                    if barFrame.size.height < 4 || barFrame.size.height > ((self.frame.size.height / 2) - self.radius) {
                        barFrame.size.height = self.initialBarHeight + CGFloat(wavePeak)
                    }
                    
                    barView?.frame = barFrame
                }
            }, completion: nil)
        }
    }
    
    func calculatePoints(angle: Int, radius: CGFloat) -> CGPoint {
        let barX = midViewX + cos((angle).degreesToRadians) * radius
        let barY = midViewY + sin((angle).degreesToRadians) * radius
        
        return CGPoint(x: barX, y: barY)
    }
}



extension BinaryInteger {
    var degreesToRadians: CGFloat { return CGFloat(Int(self)) * .pi / 180 }
}

extension FloatingPoint {
    var degreesToRadians: Self { return self * .pi / 180 }
    var radiansToDegrees: Self { return self * 180 / .pi }
}

extension UIView{
    func setAnchorPoint(anchorPoint: CGPoint) {
        
        var newPoint = CGPoint(x: self.bounds.size.width * anchorPoint.x, y: self.bounds.size.height * anchorPoint.y)
        var oldPoint = CGPoint(x: self.bounds.size.width * self.layer.anchorPoint.x, y: self.bounds.size.height * self.layer.anchorPoint.y)
        
        newPoint = newPoint.applying(self.transform)
        oldPoint = oldPoint.applying(self.transform)
        
        var position : CGPoint = self.layer.position
        
        position.x -= oldPoint.x
        position.x += newPoint.x;
        
        position.y -= oldPoint.y;
        position.y += newPoint.y;
        
        self.layer.position = position;
        self.layer.anchorPoint = anchorPoint;
    }
}

我将一个空视图拖到 storyBoard 并将自定义 class 作为 audioSpectrom。

ViewController

func startAudioVisualizer() {
        visualizerTimer?.invalidate()
        visualizerTimer = nil
        visualizerTimer = Timer.scheduledTimer(timeInterval: visualizerAnimationDuration, target: self, selector: #selector(self.visualizerTimerFunc), userInfo: nil, repeats: true)
    }
    
    @objc func visualizerTimerFunc(_ timer: CADisplayLink) {
        
        let lowResults = self.audioPlayer!.averagePowerInDecibels(forChannel: 0)
        let lowResults1 = self.audioPlayer!.averagePowerInDecibels(forChannel: 1)
        audioSpectrom.animateAudioVisualizerWithChannel(level0: -lowResults, level1: -lowResults1)
    }

输出

  1. 没有动画

  1. 有动画

根据我的观察,frame的高度值和宽度值在旋转时发生了变化。意思是当我给 bar CGSize(width: 4, height: 4) 时,然后当我使用某个角度旋转时,它会改变框架的大小,如 CGSize(width: 3.563456, height: 5.67849) (不确定该值,这是一个假设)。

如何解决这个问题? 任何建议或答案将不胜感激。

编辑

func animateAudioVisualizerWithChannel(level0: Float, level1: Float ) {
        DispatchQueue.main.async {
            UIView.animateKeyframes(withDuration: self.animateDuration, delay: 0, options: .beginFromCurrentState, animations: {
                for i in 0..<self.barsNumber {
                    let channelValue: Int = Int(arc4random_uniform(2))
                    
                    let wavePeak: Int = Int(arc4random_uniform(UInt32(self.waveFormArray[i])))
                    var barView = self.rectArray[i] as? CustomView
                    
                    
                    guard let barViewUn = barView else { return }
                    
                    let barH = (self.frame.height / 2 ) - self.radius
                    let scaled0 = (CGFloat(level0) * barH) / 60
                    let scaled1 = (CGFloat(level1) * barH) / 60
                    let calc0 = barH - scaled0
                    let calc1 = barH - scaled1

                    let kSavedTransform = barViewUn.transform
                    barViewUn.transform = .identity
                    
                    if channelValue == 0 {
                        barViewUn.frame.size.height = calc0
                    } else {
                        barViewUn.frame.size.height = calc1
                    }
                    
                    if barViewUn.frame.height < CGFloat(4) || barViewUn.frame.height > ((self.frame.size.height / 2) - self.radius) {
                        barViewUn.frame.size.height = self.initialBarHeight + CGFloat(wavePeak)
                    }
                    barViewUn.transform = kSavedTransform

                    barView = barViewUn
                }
            }, completion: nil)
        }
    }

输出

运行 下面的代码片段显示输出

<a href="https://imgflip.com/gif/227xsa"><img src="https://i.imgflip.com/227xsa.gif" title="made at imgflip.com"/></a>

知道了!!

circular-visualizer

您的代码中有两个(也许三个)问题:


1. audioSpectrom.layoutSubviews()

您在 layoutSubviews 中创建新视图并将它们添加到视图层次结构中。这不是你想要做的,因为 layoutSubviews 被多次调用,你应该只将它用于布局目的。 作为一个肮脏的解决方法,我修改了 func drawVisualizer 中的代码以仅添加一次条形图:

func drawVisualizer() {
    // ... some code here
    // ...
    mainLayer.addSublayer(circleShapeLayer)

    // This will ensure to only add the bars once:
    guard rectArray.count == 0 else { return } // If we already have bars, just return


    // Draw Bars
    rectArray = [CustomView]()

    // ... Rest of the func
}

现在,它看起来差不多不错,但是最上面的栏仍然有一些污垢效果。所以你必须改变


2。 audioSectrom.animateAudioVisualizerWithChannel(level0:level1:)

在这里,您想重新计算条形图的框架。由于它们是旋转的,因此框架也会旋转,您必须应用一些数学技巧。为了避免这种情况并让您的生活更轻松,您保存旋转变换,将其设置为 .identity,修改框架,然后恢复原始旋转变换。不幸的是,这会导致一些 0 或 2pi 旋转的污垢效果,这可能是由一些舍入问题引起的。没关系,有一个更简单的解决方案: 与其修改 frame,不如修改 bounds

  • frame 是在外部(在您的情况下:旋转)坐标系中测量的
  • bounds 是在内部(未转换)坐标系中测量的

所以我干脆把animateAudioVisualizerWithChannel函数中的frame全部换成了bounds,同时也去掉了变换矩阵的保存和恢复:

func animateAudioVisualizerWithChannel(level0: Float, level1: Float ) {
    // some code before

    guard let barViewUn = barView else { return }

    let barH = (self.bounds.height / 2 ) - self.radius
    let scaled0 = (CGFloat(level0) * barH) / 60
    let scaled1 = (CGFloat(level1) * barH) / 60
    let calc0 = barH - scaled0
    let calc1 = barH - scaled1

    if channelValue == 0 {
        barViewUn.bounds.size.height = calc0
    } else {
        barViewUn.bounds.size.height = calc1
    }

    if barViewUn.bounds.height < CGFloat(4) || barViewUn.bounds.height > ((self.bounds.height / 2) - self.radius) {
        barViewUn.bounds.size.height = self.initialBarHeight + CGFloat(wavePeak)
    }

    barView = barViewUn

    // some code after
}

3。警告

顺便说一句,你应该去掉代码中的所有警告。我没有清理我的答案代码以使其与原始代码具有可比性。

例如,在 var barView = self.rectArray[i] as? CustomView 中您不需要条件转换,因为数组已经包含 CustomView 个对象。 所以,所有 barViewUn 的东西都是不必要的。 还有更多需要查找和清理的内容。