Home > Software engineering >  Swift 5 and UIKit draw, animate and split lines between 2 to 3 UIViews
Swift 5 and UIKit draw, animate and split lines between 2 to 3 UIViews

Time:12-04

have a view that may contains 2 or 3 UIViews.

I want to draw (and possibly animate) 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.

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

CodePudding user response:

Well after some research I have come up with this solution for Swift 5:

class ViewController: UIViewController {
    
    @IBOutlet weak var someView: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let start = CGPoint(x: self.someView.bounds.midX, y: self.someView.bounds.maxY)
        let end = CGPoint(x: self.someView.layer.bounds.midX, y: (UIScreen.main.bounds.height / 2) - 100)
        
        let linePath = UIBezierPath()
        linePath.move(to: start)
        linePath.addLine(to: end)
        
        linePath.addLine(to: CGPoint(x: -50, y: (UIScreen.main.bounds.height / 2) - 100))
        linePath.move(to: end)
        linePath.addLine(to: CGPoint(x: 250, y: (UIScreen.main.bounds.height / 2) - 100))

        linePath.addLine(to: CGPoint(x: lowerViewA.x, y: lowerViewA.y))
        linePath.move(to: CGPoint(x: -50, y: (UIScreen.main.bounds.height / 2) - 100))
        linePath.addLine(to: CGPoint(x: lowerViewB.x, y: lowerViewB.y))

        let shapeLayer = CAShapeLayer()
        shapeLayer.path = linePath.cgPath
        
        shapeLayer.fillColor = UIColor.clear.cgColor
        shapeLayer.strokeColor = UIColor.green.cgColor
        shapeLayer.lineWidth = 2 
        shapeLayer.lineJoin = CAShapeLayerLineJoin.bevel

        self.someView.layer.addSublayer(shapeLayer)
        
        //Basic animation if you want to animate the line drawing.
        let pathAnimation: CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
        pathAnimation.duration = 4.0
        pathAnimation.fromValue = 0.0
        pathAnimation.toValue = 1.0
        //Animation will happen right away
        shapeLayer.add(pathAnimation, forKey: "strokeEnd")
    }
    
}

CodePudding user response:

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.

  • Related