Рецепты под Андроид: Selectable соус для LayoutManager'a
Пользователь не любит тратить время, пользователь не любит переписывать текст. Пользователь хочет копипастить. И хочет делать это даже в приложении на мобильном устройстве. И хочет, чтобы эта функция была удобной для работы пальцем на небольшом экране. Производители операционных систем по-разному реализуют эту функцию, стараясь угодить пользователям. Не отстают и разработчики приложений.
Нас тоже не обошла стороной эта тема, и в одном из приложений нам пришлось потрудиться, чтобы сделать как можно более удобную функцию выделения и копирования текста. Секретом этого рецепта мы и хотим поделиться с общественностью.
Итак, поехали!
Если вы сталкивались с задачей выделения текста, то знаете, что у 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’у о том, что контент скролится и нужно передвигать курсоры. Координаты вьюшек сменились, но позиции в тексте. Поэтому пользуемся известным алгоритмом:
1. Смотрим куда сдвинулся курсор(onTouchEvent).
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