如何在 SwiftUI 中自动扩展 NSTextView 的高度? [英] How to auto-expand height of NSTextView in SwiftUI?
问题描述
目标
一个 NSTextView,在换行时,垂直扩展其框架以强制 SwiftUI 父视图再次呈现(即,扩展文本下方的背景面板 + 下推 VStack 中的其他内容).父视图已经包含在 ScrollView 中.由于 SwiftUI 文本编辑器丑陋且功能不足,我猜其他几个刚接触 MacOS 的人会想知道如何做同样的事情.
An NSTextView that, upon new lines, expands its frame vertically to force a SwiftUI parent view to render again (i.e., expand a background panel that's under the text + push down other content in VStack). The parent view is already wrapped in a ScrollView. Since the SwiftUI TextEditor is ugly and under-featured, I'm guessing several others new to MacOS will wonder how to do the same.
更新
@Asperi 指出了埋在另一个线程中的 UIKit 示例.我尝试为 AppKit 调整它,但在 async recalculateHeight 函数中有一些循环.明天我会和咖啡一起看更多.谢谢阿斯佩里.(无论你是谁,你都是 SwiftUI SO 爸爸.)
@Asperi pointed out a sample for UIKit buried in another thread. I tried adapting that for AppKit, but there's some loop in the async recalculateHeight function. I'll look more at it with coffee tomorrow. Thanks Asperi. (Whoever you are, you are the SwiftUI SO daddy.)
问题
下面的 NSTextView 实现编辑愉快,但不遵守 SwiftUI 的垂直框架.水平方向上的所有内容都被遵守,但文本只是继续向下超过垂直高度限制.除了,当将焦点移开时,编辑器会裁剪额外的文本……直到再次开始编辑.
The NSTextView implementation below edits merrily, but disobeys SwiftUI's vertical frame. Horizontally all is obeyed, but texts just continues down past the vertical height limit. Except, when switching focus away, the editor crops that extra text... until editing begins again.
我的尝试
太多帖子作为模特.下面是一些.我认为我的不足是误解了如何设置约束、如何使用 NSTextView 对象,以及可能想多了.
Sooo many posts as models. Below are a few. My shortfall I think is misunderstanding how to set constraints, how to use NSTextView objects, and perhaps overthinking things.
- 我尝试在下面的代码中一起实现 NSTextContainer、NSLayoutManager 和 NSTextStorage 堆栈,但没有进展.
- 我玩过 GeometryReader 输入,没有骰子.
- 我已经在 textdidChange() 上打印了 LayoutManager 和 TextContainer 变量,但在新行上没有看到尺寸变化.还尝试侦听 .boundsDidChangeNotification/.frameDidChangeNotification.
- GitHub:未命名的 MacEditorTextView.swift <- 删除了其 ScrollView,但无法这样做后立即获得文本约束
- SO:SwiftUI 中的多行可编辑文本字段 <- 帮助了我了解如何包装,移除 ScrollView
- SO:使用 layoutManager 的计算 <- 我的实施无效
- Reddit:在 SwiftUI 中包装 NSTextView <- 提示似乎恰到好处,但缺乏可遵循的 AppKit 知识
- SO:使用内在内容大小自动增长高度 <-我的实施没有奏效
- SO:更改 ScrollView <- 不知道如何推断
- SO:关于设置的可可教程一个 NSTextView
- Apple NSTextContainer 类
- Apple 跟踪文本的大小查看
- GitHub: unnamedd MacEditorTextView.swift <- Removed its ScrollView, but couldn't get text constraints right after doing so
- SO: Multiline editable text field in SwiftUI <- Helped me understand how to wrap, removed the ScrollView
- SO: Using a calculation by layoutManager <- My implementation didn't work
- Reddit: Wrap NSTextView in SwiftUI <- Tips seem spot on, but lack AppKit knowledge to follow
- SO: Autogrow height with intrinsicContentSize <- My implementation didn't work
- SO: Changing a ScrollView <- Couldn't figure out how to extrapolate
- SO: Cocoa tutorial on setting up an NSTextView
- Apple NSTextContainer Class
- Apple Tracking the Size of a Text View
ContentView.swift
import SwiftUI
import Combine
struct ContentView: View {
@State var text = NSAttributedString(string: "Testing.... testing...")
let nsFont: NSFont = .systemFont(ofSize: 20)
var body: some View {
// ScrollView would go here
VStack(alignment: .center) {
GeometryReader { geometry in
NSTextEditor(text: $text.didSet { text in react(to: text) },
nsFont: nsFont,
geometry: geometry)
.frame(width: 500, // Wraps to width
height: 300) // Disregards this during editing
.background(background)
}
Text("Editing text above should push this down.")
}
}
var background: some View {
...
}
// Seeing how updates come back; I prefer setting them on textDidEndEditing to work with a database
func react(to text: NSAttributedString) {
print(#file, #line, #function, text)
}
}
// Listening device into @State
extension Binding {
func didSet(_ then: @escaping (Value) ->Void) -> Binding {
return Binding(
get: {
return self.wrappedValue
},
set: {
then($0)
self.wrappedValue = $0
}
)
}
}
NSTextEditor.swift
import SwiftUI
struct NSTextEditor: View, NSViewRepresentable {
typealias Coordinator = NSTextEditorCoordinator
typealias NSViewType = NSTextView
@Binding var text: NSAttributedString
let nsFont: NSFont
var geometry: GeometryProxy
func makeNSView(context: NSViewRepresentableContext<NSTextEditor>) -> NSTextEditor.NSViewType {
return context.coordinator.textView
}
func updateNSView(_ nsView: NSTextView, context: NSViewRepresentableContext<NSTextEditor>) { }
func makeCoordinator() -> NSTextEditorCoordinator {
let coordinator = NSTextEditorCoordinator(binding: $text,
nsFont: nsFont,
proxy: geometry)
return coordinator
}
}
class NSTextEditorCoordinator : NSObject, NSTextViewDelegate {
let textView: NSTextView
var font: NSFont
var geometry: GeometryProxy
@Binding var text: NSAttributedString
init(binding: Binding<NSAttributedString>,
nsFont: NSFont,
proxy: GeometryProxy) {
_text = binding
font = nsFont
geometry = proxy
textView = NSTextView(frame: .zero)
textView.autoresizingMask = [.height, .width]
textView.textColor = NSColor.textColor
textView.drawsBackground = false
textView.allowsUndo = true
textView.isAutomaticLinkDetectionEnabled = true
textView.displaysLinkToolTips = true
textView.isAutomaticDataDetectionEnabled = true
textView.isAutomaticTextReplacementEnabled = true
textView.isAutomaticDashSubstitutionEnabled = true
textView.isAutomaticSpellingCorrectionEnabled = true
textView.isAutomaticQuoteSubstitutionEnabled = true
textView.isAutomaticTextCompletionEnabled = true
textView.isContinuousSpellCheckingEnabled = true
textView.usesAdaptiveColorMappingForDarkAppearance = true
// textView.importsGraphics = true // 100% size, layoutManger scale didn't fix
// textView.allowsImageEditing = true // NSFileWrapper error
// textView.isIncrementalSearchingEnabled = true
// textView.usesFindBar = true
// textView.isSelectable = true
// textView.usesInspectorBar = true
// Context Menu show styles crashes
super.init()
textView.textStorage?.setAttributedString($text.wrappedValue)
textView.delegate = self
}
// Calls on every character stroke
func textDidChange(_ notification: Notification) {
switch notification.name {
case NSText.boundsDidChangeNotification:
print("bounds did change")
case NSText.frameDidChangeNotification:
print("frame did change")
case NSTextView.frameDidChangeNotification:
print("FRAME DID CHANGE")
case NSTextView.boundsDidChangeNotification:
print("BOUNDS DID CHANGE")
default:
return
}
// guard notification.name == NSText.didChangeNotification,
// let update = (notification.object as? NSTextView)?.textStorage else { return }
// text = update
}
// Calls only after focus change
func textDidEndEditing(_ notification: Notification) {
guard notification.name == NSText.didEndEditingNotification,
let update = (notification.object as? NSTextView)?.textStorage else { return }
text = update
}
}
从 UIKit 线程中快速回答 Asperi
崩溃
*** Assertion failure in -[NSCGSWindow setSize:], NSCGSWindow.m:1458
[General] Invalid parameter not satisfying:
size.width >= 0.0
&& size.width < (CGFloat)INT_MAX - (CGFloat)INT_MIN
&& size.height >= 0.0
&& size.height < (CGFloat)INT_MAX - (CGFloat)INT_MIN
import SwiftUI
struct AsperiMultiLineTextField: View {
private var placeholder: String
private var onCommit: (() -> Void)?
@Binding private var text: NSAttributedString
private var internalText: Binding<NSAttributedString> {
Binding<NSAttributedString>(get: { self.text } ) {
self.text = $0
self.showingPlaceholder = $0.string.isEmpty
}
}
@State private var dynamicHeight: CGFloat = 100
@State private var showingPlaceholder = false
init (_ placeholder: String = "", text: Binding<NSAttributedString>, onCommit: (() -> Void)? = nil) {
self.placeholder = placeholder
self.onCommit = onCommit
self._text = text
self._showingPlaceholder = State<Bool>(initialValue: self.text.string.isEmpty)
}
var body: some View {
NSTextViewWrapper(text: self.internalText, calculatedHeight: $dynamicHeight, onDone: onCommit)
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
.background(placeholderView, alignment: .topLeading)
}
@ViewBuilder
var placeholderView: some View {
if showingPlaceholder {
Text(placeholder).foregroundColor(.gray)
.padding(.leading, 4)
.padding(.top, 8)
}
}
}
fileprivate struct NSTextViewWrapper: NSViewRepresentable {
typealias NSViewType = NSTextView
@Binding var text: NSAttributedString
@Binding var calculatedHeight: CGFloat
var onDone: (() -> Void)?
func makeNSView(context: NSViewRepresentableContext<NSTextViewWrapper>) -> NSTextView {
let textField = NSTextView()
textField.delegate = context.coordinator
textField.isEditable = true
textField.font = NSFont.preferredFont(forTextStyle: .body)
textField.isSelectable = true
textField.drawsBackground = false
textField.allowsUndo = true
/// Disabled these lines as not available/neeed/appropriate for AppKit
// textField.isUserInteractionEnabled = true
// textField.isScrollEnabled = false
// if nil != onDone {
// textField.returnKeyType = .done
// }
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textField
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text, height: $calculatedHeight, onDone: onDone)
}
func updateNSView(_ NSView: NSTextView, context: NSViewRepresentableContext<NSTextViewWrapper>) {
NSTextViewWrapper.recalculateHeight(view: NSView, result: $calculatedHeight)
}
fileprivate static func recalculateHeight(view: NSView, result: Binding<CGFloat>) {
/// UIView.sizeThatFits is not available in AppKit. Tried substituting below, but there's a loop that crashes.
// let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
// tried reportedSize = view.frame, view.intrinsicContentSize
let reportedSize = view.fittingSize
let newSize = CGSize(width: reportedSize.width, height: CGFloat.greatestFiniteMagnitude)
if result.wrappedValue != newSize.height {
DispatchQueue.main.async {
result.wrappedValue = newSize.height // !! must be called asynchronously
}
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
var text: Binding<NSAttributedString>
var calculatedHeight: Binding<CGFloat>
var onDone: (() -> Void)?
init(text: Binding<NSAttributedString>, height: Binding<CGFloat>, onDone: (() -> Void)? = nil) {
self.text = text
self.calculatedHeight = height
self.onDone = onDone
}
func textDidChange(_ notification: Notification) {
guard notification.name == NSText.didChangeNotification,
let textView = (notification.object as? NSTextView),
let latestText = textView.textStorage else { return }
text.wrappedValue = latestText
NSTextViewWrapper.recalculateHeight(view: textView, result: calculatedHeight)
}
func textView(_ textView: NSTextView, shouldChangeTextIn: NSRange, replacementString: String?) -> Bool {
if let onDone = self.onDone, replacementString == "
" {
textView.resignFirstResponder()
onDone()
return false
}
return true
}
}
}
推荐答案
解决方案感谢@Asperi 的提示转换 他的 UIKit 代码在此发布. 一些事情必须改变:
Solution thanks to @Asperi's tip to convert his UIKit code in this post. A few things had to change:
- NSView 也缺少用于建议边界更改的 view.sizeThatFits(),因此我发现该视图的 .visibleRect 可以替代.
错误:
- 第一次渲染时有一个气泡(从垂直较小到适当大小).我认为这是由 recalculateHeight() 引起的,它最初会打印出一些较小的值.那里的门控声明阻止了这些值,但问题仍然存在.
- 目前我通过幻数设置占位符文本的插入,这应该基于 NSTextView 的属性来完成,但我还没有找到任何可用的东西.如果它具有相同的字体,我想我可以在占位符文本前添加一两个空格并完成它.
希望这可以为其他人节省一些时间来制作 SwiftUI Mac 应用程序.
import SwiftUI
// Wraps the NSTextView in a frame that can interact with SwiftUI
struct MultilineTextField: View {
private var placeholder: NSAttributedString
@Binding private var text: NSAttributedString
@State private var dynamicHeight: CGFloat // MARK TODO: - Find better way to stop initial view bobble (gets bigger)
@State private var textIsEmpty: Bool
@State private var textViewInset: CGFloat = 9 // MARK TODO: - Calculate insetad of magic number
var nsFont: NSFont
init (_ placeholder: NSAttributedString = NSAttributedString(string: ""),
text: Binding<NSAttributedString>,
nsFont: NSFont) {
self.placeholder = placeholder
self._text = text
_textIsEmpty = State(wrappedValue: text.wrappedValue.string.isEmpty)
self.nsFont = nsFont
_dynamicHeight = State(initialValue: nsFont.pointSize)
}
var body: some View {
ZStack {
NSTextViewWrapper(text: $text,
dynamicHeight: $dynamicHeight,
textIsEmpty: $textIsEmpty,
textViewInset: $textViewInset,
nsFont: nsFont)
.background(placeholderView, alignment: .topLeading)
// Adaptive frame applied to this NSViewRepresentable
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
}
}
// Background placeholder text matched to default font provided to the NSViewRepresentable
var placeholderView: some View {
Text(placeholder.string)
// Convert NSFont
.font(.system(size: nsFont.pointSize))
.opacity(textIsEmpty ? 0.3 : 0)
.padding(.leading, textViewInset)
.animation(.easeInOut(duration: 0.15))
}
}
// Creates the NSTextView
fileprivate struct NSTextViewWrapper: NSViewRepresentable {
@Binding var text: NSAttributedString
@Binding var dynamicHeight: CGFloat
@Binding var textIsEmpty: Bool
// Hoping to get this from NSTextView,
// but haven't found the right parameter yet
@Binding var textViewInset: CGFloat
var nsFont: NSFont
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text,
height: $dynamicHeight,
textIsEmpty: $textIsEmpty,
nsFont: nsFont)
}
func makeNSView(context: NSViewRepresentableContext<NSTextViewWrapper>) -> NSTextView {
return context.coordinator.textView
}
func updateNSView(_ textView: NSTextView, context: NSViewRepresentableContext<NSTextViewWrapper>) {
NSTextViewWrapper.recalculateHeight(view: textView, result: $dynamicHeight, nsFont: nsFont)
}
fileprivate static func recalculateHeight(view: NSView, result: Binding<CGFloat>, nsFont: NSFont) {
// Uses visibleRect as view.sizeThatFits(CGSize())
// is not exposed in AppKit, except on NSControls.
let latestSize = view.visibleRect
if result.wrappedValue != latestSize.height &&
// MARK TODO: - The view initially renders slightly smaller than needed, then resizes.
// I thought the statement below would prevent the @State dynamicHeight, which
// sets itself AFTER this view renders, from causing it. Unfortunately that's not
// the right cause of that redawing bug.
latestSize.height > (nsFont.pointSize + 1) {
DispatchQueue.main.async {
result.wrappedValue = latestSize.height
print(#function, latestSize.height)
}
}
}
}
// Maintains the NSTextView's persistence despite redraws
fileprivate final class Coordinator: NSObject, NSTextViewDelegate, NSControlTextEditingDelegate {
var textView: NSTextView
@Binding var text: NSAttributedString
@Binding var dynamicHeight: CGFloat
@Binding var textIsEmpty: Bool
var nsFont: NSFont
init(text: Binding<NSAttributedString>,
height: Binding<CGFloat>,
textIsEmpty: Binding<Bool>,
nsFont: NSFont) {
_text = text
_dynamicHeight = height
_textIsEmpty = textIsEmpty
self.nsFont = nsFont
textView = NSTextView(frame: .zero)
textView.isEditable = true
textView.isSelectable = true
// Appearance
textView.usesAdaptiveColorMappingForDarkAppearance = true
textView.font = nsFont
textView.textColor = NSColor.textColor
textView.drawsBackground = false
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
// Functionality (more available)
textView.allowsUndo = true
textView.isAutomaticLinkDetectionEnabled = true
textView.displaysLinkToolTips = true
textView.isAutomaticDataDetectionEnabled = true
textView.isAutomaticTextReplacementEnabled = true
textView.isAutomaticDashSubstitutionEnabled = true
textView.isAutomaticSpellingCorrectionEnabled = true
textView.isAutomaticQuoteSubstitutionEnabled = true
textView.isAutomaticTextCompletionEnabled = true
textView.isContinuousSpellCheckingEnabled = true
super.init()
// Load data from binding and set font
textView.textStorage?.setAttributedString(text.wrappedValue)
textView.textStorage?.font = nsFont
textView.delegate = self
}
func textDidChange(_ notification: Notification) {
// Recalculate height after every input event
NSTextViewWrapper.recalculateHeight(view: textView, result: $dynamicHeight, nsFont: nsFont)
// If ever empty, trigger placeholder text visibility
if let update = (notification.object as? NSTextView)?.string {
textIsEmpty = update.isEmpty
}
}
func textDidEndEditing(_ notification: Notification) {
// Update binding only after editing ends; useful to gate NSManagedObjects
$text.wrappedValue = textView.attributedString()
}
}
这篇关于如何在 SwiftUI 中自动扩展 NSTextView 的高度?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!