Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I allow text selection on a Text label in SwiftUI?

When I create a text view:

Text("Hello World")

I can't allow the user to select text when they long press.

I've looked at using a TextField but that doesn't seem to allow for turning off text editing.

I just want to be able to display a body of text and allow the user to highlight a selection using the system text selector.

Thanks!

like image 376
mralexhay Avatar asked Sep 19 '19 07:09

mralexhay


People also ask

How do I select text in SwiftUI?

You can use textField. inputView = UIView() and textField.

How do you use textfield in SwiftUI?

TextField in SwiftUI is a simple control that shows an editable text interface (equivalent to UITextField in UIKit). Because TextField allows a user to type text, it also needs a way of storing the entered text in a State variable which can then be used to read the input.

What is a label in SwiftUI?

It contains text and an icon. A view that uses a label can choose whether to use it based on available space and environment. We have learn all of this in SwiftUI Label: A standard way to label user interface items post. At the end of the post, I told you that we should consider using a label when building a custom view.

What is data binding in SwiftUI?

In brief, data binding is a way for SwiftUI to communicate the data between the view and the one who uses it. It serves the same purpose as UITextFieldDelegate and the target-action mechanism in UITextField to report changes made during editing. There are many ways to create a text field.

How to bridge between SwiftUI and UIKit?

Mayor: When a user selects some text he can tap in the context menu cut, paste and other items which can change the text regardless of using .constant (...) My solution to this problem involves subclassing UITextField and using UIViewRepresentable to bridge between UIKit and SwiftUI.


Video Answer


3 Answers

iOS 15.0+, macOS 12.0+, Mac Catalyst 15.0+

As of Xcode 13.0 beta 2 you can use

Text("Selectable text")
    .textSelection(.enabled)
Text("Non selectable text")
    .textSelection(.disabled)

// applying `textSelection` to a container
// enables text selection for all `Text` views inside it
VStack {
    Text("Selectable text1")
    Text("Selectable text2")
    // disable selection only for this `Text` view
    Text("Non selectable text")
        .textSelection(.disabled)
}.textSelection(.enabled)

See also the textSelection Documentation.

iOS 14 and lower

Using TextField("", text: .constant("Some text")) has two problems:

  • Minor: The cursor shows up when selecting
  • Mayor: When a user selects some text he can tap in the context menu cut, paste and other items which can change the text regardless of using .constant(...)

My solution to this problem involves subclassing UITextField and using UIViewRepresentable to bridge between UIKit and SwiftUI.

At the end I provide the full code to copy and paste into a playground in Xcode 11.3 on macOS 10.14

Subclassing the UITextField:

/// This subclass is needed since we want to customize the cursor and the context menu
class CustomUITextField: UITextField, UITextFieldDelegate {
    
    /// (Not used for this workaround, see below for the full code) Binding from the `CustomTextField` so changes of the text can be observed by `SwiftUI`
    fileprivate var _textBinding: Binding<String>!
    
    /// If it is `true` the text field behaves normally.
    /// If it is `false` the text cannot be modified only selected, copied and so on.
    fileprivate var _isEditable = true {
        didSet {
            // set the input view so the keyboard does not show up if it is edited
            self.inputView = self._isEditable ? nil : UIView()
            // do not show autocorrection if it is not editable
            self.autocorrectionType = self._isEditable ? .default : .no
        }
    }
    
    
    // change the cursor to have zero size
    override func caretRect(for position: UITextPosition) -> CGRect {
        return self._isEditable ? super.caretRect(for: position) : .zero
    }
    
    // override this method to customize the displayed items of 'UIMenuController' (the context menu when selecting text)
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    
        // disable 'cut', 'delete', 'paste','_promptForReplace:'
        // if it is not editable
        if (!_isEditable) {
            switch action {
            case #selector(cut(_:)),
                 #selector(delete(_:)),
                 #selector(paste(_:)):
                return false
            default:
                // do not show 'Replace...' which can also replace text
                // Note: This selector is private and may change
                if (action == Selector("_promptForReplace:")) {
                    return false
                }
            }
        }
        return super.canPerformAction(action, withSender: sender)
    }
    
    
    // === UITextFieldDelegate methods
    
    func textFieldDidChangeSelection(_ textField: UITextField) {
        // update the text of the binding
        self._textBinding.wrappedValue = textField.text ?? ""
    }
    
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        // Allow changing the text depending on `self._isEditable`
        return self._isEditable
    }
    
}

