⬆️ ⬇️

Recipes for Android: Selectable sauce for LayoutManager'a

The user does not like to waste time, the user does not like to rewrite the text. User wants to copy-paste. And he wants to do it even in the application on a mobile device. And he wants this function to be convenient for work with a finger on a small screen. Manufacturers of operating systems implement this function in different ways, trying to please users. Do not fall behind and application developers.







We, too, were not spared by this topic, and in one of the applications we had to work hard to make the most convenient function of selecting and copying text. The secret of this recipe we want to share with the public.



So let's go!



If you come across a text selection task, then you know that a TextView has a setTextIsSelectable method (boolean selectable) , which allows you to select text within a single TextView. But what if you have text on multiple screens (for example, a news article)? Placing all the text in one TextView and scrolling all this is at least irrational. Therefore, they usually create a RecyclerView, break the text into paragraphs, and by paragraph begin to add it to RecyclerView.

')

Forcing the user to select the text in a paragraph is not very “friendly”. The question is: how to select two or more paragraphs at once? And what if there is a picture or some other element in the text?







Total control



The first thing to begin with is to create a class that will manage the allocation process and control all its stages. It needs to be initialized in our custom SelectableRecyclerView, and in the future to transfer the status of recyclerView and its LayoutManager to our controller. To begin with, we pass the ViewGroup to the SelectionController constructor, in which the text will be selected.



public class SelectionController { private ViewGroup selectableViewGroup; public SelectionController(ViewGroup selectableViewGroup) { this.selectableViewGroup = selectableViewGroup; } } 


Our custom 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; } } 


Our custom 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); } } } 


We follow the user



Typically, the text selection mode is activated by a long tap, therefore, we need to define a long tap over our SelectableRecyclerView. GestureDetector will help us with this, which will be initialized in the SelectionController'a constructor and inform us that it’s time to turn on the text selection mode.



 private void initGesture() { gestureDetector = new GestureDetector(selectableViewGroup.getContext(), new GestureDetector.SimpleOnGestureListener() { @Override public void onLongPress(MotionEvent event) { if (!selectInProcess) { startSelection(event); } } }); } 


Now we have determined that the user wants to start text selection, and we have a MotionEvent that knows where the user wants to start.



Through easy manipulations, we can determine where the user tapped on the screen:



 selectableViewGroup.getLocationOnScreen(location); int evX = (int) (event.getX() + location[0]); int evY = (int) (event.getY() + location[1]); 


Now we have coordinates and we need to determine on which TextView the user has got, for this we will create our SelectableTextView, which will have a method:



 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; } 


We remember that RecyclerView is a Viewgroup, so we take all its child'ov, iterate over them and check what kind of child we got into.



Too easy, huh?



Surely you thought, if the child is not SelectableTextView, and in general RecyclerView is all so dynamic and the child can change it, it is also controlled by the LayoutManager , and it will all go away . Right thought, so we will look at it a little later =)



For now let's continue ...



So, we have found the SelectableTextView we need and know where the user has tapped. We need to select the text, and for this it is necessary to find the symbol in the text, from which the selection will be read.



Let's start.



Get the string in the text at the y coordinate:



 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); } 


Found a line, now by it and the x coordinate in this line we find the position (for those who are embarrassed by the getlayout () method):

 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; } 


If you have read the documentation, you should remember that getLayout () can return null, so the method for getting a position in the text looks like:



 public int getOffsetForPosition(int x, int y) { if (getLayout() == null) return -1; final int line = getLineAtCoordinate(y); return getOffsetAtCoordinate(line, x); } 


Finally, we ended up with SelectableTextView, got a position in the text, and can return to the SelectionController.



Most often, the user does not specifically aim at the beginning or end of the word, but wants to select it entirely, so we will try to select the entire word and return the starting and ending positions (using the method in the SelectionController):



 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; } 


As a result, we got the initial and final position of the selection, so we get the coordinates of these positions and draw our cursors:



 public void draw(Canvas canvas) { canvas.drawBitmap(handleImage, x, y, paint); } 


But we draw them with SelectionController, and he, in turn, has nothing to do with draw and canvas. Therefore, override the dispatchDraw method in SelectionRecyclerView and ask SelectionController.drawHandles to draw the cursors for the 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); } 


Here's what we got:







Now mark the selected area so that it looks like the selected text, we can do it in two ways:

1. Through SpannableString;

2. Draw a Canvas.



