Search code examples
swiftcocoansview

NSView: setFrameOrigin and setFrameSize - what is causing inconsistent behaviour in this code?


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)
    }
    
}

Solution

  • 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:

    1. Call the callback for the event. In this case, the callback is your addView(_:) method.

    2. 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.

    3. 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.

    4. 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)
        }
    }
    

    UPDATE

    You commented:

    I am calculating geometries in draw and I use setFrameOrigin and setFrameSize 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 of ChildView is created and then instantiate with the correct frame. what a ChildView needs to draw would not actually be in the ChildView 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
            )
        }
    }