iosuiscrollviewuikitcalayercashapelayer

Synchronize UIView CALayer draw with UIScrollView


I'm trying to make an application that will draw on top of a pdf. It is required that graphic shapes be tied to a specific position on the page.

To solve the problem, I use PDFView and CALayer, listen to the events of the internal UIScrollView scrollViewDidScroll and scrollViewDidZoom to redraw the CALayer

I found two ways to draw:

  1. Use the "path" property of CAShapeLayer (example 1)
  2. Override the "draw" method of CALayer (example 2)

I noticed that in the second case the figure lags noticeably behind the pdf document when scrolling or zooming.

The question is, what is so “magic” done in the “path” method that leads to instant redrawing? Is it possible to modify the “draw” method so that it works as synchronously as “path”? And how stable will the "path" method work in the future?

Check out the project to compile&run: https://github.com/alzhuravlev/DrawTest.git

Sample code:

class AnnotationsLayer: CAShapeLayer {
...

  override func draw(in ctx: CGContext) {
        guard let backgroundView else { return }

        ctx.setFillColor(UIColor.red.withAlphaComponent(0.4).cgColor)
        ctx.setStrokeColor(UIColor.green.withAlphaComponent(1.0).cgColor)
        ctx.setLineWidth(10)

        backgroundView.pages.forEach { page in
            let p1 = backgroundView.convert(point: CGPoint(x: page.size.width/3, y: page.size.height/3), from: page.index)
            let p2 = backgroundView.convert(point: CGPoint(x: page.size.width/3*2, y: page.size.height/3*2), from: page.index)

            ctx.move(to: CGPoint(x: p1.x, y: p1.y))
            ctx.addLine(to: CGPoint(x: p2.x, y: p1.y))
            ctx.addLine(to: CGPoint(x: p2.x, y: p2.y))
            ctx.addLine(to: CGPoint(x: p1.x, y: p2.y))
            ctx.closePath()
        }


        ctx.drawPath(using: CGPathDrawingMode.fillStroke)
    }

    func rebuildShapes() {
        
        // false - example 1
        // true - example 2
        if (false) { 
            setNeedsDisplay()
        } else {
            
            guard let backgroundView else { return }
            
            let p = UIBezierPath()
            
            fillColor = UIColor.red.withAlphaComponent(0.4).cgColor
            
            strokeColor = UIColor.green.withAlphaComponent(1.0).cgColor
            lineWidth = 10
            
            backgroundView.pages.forEach { page in
                let p1 = backgroundView.convert(point: CGPoint(x: page.size.width/3, y: page.size.height/3), from: page.index)
                let p2 = backgroundView.convert(point: CGPoint(x: page.size.width/3*2, y: page.size.height/3*2), from: page.index)
                
                p.move(to: CGPoint(x: p1.x, y: p1.y))
                p.addLine(to: CGPoint(x: p2.x, y: p1.y))
                p.addLine(to: CGPoint(x: p2.x, y: p2.y))
                p.addLine(to: CGPoint(x: p1.x, y: p2.y))
                p.close()
            }
            
            path = p.cgPath
        }
    }
...

Solution

  • Based on OP's comments, here is what I would use instead of overriding draw(in ctx: CGContext) in a CAShapeLayer:

    class AnnotationsView: UIView {
        var backgroundView: BackgroundView? {
            didSet {
                print("did set background view")
            }
        }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() {
            backgroundColor = .clear
        }
        func rebuildShapes() {
            setNeedsDisplay()
        }
        
        override func draw(_ rect: CGRect) {
            super.draw(rect)
            
            guard let backgroundView else { return }
            
            let p = UIBezierPath()
            
            let fillColor = UIColor.red.withAlphaComponent(0.4)
            
            let strokeColor = UIColor.green.withAlphaComponent(1.0)
            let lineWidth = 10
            
            backgroundView.pages.forEach { page in
                let p1 = backgroundView.convert(point: CGPoint(x: page.size.width/3, y: page.size.height/3), from: page.index)
                let p2 = backgroundView.convert(point: CGPoint(x: page.size.width/3*2, y: page.size.height/3*2), from: page.index)
                let r: CGRect = .init(x: p1.x, y: p1.y, width: p2.x - p1.x, height: p2.y - p1.y)
                if r.intersects(rect) {
                    p.append(.init(rect: r))
                }
            }
            
            fillColor.setFill()
            p.fill()
            strokeColor.setStroke()
            p.lineWidth = CGFloat(lineWidth)
            p.stroke()
        }
    }
    

    No need to add any layers.