Since SpannableString is rendered for quite a long time, with a large amount of selected text, you can forget about the smooth movement of cursors, so we will draw everything with a canvas. We have the coordinates of the initial and final positions, so it is easy to calculate what area you need to paint over.







Movement is life!



Finally, the user is satisfied, but now he wants to select more text by moving the cursors. Therefore, we need to calculate the new coordinates for the cursor, which should follow the movement of the finger:



1. We look where the cursor has moved (onTouchEvent).

2. Find what position in the text the cursor hits.

3. Find the coordinates of this position to drag the cursor to it.

4. Draw.



It looks like this:



 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; } 


For a single tapu GestureDetector.onSingleTapUp turn off the text selection mode, go over all the SelectableTextView, copy the selected text from them and put it on the clipboard.



 @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(); } 


Everything is so dynamic



Now, remember that we have a RecyclerView, LayoutManager, a large number of elements, everything is scrolled, all views are reused, and in general magic is created.



Because of what 2 serious problems arise:

1. How to keep selection in views when scrolling, if they are reused?

2. How to move cursors along with scrolling?



Let's start with a simple problem - moving the cursors along with scrolling. If you read an article about LayoutManager , then you know what is responsible for the LayoutManager scroll, which calls the offsetChildrenVertical (int dy) method. Therefore, we will redefine it and inform our SelectionController that the content will scroll and need to move the cursors. The coordinates of the views have changed, but the positions in the text. Therefore, we use the well-known algorithm:



1. We look where the cursor has moved (onTouchEvent).

2. Find what position in the text the cursor hits.

3. Find the coordinates of this position to drag the cursor to it.

4. Draw:



 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]; } 


There are wonderful selectableInfos in the code, they will help us to solve the problem 1. SelectableInfo contains information about the selected text, about what it was and SelectableTextView and what text was in it.



 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(); } } 


“Hey, View! You do not go there, you go here! ”



We remember that content is scrolled and views are reused. Every time a view is deleted or added to a RecyclerView, we save its state and a reference to it (Selectable) in 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); } 


Thus, we keep the actual data array of the selected text.



But how do we associate it with views that are reused?



To do this, we need to determine that the view that has now been added belongs to a specific SelectableInfo. Therefore, we add a key (Selectable.getKey () / setKey (String key)), by which we will understand that the view that we need. We will install this key to the view during the binding of the holder at the LayoutManager.



 @Override public void onBindViewHolder(VHolder viewHolder, int position) { viewHolder.textView.setText(sampleText); viewHolder.textView.setKey(" pos: " + position + sampleText); } 


The question is about at what point in time the LayoutManager adds views to RecyclerView, and it does this by calling the addView (View child, int index) method, which we will override:



 @Override public void addView(View child, int index) { super.addView(child, index); sh.addViewToSelectable(child); } 


You also need to remember that the views that we add to RecyclerView may not only be a TextView, but also have a complex layout. It can contain several TextViews at different levels, so we recursively walk around the entire tree of views, if there is one:



 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); } } } 


Adding a view is easy. If we have information on its getKey (), then we simply save its link (selectable). If not, create a new SelectableInfo and add it to our list of 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); } } 


We remember that views are reused, so at the time of reuse you need to erase the link to it selectableInfo.removeSelectable () in the old SelectableInfo.



It is best to check for the relevance of our list while adding a new view. We have two cases in which the link to the view is irrelevant:

1. The view has already been reused, and the new values ​​are new, and accordingly the new key (getKey ()).

2. The view went to the pool and waits until it is needed - we also do not need it, because the user is unlikely to be able to select text from the view that is not on the screen =).



Therefore, we need to check whether the key is such in the view as we expect, and for the second case, check for the presence of the parent in it:



 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(); } } } } 


Thus, we have obtained an up-to-date list of information about the view (SelectableInfo), in which there is all the data in order to restore the text selection when scrolling.



Seals at the end!



Since we did a recursive search of all the SelectableTextView in our view, we can make different layouts with different arrangement of text, and even images. Selecting text will still work!







In conclusion, it can be said that a seemingly large and complex task is solved quite simply with knowledge of the View life cycle and how the RecyclerView-LayoutManager works. We hope that this article will help developers to adopt an interesting way to implement the selection of the text. Have a good day and good luck in your development.



Related Links:



Link to the project with an example https://github.com/qw1nz/TextSelection.git

Source: https://habr.com/ru/post/268501/



All Articles