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

macOS SwiftUI实现NSTextView自适应高度(最多5行)

实现SwiftUI自适应高度的ScrollView文本框(最多5行)

需求说明

  • 已完成:
    • TextField固定显示在窗口底部
    • TextField悬浮在窗口内容上方
    • 嵌套ScrollView支持文本滚动
  • 待实现:
    • ScrollView高度随文本内容自适应,最多显示5行
    • 文本内容≥5行时,固定为5行高度并允许垂直滚动
    • 无内容或仅1行时,显示最小高度

当前问题:ScrollView高度为静态值,无法自适应内容变化。

修改后的完整代码

ContentView部分

import Combine
import SwiftUI

@available(macOS 12.0, *)
struct ContentView: View {
    @State var text: String = textSample
    
    var body: some View {
        ZStack {
            VStack{
                Spacer()
                Text("Hello")
                Spacer()
            }
            
            VStack {
                Spacer()
                DescriptionTextField(text: $text)
                    .padding(EdgeInsets(top: 3, leading: 3, bottom: 6, trailing: 3))
                    .background(Color.green)
            }
        }
        .frame(minWidth: 450, minHeight: 300)
    }
}

let textSample =
"""
hello 1
hello 2
hello 3
hello 4
hello 5
hello 6
hello 7
hello 8
"""

DescriptionTextField及CustomTextView部分

import Foundation
import SwiftUI
import AppKit

struct DescriptionTextField: NSViewRepresentable {
    @Binding var text: String
    var isEditable: Bool = true
    var font: NSFont? = .systemFont(ofSize: 17, weight: .regular)
    
    var onEditingChanged: () -> Void = { }
    var onCommit: () -> Void = { }
    var onTextChange: (String) -> Void = { _ in }
    
    func makeCoordinator() -> Coordinator { Coordinator(self) }
    
    func makeNSView(context: Context) -> CustomTextView {
        let textView = CustomTextView(text: text, isEditable: isEditable, font: font)
        textView.delegate = context.coordinator
        return textView
    }
    
    func updateNSView(_ view: CustomTextView, context: Context) {
        if view.text != text {
            view.text = text
            view.refreshScrollViewConstraints()
        }
        view.selectedRanges = context.coordinator.selectedRanges
    }
}

extension DescriptionTextField {
    class Coordinator: NSObject, NSTextViewDelegate {
        var parent: DescriptionTextField
        var selectedRanges: [NSValue] = []
        
        init(_ parent: DescriptionTextField) {
            self.parent = parent
        }
        
        func textDidBeginEditing(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            parent.text = textView.string
            parent.onEditingChanged()
        }
        
        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            parent.text = textView.string
            selectedRanges = textView.selectedRanges
            
            if let customView = textView.enclosingScrollView?.superview as? CustomTextView {
                customView.refreshScrollViewConstraints()
            }
        }
        
        func textDidEndEditing(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            parent.text = textView.string
            parent.onCommit()
        }
    }
}

final class CustomTextView: NSView {
    private var isEditable: Bool
    private var font: NSFont?
    weak var delegate: NSTextViewDelegate?
    
    var text: String {
        didSet {
            guard textView.string != text else { return }
            textView.string = text
        }
    }
    
    var selectedRanges: [NSValue] = [] {
        didSet {
            guard !selectedRanges.isEmpty else { return }
            textView.selectedRanges = selectedRanges
        }
    }
    
    private lazy var scrollView: NSScrollView = {
        let scrollView = NSScrollView()
        scrollView.drawsBackground = false
        scrollView.borderType = .noBorder
        scrollView.hasVerticalScroller = true
        scrollView.hasHorizontalRuler = false
        scrollView.autoresizingMask = [.width, .height]
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        return scrollView
    }()
    
    private lazy var textView: NSTextView = {
        let textStorage = NSTextStorage()
        let layoutManager = NSLayoutManager()
        textStorage.addLayoutManager(layoutManager)
        
        let textContainer = NSTextContainer()
        textContainer.widthTracksTextView = true
        textContainer.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
        
        layoutManager.addTextContainer(textContainer)
        
        let textView = NSTextView(frame: .zero, textContainer: textContainer)
        textView.autoresizingMask = [.width, .height]
        textView.backgroundColor = NSColor.clear
        textView.delegate = delegate
        textView.drawsBackground = true
        textView.font = font
        textView.isHorizontallyResizable = false
        textView.isVerticallyResizable = true
        textView.textColor = NSColor.labelColor
        textView.allowsUndo = true
        textView.isRichText = true
        
        // 设置文本框最大高度为5行
        if let font = font {
            let lineHeight = textView.layoutManager.defaultLineHeight(for: font)
            textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: lineHeight * 5)
            textView.minSize = NSSize(width: 150, height: lineHeight)
        }
        
        return textView
    }()
    
    // 保存高度约束,避免重复添加
    private var heightConstraint: NSLayoutConstraint?
    
    init(text: String, isEditable: Bool, font: NSFont?) {
        self.font = font
        self.isEditable = isEditable
        self.text = text
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    
    override func viewWillDraw() {
        super.viewWillDraw()
        setupScrollViewConstraints()
        scrollView.documentView = textView
    }
    
    private func setupScrollViewConstraints() {
        guard scrollView.superview == nil else { return }
        
        addSubview(scrollView)
        
        // 基础约束:scrollView填充父视图
        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(equalTo: topAnchor),
            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor)
        ])
        
        // 创建高度约束
        if let font = font {
            let lineHeight = textView.layoutManager.defaultLineHeight(for: font)
            heightConstraint = scrollView.heightAnchor.constraint(lessThanOrEqualToConstant: lineHeight * 5)
            heightConstraint?.priority = .defaultHigh
            heightConstraint?.isActive = true
        }
        
        refreshScrollViewConstraints()
    }
    
    func refreshScrollViewConstraints() {
        guard let font = font else { return }
        
        let lineHeight = textView.layoutManager.defaultLineHeight(for: font)
        let contentHeight = textView.contentSize.height
        // 计算实际高度:内容高度和5行高度取最小值
        let targetHeight = min(contentHeight, lineHeight * 5)
        
        // 更新高度约束的constant
        heightConstraint?.constant = targetHeight
        
        // 触发布局更新
        scrollView.needsLayout = true
        needsLayout = true
    }
}

extension NSTextView {
    var contentSize: CGSize {
        guard let layoutManager = layoutManager, let textContainer = textContainer else {
            print("textView missing layoutManager or textContainer")
            return .zero
        }
        layoutManager.ensureLayout(for: textContainer)
        return layoutManager.usedRect(for: textContainer).size
    }
}

关键改动说明

  1. 约束管理优化:

    • 新增heightConstraint变量保存高度约束,避免每次调用刷新方法时重复添加约束导致冲突
    • 基础约束设置为scrollView完全填充父视图,仅通过高度约束控制实际显示高度
  2. 准确计算行高:

    • 使用textView.layoutManager.defaultLineHeight(for: font)获取真实行高(包含行间距),替代简单的字体大小计算,保证5行高度的准确性
  3. 文本框尺寸限制:

    • 设置textView.maxSize为5行高度,确保文本内容超过5行时,textView不会继续拉伸,触发ScrollView的滚动
    • 设置textView.minSize为1行高度,保证无内容或少量内容时显示最小高度
  4. 布局更新逻辑:

    • 在textDidChange中通过enclosingScrollView更可靠地获取CustomTextView实例,避免多层superview的依赖
    • 在updateNSView中仅当文本内容变化时才更新视图并刷新约束,减少不必要的布局操作

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.11 17:44:54