Рецепти під Андроїд: 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.0 f, 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.0 f, 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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.