Search code examples
iosuiimageviewcabasicanimation

Animating 3 dots in swift iOS


I have a label and an image view. My goal is to have 3 dots animating infront of the label and to be at the foot of the label at the end as shown here desired result

I have been able to make this design with the dots moving fine on 12promax only however I can not figure out how to make this work always on different phone screen sizes as intended. How do I code it so no matter the screen size or uilabel fontsize It will achieve the same result?

Animate ImageView (3 dots)

  func showAnimatingDotsInImageView(dots: UIImageView)
 {
        let newX = view.bounds.width / 896 * 20
        let lay = CAReplicatorLayer()
        lay.frame = CGRect(x: newX,y: 0,width: dots.bounds.width,height: dots.bounds.height)
        let bar = CALayer()
        bar.frame = CGRect(x: 0,y: (dots.bounds.height/2) + 8 ,width: 8,height: 8)  //make the objs smaller or bigger
        bar.cornerRadius = bar.frame.width / 2  //make a circle, if you uncomment this you will get rects
        bar.backgroundColor = UIColor.black.cgColor   //colour of the objs
        lay.addSublayer(bar)
        lay.instanceCount = 3   //How many instances / objs do you want to see
        lay.instanceTransform = CATransform3DMakeTranslation(15, 0, 0) //1st arg is the spacing between the instances
        let anim = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
        anim.fromValue = 1.0
        anim.toValue = 0.2
        anim.duration = 1
        anim.repeatCount = .infinity
        bar.add(anim, forKey: nil)
        lay.instanceDelay = anim.duration / Double(lay.instanceCount)
    
        dots.layer.addSublayer(lay)    // add to the view

        }

'Retrieving boxes from the main menu' is a UILabel and the dots are animating on an UIImageView


