Skip to content

Instantly share code, notes, and snippets.

@unnamedd
Last active November 3, 2025 16:04
Show Gist options
  • Save unnamedd/6e8c3fbc806b8deb60fa65d6b9affab0 to your computer and use it in GitHub Desktop.
Save unnamedd/6e8c3fbc806b8deb60fa65d6b9affab0 to your computer and use it in GitHub Desktop.
[SwiftUI] MacEditorTextView - A simple and small NSTextView wrapped by SwiftUI.
/**
* MacEditorTextView
* Copyright (c) Thiago Holanda 2020-2021
* https://bsky.app/profile/tholanda.com
*
* (the twitter account is now deleted, please, do not try to reach me there)
* https://twitter.com/tholanda
*
* MIT license
*/
import Combine
import SwiftUI
struct MacEditorTextView: NSViewRepresentable {
@Binding var text: String
var isEditable: Bool = true
var font: NSFont? = .systemFont(ofSize: 14, weight: .regular)
var onEditingChanged: () -> Void = {}
var onCommit : () -> Void = {}
var onTextChange : (String) -> Void = { _ in }
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> CustomTextView {
let textView = CustomTextView(
text: text,
isEditable: isEditable,
font: font
)
textView.delegate = context.coordinator
return textView
}
func updateNSView(_ view: CustomTextView, context: Context) {
view.text = text
view.selectedRanges = context.coordinator.selectedRanges
}
}
// MARK: - Preview
#if DEBUG
struct MacEditorTextView_Previews: PreviewProvider {
static var previews: some View {
Group {
MacEditorTextView(
text: .constant("{ \n planets { \n name \n }\n}"),
isEditable: true,
font: .userFixedPitchFont(ofSize: 14)
)
.environment(\.colorScheme, .dark)
.previewDisplayName("Dark Mode")
MacEditorTextView(
text: .constant("{ \n planets { \n name \n }\n}"),
isEditable: false
)
.environment(\.colorScheme, .light)
.previewDisplayName("Light Mode")
}
}
}
#endif
// MARK: - Coordinator
extension MacEditorTextView {
class Coordinator: NSObject, NSTextViewDelegate {
var parent: MacEditorTextView
var selectedRanges: [NSValue] = []
init(_ parent: MacEditorTextView) {
self.parent = parent
}
func textDidBeginEditing(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else {
return
}
self.parent.text = textView.string
self.parent.onEditingChanged()
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else {
return
}
self.parent.text = textView.string
self.selectedRanges = textView.selectedRanges
}
func textDidEndEditing(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else {
return
}
self.parent.text = textView.string
self.parent.onCommit()
}
}
}
// MARK: - CustomTextView
final class CustomTextView: NSView {
private var isEditable: Bool
private var font: NSFont?
weak var delegate: NSTextViewDelegate?
var text: String {
didSet {
textView.string = text
}
}
var selectedRanges: [NSValue] = [] {
didSet {
guard selectedRanges.count > 0 else {
return
}
textView.selectedRanges = selectedRanges
}
}
private lazy var scrollView: NSScrollView = {
let scrollView = NSScrollView()
scrollView.drawsBackground = true
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalRuler = false
scrollView.autoresizingMask = [.width, .height]
scrollView.translatesAutoresizingMaskIntoConstraints = false
return scrollView
}()
private lazy var textView: NSTextView = {
let contentSize = scrollView.contentSize
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(containerSize: scrollView.frame.size)
textContainer.widthTracksTextView = true
textContainer.containerSize = NSSize(
width: contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
layoutManager.addTextContainer(textContainer)
let textView = NSTextView(frame: .zero, textContainer: textContainer)
textView.autoresizingMask = .width
textView.backgroundColor = NSColor.textBackgroundColor
textView.delegate = self.delegate
textView.drawsBackground = true
textView.font = self.font
textView.isEditable = self.isEditable
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.minSize = NSSize(width: 0, height: contentSize.height)
textView.textColor = NSColor.labelColor
textView.allowsUndo = true
return textView
}()
// MARK: - Init
init(text: String, isEditable: Bool, font: NSFont?) {
self.font = font
self.isEditable = isEditable
self.text = text
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Life cycle
override func viewWillDraw() {
super.viewWillDraw()
setupScrollViewConstraints()
setupTextView()
}
func setupScrollViewConstraints() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: topAnchor),
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor)
])
}
func setupTextView() {
scrollView.documentView = textView
}
}
/**
* MacEditorTextView
* Copyright (c) Thiago Holanda 2020-2021
* https://bsky.app/profile/tholanda.com
*
* (the twitter account is now deleted, please, do not try to reach me there)
* https://twitter.com/tholanda
*
* MIT license
*/
import SwiftUI
import Combine
struct ContentQueryView: View {
@State private var queryText = "{ \n planets { \n name \n }\n}"
@State private var responseJSONText = "{ \"name\": \"Earth\"}"
var body: some View {
let queryTextView = MacEditorTextView(
text: $queryText,
isEditable: false,
font: .systemFont(ofSize: 14, weight: .regular)
)
.frame(minWidth: 300,
maxWidth: .infinity,
minHeight: 300,
maxHeight: .infinity)
let responseTextView = MacEditorTextView(
text: $responseJSONText,
isEditable: false,
font: .userFixedPitchFont(ofSize: 14)
)
.frame(minWidth: 300,
maxWidth: .infinity,
minHeight: 300,
maxHeight: .infinity)
return HSplitView {
queryTextView
responseTextView
}
}
}
@AngeloStavrow
Copy link

