Search code examples
iosswiftcore-graphicsdrawrectcgcontext

Drawing multiple rectangle using DrawRect efficiently


I'm trying to draw rectangles pattern using DrawRect like this:

enter image description here

Currently, I'm doing this like so:

class PatternView: UIView {
    
    override func draw(_ rect: CGRect) {

        let context = UIGraphicsGetCurrentContext()
        
        let numberOfBoxesPerRow = 7
        let boxSide: CGFloat = rect.width / CGFloat(numberOfBoxesPerRow)
        var yOrigin: CGFloat = 0
        var xOrigin: CGFloat = 0
        var isBlack = true
        
        for y in 0...numberOfBoxesPerRow - 1 {
            yOrigin = boxSide * CGFloat(y)
            for x in 0...numberOfBoxesPerRow - 1 {

                xOrigin = boxSide * CGFloat(x)
                let color = isBlack ? UIColor.red : UIColor.blue
                isBlack = !isBlack

                context?.setFillColor(color.cgColor)
                
                let rectnagle =  CGRect(origin: .init(x: xOrigin, y: yOrigin), size: .init(width: boxSide, height: boxSide))
                context?.addRect(rectnagle)
                context?.fill([rectnagle])
            }
        }
           
    }
}

It's working but I'm trying to optimize it.
Any help will be highly appreciated!


