Wrap NSTextView in SwiftUI
During WWDC 2020 SwiftUI lerned a few new things. For example Map and TextEditor. Both are neat additions but still not capable of replacing their corresponding AppKit or UIKit counterparts. The SwiftUI Map can handle annotations but not overlays, yet. And the TextEditor cannot present NSAttributedStrings. So let's wrap a NSTextView in SwiftUI and handle the updating of the model data.
Create a ViewController that presents the NSTextView
This is pretty easy and no different than you'd expect:
class EditorController: NSViewController {
    var textView = NSTextView()
    
    override func loadView() {
        let scrollView = NSScrollView()
        scrollView.hasVerticalScroller = true
        
        textView.autoresizingMask = [.width]
        textView.allowsUndo = true
        textView.font = .systemFont(ofSize: 16)
        scrollView.documentView = textView
        
        self.view = scrollView
    }
    
    override func viewDidAppear() {
        self.view.window?.makeFirstResponder(self.view)
    }
}
In viewDidAppear() I make the controllers view the first responder. I like to be able to start typing immmediatly when the view get's presented and not have to click with the mouse, first. ๐
Create a Representable
To wrap a NSViewController inside a SwiftUI View struct you can use the protocol NSViewControllerRepresentable:
struct EditorControllerView: NSViewControllerRepresentable {
    @Binding var text: String
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    class Coordinator: NSObject, NSTextStorageDelegate {
        private var parent: EditorControllerView
        var shouldUpdateText = true
        
        init(_ parent: EditorControllerView) {
            self.parent = parent
        }
        
        func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) {
            guard shouldUpdateText else {
                return
            }
            let edited = textStorage.attributedSubstring(from: editedRange).string
            let insertIndex = parent.text.utf16.index(parent.text.utf16.startIndex, offsetBy: editedRange.lowerBound)
            
            func numberOfCharactersToDelete() -> Int {
                editedRange.length - delta
            }
            
            let endIndex = parent.text.utf16.index(insertIndex, offsetBy: numberOfCharactersToDelete())
            self.parent.text.replaceSubrange(insertIndex..<endIndex, with: edited)
        }
    }
    func makeNSViewController(context: Context) -> EditorController {
        let vc = EditorController()
        vc.textView.textStorage?.delegate = context.coordinator
        return vc
    }
    
    func updateNSViewController(_ nsViewController: EditorController, context: Context) {
        if text != nsViewController.textView.string {
            context.coordinator.shouldUpdateText = false
            nsViewController.textView.string = text
            context.coordinator.shouldUpdateText = true
        }
    }
}
The basic idea is to use a NSTextStorageDelegate to apply the edit that was done to the textView.textStorage to the @Binding-property.
But there's a bit to consider:
- Once the @Bindingproperty got updated it will call theupdateNSViewControllerfunction. This only needs to really do anything if the change originated from the SwiftUI-side of things. If the change came from the ViewController there is nothing more to do.
- The internal representation of the string in the NSTextStorageis utf-16. So if you enter a ๐ in thetextViewthetextStorage-delegate function will tell you that you edited from 0 to 2 and inserted 2 characters. If you replace the ๐ with a ๐จโ๐ฉโ๐งโ๐ง you will edit from 0 to 11 with a delta of 9. Easy ๐ฌ
- So the function gets the string representing the editedRange from the textStorageand calculates the position to insert from the utf16-representation. If you replace the ๐จโ๐ฉโ๐งโ๐ง with a ๐ again you edited from 0 to 2 with a delta of -9. This means: for your one character long string to remain one character long you need to delete 9 characters from 2 to 11. โค๏ธ
Since every update has to change the @Binding property we do both in one go with the handy replaceSubrange function.
Testdrive
Yeah! Now we have a nice SwiftUI component:
struct ContentView: View {
    @State private var text = ""
    
    var body: some View {
        VStack(alignment: .trailing) {
            HStack {
                Text("count_key")
                Text(String(text.count))
            }.padding()
            EditorControllerView(text: $text) // our component
            TextEditor(text: $text) // SwiftUI
        }
    }
}
Try editing in a long text > 4Mb with and without the SwiftUI TextEditor.