blob: d193aed3e92fb45511df783144500cc769af48e3 [file] [log] [blame]
// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.ui.widget;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.text.Layout;
import android.text.SpannableString;
import android.text.style.ClickableSpan;
import android.util.AttributeSet;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.PopupMenu;
import androidx.annotation.CallSuper;
import androidx.annotation.VisibleForTesting;
/**
* ClickableSpan isn't accessible by default, so we create a subclass
* of TextView that tries to handle the case where a user clicks on a view
* and not directly on one of the clickable spans. We do nothing if it's a
* touch event directly on a ClickableSpan. Otherwise if there's only one
* ClickableSpan, we activate it. If there's more than one, we pop up a
* PopupMenu to disambiguate.
*/
public class TextViewWithClickableSpans
extends TextViewWithLeading implements View.OnLongClickListener {
private AccessibilityManager mAccessibilityManager;
private PopupMenu mDisambiguationMenu;
public TextViewWithClickableSpans(Context context) {
super(context);
init();
}
public TextViewWithClickableSpans(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
@CallSuper
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
ensureValidLongClickListenerState();
}
@CallSuper
@Override
protected void onWindowVisibilityChanged(int visibility) {
super.onWindowVisibilityChanged(visibility);
if (visibility == View.GONE) return;
ensureValidLongClickListenerState();
}
private void init() {
// This disables the saving/restoring since the saved text may be in the wrong language
// (if the user just changed system language), and restoring spans doesn't work anyway.
// See crbug.com/533362
setSaveEnabled(false);
mAccessibilityManager = (AccessibilityManager)
getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
ensureValidLongClickListenerState();
}
@Override
public boolean onLongClick(View v) {
assert v == this;
if (!mAccessibilityManager.isTouchExplorationEnabled()) {
assert false : "Long click listener should have been removed if not in"
+ " accessibility mode.";
return false;
}
openDisambiguationMenu();
return true;
}
@Override
public final void setOnLongClickListener(View.OnLongClickListener listener) {
// Ensure that no one changes the long click listener to anything but this view.
assert listener == this || listener == null;
super.setOnLongClickListener(listener);
}
private void ensureValidLongClickListenerState() {
if (mAccessibilityManager == null) return;
setOnLongClickListener(mAccessibilityManager.isTouchExplorationEnabled() ? this : null);
}
@Override
public boolean performAccessibilityAction(int action, Bundle arguments) {
// BrailleBack will generate an accessibility click event directly
// on this view, make sure we handle that correctly.
if (action == AccessibilityNodeInfo.ACTION_CLICK) {
handleAccessibilityClick();
return true;
}
return super.performAccessibilityAction(action, arguments);
}
@Override
@SuppressLint("ClickableViewAccessibility")
public boolean onTouchEvent(MotionEvent event) {
boolean superResult = super.onTouchEvent(event);
if (event.getAction() != MotionEvent.ACTION_UP
&& mAccessibilityManager.isTouchExplorationEnabled()
&& !touchIntersectsAnyClickableSpans(event)) {
handleAccessibilityClick();
return true;
}
return superResult;
}
/**
* Determines whether the motion event intersects with any of the ClickableSpan(s) within the
* text.
*
* @param event The motion event to compare the spans against.
* @return Whether the motion event intersected any clickable spans.
*/
protected boolean touchIntersectsAnyClickableSpans(MotionEvent event) {
// This logic is borrowed from android.text.method.LinkMovementMethod.
//
// ClickableSpan doesn't stop propagation of the event in its click handler,
// so we should only try to simplify clicking on a clickable span if the touch event
// isn't already over a clickable span.
CharSequence text = getText();
if (!(text instanceof SpannableString)) return false;
SpannableString spannable = (SpannableString) text;
int x = (int) event.getX();
int y = (int) event.getY();
x -= getTotalPaddingLeft();
y -= getTotalPaddingTop();
x += getScrollX();
y += getScrollY();
Layout layout = getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
ClickableSpan[] clickableSpans =
spannable.getSpans(off, off, ClickableSpan.class);
return clickableSpans.length > 0;
}
/**
* Returns the ClickableSpans in this TextView's text.
*/
@VisibleForTesting
public ClickableSpan[] getClickableSpans() {
CharSequence text = getText();
if (!(text instanceof SpannableString)) return null;
SpannableString spannable = (SpannableString) text;
return spannable.getSpans(0, spannable.length(), ClickableSpan.class);
}
private void handleAccessibilityClick() {
ClickableSpan[] clickableSpans = getClickableSpans();
if (clickableSpans == null || clickableSpans.length == 0) {
return;
} else if (clickableSpans.length == 1) {
clickableSpans[0].onClick(this);
} else {
openDisambiguationMenu();
}
}
private void openDisambiguationMenu() {
ClickableSpan[] clickableSpans = getClickableSpans();
if (clickableSpans == null || clickableSpans.length == 0 || mDisambiguationMenu != null) {
return;
}
SpannableString spannable = (SpannableString) getText();
mDisambiguationMenu = new PopupMenu(getContext(), this);
Menu menu = mDisambiguationMenu.getMenu();
for (final ClickableSpan clickableSpan : clickableSpans) {
CharSequence itemText = spannable.subSequence(
spannable.getSpanStart(clickableSpan),
spannable.getSpanEnd(clickableSpan));
MenuItem menuItem = menu.add(itemText);
menuItem.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem menuItem) {
clickableSpan.onClick(TextViewWithClickableSpans.this);
return true;
}
});
}
mDisambiguationMenu.setOnDismissListener(new PopupMenu.OnDismissListener() {
@Override
public void onDismiss(PopupMenu menu) {
mDisambiguationMenu = null;
}
});
mDisambiguationMenu.show();
}
}