NSView:setFrameOrigin 和 setFrameSize - 是什么导致此代码中出现不一致的行为?

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

我创建了一个简化的示例。我想如果有经验的人运行这个,他们将很容易能够解释这种行为......

在示例中,每次按下 addView 按钮都会创建一个新的子子视图。每次生成一个子视图时,这些子视图都应该相同,但只有在生成第三个子视图时,我才能得到正确的结果。这是什么原因?

如果您在 drawRect 方法中取消注释 frame 和 bounds 调用的重复,您将在第二次尝试时得到正确的结果 - 同样,我无法解释这种行为。

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

您的两个视图都没有布局限制。您不能使用坐标定位您的视图。

你应该看看自动布局。

当您使用自动布局时,一切都由视图的固有内容大小以及内容拥抱和抗压缩优先级控制。

因此您需要覆盖视图的固有内容大小,并为具有相对于父视图的布局约束的子视图使用自动布局。 您还应该删除 drawRect 中试图控制视图的位置或 width/height 的所有代码。

只在drawRect方法中绘制。

目前 AppKit 正在自动为您添加不明确的自动布局约束。它没有足够的信息来正确放置它们。这就是您所看到的。

AppKit 不希望您在其 draw(_:) 方法中更改视图的框架。

当您点击添加子视图的按钮时,这是一个事件。 AppKit 像这样处理每个事件,按此顺序:

  1. 调用事件的回调。在这种情况下,回调是您的 addView(_:) 方法。

  2. 在任何设置了 needsUpdateConstraints 的视图上调用 updateConstraints。请注意,newly-created 视图会自动设置 needsUpdateConstraints.

    如果视图需要对自动布局约束进行大量更改,在 updateConstraints 中执行比在事件回调中执行更有效。如果您只是更改少量约束,请不要担心。

  3. 在任何设置了 needsLayout 的视图上调用 layout。请注意,newly-created 视图会自动设置 needsLayout.

    当 AppKit 调用 layout 时,它已经更新了接收视图的框架。接收视图的 layout 方法负责更新视图子视图的框架,而不是视图本身的框架。 layoutNSView 实现根据自动布局约束更新子视图的框架,因此如果您覆盖 layout 方法,调用 super.layout() 通常很重要。

  4. 在已设置 needsDisplay 或因调用 setNeedsDisplay(_:) 而具有任何无效区域的任何视图上调用 draw(_:)。请注意,newly-created 视图会自动将其整个边界标记为无效。

    当 AppKit 调用 draw(_:) 时,它已经更新了正在绘制的视图的框架。

updateConstraints 的所有调用都在对 layout 的任何调用之前进行,对 layout 的所有调用都在对 draw(_:) 的任何调用之前进行。当 AppKit 调用 draw(_:) 时,它希望您的视图具有正确的框架,并且不希望这些框架发生变化,直到另一个事件发生。

所以您的代码中的问题是您没有在 AppKit 希望您更新每个 ChildView 的框架。您应该在 layout 中更新它,但您在 draw(_:) 中更新它。

关于您的代码的另一个奇怪之处是每个 ChildView 创建自己的 ViewController 实例,只是为了访问控制器的 yMax 属性。我想你可能想要制作 yMax 属性 static 这样你就可以在没有实例的情况下访问它,或者你希望每个 ChildView 访问现有的 ViewController.但由于布置每个 ChildView 确实是 ParentView 的工作,因此 ParentView 需要对现有 ViewController.

的引用

以下是我编写代码的方式:

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

更新

您发表评论:

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?”

根据需要计算该信息并将其存储在 ChildView 中。

假设我们想要 ChildView 绘制一个正多边形。我们要指定多边形的边数和外接半径,然后布置 ChildView 以完美适合多边形。这是一种方法:

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({ [=11=] 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([=11=]) }) {
            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
        )
    }
}