/* * Copyright 2019 Alexander Rvachev * FocusOverlayView.java is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.views; import android.graphics.Rect; import android.text.Layout; import android.text.Selection; import android.text.Spannable; import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.widget.TextView; public class LargeTextMovementMethod extends LinkMovementMethod { private final Rect visibleRect = new Rect(); private int direction; @Override public void onTakeFocus(final TextView view, final Spannable text, final int dir) { Selection.removeSelection(text); super.onTakeFocus(view, text, dir); this.direction = dirToRelative(dir); } @Override protected boolean handleMovementKey(final TextView widget, final Spannable buffer, final int keyCode, final int movementMetaState, final KeyEvent event) { if (!doHandleMovement(widget, buffer, keyCode, movementMetaState, event)) { // clear selection to make sure, that it does not confuse focus handling code Selection.removeSelection(buffer); return false; } return true; } private boolean doHandleMovement(final TextView widget, final Spannable buffer, final int keyCode, final int movementMetaState, final KeyEvent event) { final int newDir = keyToDir(keyCode); if (direction != 0 && newDir != direction) { return false; } this.direction = 0; final ViewGroup root = findScrollableParent(widget); widget.getHitRect(visibleRect); root.offsetDescendantRectToMyCoords((View) widget.getParent(), visibleRect); return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event); } @Override protected boolean up(final TextView widget, final Spannable buffer) { if (gotoPrev(widget, buffer)) { return true; } return super.up(widget, buffer); } @Override protected boolean left(final TextView widget, final Spannable buffer) { if (gotoPrev(widget, buffer)) { return true; } return super.left(widget, buffer); } @Override protected boolean right(final TextView widget, final Spannable buffer) { if (gotoNext(widget, buffer)) { return true; } return super.right(widget, buffer); } @Override protected boolean down(final TextView widget, final Spannable buffer) { if (gotoNext(widget, buffer)) { return true; } return super.down(widget, buffer); } private boolean gotoPrev(final TextView view, final Spannable buffer) { final Layout layout = view.getLayout(); if (layout == null) { return false; } final View root = findScrollableParent(view); final int rootHeight = root.getHeight(); if (visibleRect.top >= 0) { // we fit entirely into the viewport, no need for fancy footwork return false; } final int topExtra = -visibleRect.top; final int firstVisibleLineNumber = layout.getLineForVertical(topExtra); // when deciding whether to pass "focus" to span, account for one more line // this ensures, that focus is never passed to spans partially outside scroll window final int visibleStart = firstVisibleLineNumber == 0 ? 0 : layout.getLineStart(firstVisibleLineNumber - 1); final ClickableSpan[] candidates = buffer.getSpans( visibleStart, buffer.length(), ClickableSpan.class); if (candidates.length != 0) { final int a = Selection.getSelectionStart(buffer); final int b = Selection.getSelectionEnd(buffer); final int selStart = Math.min(a, b); final int selEnd = Math.max(a, b); int bestStart = -1; int bestEnd = -1; for (final ClickableSpan candidate : candidates) { final int start = buffer.getSpanStart(candidate); final int end = buffer.getSpanEnd(candidate); if ((end < selEnd || selStart == selEnd) && start >= visibleStart) { if (end > bestEnd) { bestStart = buffer.getSpanStart(candidate); bestEnd = end; } } } if (bestStart >= 0) { Selection.setSelection(buffer, bestEnd, bestStart); return true; } } final float fourLines = view.getTextSize() * 4; visibleRect.left = 0; visibleRect.right = view.getWidth(); visibleRect.top = Math.max(0, (int) (topExtra - fourLines)); visibleRect.bottom = visibleRect.top + rootHeight; return view.requestRectangleOnScreen(visibleRect); } private boolean gotoNext(final TextView view, final Spannable buffer) { final Layout layout = view.getLayout(); if (layout == null) { return false; } final View root = findScrollableParent(view); final int rootHeight = root.getHeight(); if (visibleRect.bottom <= rootHeight) { // we fit entirely into the viewport, no need for fancy footwork return false; } final int bottomExtra = visibleRect.bottom - rootHeight; final int visibleBottomBorder = view.getHeight() - bottomExtra; final int lineCount = layout.getLineCount(); final int lastVisibleLineNumber = layout.getLineForVertical(visibleBottomBorder); // when deciding whether to pass "focus" to span, account for one more line // this ensures, that focus is never passed to spans partially outside scroll window final int visibleEnd = lastVisibleLineNumber == lineCount - 1 ? buffer.length() : layout.getLineEnd(lastVisibleLineNumber - 1); final ClickableSpan[] candidates = buffer.getSpans(0, visibleEnd, ClickableSpan.class); if (candidates.length != 0) { final int a = Selection.getSelectionStart(buffer); final int b = Selection.getSelectionEnd(buffer); final int selStart = Math.min(a, b); final int selEnd = Math.max(a, b); int bestStart = Integer.MAX_VALUE; int bestEnd = Integer.MAX_VALUE; for (final ClickableSpan candidate : candidates) { final int start = buffer.getSpanStart(candidate); final int end = buffer.getSpanEnd(candidate); if ((start > selStart || selStart == selEnd) && end <= visibleEnd) { if (start < bestStart) { bestStart = start; bestEnd = buffer.getSpanEnd(candidate); } } } if (bestEnd < Integer.MAX_VALUE) { // cool, we have managed to find next link without having to adjust self within view Selection.setSelection(buffer, bestStart, bestEnd); return true; } } // there are no links within visible area, but still some text past visible area // scroll visible area further in required direction final float fourLines = view.getTextSize() * 4; visibleRect.left = 0; visibleRect.right = view.getWidth(); visibleRect.bottom = Math.min((int) (visibleBottomBorder + fourLines), view.getHeight()); visibleRect.top = visibleRect.bottom - rootHeight; return view.requestRectangleOnScreen(visibleRect); } private ViewGroup findScrollableParent(final View view) { View current = view; ViewParent parent; do { parent = current.getParent(); if (parent == current || !(parent instanceof View)) { return (ViewGroup) view.getRootView(); } current = (View) parent; if (current.isScrollContainer()) { return (ViewGroup) current; } } while (true); } private static int dirToRelative(final int dir) { switch (dir) { case View.FOCUS_DOWN: case View.FOCUS_RIGHT: return View.FOCUS_FORWARD; case View.FOCUS_UP: case View.FOCUS_LEFT: return View.FOCUS_BACKWARD; } return dir; } private int keyToDir(final int keyCode) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: case KeyEvent.KEYCODE_DPAD_LEFT: return View.FOCUS_BACKWARD; case KeyEvent.KEYCODE_DPAD_DOWN: case KeyEvent.KEYCODE_DPAD_RIGHT: return View.FOCUS_FORWARD; } return View.FOCUS_FORWARD; } }