
Нас тоже не обошла стороной эта тема, и в одном из приложений нам пришлось потрудиться, чтобы сделать как можно более удобную функцию выделения и копирования текста. Секретом этого рецепта мы и хотим поделиться с общественностью.
Итак, поехали!
Если вы сталкивались с задачей выделения текста, то знаете, что у TextView есть метод setTextIsSelectable (boolean selectable), который позволяет выделить текст внутри одной TextView. Но что если у вас текста на несколько экранов (например, новостная статья)? Располагать весь текст в одной TextView и всё это скролить как минимум нерационально. Поэтому, обычно создают RecyclerView, разбивают текст на абзацы, и по абзацу начинают его добавлять в RecyclerView.
Заставлять пользователя выделять текст по абзацу не сильно “дружелюбно”. Встает вопрос: как выделить сразу два и более абзацев? А что если в тексте есть картинка или какой-нибудь другой элемент?

Тотальный контроль
Первое, с чего начнем, создадим класс, который будет управлять процессом выделения и контролировать все его этапы. Его необходимо инициализировать в нашем кастомном SelectableRecyclerView, и в дальнейшем передавать состояния recyclerView и его LayoutManager’a нашему контролеру. А для начала в конструктор SelectionController’а передаем ViewGroup, в котором и будет происходит выделение текста.
public class SelectionController { private ViewGroup selectableViewGroup; public SelectionController(ViewGroup selectableViewGroup) { this.selectableViewGroup = selectableViewGroup; } }
Наш кастомный LayoutManager:
public class SelectableLayoutManager extends LinearLayoutManager { private SelectionController sh; public SelectableLayoutManager(Context context) { super(context); } public SelectableLayoutManager(Context context, int orientation, boolean reverseLayout) { super(context, orientation, reverseLayout); } public SelectableLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } public void setSelectionController(SelectionController selectionController) { sh = selectionController; } }
Наш кастомный RecyclerView:
public class SelectableRecyclerView extends RecyclerView { private SelectionController sh; public SelectableRecyclerView(Context context) { super(context); } public SelectableRecyclerView(Context context, AttributeSet attrs) { super(context, attrs); } public SelectableRecyclerView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override protected void onFinishInflate() { super.onFinishInflate(); sh = new SelectionController(this); } @Override public void setLayoutManager(LayoutManager layout) { super.setLayoutManager(layout); if (layout instanceof SelectableLayoutManager) { ((SelectableLayoutManager) layout).setSelectionController(sh); } } }
Следим за юзером
Обычно режим выделения текста включается по долгому тапу, следовательно, нам нужно определить долгий тап над нашим SelectableRecyclerView. В этом нам поможет GestureDetector, который будет инициализироваться в конструкторе SelectionController’a и сообщать, что пора бы уже включить режим выделения текста.
private void initGesture() { gestureDetector = new GestureDetector(selectableViewGroup.getContext(), new GestureDetector.SimpleOnGestureListener() { @Override public void onLongPress(MotionEvent event) { if (!selectInProcess) { startSelection(event); } } }); }
Теперь мы определили, что пользователь желает начать выделение текста, и у нас есть MotionEvent, который знает, где пользователь хочет начать.
Посредством легких манипуляций мы можем определить, куда пользователь тапнул на экране:
selectableViewGroup.getLocationOnScreen(location); int evX = (int) (event.getX() + location[0]); int evY = (int) (event.getY() + location[1]);
Теперь у нас есть координаты и нам нужно определить на какую TextView пользователь попал, для этого создадим свою SelectableTextView, в которой будет метод:
public boolean isInside(int evX, int evY) { int[] location = new int[2]; getLocationOnScreen(location); int left = location[0]; int right = left + getWidth(); int top = location[1]; int bottom = top + getHeight(); return left <= evX && right >= evX && top <= evY && bottom >= evY; }
Мы помним, что RecyclerView — это Viewgroup, поэтому берем всех его child’ов, перебираем их и проверяем, в какой child мы попали.
Слишком просто, да?
Наверняка вы подумали, а если child не SelectableTextView, и вообще RecyclerView весь такой динамичный и child’ы у него могут поменяться, им еще и управляет LayoutManager, и все это скролится. Верно подумали, поэтому мы рассмотрим это чуть позже =)
А пока продолжим…
Итак, мы нашли нужную нам SelectableTextView и знаем, в какое место пользователь тапнул. Нам нужно выделить текст, а для этого необходимо найти символ в тексте, с которого будет начитаться выделение.
Начнем.
Получаем строку в тексте по координате y:
private int getLineAtCoordinate(float y) { y -= getTotalPaddingTop(); y = Math.max(0.0f, y); y = Math.min(getHeight() - getTotalPaddingBottom() - 1, y); y += getScrollY(); return getLayout().getLineForVertical((int) y); }
Нашли строку, теперь по ней и координате x в этой строке находим позицию (для тех, кого смутил метод getlayout() ):
private int getOffsetAtCoordinate(int line, float x) { x = convertToLocalHorizontalCoordinate(x); return getLayout().getOffsetForHorizontal(line, x); } private float convertToLocalHorizontalCoordinate(float x) { x -= getTotalPaddingLeft(); x = Math.max(0.0f, x); x = Math.min(getWidth() - getTotalPaddingRight() - 1, x); x += getScrollX(); return x; }
Если прочитали документацию, то должны помнить, что getLayout() может вернуть null, поэтому в итоге метод для получения позиции в тексте выглядит:
public int getOffsetForPosition(int x, int y) { if (getLayout() == null) return -1; final int line = getLineAtCoordinate(y); return getOffsetAtCoordinate(line, x); }
Наконец-то мы закончили с SelectableTextView, получили позицию в тексте и можем вернуться в SelectionController.
Чаще всего пользователь не целится специально в начало или конец слова, но хочет выделить его целиком, поэтому попытаемся выделить всё слово, и вернуть начальную и конечную позиции (с помощью метода в SelectionController’e):
private int[] getHandlesPosition(final String text, final int pos) { final int[] handlesPosition = new int[2]; final int textLength = text.length(); handlesPosition[0] = 0; for (int i = pos; i >= 0; i--) { if (!LetterDigitPattern.matcher(String.valueOf(text.charAt(i))).matches()){ handlesPosition[0] = i + 1; break; } } handlesPosition[1] = textLength - 1; for (int i = pos; i < textLength; i++) { if (!LetterDigitPattern.matcher(String.valueOf(text.charAt(i))).matches()){ handlesPosition[1] = i; break; } } return handlesPosition; }
Как итог, мы получили начальную и конечную позицию выделения, поэтому получаем координаты этих позиции и рисуем наши курсоры:
public void draw(Canvas canvas) { canvas.drawBitmap(handleImage, x, y, paint); }
Но рисуем мы их SelectionController’ом, а он в свою очередь никакого отношения к draw и canvas не имеет. Поэтому, переопределим в SelectionRecyclerView метод dispatchDraw и попросим SelectionController.drawHandles нарисовать курсоры для SelectionRecyclerView:
@Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); sh.drawHandles(canvas); } SelectionController.java public void drawHandles(Canvas canvas) { if (!selectInProcess) return; rightHandle.draw(canvas); leftHandle.draw(canvas); }
Вот что у нас получилось:

