Swift 5 和 UIKit 在 2 到 3 个 UIView 之间绘制、动画和分割线

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

我有一个可能包含 2 或 3 个 UIView 的视图。

我想绘制(并可能动画化一条从较高视图的底部 MidX 到底部的线。

如果我有 3 个视图,我希望线条拆分并为它们设置动画。

如果我只有一个视图,我希望使用 UIBezierPath 和 CAShapeLayer 将线条直接转到底部视图的中间顶部

考虑到屏幕高度(4.7" -> 6.2"),我附上了图片来说明我想要实现的目标。

感谢您的帮助。

经过一些研究,我想出了这个解决方案 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")
    }
    
}

你走在正确的轨道上...

绘制“分割”线的问题是有一个起点和 两个 终点。所以,生成的动画可能不是你真正想要的。

另一种方法是使用两层 - 一层带有“左侧”分割线,一层带有“右侧”分割线,然后将它们一起制作动画。

这是一个将事物包装到“连接”视图子类中的示例。

我们将使用 3 层:1 层用于单条垂直连接线,1 层用于右侧线和左侧线。

我们还可以将路径点设置为视图的中心,以及左右边缘。这样我们就可以将前缘限制在左框的中心,将后缘限制在右框的中心。

这个视图本身看起来像这样(背景为黄色,因此我们可以看到它的框架):

或:

线条将从顶部开始动画。

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

    }
    
}

和一个展示它的示例视图控制器:

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

运行 将给出此结果:

底部的第一个按钮将切换顶部中心和顶部左-右之间的连接(每次重新运行 动画)。第二个按钮将在透明和黄色之间切换视图的背景颜色,以便我们可以看到它的框架。


编辑

如果你想要像这样的圆角“台阶”:

将上面的 layoutSubviews() 代码替换为:

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
}