package io.mrarm.irc.chat; import android.annotation.TargetApi; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.graphics.Rect; import android.os.Build; import androidx.recyclerview.widget.RecyclerView; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.view.ActionMode; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.widget.TextView; import io.mrarm.irc.R; import io.mrarm.irc.util.LongPressSelectTouchListener; import io.mrarm.irc.util.RecyclerViewScrollerRunnable; import io.mrarm.irc.util.TextSelectionHandlePopup; import io.mrarm.irc.util.TextSelectionHelper; import io.mrarm.irc.view.TextSelectionHandleView; public class ChatSelectTouchListener implements RecyclerView.OnItemTouchListener, View.OnAttachStateChangeListener { private static final int MAX_CLICK_DURATION = 200; private static final int MAX_CLICK_DISTANCE = 30; private RecyclerView mRecyclerView; private BaseActionModeCallback mActionModeCallback; private ActionModeCallback2 mActionModeCallback2; private ActionModeStateCallback mActionModeStateCallback; private RecyclerViewScrollerRunnable mScroller; private long mSelectionStartId = -1; private int mSelectionStartOffset = -1; private long mSelectionEndId = -1; private int mSelectionEndOffset = -1; private boolean mSelectionLongPressMode = false; private long mSelectionLongPressId = -1; private int mSelectionLongPressStart = -1; private int mSelectionLongPressEnd = -1; private long mLastTouchTextId; private boolean mLastTouchInText; private int mLastTouchTextOffset; private float mTouchDownX; private float mTouchDownY; private TextSelectionHandlePopup mLeftHandle; private TextSelectionHandlePopup mRightHandle; private LongPressSelectTouchListener mMultiSelectListener; private int[] mTmpLocation = new int[2]; private int[] mTmpLocation2 = new int[2]; public ChatSelectTouchListener(RecyclerView recyclerView) { mRecyclerView = recyclerView; mScroller = new RecyclerViewScrollerRunnable(recyclerView, (int scrollDir) -> { if (scrollDir < 0) { handleSelection(0, 0, 0, 0); } else if (scrollDir > 0) { mRecyclerView.getLocationOnScreen(mTmpLocation); handleSelection(mRecyclerView.getWidth(), mRecyclerView.getHeight(), mTmpLocation[0] + mRecyclerView.getWidth(), mTmpLocation[1] + mRecyclerView.getHeight()); } }); mActionModeCallback = new BaseActionModeCallback(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) mActionModeCallback2 = new ActionModeCallback2(mActionModeCallback); recyclerView.getViewTreeObserver().addOnScrollChangedListener(this::showHandles); recyclerView.getViewTreeObserver().addOnGlobalLayoutListener(this::showHandles); recyclerView.addOnAttachStateChangeListener(this); } public void setMultiSelectListener(LongPressSelectTouchListener selectListener) { mMultiSelectListener = selectListener; } @Override public void onViewAttachedToWindow(View v) { } @Override public void onViewDetachedFromWindow(View v) { clearSelection(); } public void setActionModeStateCallback(ActionModeStateCallback callback) { mActionModeStateCallback = callback; } private void createHandles() { mLeftHandle = new TextSelectionHandlePopup(mRecyclerView.getContext(), false); mRightHandle = new TextSelectionHandlePopup(mRecyclerView.getContext(), true); mLeftHandle.setOnMoveListener(new HandleMoveListener(false)); mRightHandle.setOnMoveListener(new HandleMoveListener(true)); } private void showHandle(TextSelectionHandlePopup handle, long id, int offset) { TextView textView = findTextViewByItemId(id); if (textView != null) { int line = textView.getLayout().getLineForOffset(offset); int y = textView.getLayout().getLineBottom(line); float x = textView.getLayout().getPrimaryHorizontal(offset); handle.show(textView, (int) x, y); } else { handle.hide(); } } private void showHandles() { if (mLeftHandle == null) createHandles(); if (mSelectionLongPressMode) return; showHandle(mLeftHandle, mSelectionStartId, mSelectionStartOffset); showHandle(mRightHandle, mSelectionEndId, mSelectionEndOffset); if (!mLeftHandle.isVisible() && !mRightHandle.isVisible() && mSelectionStartId != -1) clearSelection(); } private void showActionMode() { if (mActionModeCallback.mCurrentActionMode != null) return; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { mRecyclerView.startActionMode(mActionModeCallback2, ActionMode.TYPE_FLOATING); } else { mRecyclerView.startActionMode(mActionModeCallback); } } private void hideActionModeForSelection() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && mActionModeCallback.mCurrentActionMode != null) mActionModeCallback.mCurrentActionMode.finish(); } private boolean handleSelection(float x, float y, float rawX, float rawY) { View view = mRecyclerView.findChildViewUnder(x, y); if (view == null) return mSelectionLongPressMode; long id = mRecyclerView.getChildItemId(view); int index = mRecyclerView.getChildAdapterPosition(view); TextView textView = findTextViewIn(view); if (textView == null) return mSelectionLongPressMode; textView.getLocationOnScreen(mTmpLocation); float viewX = rawX - mTmpLocation[0]; float viewY = rawY - mTmpLocation[1]; float tViewY = Math.min(Math.max(viewY, 0), textView.getHeight() - textView.getCompoundPaddingBottom()) - textView.getCompoundPaddingTop(); float tViewX = Math.min(Math.max(viewX, 0), textView.getWidth() - textView.getCompoundPaddingRight()) - textView.getCompoundPaddingLeft(); mLastTouchTextId = id; int line = textView.getLayout().getLineForVertical((int) tViewY); mLastTouchTextOffset = textView.getLayout().getOffsetForHorizontal(line, tViewX); mLastTouchInText = viewX >= textView.getCompoundPaddingLeft() && viewX <= textView.getWidth() - textView.getCompoundPaddingEnd() && viewY >= textView.getCompoundPaddingTop() && viewY <= textView.getHeight() - textView.getCompoundPaddingBottom() && tViewX <= textView.getLayout().getLineWidth(line); if (mSelectionLongPressMode) { long sel = TextSelectionHelper.getWordAt(textView.getText(), mLastTouchTextOffset, mLastTouchTextOffset + 1); int selStart = TextSelectionHelper.unpackTextRangeStart(sel); int selEnd = TextSelectionHelper.unpackTextRangeEnd(sel); int selLongPressIndex = getItemPosition(mSelectionLongPressId); if (index > selLongPressIndex || (index == selLongPressIndex && selEnd >= mSelectionLongPressStart)) { setSelection(mSelectionLongPressId, mSelectionLongPressStart, mLastTouchTextId, selEnd); } else { setSelection(mLastTouchTextId, selStart, mSelectionLongPressId, mSelectionLongPressEnd); } return true; } return false; } @Override public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { if (((ChatMessagesAdapter) mRecyclerView.getAdapter()).getSelectedItems().size() > 0) return false; if (e.getActionMasked() == MotionEvent.ACTION_DOWN) { mTouchDownX = e.getX(); mTouchDownY = e.getY(); hideActionModeForSelection(); } if (e.getActionMasked() == MotionEvent.ACTION_UP || e.getActionMasked() == MotionEvent.ACTION_CANCEL) { if (!mSelectionLongPressMode && e.getEventTime() - e.getDownTime() < MAX_CLICK_DURATION && Math.sqrt(Math.pow(e.getX() - mTouchDownX, 2) + Math.pow(e.getY() - mTouchDownY, 2)) < MAX_CLICK_DISTANCE * Resources.getSystem().getDisplayMetrics().density) { clearSelection(); } mRecyclerView.getParent().requestDisallowInterceptTouchEvent(false); mSelectionLongPressMode = false; if (mSelectionStartId != -1) { showHandles(); showActionMode(); } } if (mSelectionLongPressMode) { if (e.getActionMasked() == MotionEvent.ACTION_UP) mScroller.setScrollDir(0); else if (e.getY() < 0) mScroller.setScrollDir(-1); else if (e.getY() > mRecyclerView.getHeight()) mScroller.setScrollDir(1); else mScroller.setScrollDir(0); } return handleSelection(e.getX(), e.getY(), e.getRawX(), e.getRawY()); } @Override public void onTouchEvent(RecyclerView rv, MotionEvent e) { onInterceptTouchEvent(rv, e); } @Override public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { } public void startLongPressSelect() { clearSelection(); ((ChatMessagesAdapter) mRecyclerView.getAdapter()).clearSelection(); if (!mLastTouchInText && mMultiSelectListener != null) { mMultiSelectListener.startSelectMode(mRecyclerView.getAdapter().getItemId( getItemPosition(mLastTouchTextId))); return; } TextView textView = findTextViewByItemId(mLastTouchTextId); if (textView == null) return; mSelectionLongPressMode = true; mSelectionLongPressId = mLastTouchTextId; long sel = TextSelectionHelper.getWordAt(textView.getText(), mLastTouchTextOffset, mLastTouchTextOffset + 1); mSelectionLongPressStart = TextSelectionHelper.unpackTextRangeStart(sel); mSelectionLongPressEnd = TextSelectionHelper.unpackTextRangeEnd(sel); setSelection(mSelectionLongPressId, mSelectionLongPressStart, mSelectionLongPressId, mSelectionLongPressEnd); mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true); } public CharSequence getSelectedText() { if (mSelectionStartId == -1) return ""; SpannableStringBuilder builder = new SpannableStringBuilder(); boolean first = true; int selStartIndex = getItemPosition(mSelectionStartId); int selEndIndex = getItemPosition(mSelectionEndId); for (int i = selStartIndex; i <= selEndIndex; i++) { if (first) first = false; else builder.append('\n'); CharSequence text = ((AdapterInterface) mRecyclerView.getAdapter()).getTextAt(i); if (i == selStartIndex && i == selEndIndex) builder.append(text.subSequence(mSelectionStartOffset, mSelectionEndOffset)); else if (i == selStartIndex) builder.append(text.subSequence(mSelectionStartOffset, text.length())); else if (i == selEndIndex) builder.append(text.subSequence(0, mSelectionEndOffset)); else builder.append(text); } return builder; } public void clearSelection() { if (mActionModeCallback.mCurrentActionMode != null) mActionModeCallback.mCurrentActionMode.finish(); if (mSelectionStartId != -1) { int selStartIndex = getItemPosition(mSelectionStartId); int selEndIndex = getItemPosition(mSelectionEndId); for (int i = selStartIndex; i <= selEndIndex; i++) { TextView textView = findTextViewByPosition(i); if (textView != null) TextSelectionHelper.removeSelection((Spannable) textView.getText()); } } mSelectionStartId = -1; mSelectionStartOffset = -1; mSelectionEndId = -1; mSelectionEndOffset = -1; showHandles(); } public void setSelection(long startId, int startOffset, long endId, int endOffset) { int startIndex = getItemPosition(startId); int endIndex = getItemPosition(endId); int oldStartIndex = getItemPosition(mSelectionStartId); int oldEndIndex = getItemPosition(mSelectionEndId); if (mSelectionStartId != -1 && startIndex <= oldEndIndex && endIndex >= oldStartIndex) { if (startIndex > oldStartIndex) { for (int i = oldStartIndex; i < startIndex; i++) { TextView textView = findTextViewByPosition(i); if (textView != null) TextSelectionHelper.removeSelection((Spannable) textView.getText()); } } else if (startIndex < oldStartIndex) { for (int i = startIndex + 1; i <= oldStartIndex; i++) { TextView textView = findTextViewByPosition(i); if (textView != null) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), 0, textView.length()); } } if (endIndex < oldEndIndex) { for (int i = endIndex + 1; i <= oldEndIndex; i++) { TextView textView = findTextViewByPosition(i); if (textView != null) TextSelectionHelper.removeSelection((Spannable) textView.getText()); } } else if (endIndex > oldEndIndex) { for (int i = oldEndIndex; i < endIndex; i++) { TextView textView = findTextViewByPosition(i); if (textView != null) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), 0, textView.length()); } } } else { clearSelection(); for (int i = startIndex + 1; i < endIndex; i++) { TextView textView = findTextViewByPosition(i); if (textView != null) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), 0, textView.length()); } } mSelectionStartId = startId; mSelectionStartOffset = startOffset; mSelectionEndId = endId; mSelectionEndOffset = endOffset; if (startIndex == endIndex) { TextView textView = findTextViewByItemId(startId); if (textView != null) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), startOffset, endOffset); } else { TextView textView = findTextViewByItemId(startId); if (textView != null) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), startOffset, textView.length()); textView = findTextViewByItemId(endId); if (textView != null) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), 0, endOffset); } } public void applySelectionTo(View view, int position) { TextView textView = findTextViewIn(view); if (textView == null) return; int selStartIndex = ((AdapterInterface) mRecyclerView.getAdapter()) .getItemPosition(mSelectionStartId); int selEndIndex = ((AdapterInterface) mRecyclerView.getAdapter()) .getItemPosition(mSelectionEndId); if (position >= selStartIndex && position <= selEndIndex) { if (selStartIndex == selEndIndex) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), mSelectionStartOffset, mSelectionEndOffset); else if (position == selStartIndex) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), mSelectionStartOffset, textView.length()); else if (position == selEndIndex) TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), 0, mSelectionEndOffset); else TextSelectionHelper.setSelection(textView.getContext(), (Spannable) textView.getText(), 0, textView.length()); } else { TextSelectionHelper.removeSelection((Spannable) textView.getText()); } } private TextView findTextViewIn(View view) { return view.findViewById(R.id.chat_message); } private TextView findTextViewByPosition(int pos) { RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(pos); if (vh == null) return null; return findTextViewIn(vh.itemView); } private TextView findTextViewByItemId(long id) { RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForItemId(id); if (vh == null) return null; return findTextViewIn(vh.itemView); } private int getItemPosition(long id) { return ((AdapterInterface) mRecyclerView.getAdapter()).getItemPosition(id); } private class HandleMoveListener implements TextSelectionHandleView.MoveListener { private boolean mRightHandle; private boolean mCurrentlyRightHandle; private RecyclerViewScrollerRunnable mScroller; public HandleMoveListener(boolean rightHandle) { mRightHandle = rightHandle; mScroller = new RecyclerViewScrollerRunnable(mRecyclerView, (int scrollDir) -> { mRecyclerView.getLocationOnScreen(mTmpLocation); if (scrollDir < 0) onMoved(mTmpLocation[0], mTmpLocation[1] - 1); else if (scrollDir > 0) onMoved(mTmpLocation[0] + mRecyclerView.getWidth(), mTmpLocation[1] + mRecyclerView.getHeight() + 1); }); } @Override public void onMoveStarted() { mCurrentlyRightHandle = mRightHandle; } @Override public void onMoveFinished() { showActionMode(); mScroller.setScrollDir(0); } @Override public void onMoved(float x, float y) { hideActionModeForSelection(); mRecyclerView.getLocationOnScreen(mTmpLocation); if (y - mTmpLocation[1] < 0) mScroller.setScrollDir(-1); else if (y - mTmpLocation[1] > mRecyclerView.getHeight()) mScroller.setScrollDir(1); else mScroller.setScrollDir(0); View view = mRecyclerView.findChildViewUnder(x - mTmpLocation[0], y - mTmpLocation[1]); if (view == null) return; long id = mRecyclerView.getChildItemId(view); int index = mRecyclerView.getChildAdapterPosition(view); TextView textView = findTextViewIn(view); if (textView == null) return; view.getLocationOnScreen(mTmpLocation); int offset = textView.getOffsetForPosition(x - mTmpLocation[0], y - mTmpLocation[1]); if (mCurrentlyRightHandle) { int selStartIndex = getItemPosition(mSelectionStartId); if (index < selStartIndex || (index == selStartIndex && offset < mSelectionStartOffset)) { setSelection(id, offset, mSelectionStartId, mSelectionStartOffset); mCurrentlyRightHandle = false; } else { setSelection(mSelectionStartId, mSelectionStartOffset, id, offset); } } else { int selEndIndex = getItemPosition(mSelectionEndId); if (index > selEndIndex || (index == selEndIndex && offset > mSelectionEndOffset)) { setSelection(mSelectionEndId, mSelectionEndOffset, id, offset); mCurrentlyRightHandle = true; } else { setSelection(id, offset, mSelectionEndId, mSelectionEndOffset); } } showHandles(); } } public class BaseActionModeCallback implements ActionMode.Callback { private ActionMode mCurrentActionMode; @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { mCurrentActionMode = mode; MenuInflater inflater = mode.getMenuInflater(); inflater.inflate(R.menu.menu_context_messages, menu); mActionModeStateCallback.onActionModeStateChanged(mode, true); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { switch (item.getItemId()) { case R.id.action_copy: ClipboardManager clipboard = (ClipboardManager) mRecyclerView.getContext() .getSystemService(Context.CLIPBOARD_SERVICE); clipboard.setPrimaryClip( ClipData.newPlainText("IRC Messages", getSelectedText())); clearSelection(); mode.finish(); return true; case R.id.action_share: Intent intent = new Intent(Intent.ACTION_SEND); intent.putExtra(Intent.EXTRA_TEXT, getSelectedText()); intent.setType("text/plain"); mRecyclerView.getContext().startActivity(Intent.createChooser(intent, mRecyclerView.getContext().getString(R.string.message_share_title))); clearSelection(); mode.finish(); return true; default: return false; } } @Override public void onDestroyActionMode(ActionMode mode) { mActionModeStateCallback.onActionModeStateChanged(mode, false); mCurrentActionMode = null; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || mode.getType() != ActionMode.TYPE_FLOATING) clearSelection(); } } @TargetApi(Build.VERSION_CODES.M) public class ActionModeCallback2 extends ActionMode.Callback2 { private BaseActionModeCallback mActionMode; ActionModeCallback2(BaseActionModeCallback actionMode) { mActionMode = actionMode; } @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { return mActionMode.onCreateActionMode(mode, menu); } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return mActionMode.onPrepareActionMode(mode, menu); } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { return mActionMode.onActionItemClicked(mode, item); } @Override public void onDestroyActionMode(ActionMode mode) { mActionMode.onDestroyActionMode(mode); } @Override public void onGetContentRect(ActionMode mode, View view, Rect outRect) { view.getLocationOnScreen(mTmpLocation); TextView textViewStart = findTextViewByItemId(mSelectionStartId); TextView textViewEnd = findTextViewByItemId(mSelectionEndId); int lineStart = textViewStart != null ? textViewStart.getLayout().getLineForOffset(mSelectionStartOffset) : -1; int lineEnd = textViewStart != null ? textViewStart.getLayout().getLineForOffset(mSelectionEndOffset) : -1; outRect.top = 0; if (textViewStart != null) { textViewStart.getLocationOnScreen(mTmpLocation2); outRect.top = mTmpLocation2[1] - mTmpLocation[1]; outRect.top += textViewStart.getLayout().getLineTop(lineStart); } outRect.bottom = view.getHeight(); if (textViewEnd != null) { textViewEnd.getLocationOnScreen(mTmpLocation2); outRect.bottom = mTmpLocation2[1] - mTmpLocation[1]; outRect.bottom += textViewEnd.getLayout().getLineBottom(lineEnd); } outRect.left = 0; outRect.right = view.getWidth(); if (textViewStart != null && textViewStart == textViewEnd && lineStart == lineEnd) { textViewStart.getLocationOnScreen(mTmpLocation2); outRect.left = mTmpLocation2[0] - mTmpLocation[0]; outRect.left += textViewStart.getLayout().getPrimaryHorizontal(mSelectionStartOffset); outRect.right = mTmpLocation2[0] - mTmpLocation[0]; outRect.right += textViewStart.getLayout().getPrimaryHorizontal(mSelectionEndOffset); } } } public interface ActionModeStateCallback { void onActionModeStateChanged(ActionMode mode, boolean visible); } public interface AdapterInterface { CharSequence getTextAt(int position); int getItemPosition(long id); } }