Search code examples
iosswiftanimationuikit

Swift 5 and UIKit draw, animate and split lines between 2 to 3 UIViews


I have a view that may contains 2 or 3 UIViews.

I want to draw (and possibly animate a line from the bottom MidX of the higher view to the bottom one.

If I have 3 views I want the line to split and animate to both of them.

If I have a single view I want the line to go directly to the middle top of the bottom view all this using UIBezierPath and CAShapeLayer

All this considering screen height (4.7" -> 6.2") I have attached images to illustrate what I want to achieve.

Thanks for the help.enter image description here enter image description here


Solution

  • You're on the right track...

    The problem with drawing a "split" line is that there is one start point and TWO end points. So, the resulting animation may not be what you really want.

    Another approach would be to use TWO layers - one with the "left-side" split line and one with the "right-side" split line, then animate them together.

    Here's an example of wrapping things into a "Connect" view subclass.

    We'll use 3 layers: 1 for the single vertical connecting line and one each for the right-side and left-side lines.

    We can also set the path points to the center of the view, and the left and right edges. That way we can constrain the Leading edge to the center of the left-box, and the trailing edge to the center of the right-box.

    This view, by itself, will look like this (with a yellow background so we can see its frame):

    enter image description here

    or:

    enter image description here

    With the lines will be animated from the top.

    class ConnectView: UIView {
        
        // determines whether we want a single box-to-box line, or
        //  left and right split / stepped lines to two boxes
        public var single: Bool = true
        
        private let singleLineLayer = CAShapeLayer()
        private let leftLineLayer = CAShapeLayer()
        private let rightLineLayer = CAShapeLayer()
    
        private var durationFactor: CGFloat = 0
    
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        
        func commonInit() -> Void {
            
            // add and configure sublayers
            [singleLineLayer, leftLineLayer, rightLineLayer].forEach { lay in
                layer.addSublayer(lay)
                lay.lineWidth = 4
                lay.strokeColor = UIColor.blue.cgColor
                lay.fillColor = UIColor.clear.cgColor
            }
            
        }
    
        override func layoutSubviews() {
            super.layoutSubviews()
    
            // for readablility, define the points for our lines
            let topCenter = CGPoint(x: bounds.midX, y: 0)
            let midCenter = CGPoint(x: bounds.midX, y: bounds.midY)
            let botCenter = CGPoint(x: bounds.midX, y: bounds.maxY)
            let midLeft = CGPoint(x: bounds.minX, y: bounds.midY)
            let midRight = CGPoint(x: bounds.maxX, y: bounds.midY)
            let botLeft = CGPoint(x: bounds.minX, y: bounds.maxY)
            let botRight = CGPoint(x: bounds.maxX, y: bounds.maxY)
    
            let singleBez = UIBezierPath()
            let leftBez = UIBezierPath()
            let rightBez = UIBezierPath()
    
            // vertical line
            singleBez.move(to: topCenter)
            singleBez.addLine(to: botCenter)
            
            // split / stepped line to the left
            leftBez.move(to: topCenter)
            leftBez.addLine(to: midCenter)
            leftBez.addLine(to: midLeft)
            leftBez.addLine(to: botLeft)
    
            // split / stepped line to the right
            rightBez.move(to: topCenter)
            rightBez.addLine(to: midCenter)
            rightBez.addLine(to: midRight)
            rightBez.addLine(to: botRight)
            
            // set the layer paths
            //  initializing strokeEnd to 0 for all three
            
            singleLineLayer.path = singleBez.cgPath
            singleLineLayer.strokeEnd = 0
    
            leftLineLayer.path = leftBez.cgPath
            leftLineLayer.strokeEnd = 0
            
            rightLineLayer.path = rightBez.cgPath
            rightLineLayer.strokeEnd = 0
            
            // calculate total line lengths (in points)
            //  so we can adjust the "draw speed" in the animation
            let singleLength = botCenter.y - topCenter.y
            let doubleLength = singleLength + (midCenter.x - midLeft.x)
            durationFactor = singleLength / doubleLength
        }
        
        public func doAnim() -> Void {
    
            // reset the animations
            [singleLineLayer, leftLineLayer, rightLineLayer].forEach { lay in
                lay.removeAllAnimations()
                lay.strokeEnd = 0
            }
            
            let animation = CABasicAnimation(keyPath: "strokeEnd")
            
            animation.fromValue = 0.0
            animation.toValue = 1.0
            animation.duration = 2.0
            animation.fillMode = .forwards
            animation.isRemovedOnCompletion = false
            
            if self.single {
                // we want the apparent drawing speed to be the same
                //  for a single line as for a split / stepped line
                //  so change the animation duration
                animation.duration *= durationFactor
                // animate the single line layer
                self.singleLineLayer.add(animation, forKey: animation.keyPath)
            } else {
                // animate the both left and right line layers
                self.leftLineLayer.add(animation, forKey: animation.keyPath)
                self.rightLineLayer.add(animation, forKey: animation.keyPath)
            }
    
        }
        
    }
    

    and a sample view controller showing it in action:

    class ConnectTestViewController: UIViewController {
        
        let vTop = UIView()
        let vLeft = UIView()
        let vCenter = UIView()
        let vRight = UIView()
    
        let testConnectView = ConnectView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // give the 4 views different background colors
            //  add them as subviews
            //  make them all 100x100 points
            let colors: [UIColor] = [
                .systemYellow,
                .systemRed, .systemGreen, .systemBlue,
            ]
            for (v, c) in zip([vTop, vLeft, vCenter, vRight], colors) {
                v.backgroundColor = c
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
                v.widthAnchor.constraint(equalToConstant: 100.0).isActive = true
                v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
            }
    
            // add the clear-background Connect View
            testConnectView.backgroundColor = .clear
            testConnectView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(testConnectView)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                // horizontally center the top box near the top
                vTop.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                vTop.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                
                // horizontally center the center box, 200-pts below the top box
                vCenter.topAnchor.constraint(equalTo: vTop.bottomAnchor, constant: 200.0),
                vCenter.centerXAnchor.constraint(equalTo: g.centerXAnchor),
    
                // align tops of left and right boxes with center box
                vLeft.topAnchor.constraint(equalTo: vCenter.topAnchor),
                vRight.topAnchor.constraint(equalTo: vCenter.topAnchor),
    
                // position left and right boxes to left and right of center box
                vLeft.trailingAnchor.constraint(equalTo: vCenter.leadingAnchor, constant: -20.0),
                vRight.leadingAnchor.constraint(equalTo: vCenter.trailingAnchor, constant: 20.0),
                
                // constrain Connect View
                //  Top to Bottom of Top box
                testConnectView.topAnchor.constraint(equalTo: vTop.bottomAnchor),
                //  Bottom to Top of the row of 3 boxes
                testConnectView.bottomAnchor.constraint(equalTo: vCenter.topAnchor),
                //  Leading to CenterX of Left box
                testConnectView.leadingAnchor.constraint(equalTo: vLeft.centerXAnchor),
                //  Trailing to CenterX of Right box
                testConnectView.trailingAnchor.constraint(equalTo: vRight.centerXAnchor),
    
            ])
            
            // add a couple buttons at the bottom
            let stack = UIStackView()
            stack.spacing = 20
            stack.distribution = .fillEqually
            stack.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stack)
            
            ["Run Anim", "Show/Hide"].forEach { str in
                let b = UIButton()
                b.setTitle(str, for: [])
                b.backgroundColor = .red
                b.setTitleColor(.white, for: .normal)
                b.setTitleColor(.lightGray, for: .highlighted)
                b.addTarget(self, action: #selector(buttonTap(_:)), for: .touchUpInside)
                stack.addArrangedSubview(b)
            }
            NSLayoutConstraint.activate([
                stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                stack.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
                stack.heightAnchor.constraint(equalToConstant: 50.0),
            ])
            
        }
        
        @objc func buttonTap(_ sender: Any?) -> Void {
            guard let b = sender as? UIButton,
                  let t = b.currentTitle
            else {
                return
            }
            if t == "Run Anim" {
                // tap button to toggle between
                //  Top-to-Middle box line or
                //  Top-to-SideBoxes split / stepped line
                testConnectView.single.toggle()
                
                // run the animation
                testConnectView.doAnim()
            } else {
                // toggle background of Connect View between
                //  clear and yellow
                testConnectView.backgroundColor = testConnectView.backgroundColor == .clear ? .yellow : .clear
            }
        }
        
    }
    

    Running that will give this result:

    enter image description here

    enter image description here

    The first button at the bottom will toggle the connection between Top-Center and Top-Left-Right (re-running the animation each time). The second button will toggle the view's background color between clear and yellow so we can see its frame.


    Edit

    If you want rounded "step" corners that look like this:

    enter image description here

    replace the layoutSubviews() code above with this:

    override func layoutSubviews() {
        super.layoutSubviews()
        
        // for readablility, define the points for our lines
        let topCenter = CGPoint(x: bounds.midX, y: 0)
        let midCenter = CGPoint(x: bounds.midX, y: bounds.midY)
        let botCenter = CGPoint(x: bounds.midX, y: bounds.maxY)
        let midLeft = CGPoint(x: bounds.minX, y: bounds.midY)
        let midRight = CGPoint(x: bounds.maxX, y: bounds.midY)
        let botLeft = CGPoint(x: bounds.minX, y: bounds.maxY)
        let botRight = CGPoint(x: bounds.maxX, y: bounds.maxY)
        
        let singleBez = UIBezierPath()
        let leftBez = UIBezierPath()
        let rightBez = UIBezierPath()
        
        // vertical line
        singleBez.move(to: topCenter)
        singleBez.addLine(to: botCenter)
    
        // rounded "step" corners
        let radius: CGFloat = 20.0
    
        let leftArcP = CGPoint(x: midLeft.x + radius, y: midLeft.y)
        let leftArcC = CGPoint(x: midLeft.x + radius, y: midLeft.y + radius)
    
        let rightArcP = CGPoint(x: midRight.x - radius, y: midRight.y)
        let rightArcC = CGPoint(x: midRight.x - radius, y: midRight.y + radius)
        
        // split / stepped line to the left
        leftBez.move(to: topCenter)
        leftBez.addLine(to: midCenter)
        leftBez.addLine(to: leftArcP)
        leftBez.addArc(withCenter: leftArcC, radius: radius, startAngle: .pi * 1.5, endAngle: .pi, clockwise: false)
        leftBez.addLine(to: botLeft)
        
        // split / stepped line to the right
        rightBez.move(to: topCenter)
        rightBez.addLine(to: midCenter)
        rightBez.addLine(to: rightArcP)
        rightBez.addArc(withCenter: rightArcC, radius: radius, startAngle: .pi * 1.5, endAngle: 0, clockwise: true)
        rightBez.addLine(to: botRight)
        
        // set the layer paths
        //  initializing strokeEnd to 0 for all three
        
        singleLineLayer.path = singleBez.cgPath
        singleLineLayer.strokeEnd = 0
        
        leftLineLayer.path = leftBez.cgPath
        leftLineLayer.strokeEnd = 0
        
        rightLineLayer.path = rightBez.cgPath
        rightLineLayer.strokeEnd = 0
        
        // calculate total line lengths (in points)
        //  so we can adjust the "draw speed" in the animation
        let singleLength = botCenter.y - topCenter.y
        let doubleLength = singleLength + (midCenter.x - midLeft.x)
        durationFactor = singleLength / doubleLength
    }