You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

TextKit 2中绘制复选框及文本缩进与光标控制问题求助

TextKit 2 实现复选框与段落文本共存的最优方案

方案一:NSTextAttachment + 段落样式缩进(推荐)

这种方式利用TextKit原生附件支持,无需自定义NSTextLayoutFragment,能自动适配光标、退格等交互逻辑。

1. 自定义复选框附件

class CheckBoxAttachment: NSTextAttachment {
    var isChecked: Bool = false
    
    override func draw(with bounds: CGRect, characterIndex charIndex: Int, in context: CGContext, layoutManager: NSLayoutManager) {
        context.saveGState()
        defer { context.restoreGState() }
        
        // 绘制复选框边框
        let boxRect = bounds.insetBy(dx: 2, dy: 2)
        context.setStrokeColor(NSColor.labelColor.cgColor)
        context.setLineWidth(1.5)
        context.stroke(boxRect)
        
        // 绘制选中状态的对勾
        if isChecked {
            context.setFillColor(NSColor.labelColor.cgColor)
            let checkPath = NSBezierPath()
            checkPath.move(to: CGPoint(x: boxRect.minX + 3, y: boxRect.midY))
            checkPath.line(to: CGPoint(x: boxRect.midX, y: boxRect.maxY - 3))
            checkPath.line(to: CGPoint(x: boxRect.maxX - 3, y: boxRect.minY + 3))
            checkPath.fill()
        }
    }
    
    // 固定附件尺寸,让复选框与文本基线对齐
    override var bounds: CGRect {
        get {
            let size = CGSize(width: 18, height: 18)
            return CGRect(origin: CGPoint(x: 0, y: -2), size: size)
        }
        set { super.bounds = newValue }
    }
}

2. 配置段落缩进实现文本对齐

为包含复选框的段落设置首行缩进,确保文本在复选框右侧显示:

func createCheckListParagraphStyle() -> NSParagraphStyle {
    let style = NSMutableParagraphStyle()
    // 缩进值 = 复选框宽度 + 间距(18+4=22)
    style.headIndent = 22
    style.firstLineHeadIndent = 22
    return style.copy() as! NSParagraphStyle
}

3. 插入复选框到文本

将附件作为富文本属性插入段落开头:

func insertCheckBox(at range: NSRange, in textView: NSTextView) {
    let attachment = CheckBoxAttachment()
    let attrAttachment = NSAttributedString(attachment: attachment)
    
    let mutableAttr = NSMutableAttributedString(attributedString: attrAttachment)
    mutableAttr.append(NSAttributedString(string: " ")) // 添加空格分隔复选框与文本
    mutableAttr.addAttribute(.paragraphStyle, value: createCheckListParagraphStyle(), range: NSRange(location: 0, length: mutableAttr.length))
    
    textView.textStorage?.insert(mutableAttr, at: range.location)
}

交互逻辑处理

  • 光标位置:默认光标会自动定位在附件后的文本区域,符合需求。
  • 退格限制:禁止插入点移到复选框左侧,可通过NSTextViewDelegate拦截:
func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString string: String?) -> Bool {
    let textStorage = textView.textStorage!
    if range.location == 0, textStorage.attribute(.attachment, at: 0, effectiveRange: nil) is CheckBoxAttachment {
        return false
    }
    return true
}

方案二:NSLayoutManager 代理绘制(适合复选框作为段落样式)

如果不需要复选框随文本复制,可通过布局管理器代理在段落前绘制复选框,同时调整文本排版区域:

1. 标记目标段落

给需要显示复选框的段落添加自定义属性标记:

let CheckListParagraphAttribute = NSAttributedString.Key(rawValue: "CheckListParagraph")

// 给指定段落添加标记
textStorage?.addAttribute(CheckListParagraphAttribute, value: true, range: targetParagraphRange)

2. 实现布局管理器代理方法

func layoutManager(_ layoutManager: NSLayoutManager, shouldSetLineFragmentRect lineFragmentRect: UnsafeMutablePointer<CGRect>, lineFragmentUsedRect: UnsafeMutablePointer<CGRect>, baselineOffset: UnsafeMutablePointer<CGFloat>, in textContainer: NSTextContainer, forGlyphRange glyphRange: NSRange) {
    let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
    guard let isCheckList = layoutManager.textStorage?.attribute(CheckListParagraphAttribute, at: charRange.location, effectiveRange: nil) as? Bool, isCheckList else {
        return
    }
    
    // 调整文本区域,留出复选框空间
    lineFragmentRect.pointee.origin.x += 22
    lineFragmentRect.pointee.size.width -= 22
    lineFragmentUsedRect.pointee.origin.x += 22
    lineFragmentUsedRect.pointee.size.width -= 22
}

func layoutManager(_ layoutManager: NSLayoutManager, drawGlyphsForGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
    // 先绘制默认文本
    layoutManager.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
    
    // 绘制复选框
    let charRange = layoutManager.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
    guard let isCheckList = layoutManager.textStorage?.attribute(CheckListParagraphAttribute, at: charRange.location, effectiveRange: nil) as? Bool, isCheckList else {
        return
    }
    
    let firstGlyphIndex = glyphsToShow.location
    let lineRect = layoutManager.lineFragmentRect(forGlyphAt: firstGlyphIndex, effectiveRange: nil)
    let checkBoxRect = CGRect(x: origin.x + 2, y: origin.y + lineRect.origin.y + (lineRect.height - 18)/2, width: 18, height: 18)
    
    // 绘制复选框边框与对勾(代码同方案一的draw逻辑)
    let context = NSGraphicsContext.current!.cgContext
    context.saveGState()
    context.setStrokeColor(NSColor.labelColor.cgColor)
    context.setLineWidth(1.5)
    context.stroke(checkBoxRect.insetBy(dx: 2, dy: 2))
    context.restoreGState()
}

交互逻辑处理

  • 光标位置:文本排版区域已偏移,光标自动停在复选框右侧的文本起始位置。
  • 退格行为:复选框不属于文本内容,退格清空文本后,插入点自然停留在段落的文本起始位置,无需额外处理。

内容的提问来源于stack exchange,提问作者FE_Tech

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 19:28:10