SwiftUI - NSTextView : Bold on selection range - swift

I am trying to figure out how to add a bold attribute to a portion of a text based on a selection (a double click on a word for instance) in Swift using a NSTextView wrapped in a NSViewRepresentable.
In fact, it works but when a write some stuff afterwards (pressing Enter), it disappears and recovers its regular weight. I don't understand why, I would like it to stay bold and not to reset.
Below is my code, do you have an idea why it resets and how to keep it ?
Many thanks,
import SwiftUI
struct TextView: NSViewRepresentable {
#Binding var text: NSAttributedString
#Binding var range: NSRange
class Coordinator: NSObject, NSTextViewDelegate {
var control: TextView
init (_ control: TextView) {
self.control = control
}
func textDidChange(_ notification: Notification) {
guard let view = notification.object as? NSTextView else { return }
print(view.attributedString())
control.text = view.attributedString() // ça, ça efface si on met du gras etc
}
func textViewDidChangeSelection(_ notification: Notification) {
guard let view = notification.object as? NSTextView else { return }
DispatchQueue.main.async {
self.control.range = view.selectedRange()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSTextView {
let view = NSTextView()
view.allowsUndo = true
view.isEditable = true
view.isSelectable = true
view.isRichText = true
view.delegate = context.coordinator
return view
}
func updateNSView(_ nsView: NSTextView, context: Context) {
print("mise à jour de la vue")
nsView.textStorage?.setAttributedString(self.text)
}
}
struct ContentView: View {
#State var text:NSAttributedString = NSAttributedString("Je suis un texte youpi")
#State var range:NSRange = NSRange()
var body: some View {
VStack {
Button {
print("ok bold", range)
let str = NSMutableAttributedString(attributedString: text)
let attributes = [NSAttributedString.Key.font: NSFont.systemFont(ofSize: 0, weight: .bold)]
str.addAttributes(attributes, range: range)
self.text = str
print("dac")
}
label: { Image(systemName: "bold") }
.keyboardShortcut("b", modifiers: [.command])
TextView(text: $text, range: $range)
.padding()
}
}
}
‘‘‘

Related

Fastest component that works with NSAttributedString?

Looks like NSTextField is too slow for work with large attributed texts.
1000 rows with 18 symbols each are slow on M1 processor;
3000 rows slow on macbook pro 2015
Is there exist some component that works fast enough with NSAttributedString?
I need component that will be:
Fast
Ability to select/copy text
Works with NSAttributedString
PS: SwiftUI's Text with AttributedString is much slower than NSTextField with NSAttributedString
Application for testing performance of NSTextField
#main
struct TestAppApp: App {
var body: some Scene {
WindowGroup {
AttrTest()
}
}
}
struct AttrTest: View {
#State var nsString: NSAttributedString = generateText(rows: 1000)
var body: some View{
VStack {
HStack{
Button("1000") {
nsString = generateText(rows: 1000)
}
Button("2000") {
nsString = generateText(rows: 2000)
}
Button("5000") {
nsString = generateText(rows: 5000)
}
Button("7000") {
nsString = generateText(rows: 7000)
}
Button("9000") {
nsString = generateText(rows: 9000)
}
}
TabView {
VStack{
AttributedText(attributedString: $nsString, selectable: false)
}
.tabItem {
Text("NSTextField")
}
AttributedText(attributedString: $nsString, selectable: false)
.padding(.leading, 80)
.background(Color.green)
.tabItem {
Text("Other")
}
}
}
}
}
func generateText(rows: Int) -> NSMutableAttributedString {
let attrs: [[NSAttributedString.Key : Any]] = [
[.foregroundColor: NSColor.red],
[.backgroundColor: NSColor.blue],
[.strokeColor: NSColor.blue],
[.strokeColor: NSColor.green],
[.underlineColor: NSColor.green],
[.underlineColor: NSColor.yellow],
[.underlineColor: NSColor.gray],
[.backgroundColor: NSColor.yellow],
[.backgroundColor: NSColor.green],
[.backgroundColor: NSColor.magenta]
]
let str = NSMutableAttributedString(string: "")
for _ in 0...rows {
let strNew = NSMutableAttributedString(string: "fox jumps over the lazy dog\n")
strNew.setAttributes(attrs.randomElement(), range: NSRange(location: 0, length: strNew.length) )
str.append(strNew)
}
return str
}
#available(OSX 11.0, *)
public struct AttributedText: NSViewRepresentable {
#Binding var text: NSAttributedString
private let selectable: Bool
public init(attributedString: Binding<NSAttributedString>, selectable: Bool = true) {
_text = attributedString
self.selectable = selectable
}
public func makeNSView(context: Context) -> NSTextField {
let textField = NSTextField(labelWithAttributedString: text)
textField.preferredMaxLayoutWidth = textField.frame.width
textField.allowsEditingTextAttributes = true // Fix of clear of styles on click
textField.isSelectable = selectable
return textField
}
public func updateNSView(_ textField: NSTextField, context: Context) {
textField.attributedStringValue = $text.wrappedValue
}
}
Typically large text is stored in an NSTextView, not an NSTextField. But for specialized uses, it's quite common to build your own solutions in Core Text.
Code based on Rob Napier's answer:
import SwiftUI
import Cocoa
#available(OSX 11.0, *)
public struct AttributedText: View {
#Binding var text: NSAttributedString
public init(attributedString: Binding<NSAttributedString>) {
_text = attributedString
}
public var body: some View {
AttributedTextInternal(attributedString: $text)
.frame(minWidth: $text.wrappedValue.size().width + 350, minHeight: $text.wrappedValue.size().height )
}
}
#available(OSX 11.0, *)
public struct AttributedTextInternal: NSViewRepresentable {
#Binding var text: NSAttributedString
public init(attributedString: Binding<NSAttributedString>) {
_text = attributedString
}
public func makeNSView(context: Context) -> NSTextView {
let textView = NSTextView()
textView.isRichText = true
textView.isSelectable = true
textView.setContent(text: text, makeNotEditable: true)
textView.textStorage
return textView
}
public func updateNSView(_ textView: NSTextView, context: Context) {
textView.setContent(text: text, makeNotEditable: true)
}
}
extension NSTextView {
func setContent(text: NSAttributedString, makeNotEditable: Bool) {
self.isEditable = true
self.selectAll(nil)
self.insertText(text, replacementRange: self.selectedRange())
self.isEditable = !makeNotEditable
}
}

SwiftUI Binding Edits Wrong TextField after Item Reordered

Xcode 13 beta 5, iOS 14, macOS 11.6
I have a parent SwiftUI view that lists some children. Each child is bound to an NSViewRepresentable. Everything works and I can edit the values as expected. But once I reorder the items in the list and edit a field, it edits the wrong field. It appears that the binding remains intact from the previous item order.
Here's what that looks like:
Here's the parent:
struct ParentView: View {
#StateObject var model = ThingModel.shared
var body: some View {
VStack{
ForEach($model.things){ $thing in
ChildView(thing: $thing)
//Reorder
.onDrag{
model.draggedThing = thing
return NSItemProvider(object: NSString())
}
}
Text("Value: \(model.value)").font(.title)
}
.frame(width:300, height: 200)
}
}
...and here's the child view:
struct ChildView: View {
#Binding var thing: Thing
#StateObject var model = ThingModel.shared
var body: some View{
HStack{
GrowingField(text: $thing.text, submit: {
model.value = thing.text
print(thing.text)
})
Text(" = ")
.opacity(0.4)
}
.padding(10)
.onDrop(of: [UTType.text], delegate: ThingReorderDelegate(hoveredThing: thing))
}
}
Last of all, here is the NSViewRepresentable which is called GrowingField. For simplicity, I have omitted the NSTextField subclass.
struct GrowingField: NSViewRepresentable{
#Binding var text: String
var submit:(() -> Void)? //Hit enter
func makeNSView(context: Context) -> NSTextField {
let textField = NSTextField()
textField.delegate = context.coordinator
textField.stringValue = text
return textField
}
func updateNSView(_ nsView: NSTextField, context: Context) {
nsView.stringValue = text
context.coordinator.textBinding = $text
}
//Delegates
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextFieldDelegate {
let parent: GrowingField
var textBinding : Binding<String>?
init(_ field: GrowingField) {
self.parent = field
}
func controlTextDidChange(_ obj: Notification) {
guard let textField = obj.object as? NSTextField else { return }
self.textBinding?.wrappedValue = textField.stringValue
}
//Listen for certain keyboard keys
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector{
case #selector(NSStandardKeyBindingResponding.insertNewline(_:)):
//- Enter -
parent.submit?()
textView.window?.makeFirstResponder(nil) //Blur cursor
return true
default:
return false
}
}
}
}
Why does the binding to the NSViewRepresentable not follow the field after it is reordered?
Here is a sample project to download and try it out.
I believe the issue (bug?) is with the ForEach-generated binding.
If you forego the generated binding and create your own, everything seems to work as expected.
Added to the ThingModel:
func bindingForThing(id: String) -> Binding<Thing> {
.init {
self.things.first { $0.id == id }!
} set: { newThing in
self.things = self.things.map { $0.id == id ? newThing : $0 }
}
}
And the ParentView:
ForEach(model.things){ thing in
ChildView(thing: model.bindingForThing(id: thing.id))

Select all text in TextField upon click SwiftUI

How do i select all text when clicking inside the textfield? Just like how a web browser like chrome would when you click inside the address bar.
import SwiftUI
import AppKit
struct ContentView: View {
var body: some View {
TextField("Enter a URL", text: $site)
}
}
SwiftUI Solution:
struct ContentView: View {
var body: some View {
TextField("Placeholder", text: .constant("This is text data"))
.onReceive(NotificationCenter.default.publisher(for: UITextField.textDidBeginEditingNotification)) { obj in
if let textField = obj.object as? UITextField {
textField.selectedTextRange = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument)
}
}
}
}
Note : import Combine
Use UIViewRepresentable and wrap UITextField and use textField.selectedTextRange property with delegate.
Here is the sample demo
struct HighlightTextField: UIViewRepresentable {
#Binding var text: String
func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ textField: UITextField, context: Context) {
textField.text = text
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, UITextFieldDelegate {
var parent: HighlightTextField
init(parent: HighlightTextField) {
self.parent = parent
}
func textFieldDidBeginEditing(_ textField: UITextField) {
textField.selectedTextRange = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument)
}
}
}
For macOS
struct HighlightTextField: NSViewRepresentable {
#Binding var text: String
func makeNSView(context: Context) -> CustomTextField {
CustomTextField()
}
func updateNSView(_ textField: CustomTextField, context: Context) {
textField.stringValue = text
}
}
class CustomTextField: NSTextField {
override func mouseDown(with event: NSEvent) {
if let textEditor = currentEditor() {
textEditor.selectAll(self)
}
}
}
Here is my solution
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State private var renameTmpText: String = ""
#FocusState var isFocused: Bool
#State private var textSelected = false
var body: some View {
TextEditor(text: $renameTmpText)
.padding(3)
.border(Color.accentColor, width: 1)
.frame(width: 120, height: 40)
.onExitCommand(perform: {
renameTmpText = ""
})
.onAppear {
renameTmpText = "Test"
isFocused = true
}
.focused($isFocused)
.onReceive(NotificationCenter.default.publisher(for: NSTextView.didChangeSelectionNotification)) { obj in
if let textView = obj.object as? NSTextView {
guard !textSelected else { return }
let range = NSRange(location: 0, length: textView.string.count)
textView.setSelectedRange(range)
textSelected = true
}
}
.onDisappear { textSelected = false }
}
}
let view = ContentView()
PlaygroundPage.current.setLiveView(view)
I've created a ViewModifier to select all the text in a TextField.
Only downside is, it won't work with multiple TextFields.
public struct SelectTextOnEditingModifier: ViewModifier {
public func body(content: Content) -> some View {
content
.onReceive(NotificationCenter.default.publisher(for: UITextField.textDidBeginEditingNotification)) { obj in
if let textField = obj.object as? UITextField {
textField.selectedTextRange = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument)
}
}
}
}
extension View {
/// Select all the text in a TextField when starting to edit.
/// This will not work with multiple TextField's in a single view due to not able to match the selected TextField with underlying UITextField
public func selectAllTextOnEditing() -> some View {
modifier(SelectTextOnEditingModifier())
}
}
usage:
TextField("Placeholder", text: .constant("This is text data"))
.selectAllTextOnEditing()

