Рецепты под Андроид: Selectable соус для LayoutManager'a

Пользователь не любит тратить время, пользователь не любит переписывать текст. Пользователь хочет копипастить. И хочет делать это даже в приложении на мобильном устройстве. И хочет, чтобы эта функция была удобной для работы пальцем на небольшом экране. Производители операционных систем по-разному реализуют эту функцию, стараясь угодить пользователям. Не отстают и разработчики приложений.

b1f9f18f050f459eacef62890405234c.jpg

Нас тоже не обошла стороной эта тема, и в одном из приложений нам пришлось потрудиться, чтобы сделать как можно более удобную функцию выделения и копирования текста. Секретом этого рецепта мы и хотим поделиться с общественностью.

Итак, поехали!


Если вы сталкивались с задачей выделения текста, то знаете, что у TextView есть метод setTextIsSelectable (boolean selectable), который позволяет выделить текст внутри одной TextView. Но что если у вас текста на несколько экранов (например, новостная статья)? Располагать весь текст в одной TextView и всё это скролить как минимум нерационально. Поэтому, обычно создают RecyclerView, разбивают текст на абзацы, и по абзацу начинают его добавлять в RecyclerView.

Заставлять пользователя выделять текст по абзацу не сильно “дружелюбно”. Встает вопрос: как выделить сразу два и более абзацев? А что если в тексте есть картинка или какой-нибудь другой элемент?

a5c8d5545f43417e9b5f371b4a950f81.gif

Тотальный контроль


Первое, с чего начнем, создадим класс, который будет управлять процессом выделения и контролировать все его этапы. Его необходимо инициализировать в нашем кастомном 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);
}


Вот что у нас получилось:

ea89edf9865749a8b843b9472ac0b639.png

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

Поскольку SpannableString достаточно долго рендерится, то при большом количестве выделенного текста можно будет забыть о плавном передвижении курсоров, поэтому будем рисовать все canvas'ом. Координаты начальной и конечной позиций у нас есть, поэтому несложно посчитать какую область нужно закрасить.

e953329c4b7c484db9658381d03918dd.png

Движение — это жизнь!


Наконец-то пользователь доволен, но теперь он хочет выделить больше текста, передвинув курсоры. Поэтому нам нужно рассчитать новые координаты для курсора, который должен следовать за движениями пальца:

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 в нашей вьюшке, то мы можем делать различные лайауты с различным расположением текста, и даже картинок. Выделение текста всё равно будет работать!

fb89cabeba7c4a4ea2abd1d07677396b.gif

В заключение можно сказать, что с виду большая и сложная задача решается достаточно просто при знаниях жизненного цикла View и того, как работает связка RecyclerView — LayoutManager. Мы надеемся, что эта статья поможет разработчикам взять на вооружение интересный способ реализации выделения текста. Всем хорошего дня и удачи в разработке.

Ссылки по теме:

Ссылка на проект с примером https://github.com/qw1nz/TextSelection.git

© Habrahabr.ru