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:
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())
}
}
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.