How to initialise #State variables depending on each other in Swiftui?

I need to assign my MultilineTextField view (a wrapped UITextView) to a variable textField in order to be able later to call its method updateTextStyle from a button in ContentView (the method takes the selected text and turns it into bold). The problem is that MultilineTextField rely on the #State var range, therefore not compiling. What are possible workaround for this?
struct ContentView: View {
#State private var range: NSRange?
#State var textField = MultilineTextField(rangeSelected: $range)
var body: some View {
VStack {
textField
Button(action: {
self.textField.updateTextStyle()
}) {
Text("Update text style")
}
}
}
}
In case relevant, MultilineTextField (I tried to remove the unnecessary - hope it's clear)
struct MultilineTextField: UIViewRepresentable {
let textView = UITextView()
#Binding var rangeSelected: NSRange?
#State var attributedNoteText = NSMutableAttributedString(string: "Lorem ipsum")
func makeUIView(context: Context) -> UITextView {
// ...
textView.delegate = context.coordinator
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.attributedText = attributedNoteText
}
func updateTextStyle() {
if self.rangeSelected != nil {
// apply attributes (makes the selected text bold)
} else {
print("rangeSelected is nil")
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self, $attributedNoteText)
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: MultilineTextField
var text: Binding<NSMutableAttributedString>
init(parent: MultilineTextField, _ text: Binding<NSMutableAttributedString>) {
self.parent = parent
self.text = text
}
func textViewDidChange(_ textView: UITextView) {
let attributedStringCopy = textView.attributedText?.mutableCopy() as! NSMutableAttributedString
parent.textView.attributedText = attributedStringCopy
self.text.wrappedValue = attributedStringCopy
}
func textViewDidChangeSelection(_ textView: UITextView) {
parent.rangeSelected = textView.selectedRange // not sure about this one
}
}
}
(I'm aware there might be some additional errors here - it's my first time working with UIKit in SwiftUI. Thanks for any help)
It should be differently, because view is struct, so your call updateTextStyle() in button action will have no effect, because applied to copy of above textField
Instead the approach should be like following (scratchy)
struct ContentView: View {
#State private var range: NSRange?
// example of style, on place of color your style
#State var color: Color = .black
var body: some View {
VStack {
MultilineTextField(rangeSelected: $range)
.foregroundColor(self.color) // style state dependency
Button(action: {
self.color = .red // specify new style
}) {
Text("Update text style")
}
}
}
}

Handle keyboard inputs in NSTextView embedded via NSViewRepresentable? (SwiftUI/MacOS)

I'm new to SwiftUI and am utterly confused.. I managed to embed a NSTextView into my SwiftUI View and bind its text with the below code.
What I don't understand; is there a way to handle keyboard inputs to the NSTextView and change its text accordingly (e.g. CMD + R sets the text color of the selected text to red)? Is there even any way to interact with UI-Elements in SwiftUI?
"RichTextField"
struct RichTextField: NSViewRepresentable {
typealias NSViewType = NSTextView
#Binding var attributedString: NSAttributedString
func makeNSView(context: Context) -> NSTextView {...
// [...]
}
View
struct EditWindow: View {
#ObservedObject var model: EditEntryViewModel
#Binding var isPresented: Bool
var body: some View {
RichTextField(attributedString: self.$model.answer1, isEditable: true)
// [...]
}
}
Furthermore, I've managed to set up a menu command in the AppDelegate, but how could I use this to change the text (at a certain position) in a NSTextView of an arbitrary View?
#IBAction func setTagImportant(_ sender: Any) {
print("setTagImportant")
}
Thanks a lot for shedding some light on this for me...
Ironically, immediately after finally posting this question, I found a solution; simply subclass the NSTextView and then override keyDown:
import SwiftUI
class RichTextFieldExtended: NSTextView {
override func keyDown(with event: NSEvent) {
if event.modifierFlags.contains(NSEvent.ModifierFlags.command) {
switch event.keyCode {
case 18: // 1
print("1 PRESSED")
default:
print("keyCode \(event.keyCode) wasn't handled")
super.keyDown(with: event)
}
} else {
super.keyDown(with: event)
}
}
}
Then include the subclassed NSTextView in the NSViewRepresentable, as follows
struct RichTextField: NSViewRepresentable {
typealias NSViewType = RichTextFieldExtended
#Binding var attributedString: NSAttributedString
var isEditable: Bool
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> RichTextFieldExtended {
let textView = RichTextFieldExtended(frame: .zero)
textView.textStorage?.setAttributedString(self.attributedString)
textView.isEditable = isEditable
textView.delegate = context.coordinator
textView.translatesAutoresizingMaskIntoConstraints = false
textView.autoresizingMask = [.width, .height]
return textView
}
func updateNSView(_ nsView: RichTextFieldExtended, context: Context) {
// nsView.textStorage!.setAttributedString(self.attributedString)
}
// Source: https://medium.com/fantageek/use-xib-de9d8a295757
class Coordinator: NSObject, NSTextViewDelegate {
let parent: RichTextField
init(_ RichTextField: RichTextField) {
self.parent = RichTextField
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? RichTextFieldExtended else { return }
self.parent.attributedString = textView.attributedString()
}
}
}
Cheers