0

Question

I've implemented a NSTextView SwiftUI-wrapper (following this great example). There are several of these NSTextViews on my view. In the app's menu, there is a button that should change the currently focused NSTextView's content, e.g:

enter image description here

Is there a way to determine which NSTextView is currently focused? In my current solution, I resorted to storing the NSTextView in a global view model's variable by passing the NSTextView when its "becomeFirstResponder" is called.

However, I'm afraid this solution could either lead to retain cycles or to the NSTextView stored in the view model becoming nil. Is there a cleaner way of doing this? Any help is appreciated!

Current solution/Code

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()
    }
}

ViewModel

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
}

ContentView

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()
    }
}

App

@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)
                
            }
        }
        
    }
}
Sebastian
  • 115
  • 1
  • 8

0 Answers0