I have created a simplified example. I imagine if someone experienced runs this they will easily be able to explain this behaviour...
In the example the addView button creates a new child subview each time it is pressed. These subviews should be the same each time one is generated but it is only when the third subview has been generated that I get the correct result. What is the reason for this?
If you uncomment the repetition of the frame and bounds calls in the drawRect method you get the correct result on the second attempt - again I cannot explain this behaviour.
import Cocoa
class ViewController: NSViewController {
let parentView = ParentView(frame: NSRect(x: 100, y: 10, width: 100, height: 400))
let yMax = 400
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(parentView)
}
@IBAction func addView(_ sender: Any) {
let childView = ChildView(frame: NSRect(x:0, y: 0, width: 20, height: 20))
self.parentView.addSubview(childView)
for v in parentView.subviews {
print("subview frame: ", v.frame)
}
}
}
class ParentView: NSView {
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard let context = NSGraphicsContext.current?.cgContext else {
return
}
drawRect(ctx: context)
}
func drawRect(ctx: CGContext) {
let parentFrameMarker = NSRect(x: 0, y: 0, width: 100, height: 400)
ctx.addRect(parentFrameMarker)
ctx.setStrokeColor(CGColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0))
ctx.setLineWidth(1.0)
ctx.strokePath()
}
}
class ChildView: NSView {
let controller = ViewController()
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else {
return
}
let y = controller.yMax
resizeView(y: y)
drawRect(ctx: context, y: y)
}
func drawRect(ctx: CGContext, y: Int) {
let width = 20
let height = 40
// let currentY = y - height
//
// let origin = CGPoint(x: 0, y: currentY)
// let size = NSSize(width: width, height: height)
// self.setBoundsSize(size)
// self.setFrameSize(size)
// print("new frame: ", self.frame)
// print("new bounds: ", self.bounds)
// self.setFrameOrigin(origin)
let childFrameMarker = NSRect(x:0, y: 0, width: width, height: height)
print("childFrameMarker size: ", childFrameMarker.size)
ctx.addRect(childFrameMarker)
ctx.setStrokeColor(CGColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0))
ctx.setLineWidth(2.0)
ctx.strokePath()
}
func resizeView(y: Int) {
let width = 20
let height = 40
let currentY = y - height
let origin = CGPoint(x: 0, y: currentY)
let size = NSSize(width: width, height: height)
self.setBoundsSize(size)
self.setFrameSize(size)
self.setFrameOrigin(origin)
print("new frame: ", self.frame)
print("new bounds: ", self.bounds)
}
}
AppKit does not expect you to change a view's frame inside its draw(_:)
method.
When you click the button to add a subview, that's an event. AppKit handles each event like this, in this order:
Call the callback for the event. In this case, the callback is your addView(_:)
method.
Call updateConstraints
on any view that has needsUpdateConstraints
set. Note that a newly-created view automatically sets needsUpdateConstraints
.
If a view needs to make a bunch of changes to autolayout constraints, it's a little more efficient to do it in updateConstraints
than in the event callback. If you're only changing a small number of constraints, don't worry about it.
Call layout
on any view that has needsLayout
set. Note that a newly-created view automatically sets needsLayout
.
By the time AppKit calls layout
, it has already updated the frame of the receiving view. The receiving view's layout
method is responsible for updating the frames of the view's subviews, but not of the view itself. The NSView
implementation of layout
updates the frames of subviews based on autolayout constraints, so it's generally important to call super.layout()
if you override the layout
method.
Call draw(_:)
on any view that has needsDisplay
set or that has any invalid regions due to calls to setNeedsDisplay(_:)
. Note that a newly-created view automatically marks its entire bounds as invalid.
By the time AppKit calls draw(_:)
, it has already updated the frame of the view being drawn.
All calls to updateConstraints
are made before any calls to layout
, and all calls to layout
are made before any calls to draw(_:)
. By the time AppKit calls draw(_:)
, it expects your views to have their correct frames and doesn't expect those frames to change until another event happens.
So the problem in your code is that you're not updating the frame of each ChildView
when AppKit expects you to. You should be updating it in layout
, but you're updating it in draw(_:)
.
Another weird thing about your code is that each ChildView
creates its own instance of ViewController
, just to access the controller's yMax
property. I think you probably want to either make the yMax
property static
so you can access it without an instance, or you want each ChildView
to access the existing ViewController
. But since it's really ParentView
's job to lay out each ChildView
, it is ParentView
that needs a reference to the existing ViewController
.
Here's how I might write your code:
import Cocoa
class ViewController: NSViewController {
let parentView = ParentView(frame: NSRect(x: 100, y: 10, width: 100, height: 400))
let yMax = 400
override func viewDidLoad() {
super.viewDidLoad()
parentView.controller = self
self.view.addSubview(parentView)
}
@IBAction func addView(_ sender: Any) {
let childView = ChildView(frame: NSRect(x:0, y: 0, width: 20, height: 20))
parentView.addSubview(childView)
parentView.needsLayout = true
}
}
class ParentView: NSView {
weak var controller: ViewController?
override func layout() {
super.layout()
guard var y = controller?.yMax else { return }
for subview in subviews {
y -= 40
subview.frame = CGRect(x: 0, y: y, width: 20, height: 40)
}
}
override func draw(_ dirtyRect: NSRect) {
// No call to `super.draw(_:)` needed because ParentView is a direct subclass of NSView.
NSColor.red.set()
bounds.frame()
}
}
class ChildView: NSView {
override func draw(_ dirtyRect: NSRect) {
NSColor.green.set()
bounds.frame(withWidth: 2)
}
}
You commented:
I am calculating geometries in draw and I use
setFrameOrigin
andsetFrameSize
to adjust the frame if needed depending on what needs to be drawn. I guess it would be possible to calculate the geometry of the 'drawing' before an instance ofChildView
is created and then instantiate with the correct frame. what aChildView
needs to draw would not actually be in theChildView
class. Any advice on this chicken and egg problem?”
Compute and store that information in ChildView
as needed.
Let's say we want ChildView
to draw a regular polygon. We want to specify the number of sides and the circumradius of the polygon, and then have ChildView
laid out to perfectly fit the polygon. Here's a way to do it:
import Cocoa
class ViewController: NSViewController {
let parentView = ParentView(frame: NSRect(x: 100, y: 10, width: 100, height: 400))
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(parentView)
}
@IBAction func addView(_ sender: Any) {
let childView = ChildView(
sideCount: parentView.subviews.count + 3,
radius: 30
)
parentView.addSubview(childView)
parentView.needsLayout = true
}
}
class ParentView: NSView {
override func layout() {
super.layout()
var y = bounds.size.height
for subview in subviews.lazy.compactMap({ $0 as? ChildView }) {
let size = subview.intrinsicContentSize
y -= size.height
subview.frame = CGRect(x: 0, y: y, width: size.width, height: size.height)
}
}
override func draw(_ dirtyRect: NSRect) {
// No call to `super.draw(_:)` needed because ParentView is a direct subclass of NSView.
NSColor.red.set()
bounds.frame()
}
}
class ChildView: NSView {
// Here is the raw data on which my appearance depends.
var sideCount: Int { didSet { invalidate() } }
var radius: CGFloat { didSet { invalidate() } }
// Here is cached layout information that depends on my raw data.
private var detailCache: Detail? = nil
private struct Detail {
var size: CGSize
var vertices: [CGPoint]
}
init(sideCount: Int, radius: CGFloat) {
self.sideCount = sideCount
self.radius = radius
super.init(frame: .zero)
}
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: CGSize { detail.size }
override func draw(_ dirtyRect: NSRect) {
let vertices = detail.vertices
guard let first = vertices.first else { return }
let path = NSBezierPath()
path.move(to: first)
for p in vertices.dropFirst() {
path.line(to: p)
}
path.close()
NSColor.green.set()
path.fill()
}
private func invalidate() {
detailCache = nil
invalidateIntrinsicContentSize()
superview?.needsLayout = true
needsDisplay = true
}
private var detail: Detail {
if let detail = detailCache { return detail }
let detail = makeDetail()
detailCache = detail
return detail
}
private func makeDetail() -> Detail {
guard sideCount > 0 else { return Detail(size: .zero, vertices: []) }
var vertices: [CGPoint] = []
var pMin = CGPoint(x: CGFloat.infinity, y: .infinity)
var pMax = CGPoint(x: -CGFloat.infinity, y: -.infinity)
for i in (0 ..< sideCount).lazy.map({ CGFloat($0) }) {
let p = CGPoint(
x: radius * cos(2 * .pi * i / CGFloat(sideCount)),
y: radius * sin(2 * .pi * i / CGFloat(sideCount))
)
vertices.append(p)
pMin.x = min(pMin.x, p.x)
pMin.y = min(pMin.y, p.y)
pMax.x = max(pMax.x, p.x)
pMax.y = max(pMax.y, p.y)
}
for i in vertices.indices {
vertices[i].x -= pMin.x
vertices[i].y -= pMin.y
}
return Detail(
size: CGSize(
width: pMax.x - pMin.x,
height: pMax.y - pMin.y
),
vertices: vertices
)
}
}