Lawrence Gimenez

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.