Search code examples
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:

    • 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