gpt4 book ai didi

macos - 如何在 SwiftUI 中自动扩展 NSTextView 的高度?

转载 作者:行者123 更新时间:2023-12-05 02:05:47 26 4
gpt4 key购买 nike

我如何在下面的 NSTextView 上正确实现 NSView 约束,以便它与 SwiftUI .frame() 交互?

目标

一个 NSTextView,它在新行上垂直扩展其框架以强制 SwiftUI 父 View 再次呈现(即,扩展文本下方的背景面板 + 下推 VStack 中的其他内容)。父 View 已经包装在 ScrollView 中。由于 SwiftUI TextEditor 丑陋且功能不足,我猜其他几个刚接触 MacOS 的人会想知道如何做同样的事情。

更新

@Asperi 指出了埋藏在另一个线程中的 UIKit 示例。我尝试为 AppKit 调整它,但异步 recalculateHeight 函数中有一些循环。明天我会用咖啡仔细看看它。谢谢阿斯佩里。 (无论你是谁,你都是 SwiftUI SO 爸爸。)

问题

下面的 NSTextView 实现可以愉快地编辑,但不符合 SwiftUI 的垂直框架。在水平方向上,所有内容都被遵守,但文本只是继续向下超过垂直高度限制。除了,当将焦点移开时,编辑器会裁剪多余的文本……直到再次开始编辑。

我尝试过的

作为模特的帖子太多了。下面是一些。我认为我的缺点是误解了如何设置约束、如何使用 NSTextView 对象,以及可能想得太多了。

  • 我尝试在下面的代码中一起实现 NSTextContainer、NSLayoutManager 和 NSTextStorage 堆栈,但没有任何进展。
  • 我玩过 GeometryReader 输入,没有骰子。
  • 我已经在 textdidChange() 上打印了 LayoutManager 和 TextContainer 变量,但我没有看到尺寸随着换行而改变。还尝试监听 .boundsDidChangeNotification/.frameDidChangeNotification。
  1. GitHub: unnamedd MacEditorTextView.swift <- 删除了它的 ScrollView,但在这样做之后无法立即获得文本约束
  2. SO: Multiline editable text field in SwiftUI <- 帮助我了解如何包装、移除 ScrollView
  3. SO: Using a calculation by layoutManager <- 我的实现没有成功
  4. Reddit: Wrap NSTextView in SwiftUI <- 提示似乎很准确,但缺乏可遵循的 AppKit 知识
  5. SO: Autogrow height with intrinsicContentSize <- 我的实现没有成功
  6. SO: Changing a ScrollView <- 不知道如何推断
  7. SO: Cocoa tutorial on setting up an NSTextView
  8. Apple NSTextContainer Class
  9. 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 == "\n" {
textView.resignFirstResponder()
onDone()
return false
}
return true
}
}

}

最佳答案

解决方案感谢@Asperi 转换的提示 his UIKit code in this post.有几件事必须改变:

  • NSView 也缺少用于提议的边界更改的 view.sizeThatFits(),因此我发现 View 的 .visibleRect 可以代替。

错误:

  • 第一次渲染时出现气泡(从较小的垂直方向到适当的尺寸)。我认为这是由 recalculateHeight() 引起的,它最初会打印出一些较小的值。那里的门控语句停止了这些值,但泡沫仍然存在。
  • 目前我将占位符文本的插入设置为一个魔数(Magic Number),这应该根据 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()
}
}


关于macos - 如何在 SwiftUI 中自动扩展 NSTextView 的高度?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/63127103/

26 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com