@unnamedd We were chatting about how to initialize the view with a given linespacing — check out this PR to see how it's being done in the WriteFreely for Mac app.

I do need to test it more thoroughly, but many thanks to @danielpunkass for finding the workaround!

@unnamedd
Copy link
Author

unnamedd commented Feb 2, 2021

Hi @urtti, I'm not aware how to fix that, I'm sure it isn't complicated but I never had to deal with that case.

@AngeloStavrow, did you face this problem in your app? Have an idea how to help here?
And also, send me the piece of code to update the Gist, I'm sure that are more people who will appreciate a lot your help.

@MarcMV
Copy link

MarcMV commented Oct 17, 2021

This is awesome! Only issue I've found is that it does not seem to be compatible with the .focused($focusedField, equals: .title) property. It simply ignores it, tried to follow this steps to enhance it without much luck -> https://serialcoder.dev/text-tutorials/macos-tutorials/macos-programming-implementing-a-focusable-text-field-in-swiftui/
Use Case: I have two swiftUi components, one being a TextField and the other this class, trying to move between them by using a focusField and the focused property.

@unnamedd
Copy link
Author

Hey @MarcMV,
I wrote the MacEditorTextView in the first SwiftUI release, back then we didn't have .focused yet, due to lack of time, I can't stop to add that support yet, even though, if you have any success adding it, would be very good if you post here the piece of code and I will update the gist with your addition, I'm pretty sure more people should enjoy the addition.

@MarcMV
Copy link

MarcMV commented Nov 8, 2021

Hi @unnamedd , after many attempts I created it from scratch looking at WWDC2021 videos. Here's an extremely simple implementation that supports .focused in case it make it easier for you to enhance this one.

@neodave
Copy link

neodave commented Nov 11, 2021

Hi @unnamedd , after many attempts I created it from scratch looking at WWDC2021 videos. Here's an extremely simple implementation that supports .focused in case it make it easier for you to enhance this one.

Thank you thank you thank you! :)

@antingle
Copy link

antingle commented Apr 30, 2022

Hi @unnamedd , after many attempts I created it from scratch looking at WWDC2021 videos. Here's an extremely simple implementation that supports .focused in case it make it easier for you to enhance this one.

Thank you for this! I loved the simplicity of yours!
I have combined your implementation with the MacEditorTextView for the extra functions, along with a couple of other changes. One of them being the ability to add a placeholder text to the scrollview, and another being a working onSubmit function.

It is found here in case anyone can find the alterations beneficial.

@RizwanaDesai
Copy link

