我已经实现了一个 NSTextView SwiftUI-wrapper(在this great example之后)。在我看来,有几个这样的 NSTextViews。在应用程序的菜单中,有一个按钮可以更改当前聚焦的 NSTextView 的内容,例如:
有没有办法确定当前关注的是哪个 NSTextView?在我当前的解决方案中,我通过在调用“becomeFirstResponder”时传递 NSTextView 来将 NSTextView 存储在全局视图模型的变量中。
但是,我担心这个解决方案可能会导致保留周期或存储在视图模型中的 NSTextView 变为零。有 cleaner 的方法吗?任何帮助表示赞赏!
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)
}
}
}
}