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 } }
关键改动说明
约束管理优化:
- 新增
heightConstraint变量保存高度约束,避免每次调用刷新方法时重复添加约束导致冲突 - 基础约束设置为scrollView完全填充父视图,仅通过高度约束控制实际显示高度
- 新增
准确计算行高:
- 使用
textView.layoutManager.defaultLineHeight(for: font)获取真实行高(包含行间距),替代简单的字体大小计算,保证5行高度的准确性
- 使用
文本框尺寸限制:
- 设置
textView.maxSize为5行高度,确保文本内容超过5行时,textView不会继续拉伸,触发ScrollView的滚动 - 设置
textView.minSize为1行高度,保证无内容或少量内容时显示最小高度
- 设置
布局更新逻辑:
- 在
textDidChange中通过enclosingScrollView更可靠地获取CustomTextView实例,避免多层superview的依赖 - 在
updateNSView中仅当文本内容变化时才更新视图并刷新约束,减少不必要的布局操作
- 在
内容的提问来源于stack exchange,提问作者Andrew
相关产品推荐
相关产品推荐