Using UIViewRepresentable to implement SelectableText

struct SelectableText: UIViewRepresentable {
    
    private var text: String
    private var selectable: Bool
    
    init(_ text: String, selectable: Bool = true) {
        self.text = text
        self.selectable = selectable
    }
    
    func makeUIView(context: Context) -> CustomUITextField {
        let textField = CustomUITextField(frame: .zero)
        textField.delegate = textField
        textField.text = self.text
        textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
        textField.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return textField
    }
    
    func updateUIView(_ uiView: CustomUITextField, context: Context) {
        uiView.text = self.text
        uiView._textBinding = .constant(self.text)
        uiView._isEditable = false
        uiView.isEnabled = self.selectable
    }
    
    func selectable(_ selectable: Bool) -> SelectableText {
        return SelectableText(self.text, selectable: selectable)
    }
    
}

The full code

In the full code below I also implemented a CustomTextField where editing can be turned off but still be selectable.

Playground view

Selection of text

Selection of text with context menu

Code

import PlaygroundSupport
import SwiftUI


/// This subclass is needed since we want to customize the cursor and the context menu
class CustomUITextField: UITextField, UITextFieldDelegate {
    
    /// Binding from the `CustomTextField` so changes of the text can be observed by `SwiftUI`
    fileprivate var _textBinding: Binding<String>!
    
    /// If it is `true` the text field behaves normally.
    /// If it is `false` the text cannot be modified only selected, copied and so on.
    fileprivate var _isEditable = true {
        didSet {
            // set the input view so the keyboard does not show up if it is edited
            self.inputView = self._isEditable ? nil : UIView()
            // do not show autocorrection if it is not editable
            self.autocorrectionType = self._isEditable ? .default : .no
        }
    }
    
    
    // change the cursor to have zero size
    override func caretRect(for position: UITextPosition) -> CGRect {
        return self._isEditable ? super.caretRect(for: position) : .zero
    }
    
    // override this method to customize the displayed items of 'UIMenuController' (the context menu when selecting text)
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    
        // disable 'cut', 'delete', 'paste','_promptForReplace:'
        // if it is not editable
        if (!_isEditable) {
            switch action {
            case #selector(cut(_:)),
                 #selector(delete(_:)),
                 #selector(paste(_:)):
                return false
            default:
                // do not show 'Replace...' which can also replace text
                // Note: This selector is private and may change
                if (action == Selector("_promptForReplace:")) {
                    return false
                }
            }
        }
        return super.canPerformAction(action, withSender: sender)
    }
    
    
    // === UITextFieldDelegate methods
    
    func textFieldDidChangeSelection(_ textField: UITextField) {
        // update the text of the binding
        self._textBinding.wrappedValue = textField.text ?? ""
    }
    
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        // Allow changing the text depending on `self._isEditable`
        return self._isEditable
    }
    
}

struct CustomTextField: UIViewRepresentable {
    
    @Binding private var text: String
    private var isEditable: Bool
    
    init(text: Binding<String>, isEditable: Bool = true) {
        self._text = text
        self.isEditable = isEditable
    }
    
    func makeUIView(context: UIViewRepresentableContext<CustomTextField>) -> CustomUITextField {
        let textField = CustomUITextField(frame: .zero)
        textField.delegate = textField
        textField.text = self.text
        textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
        return textField
    }
    
    func updateUIView(_ uiView: CustomUITextField, context: UIViewRepresentableContext<CustomTextField>) {
        uiView.text = self.text
        uiView._textBinding = self.$text
        uiView._isEditable = self.isEditable
    }
    
    func isEditable(editable: Bool) -> CustomTextField {
        return CustomTextField(text: self.$text, isEditable: editable)
    }
}

struct SelectableText: UIViewRepresentable {
    
    private var text: String
    private var selectable: Bool
    
    init(_ text: String, selectable: Bool = true) {
        self.text = text
        self.selectable = selectable
    }
    
    func makeUIView(context: Context) -> CustomUITextField {
        let textField = CustomUITextField(frame: .zero)
        textField.delegate = textField
        textField.text = self.text
        textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
        textField.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        return textField
    }
    
    func updateUIView(_ uiView: CustomUITextField, context: Context) {
        uiView.text = self.text
        uiView._textBinding = .constant(self.text)
        uiView._isEditable = false
        uiView.isEnabled = self.selectable
    }
    
    func selectable(_ selectable: Bool) -> SelectableText {
        return SelectableText(self.text, selectable: selectable)
    }
    
}


