SwiftUI Text highlights and menu
SwiftUI’s Text()
view does not support custom colored highlights and custom menus. Here’s how I did it. The solution may not be up to everyone’s programming standards but it worked.
CustomUITextView
Create a new file called CustomUITextView
which will inherit UITextView. The initial code should look like
import UIKit
class CustomTextView: UITextView {
}
TextSelectable
Now, let’s create another file called TextSelectable which will inherit UIViewRepresentable
.
import SwiftUI
struct TextSelectable: UIViewRepresentable {
var text: NSAttributedString
init(text: NSAttributedString) {
self.text = text
}
func makeUIView(context: Context) -> CustomTextView {
let customTextView = CustomTextView()
customTextView.delegate = context.coordinator
return customTextView
}
func updateUIView(_ uiView: CustomTextView, context: Context) {
uiView.attributedText = text
}
func makeCoordinator() -> Coordinator {
Coordinator(text)
}
class Coordinator: NSObject, UITextViewDelegate {
var text: NSAttributedString
init(_ text: NSAttributedString) {
self.text = text
}
func textViewDidChange(_ textView: UITextView) {
self.text = textView.attributedText
}
}
}
highlighText()
Let’s create a highlighText()
function inside our CustomUITextView()
. And we will override
the canPerformAction
function. This is where we will capture our highlighted text. The new CustomUITextView
should be like the one below.
import UIKit
class CustomTextView: UITextView {
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText) {
return true
}
return false
}
@objc func highlightText() {
if let range = self.selectedTextRange, let selectedText = self.text(in: range) {
print("Selected text is \(selectedText)")
}
}
}
Custom UIMenu
Next, we will create our own UIMenu when a user long presses or clicks on a highlighted text. We will override the editMenu
function.
import UIKit
class CustomTextView: UITextView {
override func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
let highlightTextAction = UIAction(title: "Highlight Passage") { action in
self.highlightText()
}
let addNotesAction = UIAction(title: "Add Notes") { action in
}
var actions = suggestedActions
actions.insert(highlightTextAction, at: 0)
actions.insert(addNotesAction, at: 1)
return UIMenu(children: actions)
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText) {
return true
}
return false
}
@objc func highlightText() {
}
}
SwiftUI View
Time to implement it in our SwiftUI View. Create a new file, I called mine DetailsView
. Let’s use a verse from Kendrick Lamar as an example text passed to our TextSelectable
.
import SwiftUI
struct DetailsView: View {
@State private var attributedText = NSAttributedString(string: """
This feelin' is brought to you by adrenaline and good rap
Black Pendleton ball cap (West, west, west)
We don't share the same synonym, fall back (West, west, west)
Been in it before Internet had new acts
Mimicking radio's nemesis made me wack
My innocence limited, the experience lacked
Ten of us with no tentative tactic that cracked
The mind of a literate writer, but I did it, in fact
You admitted it once I submitted it, wrapped in plastic
Remember scribblin', scratchin' diligent sentences backwards
Visiting freestyle cyphers for your reaction
Now, I can live in a stadium, pack it the fastest
Gamblin' Benjamin benefits, sinnin' in traffic
Spinnin' women in cartwheels, linen fabric on fashion
Winnin' in every decision, Kendrick is master that mastered it
Isn't it lovely how menaces turned attraction?
Pivotin' rappers, finish your fraction while writing blue magic
Thank God for rap
I would say it got me a plaque, but what's better than that?
The fact it brought me back home
""")
var body: some View {
VStack {
TextSelectable(text: NSAttributedString(string: attributedText))
}
.padding()
}
}
Now try to run it in your emulator. And drag a portion of the text and click on our Highlight Passage menu.
We should be able to capture the highlighted text.
Colored highlights
Now that we can capture the highlighted texts, we want to add colors to it. Let’s create a file called Highlight.
import SwiftUI
struct Highlight {
let text: String
let location: Int
let length: Int
let color: Color
}
Now, inside our highlightText()
function, let’s create that Highlight object. And we will pass this object to our NotificationCenter, where our DetailsView will listen and capture our object.
import UIKit
class CustomTextView: UITextView {
override func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
let highlightTextAction = UIAction(title: "Highlight Passage") { action in
self.highlightText()
}
let addNotesAction = UIAction(title: "Add Notes") { action in
}
var actions = suggestedActions
actions.insert(highlightTextAction, at: 0)
actions.insert(addNotesAction, at: 1)
return UIMenu(children: actions)
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText) {
return true
}
return false
}
@objc func highlightText() {
if let range = self.selectedTextRange, let selectedText = self.text(in: range) {
print("Selected text is \(selectedText)")
let highlight = Highlight(text: selectedText, location: selectedRange.location, length: selectedRange.length, color: .purple)
let highlightDict = [
"data": highlight
]
NotificationCenter.default.post(name: Notification.Name("highlightAdded"), object: nil, userInfo: highlightDict)
}
}
}
I am not sure if this is the best way, please let me know if you have any better solutions.
And now in our DetailsView
, let’s capture it using onReceive
modifier.
import SwiftUI
struct DetailsView: View {
@State private var attributedText = NSAttributedString(string: """
This feelin' is brought to you by adrenaline and good rap
Black Pendleton ball cap (West, west, west)
We don't share the same synonym, fall back (West, west, west)
Been in it before Internet had new acts
Mimicking radio's nemesis made me wack
My innocence limited, the experience lacked
Ten of us with no tentative tactic that cracked
The mind of a literate writer, but I did it, in fact
You admitted it once I submitted it, wrapped in plastic
Remember scribblin', scratchin' diligent sentences backwards
Visiting freestyle cyphers for your reaction
Now, I can live in a stadium, pack it the fastest
Gamblin' Benjamin benefits, sinnin' in traffic
Spinnin' women in cartwheels, linen fabric on fashion
Winnin' in every decision, Kendrick is master that mastered it
Isn't it lovely how menaces turned attraction?
Pivotin' rappers, finish your fraction while writing blue magic
Thank God for rap
I would say it got me a plaque, but what's better than that?
The fact it brought me back home
""")
var body: some View {
VStack {
TextSelectable(text: NSAttributedString(string: attributedText))
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("highlightAdded"))) { output in
if let highlight = output.userInfo!["data"] as? Highlight {
let mutableString = NSMutableAttributedString.init(string: attributedText.string)
let highlightAttributes: [NSAttributedString.Key: Any] = [
.backgroundColor: highlight.color,
]
mutableString.addAttributes(highlightAttributes, range: NSRange(location: highlight.location, length: highlight.length))
attributedText = mutableString
}
}
.padding()
}
}
Run the app and it will throw an error.
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__SwiftValue set]: unrecognized selector sent to instance 0x600000c6c360'
*** First throw call stack:
(
0 CoreFoundation 0x00000001804ae0f8 __exceptionPreprocess + 172
This is because of our color variable in our Highlight. Let’s update our Highlights class to use uiColor
instead.
import SwiftUI
struct Highlight {
let text: String
let location: Int
let length: Int
var uiColor: UIColor
var color: Color {
get {
.init(uiColor: uiColor)
}
set {
uiColor = .init(newValue)
}
}
}
In our highlighText()
function, use uiColor
instead of color
.
@objc func highlightText() {
if let range = self.selectedTextRange, let selectedText = self.text(in: range) {
print("Selected text is \(selectedText)")
let highlight = Highlight(text: selectedText, location: selectedRange.location, length: selectedRange.length, uiColor: .purple)
let highlightDict = [
"data": highlight
]
NotificationCenter.default.post(name: Notification.Name("highlightAdded"), object: nil, userInfo: highlightDict)
}
}
Also, update our onReceive
highlight object.
if let highlight = output.userInfo!["data"] as? Highlight {
let mutableString = NSMutableAttributedString.init(string: attributedText.string)
let highlightAttributes: [NSAttributedString.Key: Any] = [
.backgroundColor: highlight.uiColor,
]
mutableString.addAttributes(highlightAttributes, range: NSRange(location: highlight.location, length: highlight.length))
attributedText = mutableString
}
}
Everything should work now.
You can find the code at https://github.com/lawgimenez/customtext.