Special MovementMethod for video description

Video descriptions can be very long. Some of them are
basically walls of text with couple of lines at top or bottom.
They are also not scrolled within TextView itself, - instead
NewPipe expects user to scroll their containing ViewGroup.
This renders all builtin MovementMethod implementations useless.

This commit adds a new MovementMethod, that uses requestRectangleOnScreen
to intelligently re-position the TextView within it's scrollable container.
This commit is contained in:
Alexander-- 2019-11-14 20:37:16 +06:59
parent 9801cf50e3
commit 7bb5cacb0d
3 changed files with 295 additions and 1 deletions

View file

@ -87,6 +87,7 @@ import org.schabi.newpipe.util.ShareUtils;
import org.schabi.newpipe.util.StreamItemAdapter;
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
import org.schabi.newpipe.views.AnimatedProgressBar;
import org.schabi.newpipe.views.LargeTextMovementMethod;
import java.io.Serializable;
import java.util.Collection;
@ -441,10 +442,13 @@ public class VideoDetailFragment
if (videoDescriptionRootLayout.getVisibility() == View.VISIBLE) {
videoTitleTextView.setMaxLines(1);
videoDescriptionRootLayout.setVisibility(View.GONE);
videoDescriptionView.setFocusable(false);
videoTitleToggleArrow.setImageResource(R.drawable.arrow_down);
} else {
videoTitleTextView.setMaxLines(10);
videoDescriptionRootLayout.setVisibility(View.VISIBLE);
videoDescriptionView.setFocusable(true);
videoDescriptionView.setMovementMethod(new LargeTextMovementMethod());
videoTitleToggleArrow.setImageResource(R.drawable.arrow_up);
}
}
@ -481,7 +485,6 @@ public class VideoDetailFragment
videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout);
videoUploadDateView = rootView.findViewById(R.id.detail_upload_date_view);
videoDescriptionView = rootView.findViewById(R.id.detail_description_view);
videoDescriptionView.setMovementMethod(LinkMovementMethod.getInstance());
videoDescriptionView.setAutoLinkMask(Linkify.WEB_URLS);
thumbsUpTextView = rootView.findViewById(R.id.detail_thumbs_up_count_view);

View file

@ -0,0 +1,290 @@
/*
* Copyright 2019 Alexander Rvachev <rvacheva@nxt.ru>
* 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 <http://www.gnu.org/licenses/>.
*/
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 dir;
@Override
public void onTakeFocus(TextView view, Spannable text, int dir) {
Selection.removeSelection(text);
super.onTakeFocus(view, text, dir);
this.dir = dirToRelative(dir);
}
@Override
protected boolean handleMovementKey(TextView widget, Spannable buffer, int keyCode, int movementMetaState, 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(TextView widget, Spannable buffer, int keyCode, int movementMetaState, KeyEvent event) {
int newDir = keyToDir(keyCode);
if (dir != 0 && newDir != dir) {
return false;
}
this.dir = 0;
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(TextView widget, Spannable buffer) {
if (gotoPrev(widget, buffer)) {
return true;
}
return super.up(widget, buffer);
}
@Override
protected boolean left(TextView widget, Spannable buffer) {
if (gotoPrev(widget, buffer)) {
return true;
}
return super.left(widget, buffer);
}
@Override
protected boolean right(TextView widget, Spannable buffer) {
if (gotoNext(widget, buffer)) {
return true;
}
return super.right(widget, buffer);
}
@Override
protected boolean down(TextView widget, Spannable buffer) {
if (gotoNext(widget, buffer)) {
return true;
}
return super.down(widget, buffer);
}
private boolean gotoPrev(TextView view, Spannable buffer) {
Layout layout = view.getLayout();
if (layout == null) {
return false;
}
View root = findScrollableParent(view);
int rootHeight = root.getHeight();
if (visibleRect.top >= 0) {
// we fit entirely into the viewport, no need for fancy footwork
return false;
}
int topExtra = -visibleRect.top;
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
int visibleStart = firstVisibleLineNumber == 0 ? 0 : layout.getLineStart(firstVisibleLineNumber - 1);
ClickableSpan[] candidates = buffer.getSpans(visibleStart, buffer.length(), ClickableSpan.class);
if (candidates.length != 0) {
int a = Selection.getSelectionStart(buffer);
int b = Selection.getSelectionEnd(buffer);
int selStart = Math.min(a, b);
int selEnd = Math.max(a, b);
int bestStart = -1;
int bestEnd = -1;
for (int i = 0; i < candidates.length; i++) {
int start = buffer.getSpanStart(candidates[i]);
int end = buffer.getSpanEnd(candidates[i]);
if ((end < selEnd || selStart == selEnd) && start >= visibleStart) {
if (end > bestEnd) {
bestStart = buffer.getSpanStart(candidates[i]);
bestEnd = end;
}
}
}
if (bestStart >= 0) {
Selection.setSelection(buffer, bestEnd, bestStart);
return true;
}
}
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(TextView view, Spannable buffer) {
Layout layout = view.getLayout();
if (layout == null) {
return false;
}
View root = findScrollableParent(view);
int rootHeight = root.getHeight();
if (visibleRect.bottom <= rootHeight) {
// we fit entirely into the viewport, no need for fancy footwork
return false;
}
int bottomExtra = visibleRect.bottom - rootHeight;
int visibleBottomBorder = view.getHeight() - bottomExtra;
int lineCount = layout.getLineCount();
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
int visibleEnd = lastVisibleLineNumber == lineCount - 1 ? buffer.length() : layout.getLineEnd(lastVisibleLineNumber - 1);
ClickableSpan[] candidates = buffer.getSpans(0, visibleEnd, ClickableSpan.class);
if (candidates.length != 0) {
int a = Selection.getSelectionStart(buffer);
int b = Selection.getSelectionEnd(buffer);
int selStart = Math.min(a, b);
int selEnd = Math.max(a, b);
int bestStart = Integer.MAX_VALUE;
int bestEnd = Integer.MAX_VALUE;
for (int i = 0; i < candidates.length; i++) {
int start = buffer.getSpanStart(candidates[i]);
int end = buffer.getSpanEnd(candidates[i]);
if ((start > selStart || selStart == selEnd) && end <= visibleEnd) {
if (start < bestStart) {
bestStart = start;
bestEnd = buffer.getSpanEnd(candidates[i]);
}
}
}
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
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(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 int dirToRelative(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(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;
}
}

View file

@ -15,6 +15,7 @@
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="5"
android:isScrollContainer="true"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout