Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
[SwiftUI] MacEditorTextView - A simple and small NSTextView wrapped by SwiftUI.
/**
* MacEditorTextView
* Copyright (c) Thiago Holanda 2020-2021
* 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://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
}
}
}
@insidegui

This comment has been minimized.

Copy link

@insidegui insidegui commented Feb 18, 2020

Thank you :)

@urtti

This comment has been minimized.

Copy link

@urtti urtti commented Feb 23, 2020

Thanks, great work!

@vBoykoGit

This comment has been minimized.

Copy link

@vBoykoGit vBoykoGit commented Apr 11, 2020

Perfect!)

@CineDev

This comment has been minimized.

Copy link

@CineDev CineDev commented Apr 12, 2020

Great! But not perfect. I've noticed this configuration is loosing spaces on the trailing side of text container. In other words, if you just press and hold the spacebar key, when text cursor reaches the right side of the container, you'll start to loose space characters. I was trying to find the source of this bug, but I have failed. Maybe someone can tell, what causing this behaviour?
P.S.: macOS 10.15.4

@unnamedd

This comment has been minimized.

Copy link
Owner Author

@unnamedd unnamedd commented Apr 15, 2020

Great! But not perfect. I've noticed this configuration is loosing spaces on the trailing side of text container. In other words, if you just press and hold the spacebar key, when text cursor reaches the right side of the container, you'll start to loose space characters. I was trying to find the source of this bug, but I have failed. Maybe someone can tell, what causing this behaviour?
P.S.: macOS 10.15.4

Hi @CineDev, as soon as possible, I will double check what is happening and how to fix the bug, unfortunately, I'm really busy at the moment, if you want, you can drop me a message via twitter.com/tholanda

@CineDev

This comment has been minimized.

Copy link

@CineDev CineDev commented Apr 15, 2020

Thanks for your attention, but I figured out that it's a system-wide bug in NSTextView, at least in macOS 10.15.4. Filed bug report, so Apple fix that. I see this behaviour even in Apple Pages.

@unnamedd

This comment has been minimized.

Copy link
Owner Author

@unnamedd unnamedd commented Apr 15, 2020

Thanks for your attention, but I figured out that it's a system-wide bug in NSTextView, at least in macOS 10.15.4. Filed bug report, so Apple fix that. I see this behaviour even in Apple Pages.

I see, nice to know and thanks to share here with us!

@saket

This comment has been minimized.

Copy link

@saket saket commented May 7, 2020

I haven't been able to figure out why, but textDidEndEditing() never gets called so onCommit() never gets called either.

@CineDev

This comment has been minimized.

Copy link

@CineDev CineDev commented May 7, 2020

I haven't been able to figure out why, but textDidEndEditing() never gets called so onCommit() never gets called either.

It called when NSTextView looses focus. So, in case of full-fledged text editor onCommit() is basically useless.

@CineDev

This comment has been minimized.

Copy link

@CineDev CineDev commented May 8, 2020

If you need more precise change notifications, check my implementation of NSTextStorage. Maybe it'll be more handy for your task: https://github.com/CineDev/ParagraphTextKit

@Hoyiki

This comment has been minimized.

Copy link

@Hoyiki Hoyiki commented May 9, 2020

omg this is amazing!!! Thank you!!!!!

@CineDev

This comment has been minimized.

Copy link

@CineDev CineDev commented May 9, 2020

omg this is amazing!!! Thank you!!!!!

Thanks )))

@ChidiNweze

This comment has been minimized.

Copy link

@ChidiNweze ChidiNweze commented May 21, 2020

Any tips on how to implement this without scrolling? I.e. having the text box grow as the user writes? Any help would be greatly appreciated.

@unnamedd

This comment has been minimized.

Copy link
Owner Author

@unnamedd unnamedd commented May 25, 2020

Any tips on how to implement this without scrolling? I.e. having the text box grow as the user writes? Any help would be greatly appreciated.

Hi @ChidiNweze, you will need:
1: Define constraints for the height of your textView
2: Get the contentSize.height of the textView
3: Set the contentSize.height to the textView's height constraint.

This is the way you can solve on iOS. I'm not sure if it helps you in the AppKit, but this is the way I'd do! ;)

@ChidiNweze

This comment has been minimized.

Copy link

@ChidiNweze ChidiNweze commented May 31, 2020

1: Define constraints for the height of your textView
2: Get the contentSize.height of the textView
3: Set the contentSize.height to the textView's height constraint.

Hi again. I'm still having trouble with this. Perhaps I am not defining the height constraints properly. Right now, I believe this is being done by "textView.maxSize" and "textView.minSize". I have set textView.minSize = NSSize(width: 0, height: contentSize.height). Currently, I have the textView in a list view, where the cell will not expand but will become scrollable instead. Could there be a problem in the list view?

@w-i-n-s

This comment has been minimized.

Copy link

@w-i-n-s w-i-n-s commented Jun 12, 2020

@unnamedd Great work, thank you!

@wingovers

This comment has been minimized.

Copy link

@wingovers wingovers commented Jul 27, 2020

@ChidiNweze did you sort this?

@wingovers

This comment has been minimized.

Copy link

@wingovers wingovers commented Jul 29, 2020

@ChidiNweze Here is an updated auto-height-scaling example, albeit with a slight bug on appearance that I'll need to sort and update. https://github.com/wingovers/HeightExpandingNSTextViewforSwiftUI

@ChidiNweze

This comment has been minimized.

Copy link

@ChidiNweze ChidiNweze commented Jul 29, 2020

Hi, I sorted this out by using a different wrapper entirely (which implemented a similar, but sketchier, dynamicHeight function). I may swap it for your solution. Looks great!

@wingovers

This comment has been minimized.

Copy link

@wingovers wingovers commented Jul 29, 2020

Yeah, I benefitted from Asperi's UIKit post on StackOverflow, converted it to AppKit after playing with the ways available to get data, and deleted some parts. Playing again this morning, the initial jiggle disappears by setting dynamicHeight by a multiplier of the font pointSize, updated on git, but that's a magic number that just worked for me and thus not a good solution. Please let me know if you solve that.. :)

@manngo

This comment has been minimized.

Copy link

@manngo manngo commented Sep 6, 2020

I have tried to use this, but I get an error: Use of unresolved identifier 'MacEditorTextView'. Obviously, there’s a step I’m missing.

@unnamedd

This comment has been minimized.

Copy link
Owner Author

@unnamedd unnamedd commented Sep 6, 2020

I have tried to use this, but I get an error: Use of unresolved identifier 'MacEditorTextView'. Obviously, there’s a step I’m missing.

Hi @manngo,
is the MacEditorTextView file checked for the target you are working on? E.g:
image

@manngo

This comment has been minimized.

Copy link

@manngo manngo commented Sep 6, 2020

Ah, that was it. Thanks so much for your help. It worked beautifully.

@jfranknichols

This comment has been minimized.

Copy link

@jfranknichols jfranknichols commented Nov 18, 2020

hello, Thank you for this, it appears to be exactly what I need while I wait for Apple to provide RTF text fields native in SwiftUI.

I am new to NSAttributedString and NSTextView and I am struggling with trying to figure out how to get the attributed text string in and out of the MacEditorTextView.

I have this working with plain text, but I can't figure out how to get Attributed strings in and out of it.

I assume I am missing something obvious?

@unnamedd

This comment has been minimized.

Copy link
Owner Author

@unnamedd unnamedd commented Nov 18, 2020

hello, Thank you for this, it appears to be exactly what I need while I wait for Apple to provide RTF text fields native in SwiftUI.

I am new to NSAttributedString and NSTextView and I am struggling with trying to figure out how to get the attributed text string in and out of the MacEditorTextView.

I have this working with plain text, but I can't figure out how to get Attributed strings in and out of it.

I assume I am missing something obvious?

Hi @jfranknichols,
Yes, for sure there are somethings I'm not doing here and NSAttributedString is one of them, but this isn't very complicated to give support, to set or to get. What you will need to do is to follow a bit the way I implemented to set/get the property text or maybe the font, to do the same with the NSAttributedString. I didn't give support to it because I didn't need, but if you decide to implement this support, I will appreciate a lot if you decide to send it to me to add here.

In any case, we can do this together and you can send me a message on Twitter, what do you think?

@AngeloStavrow

This comment has been minimized.

Copy link

@AngeloStavrow AngeloStavrow commented Dec 18, 2020

@unnamedd To connect the NSTextView to the undo manager, you can add the following line to your CustomTextView class' textView var:

textView.allowsUndo = true
@unnamedd

This comment has been minimized.

Copy link
Owner Author

@unnamedd unnamedd commented Dec 18, 2020

Thanks a lot for the solution @AngeloStavrow! I will update the gist!

@urtti

This comment has been minimized.

Copy link

@urtti urtti commented Feb 2, 2021

This has been a lifesaver, using it in my app. Ran into an issue where I can't seem to get the smart quotes to automatically fix the text.
" should change to “

It can be done with the substitutions menu, but does seem to require a manual step from the user. Didn't have luck changing the related variables on NSTextView.

The new TextEditor from Apple seems to work, but that doesn't support Catalina...

@AngeloStavrow

This comment has been minimized.

Copy link

@AngeloStavrow AngeloStavrow commented Feb 2, 2021

@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

This comment has been minimized.

Copy link
Owner Author

@unnamedd 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.

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