[SOLVED] CATiledLayer in NSView flashes on changing contentsScale

Issue

I have a CATiledLayer inside a NSView which is a documentView property of NSScrollView.
Storyboard setup is pretty straitforward: add NSScrollView to the default view controller and assign View class to the NSView of clipping view.

The following code draws a number of squares of random color. Scrolling works exactly as it should in CATiledLayer but zooming doesn’t work very well:

enter image description here

Found tons of CATiledLayer problems and all the proposed solutions don’t work for me (like subclassing with 0 fadeDuration or disabling CATransaction actions). I guess that setNeedsDisplay() screws it all but can’t figure out the proper way to do that. If I use CALayer then I don’t see the flashing issues but then I can’t deal with large layers of thousands of boxes inside.

The View class source:

import Cocoa
import CoreGraphics
import Combine

let rows = 1000
let columns = 1000
let width = 50.0
let height = 50.0

class View: NSView {
    typealias Coordinate = (x: Int, y: Int)
    
    private let colors: [[CGColor]]
    private let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height))
    private var store = Set<AnyCancellable>()
    private var scale: CGFloat {
        guard let scrollView = self.superview?.superview as? NSScrollView else { fatalError() }
        return NSScreen.main!.backingScaleFactor * scrollView.magnification
    }
    
    required init?(coder: NSCoder) {
        colors = (0..<rows).map { _ in (0..<columns).map { _ in .random } }
        super.init(coder: coder)
        
        setFrameSize(NSSize(width: width * CGFloat(columns), height: height * CGFloat(rows)))
        
        wantsLayer = true
        
        NotificationCenter.default.publisher(for: NSScrollView.didEndLiveMagnifyNotification).sink { [unowned self] _ in
            self.layer?.contentsScale = scale
            self.layer?.setNeedsDisplay()
        }.store(in: &store)
    }
    
    override func makeBackingLayer() -> CALayer {
        let layer = CATiledLayer()
        layer.tileSize = CGSize(width: 1000, height: 1000)
        return layer
    }
    
    override func draw(_ dirtyRect: NSRect) {
        guard let context = NSGraphicsContext.current?.cgContext else { return }
        
        let (min, max) = coordinates(in: dirtyRect)
        
        context.translateBy(x: CGFloat(min.x) * width, y: CGFloat(min.y) * height)
        
        (min.y...max.y).forEach { row in
            context.saveGState()
            
            (min.x...max.x).forEach { column in
                context.setFillColor(colors[row][column])
                context.addRect(rect)
                context.drawPath(using: .fillStroke)
                
                context.translateBy(x: width, y: 0)
            }
            
            context.restoreGState()
            context.translateBy(x: 0, y: height)
        }
    }
    
    private func coordinates(in rect: NSRect) -> (Coordinate, Coordinate) {
        var minX = Int(rect.minX / width)
        var minY = Int(rect.minY / height)
        var maxX = Int(rect.maxX / width)
        var maxY = Int(rect.maxY / height)
        
        if minX >= columns {
            minX = columns - 1
        }
        
        if maxX >= columns {
            maxX = columns - 1
        }
        
        if minY >= rows {
            minY = rows - 1
        }
        
        if maxY >= rows {
            maxY = rows - 1
        }
        
        return ((minX, minY), (maxX, maxY))
    }
}


extension CGColor {
    class var random: CGColor {
        let random = { CGFloat(arc4random_uniform(255)) / 255.0 }
        return CGColor(red: random(), green: random(), blue: random(), alpha: random())
    }
}

Solution

To be able to support zooming into a CATiledLayer, you set the layer’s levelOfDetailBias. You don’t need to observe the scroll view’s magnification notifications, change the layers contentScale, or trigger manual redraws.

Here’s a quick implementation that shows what kinds of dirtyRects you get at different zoom levels:

class View: NSView {
    
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        wantsLayer = true
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        wantsLayer = true
    }
    
    override func makeBackingLayer() -> CALayer {
        let layer = CATiledLayer()
        layer.tileSize = CGSize(width: 400, height: 400)
        layer.levelsOfDetailBias = 3
        return layer
    }
    
    override func draw(_ dirtyRect: NSRect) {
        let context = NSGraphicsContext.current!
        let scale = context.cgContext.ctm.a
        
        NSColor.red.setFill()
        dirtyRect.frame(withWidth: 10 / scale, using: .overlay)
        
        NSColor.black.setFill()
        let string: NSString = "Scale: \(scale)" as NSString
        let attributes = [NSAttributedString.Key.font: NSFont.systemFont(ofSize: 40 / scale)]
        let size = string.size(withAttributes: attributes)
        string.draw(at: CGPoint(x: dirtyRect.midX - size.width / 2, y: dirtyRect.midY - size.height / 2),
                    withAttributes: attributes)
    }
    
}

The current drawing contexts is already scaled to match the current zoom level (and the dirtyRect’s get smaller and smaller for each level of detail down). You can extract the current scale from CGContext’s transformation matrix as shown above, if needed.

Answered By – adeasismont

Answer Checked By – Marilyn (BugsFixing Volunteer)

Leave a Reply

Your email address will not be published.