如何为NSTextView添加带引力规则的浮动注释视图?
在AppKit中实现注释视图的智能布局方案
针对你描述的只读NSTextView注释布局需求,AutoLayout和自定义位置计算两种方案都能实现,各有适用场景,具体如下:
一、AutoLayout方案:利用系统约束简化逻辑
AutoLayout完全支持你需要的灵活约束规则,核心是通过约束优先级和范围约束来实现「优先贴紧文本、避免重叠、不超出边界」的需求:
关键实现步骤
- 获取文本附件的位置:通过
NSTextView.firstRect(for:)获取对应NSTextAttachment的渲染矩形,再转换到父视图坐标系。 - 添加基础约束:给注释视图设置与父视图的边距约束(
leading >= 父视图leading、trailing <= 父视图trailing、bottom <= 父视图bottom),防止超出边界。 - 设置优先级约束:
- 高优先级约束:注释视图顶部与文本附件底部保持固定偏移(比如4pt),确保优先贴紧目标位置。
- 低优先级约束:每个注释视图的顶部必须大于前一个注释视图的底部,避免重叠;同时添加约束确保注释视图不遮挡文本(比如顶部小于对应文本行的顶部)。
- 定期更新时的清理:每次NSTextView内容更新后,先移除旧的注释视图和对应约束,再重新创建并添加新约束。
代码示例
// 假设parentView是NSTextView的父容器 let textView: NSTextView = ... let parentView: NSView = ... // 清理旧注释视图 parentView.subviews.filter { $0 is CommentView }.forEach { $0.removeFromSuperview() } // 遍历所有带附件的文本范围 for attachmentRange in attachmentRanges { // 获取附件在文本视图中的位置 let attachmentRect = textView.firstRect(for: attachmentRange) let convertedRect = textView.convert(attachmentRect, to: parentView) // 创建注释视图 let commentView = CommentView(frame: .zero) commentView.translatesAutoresizingMaskIntoConstraints = false parentView.addSubview(commentView) // 获取对应文本行的范围(避免遮挡文本) let lineRect = textView.layoutManager.lineFragmentRect(forGlyphAt: attachmentRange.location, effectiveRange: nil) let convertedLineRect = textView.convert(lineRect, to: parentView) // 激活约束 NSLayoutConstraint.activate([ // 边界约束 commentView.leading.greaterThanOrEqualTo(parentView.leadingAnchor, constant: 8), commentView.trailing.lessThanOrEqualTo(parentView.trailingAnchor, constant: -8), commentView.bottom.lessThanOrEqualTo(parentView.bottomAnchor, constant: -8), // 优先贴紧文本附件下方(高优先级) commentView.top.equalTo(convertedRect.bottomAnchor, constant: 4).priority(.defaultHigh), // 避免遮挡文本(中等优先级) commentView.bottom.lessThanOrEqualTo(convertedLineRect.topAnchor, constant: -4).priority(.defaultMedium), // 避免与前一个注释重叠(如果有) commentView.top.greaterThanOrEqualTo(prevCommentView?.bottomAnchor ?? parentView.topAnchor, constant: 4).priority(.defaultMedium) ]) prevCommentView = commentView }
二、自定义位置计算:高度定制布局规则
如果需要更复杂的避障逻辑(比如注释可以左右偏移、自定义重叠判断规则),自定义计算每个注释的NSRect会更灵活:
关键实现步骤
- 收集所有目标位置:遍历所有文本附件,获取每个附件在父视图中的坐标,计算注释的理想位置(比如附件下方偏移4pt)。
- 碰撞检测与调整:
- 检查当前注释的理想位置是否与已布局的注释重叠,若重叠则向下(或向左/右)调整位置直到不重叠。
- 检查是否超出父视图边界,超出则将注释移动到边界内。
- 检查是否遮挡文本,通过
NSTextView.layoutManager获取对应文本行的矩形,确保注释顶部低于文本行底部。
- 设置注释视图frame:将调整后的NSRect直接赋值给注释视图的frame。
代码示例
let textView: NSTextView = ... let parentView: NSView = ... let commentSize = NSSize(width: 200, height: 40) var laidOutCommentRects: [NSRect] = [] // 清理旧注释视图 parentView.subviews.filter { $0 is CommentView }.forEach { $0.removeFromSuperview() } for attachmentRange in attachmentRanges { // 获取附件位置 let attachmentRect = textView.firstRect(for: attachmentRange) let convertedRect = textView.convert(attachmentRect, to: parentView) // 初始理想位置:附件下方4pt var targetRect = NSRect( origin: NSPoint(x: convertedRect.minX, y: convertedRect.maxY + 4), size: commentSize ) // 调整避免与已布局的注释重叠 for existingRect in laidOutCommentRects { while targetRect.intersects(existingRect) { // 向下移动直到不重叠 targetRect.origin.y = existingRect.maxY + 4 } } // 调整不超出父视图边界 if targetRect.maxX > parentView.bounds.maxX { targetRect.origin.x = parentView.bounds.maxX - targetRect.width - 8 } if targetRect.minX < parentView.bounds.minX { targetRect.origin.x = parentView.bounds.minX + 8 } if targetRect.maxY > parentView.bounds.maxY { targetRect.origin.y = parentView.bounds.maxY - targetRect.height - 8 } // 调整不遮挡文本 let lineRect = textView.layoutManager.lineFragmentRect(forGlyphAt: attachmentRange.location, effectiveRange: nil) let convertedLineRect = textView.convert(lineRect, to: parentView) if targetRect.minY < convertedLineRect.maxY { targetRect.origin.y = convertedLineRect.maxY + 4 } // 保存已布局的rect laidOutCommentRects.append(targetRect) // 创建并设置注释视图 let commentView = CommentView(frame: targetRect) parentView.addSubview(commentView) }
方案选择建议
- 若布局规则相对简单(仅上下避障、边界限制),AutoLayout方案更省心,无需自己写碰撞检测逻辑,系统会自动处理约束冲突。
- 若需要高度定制的避障逻辑(比如左右偏移、特殊重叠判断),自定义计算方案更灵活,能完全掌控布局规则。
内容的提问来源于stack exchange,提问作者Peter R
相关产品推荐
相关产品推荐

