Skip to content
Snippets Groups Projects
Verified Commit eacd04f0 authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

Fixes a bug where long clicking a link would remove background colors

parent 516a63d2
No related branches found
No related tags found
No related merge requests found
Pipeline #
......@@ -175,7 +175,6 @@ dependencies {
implementation("com.github.bumptech.glide", "recyclerview-integration", version)
kapt("com.github.bumptech.glide", "compiler", version)
}
implementation("me.saket", "better-link-movement-method", "2.1.0")
implementation(project(":slidingpanel"))
// Quality Assurance
......
......@@ -42,8 +42,8 @@ import de.kuschku.quasseldroid.util.helper.setTooltip
import de.kuschku.quasseldroid.util.helper.toLiveData
import de.kuschku.quasseldroid.util.irc.format.ContentFormatter
import de.kuschku.quasseldroid.util.service.ServiceBoundFragment
import de.kuschku.quasseldroid.util.ui.BetterLinkMovementMethod
import de.kuschku.quasseldroid.util.ui.LinkLongClickMenuHelper
import me.saket.bettermovementmethod.BetterLinkMovementMethod
import javax.inject.Inject
class ChannelInfoFragment : ServiceBoundFragment() {
......
......@@ -44,10 +44,10 @@ import de.kuschku.quasseldroid.util.AvatarHelper
import de.kuschku.quasseldroid.util.helper.*
import de.kuschku.quasseldroid.util.irc.format.ContentFormatter
import de.kuschku.quasseldroid.util.service.ServiceBoundFragment
import de.kuschku.quasseldroid.util.ui.BetterLinkMovementMethod
import de.kuschku.quasseldroid.util.ui.LinkLongClickMenuHelper
import de.kuschku.quasseldroid.util.ui.TextDrawable
import io.reactivex.Observable
import me.saket.bettermovementmethod.BetterLinkMovementMethod
import javax.inject.Inject
class UserInfoFragment : ServiceBoundFragment() {
......
......@@ -40,9 +40,9 @@ import de.kuschku.quasseldroid.R
import de.kuschku.quasseldroid.persistence.QuasselDatabase
import de.kuschku.quasseldroid.util.helper.getOrPut
import de.kuschku.quasseldroid.util.helper.loadAvatars
import de.kuschku.quasseldroid.util.ui.BetterLinkMovementMethod
import de.kuschku.quasseldroid.util.ui.DoubleClickHelper
import de.kuschku.quasseldroid.viewmodel.data.FormattedMessage
import me.saket.bettermovementmethod.BetterLinkMovementMethod
import javax.inject.Inject
class MessageAdapter @Inject constructor(
......
package de.kuschku.quasseldroid.util.ui;
import android.app.Activity;
import android.graphics.RectF;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.BackgroundColorSpan;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.TextView;
/**
* Handles URL clicks on TextViews. Unlike the default implementation, this:
* <p>
* <ul>
* <li>Reliably applies a highlight color on links when they're touched.</li>
* <li>Let's you handle single and long clicks on URLs</li>
* <li>Correctly identifies focused URLs (Unlike the default implementation where a click is registered even if it's
* made outside of the URL's bounds if there is no more text in that direction.)</li>
* </ul>
*/
public class BetterLinkMovementMethod extends LinkMovementMethod {
private static final int LINKIFY_NONE = -2;
private static BetterLinkMovementMethod singleInstance;
private final RectF touchedLineBounds = new RectF();
private OnLinkClickListener onLinkClickListener;
private OnLinkLongClickListener onLinkLongClickListener;
private boolean isUrlHighlighted;
private ClickableSpan clickableSpanUnderTouchOnActionDown;
private int activeTextViewHashcode;
private LongPressTimer ongoingLongPressTimer;
private boolean wasLongPressRegistered;
protected BetterLinkMovementMethod() {
}
/**
* Return a new instance of BetterLinkMovementMethod.
*/
public static BetterLinkMovementMethod newInstance() {
return new BetterLinkMovementMethod();
}
/**
* @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
* {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
* @param textViews The TextViews on which a {@link BetterLinkMovementMethod} should be registered.
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkify(int linkifyMask, TextView... textViews) {
BetterLinkMovementMethod movementMethod = newInstance();
for (TextView textView : textViews) {
addLinks(linkifyMask, movementMethod, textView);
}
return movementMethod;
}
/**
* Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
*
* @param textViews The TextViews on which a {@link BetterLinkMovementMethod} should be registered.
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkifyHtml(TextView... textViews) {
return linkify(LINKIFY_NONE, textViews);
}
/**
* Recursively register a {@link BetterLinkMovementMethod} on every TextView inside a layout.
*
* @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
* {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkify(int linkifyMask, ViewGroup viewGroup) {
BetterLinkMovementMethod movementMethod = newInstance();
rAddLinks(linkifyMask, viewGroup, movementMethod);
return movementMethod;
}
/**
* Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
*
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkifyHtml(ViewGroup viewGroup) {
return linkify(LINKIFY_NONE, viewGroup);
}
/**
* Recursively register a {@link BetterLinkMovementMethod} on every TextView inside a layout.
*
* @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
* {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkify(int linkifyMask, Activity activity) {
// Find the layout passed to setContentView().
ViewGroup activityLayout = ((ViewGroup) ((ViewGroup) activity.findViewById(Window.ID_ANDROID_CONTENT)).getChildAt(0));
BetterLinkMovementMethod movementMethod = newInstance();
rAddLinks(linkifyMask, activityLayout, movementMethod);
return movementMethod;
}
/**
* Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
*
* @return The registered {@link BetterLinkMovementMethod} on the TextViews.
*/
public static BetterLinkMovementMethod linkifyHtml(Activity activity) {
return linkify(LINKIFY_NONE, activity);
}
/**
* Get a static instance of BetterLinkMovementMethod. Do note that registering a click listener on the returned
* instance is not supported because it will potentially be shared on multiple TextViews.
*/
public static BetterLinkMovementMethod getInstance() {
if (singleInstance == null) {
singleInstance = new BetterLinkMovementMethod();
}
return singleInstance;
}
private static void rAddLinks(int linkifyMask, ViewGroup viewGroup, BetterLinkMovementMethod movementMethod) {
for (int i = 0; i < viewGroup.getChildCount(); i++) {
View child = viewGroup.getChildAt(i);
if (child instanceof ViewGroup) {
// Recursively find child TextViews.
rAddLinks(linkifyMask, ((ViewGroup) child), movementMethod);
} else if (child instanceof TextView) {
TextView textView = (TextView) child;
addLinks(linkifyMask, movementMethod, textView);
}
}
}
private static void addLinks(int linkifyMask, BetterLinkMovementMethod movementMethod, TextView textView) {
textView.setMovementMethod(movementMethod);
if (linkifyMask != LINKIFY_NONE) {
Linkify.addLinks(textView, linkifyMask);
}
}
/**
* Set a listener that will get called whenever any link is clicked on the TextView.
*/
public BetterLinkMovementMethod setOnLinkClickListener(OnLinkClickListener clickListener) {
if (this == singleInstance) {
throw new UnsupportedOperationException("Setting a click listener on the instance returned by getInstance() is not supported to avoid memory " +
"leaks. Please use newInstance() or any of the linkify() methods instead.");
}
this.onLinkClickListener = clickListener;
return this;
}
/**
* Set a listener that will get called whenever any link is clicked on the TextView.
*/
public BetterLinkMovementMethod setOnLinkLongClickListener(OnLinkLongClickListener longClickListener) {
if (this == singleInstance) {
throw new UnsupportedOperationException("Setting a long-click listener on the instance returned by getInstance() is not supported to avoid " +
"memory leaks. Please use newInstance() or any of the linkify() methods instead.");
}
this.onLinkLongClickListener = longClickListener;
return this;
}
// ======== PUBLIC APIs END ======== //
@Override
public boolean onTouchEvent(final TextView textView, Spannable text, MotionEvent event) {
if (activeTextViewHashcode != textView.hashCode()) {
// Bug workaround: TextView stops calling onTouchEvent() once any URL is highlighted.
// A hacky solution is to reset any "autoLink" property set in XML. But we also want
// to do this once per TextView.
activeTextViewHashcode = textView.hashCode();
textView.setAutoLinkMask(0);
}
final ClickableSpan clickableSpanUnderTouch = findClickableSpanUnderTouch(textView, text, event);
if (event.getAction() == MotionEvent.ACTION_DOWN) {
clickableSpanUnderTouchOnActionDown = clickableSpanUnderTouch;
}
final boolean touchStartedOverAClickableSpan = clickableSpanUnderTouchOnActionDown != null;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (clickableSpanUnderTouch != null) {
highlightUrl(textView, clickableSpanUnderTouch, text);
}
if (touchStartedOverAClickableSpan && onLinkLongClickListener != null) {
LongPressTimer.OnTimerReachedListener longClickListener = new LongPressTimer.OnTimerReachedListener() {
@Override
public void onTimerReached() {
wasLongPressRegistered = true;
textView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
removeUrlHighlightColor(textView);
dispatchUrlLongClick(textView, clickableSpanUnderTouch);
}
};
startTimerForRegisteringLongClick(textView, longClickListener);
}
return touchStartedOverAClickableSpan;
case MotionEvent.ACTION_UP:
// Register a click only if the touch started and ended on the same URL.
if (!wasLongPressRegistered && touchStartedOverAClickableSpan && clickableSpanUnderTouch == clickableSpanUnderTouchOnActionDown) {
dispatchUrlClick(textView, clickableSpanUnderTouch);
}
cleanupOnTouchUp(textView);
// Consume this event even if we could not find any spans to avoid letting Android handle this event.
// Android's TextView implementation has a bug where links get clicked even when there is no more text
// next to the link and the touch lies outside its bounds in the same direction.
return touchStartedOverAClickableSpan;
case MotionEvent.ACTION_CANCEL:
cleanupOnTouchUp(textView);
return false;
case MotionEvent.ACTION_MOVE:
// Stop listening for a long-press as soon as the user wanders off to unknown lands.
if (clickableSpanUnderTouch != clickableSpanUnderTouchOnActionDown) {
removeLongPressCallback(textView);
}
if (!wasLongPressRegistered) {
// Toggle highlight.
if (clickableSpanUnderTouch != null) {
highlightUrl(textView, clickableSpanUnderTouch, text);
} else {
removeUrlHighlightColor(textView);
}
}
return touchStartedOverAClickableSpan;
default:
return false;
}
}
private void cleanupOnTouchUp(TextView textView) {
wasLongPressRegistered = false;
clickableSpanUnderTouchOnActionDown = null;
removeUrlHighlightColor(textView);
removeLongPressCallback(textView);
}
/**
* Determines the touched location inside the TextView's text and returns the ClickableSpan found under it (if any).
*
* @return The touched ClickableSpan or null.
*/
protected ClickableSpan findClickableSpanUnderTouch(TextView textView, Spannable text, MotionEvent event) {
// So we need to find the location in text where touch was made, regardless of whether the TextView
// has scrollable text. That is, not the entire text is currently visible.
int touchX = (int) event.getX();
int touchY = (int) event.getY();
// Ignore padding.
touchX -= textView.getTotalPaddingLeft();
touchY -= textView.getTotalPaddingTop();
// Account for scrollable text.
touchX += textView.getScrollX();
touchY += textView.getScrollY();
final Layout layout = textView.getLayout();
final int touchedLine = layout.getLineForVertical(touchY);
final int touchOffset = layout.getOffsetForHorizontal(touchedLine, touchX);
touchedLineBounds.left = layout.getLineLeft(touchedLine);
touchedLineBounds.top = layout.getLineTop(touchedLine);
touchedLineBounds.right = layout.getLineWidth(touchedLine) + touchedLineBounds.left;
touchedLineBounds.bottom = layout.getLineBottom(touchedLine);
if (touchedLineBounds.contains(touchX, touchY)) {
// Find a ClickableSpan that lies under the touched area.
final Object[] spans = text.getSpans(touchOffset, touchOffset, ClickableSpan.class);
for (final Object span : spans) {
if (span instanceof ClickableSpan) {
return (ClickableSpan) span;
}
}
// No ClickableSpan found under the touched location.
return null;
} else {
// Touch lies outside the line's horizontal bounds where no spans should exist.
return null;
}
}
/**
* Adds a background color span at <var>clickableSpan</var>'s location.
*/
protected void highlightUrl(TextView textView, ClickableSpan clickableSpan, Spannable text) {
if (isUrlHighlighted) {
return;
}
isUrlHighlighted = true;
final int spanStart = text.getSpanStart(clickableSpan);
final int spanEnd = text.getSpanEnd(clickableSpan);
text.setSpan(new BackgroundColorSpan(textView.getHighlightColor()), spanStart, spanEnd, Spannable.SPAN_INCLUSIVE_INCLUSIVE | Spannable.SPAN_COMPOSING);
Selection.setSelection(text, spanStart, spanEnd);
}
/**
* Removes the highlight color under the Url.
*/
protected void removeUrlHighlightColor(TextView textView) {
if (!isUrlHighlighted) {
return;
}
isUrlHighlighted = false;
final Spannable text = (Spannable) textView.getText();
BackgroundColorSpan[] highlightSpans = text.getSpans(0, text.length(), BackgroundColorSpan.class);
for (BackgroundColorSpan highlightSpan : highlightSpans) {
if ((text.getSpanFlags(highlightSpan) & Spannable.SPAN_COMPOSING) != 0)
text.removeSpan(highlightSpan);
}
Selection.removeSelection(text);
}
protected void startTimerForRegisteringLongClick(TextView textView, LongPressTimer.OnTimerReachedListener longClickListener) {
ongoingLongPressTimer = new LongPressTimer();
ongoingLongPressTimer.setOnTimerReachedListener(longClickListener);
textView.postDelayed(ongoingLongPressTimer, ViewConfiguration.getLongPressTimeout());
}
/**
* Remove the long-press detection timer.
*/
protected void removeLongPressCallback(TextView textView) {
if (ongoingLongPressTimer != null) {
textView.removeCallbacks(ongoingLongPressTimer);
ongoingLongPressTimer = null;
}
}
protected void dispatchUrlClick(TextView textView, ClickableSpan clickableSpan) {
ClickableSpanWithText clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan);
boolean handled = onLinkClickListener != null && onLinkClickListener.onClick(textView, clickableSpanWithText.text());
if (!handled) {
// Let Android handle this click.
clickableSpanWithText.span().onClick(textView);
}
}
protected void dispatchUrlLongClick(TextView textView, ClickableSpan clickableSpan) {
ClickableSpanWithText clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan);
boolean handled = onLinkLongClickListener != null && onLinkLongClickListener.onLongClick(textView, clickableSpanWithText.text());
if (!handled) {
// Let Android handle this long click as a short-click.
clickableSpanWithText.span().onClick(textView);
}
}
public interface OnLinkClickListener {
/**
* @param textView The TextView on which a click was registered.
* @param url The clicked URL.
* @return True if this click was handled. False to let Android handle the URL.
*/
boolean onClick(TextView textView, String url);
}
public interface OnLinkLongClickListener {
/**
* @param textView The TextView on which a long-click was registered.
* @param url The long-clicked URL.
* @return True if this long-click was handled. False to let Android handle the URL (as a short-click).
*/
boolean onLongClick(TextView textView, String url);
}
protected static final class LongPressTimer implements Runnable {
private OnTimerReachedListener onTimerReachedListener;
@Override
public void run() {
onTimerReachedListener.onTimerReached();
}
public void setOnTimerReachedListener(OnTimerReachedListener listener) {
onTimerReachedListener = listener;
}
interface OnTimerReachedListener {
void onTimerReached();
}
}
/**
* A wrapper to support all {@link ClickableSpan}s that may or may not provide URLs.
*/
protected static class ClickableSpanWithText {
private ClickableSpan span;
private String text;
private ClickableSpanWithText(ClickableSpan span, String text) {
this.span = span;
this.text = text;
}
protected static ClickableSpanWithText ofSpan(TextView textView, ClickableSpan span) {
Spanned s = (Spanned) textView.getText();
String text;
if (span instanceof URLSpan) {
text = ((URLSpan) span).getURL();
} else {
int start = s.getSpanStart(span);
int end = s.getSpanEnd(span);
text = s.subSequence(start, end).toString();
}
return new ClickableSpanWithText(span, text);
}
ClickableSpan span() {
return span;
}
String text() {
return text;
}
}
}
......@@ -29,7 +29,6 @@ import android.content.Intent
import android.support.v7.widget.PopupMenu
import android.widget.TextView
import de.kuschku.quasseldroid.R
import me.saket.bettermovementmethod.BetterLinkMovementMethod
class LinkLongClickMenuHelper :
BetterLinkMovementMethod.OnLinkLongClickListener,
......
......@@ -197,14 +197,14 @@
</style>
<style name="Widget.Info.Header.Name" parent="Widget.RtlConformTextView">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textAppearance">@style/TextAppearance.AppCompat.Headline</item>
<item name="android:animateLayoutChanges">true</item>
</style>
<style name="Widget.Info.Header.Description" parent="Widget.RtlConformTextView">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:textAppearance">@style/TextAppearance.AppCompat.Medium</item>
<item name="android:animateLayoutChanges">true</item>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment