从 WindowGroup 的 CommandGround-item 获取 SwiftUI 中当前聚焦的 NSTextView

问题描述 投票:0回答:0

问题

我已经实现了一个 NSTextView SwiftUI-wrapper(在this great example之后)。在我看来,有几个这样的 NSTextViews。在应用程序的菜单中,有一个按钮可以更改当前聚焦的 NSTextView 的内容,例如:

有没有办法确定当前关注的是哪个 NSTextView?在我当前的解决方案中,我通过在调用“becomeFirstResponder”时传递 NSTextView 来将 NSTextView 存储在全局视图模型的变量中。

但是,我担心这个解决方案可能会导致保留周期或存储在视图模型中的 NSTextView 变为零。有 cleaner 的方法吗?任何帮助表示赞赏!

当前解决方案/代码

NSTextView

struct TextArea: NSViewRepresentable {
    // Source : https://stackoverflow.com/a/63761738/2624880
    @Binding var text: NSAttributedString
    @Binding var selectedRange: NSRange
    @Binding var isFirstResponder: Bool
    
    func makeNSView(context: Context) -> NSScrollView {
        context.coordinator.createTextViewStack()
    }

    func updateNSView(_ nsView: NSScrollView, context: Context) {
        
        if let textArea = nsView.documentView as? NSTextView {
            
            textArea.textStorage?.setAttributedString(self.text)
            if !(self.selectedRange.location == textArea.selectedRange().location && self.selectedRange.length == textArea.selectedRange().length) {
                textArea.setSelectedRange(self.selectedRange)
            }
            
            // Set focus (SwiftUI 👉 AppKit)
            if isFirstResponder {
                nsView.becomeFirstResponder()
                DispatchQueue.main.async {
                    if ViewModel.shared.focusedTextView != textArea {
                        ViewModel.shared.focusedTextView = textArea
                    }
                }
            } else {
                nsView.resignFirstResponder()
            }
            
        }
        
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text, selectedRange: $selectedRange, isFirstResponder: $isFirstResponder)
    }

    class Coordinator: NSObject, NSTextViewDelegate {
        
        var text: Binding<NSAttributedString>
        var selectedRange: Binding<NSRange>
        var isFirstResponder: Binding<Bool>
        
        init(text: Binding<NSAttributedString>,
             selectedRange: Binding<NSRange>,
             isFirstResponder: Binding<Bool>) {
            self.text = text
            self.selectedRange = selectedRange
            self.isFirstResponder = isFirstResponder
        }

        func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementNSAttributedString text: NSAttributedString?) -> Bool {
            defer {
                self.text.wrappedValue = textView.attributedString()
                self.selectedRange.wrappedValue = textView.selectedRange()
            }
            return true
        }
        
        fileprivate lazy var textStorage = NSTextStorage()
        fileprivate lazy var layoutManager = NSLayoutManager()
        fileprivate lazy var textContainer = NSTextContainer()
        fileprivate lazy var textView: NSTextViewWithFocusHandler = NSTextViewWithFocusHandler(frame: CGRect(), textContainer: textContainer)
        fileprivate lazy var scrollview = NSScrollView()

        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            self.text.wrappedValue = NSAttributedString(attributedString: textView.attributedString())
            self.selectedRange.wrappedValue = textView.selectedRange()
        }
        
        func textViewDidChangeSelection(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            DispatchQueue.main.async {
                if !(self.selectedRange.wrappedValue.location == textView.selectedRange().location && self.selectedRange.wrappedValue.length == textView.selectedRange().length) {
                    self.selectedRange.wrappedValue = textView.selectedRange()
                }
            }
        }
        
        func textDidBeginEditing(_ notification: Notification) {
            DispatchQueue.main.async {
                self.isFirstResponder.wrappedValue = true
            }
        }
        
        func textDidEndEditing(_ notification: Notification) {
            DispatchQueue.main.async {
                self.isFirstResponder.wrappedValue = false
            }
        }
        
        func createTextViewStack() -> NSScrollView {
            let contentSize = scrollview.contentSize

            textContainer.containerSize = CGSize(width: contentSize.width, height: CGFloat.greatestFiniteMagnitude)
            textContainer.widthTracksTextView = true

            textView.minSize = CGSize(width: 0, height: 0)
            textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
            textView.isVerticallyResizable = true
            textView.frame = CGRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height)
            textView.autoresizingMask = [.width]
            textView.delegate = self
            
            scrollview.borderType = .noBorder
            scrollview.hasVerticalScroller = true
            scrollview.documentView = textView
            scrollview.layer?.cornerRadius = 10
            scrollview.drawsBackground = false
            
            
            textStorage.addLayoutManager(layoutManager)
            layoutManager.addTextContainer(textContainer)

            return scrollview
        }
        
    }
}

class NSTextViewWithFocusHandler: NSTextView {
    override func becomeFirstResponder() -> Bool {
        // ⚠️ Set self as currently focused TextView (AppKit 👉 SwiftUI)
        ViewModel.shared.focusedTextView = self
        return super.becomeFirstResponder()
    }
}

视图模型

class ViewModel: ObservableObject {
    static let shared = ViewModel()
    
    @Published var attributedTextQuestion: NSAttributedString = NSAttributedString(string: "Initial value question")
    @Published var attributedTextAnswer: NSAttributedString = NSAttributedString(string: "Initial value answer")
    @Published var selectedRangeQuestion: NSRange = NSRange()
    @Published var selectedRangeAnswer: NSRange = NSRange()
    @Published var questionFocused: Bool = false // Only works in direction SwiftUI 👉 AppKit
    @Published var answerFocused: Bool = false // (dito)
    weak var focusedTextView: NSTextView? {didSet{
        DispatchQueue.main.async {
            self.menuItemEnabled = self.focusedTextView != nil
        }
    }}
    @Published var menuItemEnabled: Bool = false
}

内容视图

struct ContentView: View {
    @ObservedObject var model: ViewModel
    
    var body: some View {
        VStack {            
            Text("Question")
            TextArea(text: $model.attributedTextQuestion,
                     selectedRange: $model.selectedRangeQuestion,
                     isFirstResponder: $model.questionFocused)
            Text("Answer")
            TextArea(text: $model.attributedTextAnswer,
                     selectedRange: $model.selectedRangeAnswer,
                     isFirstResponder: $model.answerFocused)
        }
        .padding()
    }
}

应用程序

@main
struct TextViewMacOSSOFrageApp: App {
    
    @ObservedObject var model: ViewModel = ViewModel.shared
    
    var body: some Scene {
        WindowGroup {
            ContentView(model: model)
        }.commands {
            CommandGroup(replacing: .textFormatting) {
                Button(action: {
                    // ⚠️ The currently focused TextView is retrieved and its AttributedString updated
                    guard let focusedTextView = model.focusedTextView else { return }
                    
                    let newAttString = NSMutableAttributedString(string: "Value set through menu item")
                    newAttString.addAttribute(.backgroundColor, value: NSColor.yellow, range: NSRange(location: 0, length: 3))
                    focusedTextView.textStorage?.setAttributedString(newAttString)
                    focusedTextView.didChangeText()
                }) {
                    Text("Insert image")
                }.disabled(!model.menuItemEnabled)
                
            }
        }
        
    }
}
macos swiftui appkit nstextview
© www.soinside.com 2019 - 2024. All rights reserved.