Solution

  • You're pretty close... just a few changes should get you where you want to be.

    If you have a UILabel and a UIImageView (or plain UIView) constrained to the label's right-edge, you should be able to position your CAReplicatorLayer without having to worry about "screen size."

    Take a look at how I have modified your code:

    // baseline = to put the bottom of the dots at the baseline of the text in the label
    // dotXOffset = gap between end of label and first dot
    // dotSize = dot width and height
    // dotSpacing = gap between dots
    func showAnimatingDotsInImageView(dotsView: UIView, baseline: CGFloat, dotXOffset: CGFloat, dotSize: CGFloat, dotSpacing: CGFloat) {
        let lay = CAReplicatorLayer()
        let bar = CALayer()
        bar.frame = CGRect(x: dotXOffset, y: baseline - dotSize, width: dotSize, height: dotSize)
        bar.cornerRadius = bar.frame.width / 2  // we want round dots
        bar.backgroundColor = UIColor.black.cgColor
        lay.addSublayer(bar)
        lay.instanceCount = 3   //How many instances / objs do you want to see
        lay.instanceTransform = CATransform3DMakeTranslation(dotSpacing, 0, 0) //1st arg is the spacing between the instances
        let anim = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
        anim.fromValue = 1.0
        anim.toValue = 0.2
        anim.duration = 1
        anim.repeatCount = .infinity
        bar.add(anim, forKey: nil)
        lay.instanceDelay = anim.duration / Double(lay.instanceCount)
        dotsView.layer.addSublayer(lay)    // add to the view
    }
    

    Here's a complete example:

    class SimpleViewController: UIViewController {
        
        let testLabel = UILabel()
        let testDotsView = UIView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            testLabel.font = .systemFont(ofSize: 24.0)
            
            testLabel.text = "Retrieving boxes"
            
            // so we can see the label frame
            testLabel.backgroundColor = .cyan
            
            testLabel.translatesAutoresizingMaskIntoConstraints = false
            testDotsView.translatesAutoresizingMaskIntoConstraints = false
            
            view.addSubview(testLabel)
            view.addSubview(testDotsView)
            
            // always respect safe area
            let g = view.safeAreaLayoutGuide
            
            NSLayoutConstraint.activate([
                
                // let's constrain the label
                //  40-pts from Leading
                //  40-pts from Bottom
                testLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                testLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
                
                // constrain dots view to
                //  Top of label
                //  Trailing of label
                testDotsView.topAnchor.constraint(equalTo: testLabel.topAnchor),
                testDotsView.leadingAnchor.constraint(equalTo: testLabel.trailingAnchor, constant: 0.0),
                // dots image view Width and Height can be 0 (we can draw the layer outside the bounds)
                testDotsView.heightAnchor.constraint(equalToConstant: 0.0),
                testDotsView.widthAnchor.constraint(equalToConstant: 0.0),
                
            ])
            
            // get the label font's baseline y-value
            let bl: CGFloat = testLabel.font.ascender
            showAnimatingDotsInImageView(dotsView: testDotsView, baseline: bl, dotXOffset: 4.0, dotSize: 4.0, dotSpacing: 8.0)
        }
        
        // baseline = to put the bottom of the dots at the baseline of the text in the label
        // dotXOffset = gap between end of label and first dot
        // dotSize = dot width and height
        // dotSpacing = gap between dots
        func showAnimatingDotsInImageView(dotsView: UIView, baseline: CGFloat, dotXOffset: CGFloat, dotSize: CGFloat, dotSpacing: CGFloat) {
            let lay = CAReplicatorLayer()
            let bar = CALayer()
            bar.frame = CGRect(x: dotXOffset, y: baseline - dotSize, width: dotSize, height: dotSize)
            bar.cornerRadius = bar.frame.width / 2  // we want round dots
            bar.backgroundColor = UIColor.black.cgColor
            lay.addSublayer(bar)
            lay.instanceCount = 3   //How many instances / objs do you want to see
            lay.instanceTransform = CATransform3DMakeTranslation(dotSpacing, 0, 0) //1st arg is the spacing between the instances
            let anim = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
            anim.fromValue = 1.0
            anim.toValue = 0.2
            anim.duration = 1
            anim.repeatCount = .infinity
            bar.add(anim, forKey: nil)
            lay.instanceDelay = anim.duration / Double(lay.instanceCount)
            dotsView.layer.addSublayer(lay)    // add to the view
        }
    
    }
    

    Edit - in response to "krishan kumar" comment...

    To get the animation to resume when returning from the background, you'll want to add a Notification Observer.

    It will be much easier to use a custom UIView subclass for the "animated dots" view, so here's a quick example:

    class DotsView: UIView {
        
        // baseline = to put the bottom of the dots at the baseline of the text in the label
        // dotXOffset = gap between end of label and first dot
        // dotSize = dot width and height
        // dotSpacing = gap between dots
    
        public var baseline: CGFloat = 0
        public var dotXOffset: CGFloat = 4.0
        public var dotSize: CGFloat = 4.0
        public var dotSpacing: CGFloat = 8.0
    
        private let lay = CAReplicatorLayer()
        private let bar = CALayer()
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
            lay.addSublayer(bar)
            layer.addSublayer(lay)
        }
        public func beginAnimating() {
            bar.frame = CGRect(x: dotXOffset, y: baseline - dotSize, width: dotSize, height: dotSize)
            // we want round dots
            bar.cornerRadius = bar.frame.width / 2.0
            bar.backgroundColor = UIColor.black.cgColor
            //How many instances / objs we want to see
            lay.instanceCount = 3
            //1st arg is the spacing between the instances
            lay.instanceTransform = CATransform3DMakeTranslation(dotSpacing, 0, 0)
            let anim = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
            anim.fromValue = 1.0
            anim.toValue = 0.2
            anim.duration = 1
            anim.repeatCount = .infinity
            bar.add(anim, forKey: nil)
            // so the dots animate in sequence
            lay.instanceDelay = anim.duration / Double(lay.instanceCount)
        }
        public func stopAnimating() {
            layer.removeAllAnimations()
        }
    }
    

    and an example controller showing how to use it, including starting/stopping the animation when the app moves between foreground and background:

    class SimpleViewController: UIViewController {
        
        let testLabel = UILabel()
        
        // custom DotsView
        let testDotsView = DotsView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBackground
            
            testLabel.font = .systemFont(ofSize: 24.0)
            
            testLabel.text = "Retrieving boxes"
            
            // so we can see the label frame
            testLabel.backgroundColor = .cyan
            
            testLabel.translatesAutoresizingMaskIntoConstraints = false
            testDotsView.translatesAutoresizingMaskIntoConstraints = false
            
            view.addSubview(testLabel)
            view.addSubview(testDotsView)
            
            // always respect safe area
            let g = view.safeAreaLayoutGuide
            
            NSLayoutConstraint.activate([
                
                // let's constrain the label
                //  40-pts from Leading
                //  40-pts from Bottom
                testLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                testLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
                
                // constrain dots view to
                //  Top of label
                //  Trailing of label
                testDotsView.topAnchor.constraint(equalTo: testLabel.topAnchor),
                testDotsView.leadingAnchor.constraint(equalTo: testLabel.trailingAnchor, constant: 0.0),
                // dots image view Width and Height can be 0 (we can draw the layer outside the bounds)
                testDotsView.heightAnchor.constraint(equalToConstant: 0.0),
                testDotsView.widthAnchor.constraint(equalToConstant: 0.0),
                
            ])
            
            // get the label font's baseline y-value
            testDotsView.baseline = testLabel.font.ascender
            
            // use defaults or set values here
            //testDotsView.dotXOffset = 4.0
            //testDotsView.dotSize = 4.0
            //testDotsView.dotSpacing = 8.0
            
            testDotsView.beginAnimating()
    
            // we want to
            //  Stop the Dots animation when the app goes into the Background, and
            //  Start the Dots animation when the app Enters the Foreground
            NotificationCenter.default.addObserver(self, selector: #selector(myEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
            NotificationCenter.default.addObserver(self, selector: #selector(myEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
        }
        
        @objc func myEnterBackground() {
            testDotsView.stopAnimating()
        }
        @objc func myEnterForeground() {
            testDotsView.beginAnimating()
        }
        
    }