package yuku.alkitab.base.widget; import android.content.Context; import android.graphics.Rect; import android.support.v4.view.MotionEventCompat; import android.text.Layout; import android.text.Spanned; import android.text.style.ClickableSpan; import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.widget.TextView; import yuku.alkitab.debug.BuildConfig; import java.util.ArrayList; import java.util.List; public class VerseTextView extends TextView { public static final String TAG = VerseTextView.class.getSimpleName(); static class SpanEntry { public Rect rect = new Rect(); public ClickableSpan span; void clear() { rect.setEmpty(); span = null; } } public static ThreadLocal<List<SpanEntry>> spanEntriesBuffer = new ThreadLocal<List<SpanEntry>>() { @Override protected List<SpanEntry> initialValue() { return new ArrayList<>(8); } }; public VerseTextView(Context context, AttributeSet attrs) { super(context, attrs); } /** * Detects link clicks more accurately using the following algorithm: * 1. Get all the clickable spans. * 2. For each of the clickable spans, try to approximate the bounds rects of the span by: * a. If the span start and end are in the same line, the bounds rect is from span's start to end. * b. Else, there are three bounds rects: the span start until the right side, span end until the left side, and the big * multi-line bounds rect between the bottom of span start and top of span end. TODO: Support RTL. * 3. Look for the entry that is nearest to the touch with max distance 24dp (so the touch diameter is 48dp) and perform click on the span. * If there is no such entry, make our handling return false to let the touch handled by this view's parent. */ @Override public boolean onTouchEvent(MotionEvent event) { final int action = MotionEventCompat.getActionMasked(event); if (action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_DOWN) return false; final CharSequence text = this.getText(); if (!(text instanceof Spanned)) return false; final Layout layout = this.getLayout(); if (layout == null) return false; final Spanned buffer = (Spanned) text; final int touchX = (int) (event.getX() + 0.5f) - this.getTotalPaddingLeft() + this.getScrollX(); final int touchY = (int) (event.getY() + 0.5f) - this.getTotalPaddingTop() + this.getScrollY(); final List<SpanEntry> spanEntries = spanEntriesBuffer.get(); int spanEntries_count = 0; // we don't clear the list to prevent deallocation and reallocation of SpanEntries. for (final ClickableSpan span : buffer.getSpans(0, buffer.length(), ClickableSpan.class)) { final int spanStart = buffer.getSpanStart(span); final int lineStart = layout.getLineForOffset(spanStart); final int xStart = (int) (layout.getPrimaryHorizontal(spanStart) + 0.5f); final int spanEnd = buffer.getSpanEnd(span); final int lineEnd = layout.getLineForOffset(spanEnd); final int xEnd = (int) (layout.getPrimaryHorizontal(spanEnd) + 0.5f); if (lineStart == lineEnd) { final int top = layout.getLineTop(lineStart); final int bottom = layout.getLineBottom(lineStart); spanEntries_count = addSpanEntry(spanEntries, spanEntries_count, span, xStart, top, xEnd, bottom); } else { final int topStart = layout.getLineTop(lineStart); final int bottomStart = layout.getLineBottom(lineStart); final int topEnd = layout.getLineTop(lineEnd); final int bottomEnd = layout.getLineBottom(lineEnd); // line where span start is contained spanEntries_count = addSpanEntry(spanEntries, spanEntries_count, span, xStart, topStart, layout.getWidth(), bottomStart); // line where span end is contained spanEntries_count = addSpanEntry(spanEntries, spanEntries_count, span, 0, topEnd, xEnd, bottomEnd); // add the in-between span only if line difference is > 1 if (lineEnd - lineStart > 1) { spanEntries_count = addSpanEntry(spanEntries, spanEntries_count, span, 0, bottomStart, layout.getWidth(), topEnd); } } } if (BuildConfig.DEBUG) { Log.d(TAG, "----------"); Log.d(TAG, "touchX=" + touchX); Log.d(TAG, "touchY=" + touchY); for (int i = 0; i < spanEntries_count; i++) { final SpanEntry e = spanEntries.get(i); Log.d(TAG, "SpanEntry " + i + " at " + e.rect.toString() + ": span " + e.span + " '" + buffer.subSequence(buffer.getSpanStart(e.span), buffer.getSpanEnd(e.span)) + "'"); } } if (spanEntries_count == 0) return false; final float density = getResources().getDisplayMetrics().density; final int maxDistanceSquared = (int) (24 * 24 * density * density); // radius 24dp ClickableSpan bestSpan = null; int bestDistanceSquared = Integer.MAX_VALUE; for (int i = 0; i < spanEntries_count; i++) { final SpanEntry spanEntry = spanEntries.get(i); // is touch inside the span rect? final Rect r = spanEntry.rect; if (r.contains(touchX, touchY)) { bestDistanceSquared = 0; bestSpan = spanEntry.span; break; // no possible better target } else { final int distanceSquared; if (touchY < r.top) { if (touchX < r.left) { distanceSquared = ds(touchX - r.left, touchY - r.top); } else if (touchX >= r.right) { distanceSquared = ds(touchX - r.right, touchY - r.top); } else { // on the top of bounds distanceSquared = ds(0, touchY - r.top); } } else if (touchY >= r.bottom) { if (touchX < r.left) { distanceSquared = ds(touchX - r.left, touchY - r.bottom); } else if (touchX >= r.right) { distanceSquared = ds(touchX - r.right, touchY - r.bottom); } else { // on the bottom of bounds distanceSquared = ds(0, touchY - r.bottom); } } else { // on the left or right of bounds if (touchX < r.left) { distanceSquared = ds(touchX - r.left, 0); } else { distanceSquared = ds(touchX - r.right, 0); } } if (distanceSquared <= maxDistanceSquared) { if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestSpan = spanEntry.span; } } } } for (int i = 0; i < spanEntries_count; i++) { final SpanEntry spanEntry = spanEntries.get(i); spanEntry.clear(); // don't keep any references to span! } if (BuildConfig.DEBUG) { final double dist = Math.sqrt(bestDistanceSquared); Log.d(TAG, "Best span is: " + bestSpan + " with distance " + dist + " (" + (dist / density) + "dp)"); } if (bestSpan != null) { if (action == MotionEvent.ACTION_UP) { bestSpan.onClick(this); } return true; } return false; } private static int ds(final int dx, final int dy) { return dx * dx + dy * dy; } private static int addSpanEntry(final List<SpanEntry> spanEntries, int spanEntries_count, final ClickableSpan span, final int left, final int top, final int right, final int bottom) { final SpanEntry spanEntry; if (spanEntries.size() > spanEntries_count) { spanEntry = spanEntries.get(spanEntries_count); } else { spanEntry = new SpanEntry(); spanEntries.add(spanEntry); } spanEntries_count++; spanEntry.rect.set(left, top, right, bottom); spanEntry.span = span; return spanEntries_count; } }