Теперь пометим выделенную область, чтобы это было похоже на выделенный текст, сделать мы можем это двумя способами:
1. Через SpannableString;
2. Отрисовать Canvas’ом.
Поскольку SpannableString достаточно долго рендерится, то при большом количестве выделенного текста можно будет забыть о плавном передвижении курсоров, поэтому будем рисовать все canvas'ом. Координаты начальной и конечной позиций у нас есть, поэтому несложно посчитать какую область нужно закрасить.

Движение — это жизнь!
Наконец-то пользователь доволен, но теперь он хочет выделить больше текста, передвинув курсоры. Поэтому нам нужно рассчитать новые координаты для курсора, который должен следовать за движениями пальца:
1. Смотрим куда сдвинулся курсор (onTouchEvent).
2. Находим на какую позицию в тексте курсор попадает.
3. Находим координаты этой позиции, чтобы притянуть курсор к ней.
4. Рисуем.
Выглядит это так:
public boolean onTouchEvent(MotionEvent ev) { if (gestureDetector != null) gestureDetector.onTouchEvent(ev); boolean dispatched = false; if (selectInProcess) { boolean right = rightHandleListener.onTouchHandle(ev); boolean left = leftHandleListener.onTouchHandle(ev); dispatched = right || left; } return dispatched; } public boolean onTouchHandle(MotionEvent event) { switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: //проверяем попали ли мы на курсор handle.isMoving = handle.contains(event.getX(), event.getY()); if (handle.isMoving) { //если попали сохраняем координаты для дальнейших манипуляций yDelta = (int) (event.getY() - handle.y + 1); xDelta = (int) (event.getX() - handle.x + handle.correctX); //и конечно же, говорим parent'у что мы все touchevent'ы перехватили selectableViewGroup.getParent().requestDisallowInterceptTouchEvent(true); } break; case MotionEvent.ACTION_UP: handle.isMoving = false; selectableViewGroup.getParent().requestDisallowInterceptTouchEvent(false); break; case MotionEvent.ACTION_POINTER_DOWN: break; case MotionEvent.ACTION_POINTER_UP: break; case MotionEvent.ACTION_MOVE: if (handle.isMoving) { //находим новые координаты x = (int) (event.getRawX() - xDelta); y = (int) (event.getRawY() - yDelta); int oldHandlePos = handle.position; //получаем позицию в тексте для курсора handle.position = getCursorPosition(x, y, handle.position); if (handle.position != oldHandlePos) { //выставляем координаты курсору по позиции в тексте setHandleCoordinate(handle); //Ищем в какую текствью попали, выделяем у нее текст setSelectionText(); //проверим нужно ли поменять backround у курсоров, //если пользователь перенес к примеру правый курсор за левый курсор и они поменялись местами checkBackground(); //вызываем перерисовку всего selectableViewGroup.invalidate(); } } break; } return handle.isMoving; }
По одиночному тапу GestureDetector.onSingleTapUp выключаем режим выделения текста, пробегаемся по всем SelectableTextView, копируем у них выделенный текст и кладем его в буфер обмена.
@Override public boolean onSingleTapUp(MotionEvent e) { if (selectInProcess) { copyToClipBoard(stopSelection().toString()); } return super.onSingleTapUp(e); } private void copyToClipBoard(String s) { ClipboardManager clipboard = (ClipboardManager) selectableViewGroup.getContext().getSystemService(Context.CLIPBOARD_SERVICE); ClipData clip = ClipData.newPlainText("Article", s); clipboard.setPrimaryClip(clip); Toast.makeText(selectableViewGroup.getContext(), "Text was copied to clipboard", Toast.LENGTH_LONG).show(); }
Всё такое динамичное
А теперь вспомним, что у нас RecyclerView, LayoutManager, большое количество элементов, всё скролится, все вьюшки переиспользуются, и вообще творится магия.
Из-за чего встает 2 серьезных проблемы:
1. Как сохранить выделение во вьюшках при скроле, если они переиспользуются?
2. Как двигать курсоры вместе со скролом?
Начнем с простой проблемы — передвижение курсоров вместе со скролом. Если вы читали статью про LayoutManager, то знаете, что отвечает за скролл LayoutManager, который вызывает метод offsetChildrenVertical(int dy). Поэтому переопределим его и сообщим нашему SelectionController’у о том, что контент скролится и нужно передвигать курсоры. Координаты вьюшек сменились, но позиции в тексте. Поэтому пользуемся известным алгоритмом:
2. Находим на какую позицию в тексте курсор попадает.
3. Находим координаты этой позиции, чтобы притянуть курсор к ней.
4. Рисуем:
public void checkHandlesPosition() { if (!selectInProcess) return; setHandleCoordinate(rightHandle); setHandleCoordinate(leftHandle); selectableViewGroup.postInvalidate(); } private void setHandleCoordinate(Handle handle) { Selectable textView = null; int totalPos = 0; for (SelectableInfo selectableInfo : selectableInfos) { String text = selectableInfo.getText().toString(); int length = text.length(); if (handle.position >= totalPos && handle.position < totalPos + length) { textView = selectableInfo.getSelectable(); break; } totalPos += length; } if (textView == null) { handle.visible = false; return; } if (!isSvgParent((View)textView)) { handle.visible = false; checkSelectableList(); return; } handle.visible = true; float[] coordinate = new float[2]; coordinate = textView.getPositionForOffset(handle.position - totalPos, coordinate); int[] location = new int[2]; selectableViewGroup.getLocationOnScreen(location); if (coordinate[0] == -1 || coordinate[1] == -1) return; handle.x = coordinate[0] - location[0] + handle.correctX; handle.y = coordinate[1] - location[1]; }
В коде есть чудесные selectableInfos, они нам и помогут решить проблему 1. SelectableInfo содержит в себе информацию о выделенном тексте, о том, какая это была SelectableTextView и какой в ней был текст.
public class SelectableInfo { private int start; private int end; private String selectedText; private String text; private String key; private Selectable selectable; public SelectableInfo(Selectable selectable) { this.start = 0; this.end = 0; this.selectedText = ""; this.selectable = selectable; this.text = selectable.getText(); this.key = selectable.getKey(); } }
“Эй, Вьюшка! Ты туда не ходи, ты сюда ходи!”
Мы помним, что у нас скролится контент и переиспользуются вьюшки. Каждый раз, когда вьюшка удаляется или добавляется в RecyclerView, мы сохраняем ее состояние и ссылку на нее (Selectable) в SelectableInfos.
Selectable - Интерфейс, который имплементирует наша SelectableTextView. public interface Selectable { int getOffsetForPosition(int x, int y); int getVisibility(); CharSequence getText(); void setText(CharSequence text); void getLocationOnScreen(int[] location); int getHeight(); int getWidth(); float[] getPositionForOffset(int offset, float[] position); void selectText(int start, int end); CharSequence getSelectedText(); boolean isInside(int evX, int evY); void setColor(int selectionColor); int getStartSelection(); int getEndSelection(); String getKey(); void setKey(String key); }
Таким образом, у нас хранится актуальный массив данных о выделенном тексте.
Но как нам его связать с вьюшками, которые переиспользуются?
Для этого нам необходимо определить, что вьюшка, которая сейчас добавилась, относится к определенному SelectableInfo. Поэтому добавим ключ (Selectable.getKey() / setKey(String key)), по которому мы будем понимать, что вьюшка та, которая нам нужна. Устанавливать этот ключ вьюшке будем во время биндинга холдера у LayoutManager’a.
@Override public void onBindViewHolder(VHolder viewHolder, int position) { viewHolder.textView.setText(sampleText); viewHolder.textView.setKey(" pos: " + position + sampleText); }
Встает вопрос о том, в какой момент времени LayoutManager добавляет вьюшки в RecyclerView, а делает он это посредством вызова метода addView(View child, int index), который мы и переопределим:
@Override public void addView(View child, int index) { super.addView(child, index); sh.addViewToSelectable(child); }
Также нужно помнить, что вьюшки, которые добавляются у нас в RecyclerView, могут быть не только TextView, а иметь сложный layout. Он может содержать в себе несколько TextView на разных уровнях, поэтому рекурсивно обходим все дерево вьюшек, если оно есть:
public void addViewToSelectable(View view) { checkSelectableList(); if (view instanceof Selectable){ addSelectableToSelectableInfos((Selectable) view); } else if (view instanceof ViewGroup){ findSelectableTextView((ViewGroup) view); } } public void findSelectableTextView(ViewGroup viewGroup) { for (int i = 0; i < viewGroup.getChildCount(); i++){ View view = viewGroup.getChildAt(i); if (view instanceof Selectable){ addSelectableToSelectableInfos((Selectable) view); continue; } if (view instanceof ViewGroup){ findSelectableTextView((ViewGroup) view); } } }
Добавление вьюшки происходит просто. Если у нас есть информация по ее getKey(), то мы просто сохраняем ее ссылку(selectable). Если нет, то создаем новый SelectableInfo и добавляем его в список наших selectableInfos:
private void addSelectableToSelectableInfos(Selectable selectable) { boolean found = false; for (SelectableInfo selectableInfo : selectableInfos) { if (selectableInfo.getKey().equals(selectable.getKey())) { selectableInfo.setSelectable(selectable); found = true; break; } } if (!found) { final SelectableInfo selectableInfo = new SelectableInfo(selectable); selectableInfos.add(selectableInfo); } }
Мы помним, что вьюшки переиспользуются, поэтому в момент переиспользования нужно в старом SelectableInfo стереть ссылку на нее selectableInfo.removeSelectable().
Лучше всего осуществлять проверку на актуальность нашего списка во время добавления новой вьюшки. У нас бывает два случая, когда ссылка на вьюшку у нас неактуальная:
1. Вьюшка уже переиспользовалась, и забиндины новые значения в нее, и соответственно новый ключ (getKey()).
2. Вьюшка ушла в пулл и ждет, пока будет необходима — она нам тоже не нужна, потому что вряд ли пользователю удастся выделить текст у вьюшки, которая не находится на экране =).
Поэтому нам нужно проверить, действительно ли ключ такой у вьюшки, какой мы ожидаем, и для второго случая проверить наличие parent’а у нее:
public void checkSelectableList() { for (SelectableInfo selectableInfo : selectableInfos) { if (selectableInfo.getSelectable() != null) { if (!selectableInfo.getSelectable().getKey().equals(selectableInfo.getKey())) { selectableInfo.removeSelectable(); continue; } if (!isSvgParent((View) selectableInfo.getSelectable())) { selectableInfo.removeSelectable(); } } } }
Таким образом, мы получили актуальный список информаций о вьюшке (SelectableInfo), в котором есть все данные, чтобы при скроле восстанавливать выделение текста.
Котики в конце!
Поскольку мы сделали рекурсивный поиск всех SelectableTextView в нашей вьюшке, то мы можем делать различные лайауты с различным расположением текста, и даже картинок. Выделение текста всё равно будет работать!
В заключение можно сказать, что с виду большая и сложная задача решается достаточно просто при знаниях жизненного цикла View и того, как работает связка RecyclerView — LayoutManager. Мы надеемся, что эта статья поможет разработчикам взять на вооружение интересный способ реализации выделения текста. Всем хорошего дня и удачи в разработке.
Ссылки по теме:
Ссылка на проект с примером https://github.com/qw1nz/TextSelection.git
