Paragon
Paragon

Reputation: 1012

UIView horizontal bar animation in swift

I am working on this animation where a number will be received every second and progress bar has to fill or go down based on the double value.

I have created the views and have added all the views in the UIStackView. Also made the outlet collection for all the views. (sorting them by the tag and making them round rect).

I can loop the views and change their background color but trying to see if there is a better way to do it. Any suggestions?

Thanks

Upvotes: 2

Views: 1735

Answers (2)

allenh
allenh

Reputation: 6622

I'm a firm believer in learning through solving organic problems and slowly building my global knowledge on a subject. So I'm afraid I don't have any good tutorials for you.

Here is an example that will jump start you, though.

// For participating in Simulator's "slow animations"
@_silgen_name("UIAnimationDragCoefficient") func UIAnimationDragCoefficient() -> Float

import UIKit

@IBDesignable
class VerticalProgessView: UIControl {

    @IBInspectable
    var numberOfSegments: UInt = 0

    @IBInspectable
    var verticalSegmentGap: CGFloat = 4.0

    @IBInspectable
    var outerColor: UIColor = UIColor(red: 33, green: 133, blue: 109)

    @IBInspectable
    var unfilledColor: UIColor = UIColor(red: 61, green: 202, blue: 169)

    @IBInspectable
    var filledColor: UIColor = UIColor.white

    private var _progress: Float = 0.25
    @IBInspectable
    open var progress: Float {
        get {
            return _progress
        }
        set {
            self.setProgress(newValue, animated: false)
        }
    }

    let progressLayer = CALayer()

    let maskLayer = CAShapeLayer()

    var skipLayoutSubviews = false

    open func setProgress(_ progress: Float, animated: Bool) {

        if progress < 0 {
            _progress = 0
        } else if progress > 1.0 {
            _progress = 1
        } else {
            // Clamp the percentage to discreet values
            let discreetPercentageDistance: Float = 1.0 / 28.0
            let nearestProgress = discreetPercentageDistance * round(progress/discreetPercentageDistance)

            _progress = nearestProgress
        }

        CATransaction.begin()
        CATransaction.setCompletionBlock { [weak self] in
            self?.skipLayoutSubviews = false
        }
        if !animated {
            CATransaction.setDisableActions(true)
        } else {
            CATransaction.setAnimationDuration(0.25 * Double(UIAnimationDragCoefficient()))
        }

        let properties = progressLayerProperties()
        progressLayer.bounds = properties.bounds
        progressLayer.position = properties.position

        skipLayoutSubviews = true
        CATransaction.commit() // This triggers layoutSubviews
    }

    override func prepareForInterfaceBuilder() {
        awakeFromNib()
    }

    override func awakeFromNib() {
        super.awakeFromNib()

        self.backgroundColor = UIColor.clear

        self.layer.backgroundColor = unfilledColor.cgColor

        // Initialize and add the progressLayer
        let properties = progressLayerProperties()
        progressLayer.bounds = properties.bounds
        progressLayer.position = properties.position
        progressLayer.backgroundColor = filledColor.cgColor
        self.layer.addSublayer(progressLayer)

        // Initialize and add the maskLayer (it has the holes)
        maskLayer.frame = self.layer.bounds
        maskLayer.fillColor = outerColor.cgColor
        maskLayer.fillRule = kCAFillRuleEvenOdd
        maskLayer.path = maskPath(for: maskLayer.bounds)
        self.layer.addSublayer(maskLayer)

        // Layer hierarchy

        // self.maskLayer
        // self.progressLayer
        // self.layer
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        if skipLayoutSubviews {
            // Crude but effective, not fool proof though
            skipLayoutSubviews = false
            return
        }

        let timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        // Doesn't work for 180° rotations
        let duration = UIApplication.shared.statusBarOrientationAnimationDuration * Double(UIAnimationDragCoefficient())

        CATransaction.begin()
        CATransaction.setAnimationTimingFunction(timingFunction)
        CATransaction.setAnimationDuration(duration)

        let properties = progressLayerProperties()
        progressLayer.bounds = properties.bounds
        progressLayer.position = properties.position

        let size = self.bounds.size

        let anchorPoint = CGPoint(x: 0.5, y: 1.0)
        maskLayer.anchorPoint = anchorPoint
        maskLayer.bounds = CGRect(origin: CGPoint.zero, size: size)
        maskLayer.position = CGPoint(x: size.width * anchorPoint.x, y: size.height * anchorPoint.y)

        // Animate the segments

        let pathChangeAnimation = CAKeyframeAnimation(keyPath: "path")
        let finalPath = maskPath(for: maskLayer.bounds)

        pathChangeAnimation.values = [maskLayer.path!, finalPath]
        pathChangeAnimation.keyTimes = [0, 1]
        pathChangeAnimation.timingFunction = timingFunction
        pathChangeAnimation.duration = duration
        pathChangeAnimation.isRemovedOnCompletion = true
        maskLayer.add(pathChangeAnimation, forKey: "pathAnimation")

        CATransaction.setCompletionBlock {
            // CAAnimation's don't actually change the value
            self.maskLayer.path = finalPath
        }

        CATransaction.commit()
    }

