如何在 SwiftUI 中自动扩展 NSTextView 的高度? [英] How to auto-expand height of NSTextView in SwiftUI?

查看:32
本文介绍了如何在 SwiftUI 中自动扩展 NSTextView 的高度?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

目标

一个 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.
  1. GitHub:未命名的 MacEditorTextView.swift <- 删除了其 ScrollView,但无法这样做后立即获得文本约束
  2. SO:SwiftUI 中的多行可编辑文本字段 <- 帮助了我了解如何包装,移除 ScrollView
  3. SO:使用 layoutManager 的计算 <- 我的实施无效
  4. Reddit:在 SwiftUI 中包装 NSTextView <- 提示似乎恰到好处,但缺乏可遵循的 AppKit 知识
  5. SO:使用内在内容大小自动增长高度 <-我的实施没有奏效
  6. SO:更改 ScrollView <- 不知道如何推断
  7. SO:关于设置的可可教程一个 NSTextView
  8. Apple NSTextContainer 类
  9. Apple 跟踪文本的大小查看
  1. GitHub: unnamedd MacEditorTextView.swift <- Removed its ScrollView, but couldn't get text constraints right after doing so
  2. SO: Multiline editable text field in SwiftUI <- Helped me understand how to wrap, removed the ScrollView
  3. SO: Using a calculation by layoutManager <- My implementation didn't work
  4. Reddit: Wrap NSTextView in SwiftUI <- Tips seem spot on, but lack AppKit knowledge to follow
  5. SO: Autogrow height with intrinsicContentSize <- My implementation didn't work
  6. SO: Changing a ScrollView <- Couldn't figure out how to extrapolate
  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 == "
" {
                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屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