Selectable text with no context menu in Xamarin Forms

Recently I've been working on an app where users can read articles in a foreign language and get immediate translation of the words they select. Unfortunately, it turns out that text selection isn't supported out of the box in Xamarin Forms so I needed to do some digging.

First of all, I found these two great articles explaining how to create selectable labels:

- Article by @HeikkiDev

- Article by @anna.domashych

That's almost what I needed but the default selection functionality shows a context menu allowing the user to copy text etc. In my application, I wanted to handle the selected word on my own, without showing the menu. Here is how to do it.

Shared code

First of all, we need to define a custom control in our shared project. It will support basic text properties and a bindable command to get the currently selected word.

public class SelectableLabel : View
    {
        public static readonly BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(SelectableLabel), default(string));
        public static readonly BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(SelectableLabel), Color.Black);
        public static readonly BindableProperty FontAttributesProperty = BindableProperty.Create(nameof(FontAttributes), typeof(FontAttributes), typeof(SelectableLabel), FontAttributes.None);
        public static readonly BindableProperty FontSizeProperty = BindableProperty.Create(nameof(FontSize), typeof(double), typeof(SelectableLabel), -1.0);

        public string Text
        {
            get { return (string)GetValue(TextProperty); }
            set { SetValue(TextProperty, value); }
        }

        public Color TextColor
        {
            get { return (Color)GetValue(TextColorProperty); }
            set { SetValue(TextColorProperty, value); }
        }

        public FontAttributes FontAttributes
        {
            get { return (FontAttributes)GetValue(FontAttributesProperty); }
            set { SetValue(FontAttributesProperty, value); }
        }

        [TypeConverter(typeof(FontSizeConverter))]
        public double FontSize
        {
            get { return (double)GetValue(FontSizeProperty); }
            set { SetValue(FontSizeProperty, value); }
        }

        public static readonly BindableProperty OnTextSelectedCommandProperty =
            BindableProperty.Create(nameof(OnTextSelectedCommand), typeof(ICommand), typeof(SelectableLabel), null);

        public ICommand OnTextSelectedCommand
        {
            get { return (ICommand)GetValue(OnTextSelectedCommandProperty); }
            set { SetValue(OnTextSelectedCommandProperty, value); }
        }
      
        public static void Execute(ICommand command, object parameter)
        {
            if (command == null) return;
            if (command.CanExecute(parameter))
            {
                command.Execute(parameter);
            }
        }

        public Command<string> OnTextSelected => new Command<string>((s) => Execute(OnTextSelectedCommand, s));
    }

iOS

On iOS, we enable selection by setting UITextView's properties Editable = false and Selectable = true. In order to remove the context menu, we have to subclass UITextView and override its CanPerform(Selector action, NSObject withSender) function to return false. I also added a callback to send the selected word back to the shared code.

[assembly: ExportRenderer(typeof(SelectableLabel), typeof(XFSelectableLabel.iOS.Renderers.SelectableLabelRenderer))]
namespace XFSelectableLabel.iOS.Renderers
{
    public class SelectableLabelRenderer : ViewRenderer<SelectableLabel, UITextView>
    {
        CustomTextView uiTextView;

        protected override void OnElementChanged(ElementChangedEventArgs<SelectableLabel> e)
        {
            base.OnElementChanged(e);

            var label = (SelectableLabel)Element;
            if (label == null)
                return;

            if (Control == null)
            {
                uiTextView = new CustomTextView(Element.OnTextSelected.Execute);
            }

            uiTextView.Selectable = true;
            uiTextView.Editable = false;
            uiTextView.ScrollEnabled = false;
            uiTextView.TextContainerInset = UIEdgeInsets.Zero;
            uiTextView.TextContainer.LineFragmentPadding = 0;
            uiTextView.BackgroundColor = UIColor.Clear;

            // Initial properties Set
            uiTextView.Text = label.Text;
            uiTextView.TextColor = label.TextColor.ToUIColor();
            switch (label.FontAttributes)
            {
                case FontAttributes.None:
                    uiTextView.Font = UIFont.SystemFontOfSize(new nfloat(label.FontSize));
                    break;
                case FontAttributes.Bold:
                    uiTextView.Font = UIFont.BoldSystemFontOfSize(new nfloat(label.FontSize));
                    break;
                case FontAttributes.Italic:
                    uiTextView.Font = UIFont.ItalicSystemFontOfSize(new nfloat(label.FontSize));
                    break;
                default:
                    uiTextView.Font = UIFont.BoldSystemFontOfSize(new nfloat(label.FontSize));
                    break;
            }

            SetNativeControl(uiTextView);
        }

        protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            base.OnElementPropertyChanged(sender, e);

