iosswiftuiviewanimationcgaffinetransform

How to make Circular audio visualizer in swift?


I want to make a visualizer like this Circular visualizer, click the green flag to see the animation.

In my project first I draw a circle, I calculate the points on the circle to draw the visualizer bars, I rotate the view to make the bars feels like circle. I use StreamingKit to stream live radio. StreamingKit provides the live audio power in decibels. Then I animate the visualizer bars. But when I rotate the view the height and width changes according to the angle I rotate. But the bounds value not change (I know the frame depends on 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;
    }
}

I drag a empty view to storyBoard and give custom class as 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)
    }

OUTPUT

  1. Without animation

enter image description here

  1. With animation

enter image description here

In my observation, the height value and width value of frame changed when rotates. Means when I give CGSize(width: 4, height: 4) to bar, then when I rotate using some angle it changes the size of frame like CGSize(width: 3.563456, height: 5.67849) (not sure for the value, it's an assumption).

How to resolve this problem? Any suggestions or answers will be appreciated.

Edit

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)
        }
    }

Output

Run the below code snippet show the output

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

GOT IT!!

circular-visualizer


Solution

  • There are two (maybe three) issues in your code:


    1. audioSpectrom.layoutSubviews()

    You create new views in layoutSubviews and add them to the view hierarchy. This is not what you are intened to do, because layoutSubviews is called multiple times and you should use it only for layouting purposes. As a dirty work-around, I modified the code in the func drawVisualizer to only add the bars once:

    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
    }
    

    Now, it almost looks good, but there are still some dirt effects with the topmost bar. So you'll have to change


    2. audioSectrom.animateAudioVisualizerWithChannel(level0:level1:)

    Here, you want to recalculate the frame of the bars. Since they are rotated, the frame also is rotated, and you'd have to apply some mathematical tricks. To avoid this adn make your life more easy, you save the rotated transform, set it to .identity, modify the frame, and then restore the original rotated transform. Unfortunately, this causes some dirt effects with rotations of 0 or 2pi, maybe caused by some rounding issues. Never mind, there is a much more simple solution: Instead of modifiying the frame, you better modify the bounds.

    So I simply replaced all the frames with bounds in the function animateAudioVisualizerWithChannel and also removed the saving and restoring of the transformation matrix:

    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. Warnings

    By the way, you should get rid of all the warnings in your code. I didn't clean up my answer code to keep it comparable with the orginal code.

    For example, in var barView = self.rectArray[i] as? CustomView you don't need the conditional cast, because the array already contains CustomView objects. So, all the barViewUn stuff is unnecessary. Much more to find and to clean up.