How do I update state in NSViewRepresentable NSTextView?

I am trying to wrap a NSTextView in a NSViewRepresentable SwiftUI view.


If text is changed, then updateNSView is called, and the view is correctly updated, but how do I do it the other way around?


I want text to be updated when the user types in the NSTextView and I cannot figure out how to do this. The TextView needs a delegate to handle text changed, but the NSViewRepresentable view cannot implment this protocol (because it is not a class).


I have a simple ContentView below that illustrates my problem. It contains Text, my TextView and a TextField. All three use the same text binding. If you type in the TextField, then Text and my TextView is updated, but when you type in my TextView, it does not update text, and hence nothing happens. How can I let my TextView work like TextField?



struct TextView: NSViewRepresentable {
  typealias NSViewType = NSTextView
  
  @Binding var text: String
  
  func makeNSView(context: Self.Context) -> Self.NSViewType {
    let view = NSTextView()
    return view
  }
  
  func updateNSView(_ nsView: Self.NSViewType, context: Self.Context) {
    nsView.string = text
  }
}

struct ContentView: View {
  @State var text: String = ""
  var body: some View {
    VStack {
      Text(text)
      Divider()
      TextField("", text: $text)
      TextView(text: $text)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
  }
}

Accepted Reply

You receive changes from an NSTextView by installing a delegate. For NSViewRepresentable types this requires the use of a Coordinator class, which you provide.


Here's a working TextView I use in an iOS app; the general application is the same, so it shouldn't be difficult to port to macOS. The UITraitCollection code in updateUIView(_:context:) likely wouldn't be needed, because I don't recall macOS using dynamic type.


import SwiftUI
import UIKit

struct TextView: UIViewRepresentable {
    @Binding var text: String
    
    var onEditingChanged: ((Bool) -> ())? = nil
    var onEditingCommit: (() -> ())? = nil
    
    func makeUIView(context: Context) -> UITextView {
        let view = UITextView()
        view.delegate = context.coordinator
        view.font = UIFont.preferredFont(forTextStyle: .body)
        view.textColor = UIColor.label
        
        return view
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
        uiView.textColor = .label
        
        let traits = UITraitCollection(traitsFrom: [
            uiView.traitCollection,
            UITraitCollection(swiftUIContentSizeCategory: context.environment.sizeCategory)
        ])
        uiView.font = UIFont.preferredFont(forTextStyle: .body, compatibleWith: traits)
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: TextView
        
        init(_ parent: TextView) {
            self.parent = parent
        }
        
        func textViewDidChange(_ textView: UITextView) {
            self.parent.text = textView.text
        }
        
        func textViewDidBeginEditing(_ textView: UITextView) {
            parent.onEditingChanged?(true)
        }

        func textViewDidEndEditing(_ textView: UITextView) {
            parent.onEditingCommit?()
            parent.onEditingChanged?(false)
        }
    }
}

struct TextView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            StatefulPreviewWrapper("This is the sample text.\n\nIt has many lines.") {
                TextView(text: $0)
            }
            StatefulPreviewWrapper("This is the sample text.\n\nIt has many lines.") {
                TextView(text: $0)
                    .environment(\.colorScheme, .dark)
            }
        }
        .previewLayout(.fixed(width: 400, height: 200))
    }
}

Replies

You receive changes from an NSTextView by installing a delegate. For NSViewRepresentable types this requires the use of a Coordinator class, which you provide.


Here's a working TextView I use in an iOS app; the general application is the same, so it shouldn't be difficult to port to macOS. The UITraitCollection code in updateUIView(_:context:) likely wouldn't be needed, because I don't recall macOS using dynamic type.


import SwiftUI
import UIKit

struct TextView: UIViewRepresentable {
    @Binding var text: String
    
    var onEditingChanged: ((Bool) -> ())? = nil
    var onEditingCommit: (() -> ())? = nil
    
    func makeUIView(context: Context) -> UITextView {
        let view = UITextView()
        view.delegate = context.coordinator
        view.font = UIFont.preferredFont(forTextStyle: .body)
        view.textColor = UIColor.label
        
        return view
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
        uiView.textColor = .label
        
        let traits = UITraitCollection(traitsFrom: [
            uiView.traitCollection,
            UITraitCollection(swiftUIContentSizeCategory: context.environment.sizeCategory)
        ])
        uiView.font = UIFont.preferredFont(forTextStyle: .body, compatibleWith: traits)
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: TextView
        
        init(_ parent: TextView) {
            self.parent = parent
        }
        
        func textViewDidChange(_ textView: UITextView) {
            self.parent.text = textView.text
        }
        
        func textViewDidBeginEditing(_ textView: UITextView) {
            parent.onEditingChanged?(true)
        }

        func textViewDidEndEditing(_ textView: UITextView) {
            parent.onEditingCommit?()
            parent.onEditingChanged?(false)
        }
    }
}

struct TextView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            StatefulPreviewWrapper("This is the sample text.\n\nIt has many lines.") {
                TextView(text: $0)
            }
            StatefulPreviewWrapper("This is the sample text.\n\nIt has many lines.") {
                TextView(text: $0)
                    .environment(\.colorScheme, .dark)
            }
        }
        .previewLayout(.fixed(width: 400, height: 200))
    }
}

Thanks.