    // Provides a path that will mask out all the holes to show self.layer and the progressLayer behind
    private func maskPath(for rect: CGRect) -> CGPath {

        let horizontalSegmentGap: CGFloat = 5.0

        let segmentWidth = rect.width - horizontalSegmentGap * 2
        let segmentHeight = rect.height/CGFloat(numberOfSegments) - verticalSegmentGap + verticalSegmentGap/CGFloat(numberOfSegments)

        let segmentSize = CGSize(width: segmentWidth, height: segmentHeight)
        let segmentRect = CGRect(x: horizontalSegmentGap, y: 0, width: segmentSize.width, height: segmentSize.height)

        let path = CGMutablePath()
        for i in 0..<numberOfSegments {

            // Literally, just move it down by the y value here
            // this simplifies the math of calculating the starting points and what not
            let transform = CGAffineTransform.identity.translatedBy(x: 0, y: (segmentSize.height + verticalSegmentGap) * CGFloat(i))

            let segmentPath = UIBezierPath(roundedRect: segmentRect, cornerRadius: segmentSize.height / 2)
            segmentPath.apply(transform)

            path.addPath(segmentPath.cgPath)
        }

        // Without the outerPath, we'll end up with a bunch of squircles instead of a bunch of holes
        let outerPath = CGPath(rect: rect, transform: nil)

        path.addPath(outerPath)

        return path
    }

    /// Provides the current and correct bounds and position for the progressLayer
    private func progressLayerProperties() -> (bounds: CGRect, position: CGPoint) {
        let frame = self.bounds
        let height = frame.height * CGFloat(progress)
        let y = frame.height * CGFloat(1 - progress)
        let width = frame.width

        let anchorPoint = maskLayer.anchorPoint

        let bounds = CGRect(x: 0, y: 0, width: width, height: height)
        let position = CGPoint(x: 0 + width * anchorPoint.x, y: y + height * anchorPoint.x)

        return (bounds: bounds, position: position)
    }

    // TODO: Implement functions to further mimic UIProgressView
}

extension UIColor {
    convenience init(red: Int, green: Int, blue: Int) {
        self.init(red: CGFloat(red) / 255.0, green: CGFloat(green) / 255.0, blue: CGFloat(blue) / 255.0, alpha: 1)
    }
}

Using in a storyboard

enter image description here

Enjoy the magic

enter image description here

Upvotes: 1

agibson007
agibson007

Reputation: 4373

So how you are doing it is fine. Here would be two different ways. The first with Core Graphics. You may want to update methods and even make the color gradient in the sublayer.

1st Way

import UIKit

class Indicator: UIView {

    var padding : CGFloat = 5.0
    var minimumSpace : CGFloat = 4.0
    var indicators : CGFloat = 40
    var indicatorColor : UIColor = UIColor.lightGray
    var filledIndicatorColor = UIColor.blue

    var currentProgress = 0.0
    var radiusFactor : CGFloat = 0.25
    var fillReversed = false

    override init(frame: CGRect) {
        super.init(frame: frame)
        setUp(animated: false)

    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setUp(animated: false)
        backgroundColor = UIColor.green
    }

    func updateProgress(progress:Double, animated:Bool) {
        currentProgress = progress
        setUp(animated: animated)
    }

