iosswifttextswiftui

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!


Solution

  • 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:

    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