Search code examples
iosuiscrollviewuikitcore-graphicsdrawrect

UIScrollView draw ruler using drawRect


I am trying to draw a ruler on top of UIScrollView. The way I do it is by adding a custom view called RulerView. I add this rulerView to superview of scrollView setting its frame to be same as frame of scrollView. I then do custom drawing to draw lines as scrollView scrolls. But the drawing is not smooth, it stutters as I scroll and the end or begin line suddenly appears/disappears. What's wrong in my drawRect?

class RulerView: UIView {

   public var contentOffset = CGFloat(0) {
      didSet {
         self.setNeedsDisplay()
      }
   }

   public var contentSize = CGFloat(0)

   let smallLineHeight = CGFloat(4)
   let bigLineHeight = CGFloat(10)


  override open func layoutSubviews() {   
    super.layoutSubviews()
    self.backgroundColor = UIColor.clear     
  }

  override func draw(_ rect: CGRect) {
 
     UIColor.white.set()
     let contentWidth = max(rect.width, contentSize)
     let lineGap:CGFloat = 5
    
     let totalNumberOfLines = Int(contentWidth/lineGap)
    
     let startIndex = Int(contentOffset/lineGap)
     let endIndex = Int((contentOffset + rect.width)/lineGap)
     let beginOffset = contentOffset - CGFloat(startIndex)*lineGap
    
     if let context = UIGraphicsGetCurrentContext() {
        for i in startIndex...endIndex {
            let path = UIBezierPath()
            path.move(to: CGPoint(x: beginOffset + CGFloat(i - startIndex)*lineGap , y:0))
            path.addLine(to: CGPoint(x: beginOffset + CGFloat(i - startIndex)*lineGap, y: i % 5 == 0 ? bigLineHeight : smallLineHeight))
            path.lineWidth = 0.5
            path.stroke()

            
        }
    }
    
}

And in the scrollview delegate, I set this:

  //MARK:- UIScrollViewDelegate

public func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let offset = scrollView.contentOffset.x
    
    rulerView.contentSize = scrollView.contentSize.width
    rulerView.contentOffset = offset
}

Solution

  • Your override func draw(_ rect: CGRect) is very "heavy." I think you'll get much better performance by using a shape layer for your "tick marks" and letting UIKit handle the drawing.


    Edit - as per comments

    Added numbering to the tick marks using CATextLayer as sublayers.

    Here's a sample RulerView (using your tick mark dimensions and spacing):

    class RulerView: UIView {
    
        public var contentOffset: CGFloat = 0 {
            didSet {
                layer.bounds.origin.x = contentOffset
            }
        }
        public var contentSize = CGFloat(0) {
            didSet {
                updateRuler()
            }
        }
        
        let smallLineHeight: CGFloat = 4
        let bigLineHeight: CGFloat = 10
        let lineGap:CGFloat = 5
        
        // numbers under the tick marks
        //  with 12-pt system font .light
        //  40-pt width will fit up to 5 digits
        let numbersWidth: CGFloat = 40
        let numbersFontSize: CGFloat = 12
    
        var shapeLayer: CAShapeLayer!
        
        override class var layerClass: AnyClass {
            return CAShapeLayer.self
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        func commonInit() -> Void {
            shapeLayer = self.layer as? CAShapeLayer
            // these properties don't change
            backgroundColor = .clear
            shapeLayer.fillColor = UIColor.clear.cgColor
            shapeLayer.strokeColor = UIColor.white.cgColor
            shapeLayer.lineWidth = 0.5
            shapeLayer.masksToBounds = true
        }
        func updateRuler() -> Void {
            // size is set by .fontSize, so ofSize here is ignored
            let numbersFont = UIFont.systemFont(ofSize: 1, weight: .light)
            let pth = UIBezierPath()
            var x: CGFloat = 0
            var i = 0
            while x < contentSize {
                pth.move(to: CGPoint(x: x, y: 0))
                pth.addLine(to: CGPoint(x: x, y: i % 5 == 0 ? bigLineHeight : smallLineHeight))
                
                // number every 10 ticks - change as desired
                if i % 10 == 0 {
                    let layer = CATextLayer()
                    
                    layer.contentsScale = UIScreen.main.scale
                    layer.font = numbersFont
                    layer.fontSize = numbersFontSize
                    layer.alignmentMode = .center
                    layer.foregroundColor = UIColor.white.cgColor
    
                    // if we want to number by tick count
                    layer.string = "\(i)"
                    
                    // if we want to number by point count
                    //layer.string = "\(i * Int(lineGap))"
                    
                    layer.frame = CGRect(x: x - (numbersWidth * 0.5), y: bigLineHeight, width: numbersWidth, height: numbersFontSize)
                    
                    shapeLayer.addSublayer(layer)
                }
                
                x += lineGap
                i += 1
            }
            shapeLayer.path = pth.cgPath
    
        }
    }
    

    and here's a sample controller class to demonstrate:

    class RulerViewController: UIViewController, UIScrollViewDelegate {
        var rulerView: RulerView = RulerView()
        var scrollView: UIScrollView = UIScrollView()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .blue
            
            [scrollView, rulerView].forEach {
                view.addSubview($0)
                $0.translatesAutoresizingMaskIntoConstraints = false
            }
            
            // sample scroll content will be a horizontal stack view
            //  with 30 labels
            //  spaced 20-pts apart
            let stack = UIStackView()
            stack.translatesAutoresizingMaskIntoConstraints = false
            stack.spacing = 20
            
            for i in 1...30 {
                let v = UILabel()
                v.textAlignment = .center
                v.backgroundColor = .yellow
                v.text = "Label \(i)"
                stack.addArrangedSubview(v)
            }
            
            scrollView.addSubview(stack)
            
            let g = view.safeAreaLayoutGuide
            let contentG = scrollView.contentLayoutGuide
            
            NSLayoutConstraint.activate([
                
                // scroll view 20-pts Top / Leading / Trailing
                scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                // scroll view Height: 60-pts
                scrollView.heightAnchor.constraint(equalToConstant: 60.0),
                
                // stack view 20-pts Top, 0-pts Leading / Trailing / Bottom (to scroll view's content layout guide)
                stack.topAnchor.constraint(equalTo: contentG.topAnchor, constant: 20.0),
                stack.leadingAnchor.constraint(equalTo: contentG.leadingAnchor, constant: 0.0),
                stack.trailingAnchor.constraint(equalTo: contentG.trailingAnchor, constant: 0.0),
                stack.bottomAnchor.constraint(equalTo: contentG.bottomAnchor, constant: 0.0),
                
                // ruler view 4-pts from scroll view Bottom
                rulerView.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 4.0),
                rulerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
                // ruler view 0-pts from scroll view Leading / Trailing (equal width and horizontal position of scroll view)
                rulerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
                // ruler view Height: 24-pts (make sure it's enough to accomodate ruler view's bigLineHeight plus numbering height)
                rulerView.heightAnchor.constraint(equalToConstant: 24.0),
    
            ])
            
            scrollView.delegate = self
            
            // so we can see the sroll view frame
            scrollView.backgroundColor = .red
            
            // if we want to see the rulerView's frame
            //rulerView.backgroundColor = .brown
            
        }
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            // this is when we know the scroll view's content size
            rulerView.contentSize = scrollView.contentSize.width
        }
        public func scrollViewDidScroll(_ scrollView: UIScrollView) {
            // update rulerView's x-offset
            rulerView.contentOffset = scrollView.contentOffset.x
        }
        
    }
    

    Output:

    enter image description here

    the tick marks (and numbers) will, of course, scroll left-right synched with the scroll view.