            if (e.PropertyName == SelectableLabel.TextProperty.PropertyName)
            {
                if (Control != null && Element != null && !string.IsNullOrWhiteSpace(Element.Text))
                {
                    uiTextView.Text = Element.Text;
                }
            }
            else if (e.PropertyName == SelectableLabel.TextColorProperty.PropertyName)
            {
                if (Control != null && Element != null)
                {
                    uiTextView.TextColor = Element.TextColor.ToUIColor();
                }
            }
            else if (e.PropertyName == SelectableLabel.FontAttributesProperty.PropertyName
                        || e.PropertyName == SelectableLabel.FontSizeProperty.PropertyName)
            {
                if (Control != null && Element != null)
                {
                    switch (Element.FontAttributes)
                    {
                        case FontAttributes.None:
                            uiTextView.Font = UIFont.SystemFontOfSize(new nfloat(Element.FontSize));
                            break;
                        case FontAttributes.Bold:
                            uiTextView.Font = UIFont.BoldSystemFontOfSize(new nfloat(Element.FontSize));
                            break;
                        case FontAttributes.Italic:
                            uiTextView.Font = UIFont.ItalicSystemFontOfSize(new nfloat(Element.FontSize));
                            break;
                        default:
                            uiTextView.Font = UIFont.BoldSystemFontOfSize(new nfloat(Element.FontSize));
                            break;
                    }
                }
            }
        }

        public class CustomTextView : UITextView
        {
            private Action<string> _callback;

            public CustomTextView(Action<string> callback)
            {
                _callback = callback;
            }

            public override bool CanPerform(Selector action, NSObject withSender)
            {
                var word = Text.Substring((int)SelectedRange.Location, (int)SelectedRange.Length);

                _callback(word);

                return false;
            }
        }
    }

Android

To make Android TextView selectable we need to call textView.SetTextIsSelectable(true). This should be enough, however there might be an issue of it not working where you will see an error in the console log saying "TextView does not support text selection. Selection cancelled." This seems to be an Android bug, which you can work around by overriding OnAttachedToWindow() function in your renderer code like so:

protected override void OnAttachedToWindow()
{
    base.OnAttachedToWindow();
    textView.Enabled = false;
    textView.Enabled = true;
}

In order to remove the context menu, we need to create our own CustomSelectionActionModeCallback and overwrite its OnActionItemClickedand OnPrepareActionMode functions. The whole renderer code looks like this:

[assembly: ExportRenderer(typeof(SelectableLabel), typeof(XFSelectableLabel.Droid.Renderers.SelectableLabelRenderer))]

namespace XFSelectableLabel.Droid.Renderers
{
    public class SelectableLabelRenderer : ViewRenderer<SelectableLabel, TextView>
    {
        TextView textView;

        public SelectableLabelRenderer(Context context) : base(context)
        {
        }

        protected override void OnElementChanged(ElementChangedEventArgs<SelectableLabel> e)
        {
            base.OnElementChanged(e);

            var label = (SelectableLabel)Element;

            if (label == null) return;

            if (Control == null)
            {
                textView = new TextView(this.Context);
            }

            textView.Enabled = true;
            textView.SetTextIsSelectable(true);
            textView.DefaultFocusHighlightEnabled = true;

            textView.CustomSelectionActionModeCallback = new CustomSelectionActionModeCallback(TextSelected);

            textView.Text = label.Text;

            textView.SetTextColor(label.TextColor.ToAndroid());

            switch (label.FontAttributes)
            {
                case FontAttributes.None:
                    textView.SetTypeface(null, Android.Graphics.TypefaceStyle.Normal);
                    break;
                case FontAttributes.Bold:
                    textView.SetTypeface(null, Android.Graphics.TypefaceStyle.Bold);
                    break;
                case FontAttributes.Italic:
                    textView.SetTypeface(null, Android.Graphics.TypefaceStyle.Italic);
                    break;
                default:
                    textView.SetTypeface(null, Android.Graphics.TypefaceStyle.Normal);
                    break;
            }

            textView.TextSize = (float)label.FontSize;
            SetNativeControl(textView);
        }

        private void TextSelected()
        {
            var word = textView.Text[textView.SelectionStart..textView.SelectionEnd];

            Element.OnTextSelected.Execute(word);
        }

        protected override void OnAttachedToWindow()
        {
            base.OnAttachedToWindow();
            textView.Enabled = false;
            textView.Enabled = true;
        }

        private class CustomSelectionActionModeCallback : Java.Lang.Object, ActionMode.ICallback
        {
            private Action _selectionCallback;

            public CustomSelectionActionModeCallback(Action selectionCallback)
            {
                _selectionCallback = selectionCallback;
            }

            public bool OnActionItemClicked(ActionMode m, IMenuItem i) => false;

            public bool OnCreateActionMode(ActionMode mode, IMenu menu) => true;

            public void OnDestroyActionMode(ActionMode mode) { }

            public bool OnPrepareActionMode(ActionMode mode, IMenu menu)
            {
                menu?.Clear();
                _selectionCallback?.Invoke();

                return false;
            }
        }
    }
}

That's it! All that's left now is to add the label into your view and bind its OnTextSelectedCommand to get the selected word. An example of how to do it and the whole code is available here:

https://github.com/KlaudiaBronowicka/XFSelectableLabel

Happy coding!