    private func setUp(animated:Bool){

        // internal space
        let neededPadding = (indicators - 1) * minimumSpace
        //calculate height and width minus padding and space since vertical
        let height = (bounds.height - neededPadding - (padding * 2.0)) / indicators
        let width = bounds.width - padding * 2.0

        if animated == true{
            let trans = CATransition()
            trans.type = kCATransitionFade
            trans.duration = 0.5
            self.layer.add(trans, forKey: nil)
        }
        layer.sublayers?.removeAll()
        for i in 0...Int(indicators - 1.0){
            let indicatorLayer = CALayer()
            indicatorLayer.frame = CGRect(x: padding, y: CGFloat(i) * height + padding + (minimumSpace * CGFloat(i)), width: width, height: height)
            //haha i don't understand my logic below but it works hahaha
            // i know it has to go backwards
            if fillReversed{
                if CGFloat(1 - currentProgress) * self.bounds.height < indicatorLayer.frame.origin.y{
                    indicatorLayer.backgroundColor = filledIndicatorColor.cgColor
                }else{
                    indicatorLayer.backgroundColor = indicatorColor.cgColor
                }
            }else{
                if CGFloat(currentProgress) * self.bounds.height > indicatorLayer.frame.origin.y{
                     indicatorLayer.backgroundColor = indicatorColor.cgColor
                }else{
                    indicatorLayer.backgroundColor = filledIndicatorColor.cgColor

                }
            }

            indicatorLayer.cornerRadius = indicatorLayer.frame.height * radiusFactor
            indicatorLayer.masksToBounds = true
            self.layer.addSublayer(indicatorLayer)
        }
    }

    //handle rotation
    override func layoutSubviews() {
        super.layoutSubviews()
        setUp(animated: false)
    }
}

The second way is using CAShapeLayer and the benefit is that the progress will get a natural animation.

    import UIKit

class Indicator: UIView {

    var padding : CGFloat = 5.0
    var minimumSpace : CGFloat = 4.0
    var indicators : CGFloat = 40
    var indicatorColor : UIColor = UIColor.lightGray
    var filledIndicatorColor = UIColor.blue

    var currentProgress = 0.0
    var radiusFactor : CGFloat = 0.25
    private var progressLayer : CALayer?
    private var shapeHoles : CAShapeLayer?

    override init(frame: CGRect) {
        super.init(frame: frame)
        transparentDotsAndProgress()

    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        transparentDotsAndProgress()
    }

    func updateProgress(progress:Double) {
        if progress <= 1 && progress >= 0{
            currentProgress = progress
            transparentDotsAndProgress()
        }
    }

    //handle rotation
    override func layoutSubviews() {
        super.layoutSubviews()
        transparentDotsAndProgress()
    }

    func transparentDotsAndProgress(){
        self.layer.masksToBounds = true

        let neededPadding = (indicators - 1) * minimumSpace
        //calculate height and width minus padding and space since vertical
        let height = (bounds.height - neededPadding - (padding * 2.0)) / indicators
        let width = bounds.width - padding * 2.0

        let path = UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: self.bounds.width, height: self.bounds.height), cornerRadius: 0)
        path.usesEvenOddFillRule = true
        for i in 0...Int(indicators - 1.0){
            let circlePath = UIBezierPath(roundedRect: CGRect(x: padding, y: CGFloat(i) * height + padding + (minimumSpace * CGFloat(i)), width: width, height: height), cornerRadius: height * radiusFactor)
            path.append(circlePath)
        }


        if progressLayer == nil{
            progressLayer = CALayer()
            progressLayer?.backgroundColor = filledIndicatorColor.cgColor
            self.layer.addSublayer(progressLayer!)

        }

        progressLayer?.frame =  CGRect(x: 0, y: -self.bounds.height - padding + CGFloat(currentProgress) * self.bounds.height, width: bounds.width, height: bounds.height)


        self.shapeHoles?.removeFromSuperlayer()
        shapeHoles = CAShapeLayer()
        shapeHoles?.path = path.cgPath
        shapeHoles?.fillRule = kCAFillRuleEvenOdd
        shapeHoles?.fillColor = UIColor.white.cgColor
        shapeHoles?.strokeColor = UIColor.clear.cgColor
        self.layer.backgroundColor = indicatorColor.cgColor
        self.layer.addSublayer(shapeHoles!)


    }

}

Both of these ways should work but the advantage of the CAShapeLayer is you get a natural animation.

Upvotes: 2

Related Questions