Solution

  • It's difficult to answer "abstract" questions... which this one is, without knowing if you've run some tests / profiling to determine if this code is slow.

    However, a couple things you can do to speed it up...

    • fill the view with one color (red, in this case) and then draw only the other-color boxes
    • add rects to the context's path, and fill the path once

    Take a look at this modification:

    class PatternView: UIView {
        
        override func draw(_ rect: CGRect) {
            
            guard let context = UIGraphicsGetCurrentContext() else { return }
            
            let numberOfBoxesPerRow = 7
            let boxSide: CGFloat = rect.width / CGFloat(numberOfBoxesPerRow)
            
            context.setFillColor(UIColor.red.cgColor)
            context.fill(bounds)
            
            var r: CGRect = CGRect(origin: .zero, size: CGSize(width: boxSide, height: boxSide))
            
            context.beginPath()
            
            for row in 0..<numberOfBoxesPerRow {
                r.origin.x = 0.0
                for col in 0..<numberOfBoxesPerRow {
                    if (row % 2 == 0 && col % 2 == 1) || (row % 2 == 1 && col % 2 == 0) {
                        context.addRect(r)
                    }
                    r.origin.x += boxSide
                }
                r.origin.y += boxSide
            }
            
            context.setFillColor(UIColor.blue.cgColor)
            
            context.fillPath()
    
        }
    }
    

    There are other options... create a "pattern" background color... use CAShapeLayers and/or CAReplicatorLayers... for example.


    Edit

    The reason you are getting "blurry edges" is because, as you guessed, you're drawing on partial pixels.

    If we modify the values to use whole numbers (using floor()), we can avoid that. Note that the wholeNumberBoxSide * numBoxes may then NOT be exactly equal to the view's rect, so we'll also want to inset the "grid":

    class PatternView: UIView {
        
        override func draw(_ rect: CGRect) {
            
            guard let context = UIGraphicsGetCurrentContext() else { return }
            
            let c1: UIColor = .white
            let c2: UIColor = .lightGray
            
            let numberOfBoxesPerRow = 7
            
            // use a whole number
            let boxSide: CGFloat = floor(rect.width / CGFloat(numberOfBoxesPerRow))
    
            // inset because numBoxes * boxSide may not be exactly equal to rect
            let inset: CGFloat = floor((rect.width - boxSide * CGFloat(numberOfBoxesPerRow)) * 0.5)
            
            context.setFillColor(c1.cgColor)
            context.fill(CGRect(x: inset, y: inset, width: boxSide * CGFloat(numberOfBoxesPerRow), height: boxSide * CGFloat(numberOfBoxesPerRow)))
            
            var r: CGRect = CGRect(x: inset, y: inset, width: boxSide, height: boxSide)
            
            context.beginPath()
            
            for row in 0..<numberOfBoxesPerRow {
                r.origin.x = inset
                for col in 0..<numberOfBoxesPerRow {
                    if (row % 2 == 0 && col % 2 == 1) || (row % 2 == 1 && col % 2 == 0) {
                        context.addRect(r)
                    }
                    r.origin.x += boxSide
                }
                r.origin.y += boxSide
            }
            
            context.setFillColor(c2.cgColor)
            
            context.fillPath()
    
        }
    }
    

    We could also get the scale of the main screen (which will be 2x or 3x) and round the boxSide to half- or one-third points to align with the pixels... if really desired.


    Edit 2

    Additional modifications... settable colors and number of boxes.

    Also, using this extension:

    // extension to round CGFloat values to floor/nearest CGFloat
    //  so, for example
    //  if f == 10.6
    //      f.floor(nearest: 0.5)    = 10.5
    //      f.floor(nearest: 0.3333) = 10.3333
    //      f.round(nearest: 0.5)    = 10.5
    //      f.round(nearest: 0.3333) = 10.66666
    extension CGFloat {
        func round(nearest: CGFloat) -> CGFloat {
            let n = 1/nearest
            let numberToRound = self * n
            return numberToRound.rounded() / n
        }
        
        func floor(nearest: CGFloat) -> CGFloat {
            let intDiv = CGFloat(Int(self / nearest))
            return intDiv * nearest
        }
    }
    

    We can round the coordinates to match the screen scale.

    PatternView class

    class PatternView: UIView {
    
        var c1: UIColor = .white { didSet { setNeedsDisplay() } }
        var c2: UIColor = .lightGray { didSet { setNeedsDisplay() } }
        var numberOfBoxesPerRow = 21 { didSet { setNeedsDisplay() } }
    
        override func draw(_ rect: CGRect) {
            
            guard let context = UIGraphicsGetCurrentContext() else { return }
            
            let sc: CGFloat = 1.0 // / CGFloat(UIScreen.main.scale)
            
            // use a whole number
            let boxSide: CGFloat = (rect.width / CGFloat(numberOfBoxesPerRow)).floor(nearest: sc)
            
            // inset because numBoxes * boxSide may not be exactly equal to rect
            let inset: CGFloat = ((rect.width - boxSide * CGFloat(numberOfBoxesPerRow)) * 0.5).floor(nearest: sc)
            
            context.setFillColor(c1.cgColor)
            context.fill(CGRect(x: inset, y: inset, width: boxSide * CGFloat(numberOfBoxesPerRow), height: boxSide * CGFloat(numberOfBoxesPerRow)))
            
            var r: CGRect = CGRect(x: inset, y: inset, width: boxSide, height: boxSide)
            
            context.beginPath()
            
            for row in 0..<numberOfBoxesPerRow {
                r.origin.x = inset
                for col in 0..<numberOfBoxesPerRow {
                    if (row % 2 == 0 && col % 2 == 1) || (row % 2 == 1 && col % 2 == 0) {
                        context.addRect(r)
                    }
                    r.origin.x += boxSide
                }
                r.origin.y += boxSide
            }
            
            context.setFillColor(c2.cgColor)
            
            context.fillPath()
            
        }
    }
    

    Example Controller View class

    class PatternTestVC: UIViewController {
        
        let pvA = PatternView()
        let pvB = PatternView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = .systemBlue
            
            let stack = UIStackView()
            stack.axis = .vertical
            stack.spacing = 8
            
            stack.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(stack)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                stack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                stack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                stack.centerYAnchor.constraint(equalTo: g.centerYAnchor),
            ])
            
            [pvA, pvB].forEach { v in
                v.backgroundColor = .red
                v.numberOfBoxesPerRow = 7
                v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
                stack.addArrangedSubview(v)
            }
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            pvB.numberOfBoxesPerRow += 1
        }
    }
    

    Sets up two pattern views... both start at 7 boxes... each tap anywhere increments the boxes per row in the bottom view.

    Here's how it looks with 21 boxes per row (actual size - so really big image):

    enter image description here

    and zoomed-in 1600%:

    enter image description here

    Note the red borders... I set the background of the view to red, so we can see that the grid must be inset to account for the non-whole-number box size.


    Edit 3

    Options to avoid "blurry edges" ...

    Suppose we have a view width of 209 and we want 10 boxes.

    That gives us a box width of 20.9 ... which results in "blurry edges" -- so we know we need to get to a whole number.

    If we round it, we'll get 21 -- 21 x 10 = 210 which will exceed the width of the view. So we need to round it down (floor()).

    So...

    enter image description here

    Option 1:

    enter image description here

    Option 2:

    enter image description here

    Option 3:

    enter image description here