struct TextTestView: View {
    
    @State private var selectableText = true
    
    var body: some View {
        VStack {
            
            // Even though the text should be constant, it is not because the user can select and e.g. 'cut' the text
            TextField("", text: .constant("Test SwiftUI TextField"))
                .background(Color(red: 0.5, green: 0.5, blue: 1))
            
            // This view behaves like the `SelectableText` however the layout behaves like a `TextField`
            CustomTextField(text: .constant("Test `CustomTextField`"))
                .isEditable(editable: false)
                .background(Color.green)
            
            // A non selectable normal `Text`
            Text("Test SwiftUI `Text`")
                .background(Color.red)
            
            // A selectable `text` where the selection ability can be changed by the button below
            SelectableText("Test `SelectableText` maybe selectable")
                .selectable(self.selectableText)
                .background(Color.orange)
            
            Button(action: {
                self.selectableText.toggle()
            }) {
                Text("`SelectableText` can be selected: \(self.selectableText.description)")
            }
            
            // A selectable `text` which cannot be changed
            SelectableText("Test `SelectableText` always selectable")
                .background(Color.yellow)
            
        }.padding()
    }
    
}

let viewController = UIHostingController(rootView: TextTestView())
viewController.view.frame = CGRect(x: 0, y: 0, width: 400, height: 200)

PlaygroundPage.current.liveView = viewController.view
like image 161
Qbyte Avatar answered Oct 18 '22 22:10

Qbyte


A simple workaround solution I found is to just use context menus instead:

Text($someText)
.contextMenu(ContextMenu(menuItems: {
  Button("Copy", action: {
    UIPasteboard.general.string = someText
  })
}))

like image 42
thisIsTheFoxe Avatar answered Oct 18 '22 20:10

thisIsTheFoxe


I ran into a similar problem, where I wanted in essence to select the text without allowing editing. In my case, I wanted to show the UIMenuController when the text was tapped on, without allowing editing of the text or showing the cursor or keyboard. Building on the prior answers:

import SwiftUI
import UIKit


struct SelectableText: UIViewRepresentable {
    var text: String
    @Binding var isSelected: Bool

    func makeUIView(context: Context) -> SelectableLabel {
        let label = SelectableLabel()
        label.textColor = .white
        label.font = .systemFont(ofSize: 60, weight: .light)
        label.minimumScaleFactor = 0.6
        label.adjustsFontSizeToFitWidth = true
        label.textAlignment = .right
        label.numberOfLines = 1
        label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
        label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        label.text = text
        return label
    }

    func updateUIView(_ uiView: SelectableLabel, context: Context) {
        uiView.text = text
        if isSelected {
            uiView.showMenu()
        } else {
            let _ = uiView.resignFirstResponder()
        }
    }
}

class SelectableLabel: UILabel {
    override var canBecomeFirstResponder: Bool {
        return true
    }

    override init(frame: CGRect) {
        super.init(frame: .zero)
        highlightedTextColor = .gray
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        switch action {
        case #selector(copy(_:)), #selector(paste(_:)), #selector(delete(_:)):
            return true
        default:
            return super.canPerformAction(action, withSender: sender)
        }
    }

    override func copy(_ sender: Any?) {
        UIPasteboard.general.string = self.stringValue
    }

    override func paste(_ sender: Any?) {
        guard let string = UIPasteboard.general.string else { return }
        NotificationCenter.default.post(name: Notification.Name.Paste, object: nil, userInfo: [Keys.PastedString: string])
    }

    override func delete(_ sender: Any?) {
        NotificationCenter.default.post(name: Notification.Name.Delete, object: nil)
    }

    override func resignFirstResponder() -> Bool {
        isHighlighted = false
        return super.resignFirstResponder()
    }

    public func showMenu() {
        becomeFirstResponder()
        isHighlighted = true
        let menu = UIMenuController.shared            
        menu.showMenu(from: self, rect: bounds)
    }
}

I use custom paste and delete notifications to message my model object, where the paste and delete actions are processed to update the display appropriately, which works for my purposes. Bindings could also be used.

To use:

SelectableText(text: text, isSelected: self.$isSelected)
    .onTapGesture {
         self.isSelected.toggle()
     }
     .onReceive(NotificationCenter.default.publisher(for: UIMenuController.willHideMenuNotification)) { _ in
         self.isSelected = false
     }
like image 4
jbaraga Avatar answered Oct 18 '22 22:10

jbaraga