RizwanaDesai commented Apr 15, 2024

@unnamedd Find bar is not working, Could you please help fixing it.
I added below line of code for NSTextView.

textView.usesFindBar = true
textView.usesFindPanel = false
textView.isIncrementalSearchingEnabled = true

This lines works when i add textview using storyboard but not working when using this for SwiftUI

@unnamedd
Copy link
Author

Hi @RizwanaDesai,
unfortunately I don't see why it is not working for you, in any case, I am working with a friend to create a package of this humble gist in order to make it better and easier to use.

I will give a check on the problem you've reported, but you can do that too, since the code is all exposed here and there's no hidden magic being done. I would of course highly appreciate if you post here a possible solution you may find (in case you find a fix before me).

@RizwanaDesai
Copy link

Sure @unnamedd .

@fletcher
Copy link

fletcher commented Nov 2, 2025

I am relatively new to Swift/SwiftUI (but lots of experience with Objective-C, NSTextView/NSTextStorage, etc.). So far this seems great -- thank you!

One thing I noticed though -- it seems that whenever the bound text string changes, updateNSView replaces the entire text in the NSTextView. This makes sense if the Binding is changed from somewhere else, but not when typing inside the NSTextView. For example, if I have a long sample of text in the editor, and type one additional character at the end, the edit seems to trigger updateNSView, which then replaces the entire string. For short strings with limited functionality, this is not too big of a deal, I guess. But when implementing a syntax highlighter, for example, it means that typing a single character forces the entire content of the NSTextView to be re-processed.

When I completely disable the functionality inside updateNSView, then this no longer happens. Everything seems to work fine, except that the editor is empty when it first appears, even if the bound string is not empty.

I think this can be fixed by removing the private for lazy var textView and adding a guard condition inside updateNSView:

guard view.textView.string != text else { return }

I am not sure that this is the best solution, however, since I am new to experimenting with MacEditorTextView and SwiftUI in general. Any thoughts or better suggestions would be appreciated!

@unnamedd
Copy link
Author

unnamedd commented Nov 3, 2025

Hi @fletcher, it is amazing to know that MacEditorTextView, even after all that time, is still useful for someone in the community, like you.

Perhaps you can make the changes yourself in your code to confirm all your guesses, and after that you can make an improvement in the code and I will be very happy to update the code here with your changes.

Would have been amazing if I had created this as a proper library instead of just code in a GitHub Gist, in this way, more people could have submitted suggestions via pull requests. As I didn't do that in the past, I am not sure if it makes sense to do it now, therefore, your changes might be very helpful here for our future friends to use them.

@fletcher
Copy link

fletcher commented Nov 3, 2025

It's always interesting to see which "small" thing we put out there turns out to have long-lasting impact, and which "big" things fade quickly into obscurity.... ;)

As I mentioned, the change above seems to work. I'm not certain what downstream effects, if any, there may be. I'll defer to you as to whether it is a good fit for "upstream merging", but either way it is something that any user of MacEditorTextView should be aware of.

I had two initial desires for a more customizable text editor component as I experiment with SwiftUI to re-engineer my older Objective-C code. One part was custom typing behavior (e.g. typing a double quote should insert a pair of double quotes and move the selected range in between them.) The other was to customize autocompletion behavior (in one case to be based on data stored via SwiftData). MacEditorTextView made it easy for me substitute my own NSTextView subclass to handle the first part. (I also had a custom NSTextStorage that supports syntax highlighting that was easily swapped in as well.)

As I experiment with the autocompletion aspect, I think I am beginning to bump up against the edges of the architecture of MacEditorTextView. For example, I sometimes want to stick with the default NSTextStorage, and sometimes want to use a custom subclass that supports syntax highlighting. And now I need to use a custom NSTextViewDelegate for autocompletion, but it looks like that would require modifying the Coordinator rather than an easier swap. I suspect that the right approach for me will probably be to build something new that uses the parts of MacEditorTextView that I need and rearchitects the pieces that need to be behave differently for me.

Regardless, I am very appreciative for MacEditorTextView!!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment