/**
* Copyright (c) 2017-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.litho.widget;
import javax.annotation.Nullable;
import java.util.Collections;
import java.util.List;
import android.content.res.ColorStateList;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.Region;
import android.graphics.drawable.Drawable;
import android.text.Layout;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.text.style.ImageSpan;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.View;
import com.facebook.litho.TextContent;
import com.facebook.litho.Touchable;
import com.facebook.fbui.textlayoutbuilder.util.LayoutMeasureUtil;
import static android.view.MotionEvent.ACTION_CANCEL;
import static android.view.MotionEvent.ACTION_DOWN;
import static android.view.MotionEvent.ACTION_UP;
/**
* A {@link Drawable} for mounting text content from a
* {@link Component}.
*
* @see Component
* @see TextSpec
*/
public class TextDrawable extends Drawable implements Touchable, TextContent, Drawable.Callback {
private static final float DEFAULT_TOUCH_RADIUS_IN_SP = 18f;
private Layout mLayout;
private float mLayoutTranslationY;
private boolean mShouldHandleTouch;
private CharSequence mText;
private ColorStateList mColorStateList;
private int mUserColor;
private int mHighlightColor;
private ClickableSpan[] mClickableSpans;
private int mSelectionStart;
private int mSelectionEnd;
private Path mSelectionPath;
private Path mTouchAreaPath;
private boolean mSelectionPathNeedsUpdate;
private Paint mHighlightPaint;
@Override
public void draw(Canvas canvas) {
if (mLayout == null) {
return;
}
final Rect bounds = getBounds();
canvas.translate(bounds.left, bounds.top + mLayoutTranslationY);
mLayout.draw(canvas, getSelectionPath(), mHighlightPaint, 0);
canvas.translate(-bounds.left, -bounds.top - mLayoutTranslationY);
}
@Override
public boolean isStateful() {
return mColorStateList != null;
}
@Override
protected boolean onStateChange(int[] states) {
if (mColorStateList != null && mLayout != null) {
final int previousColor = mLayout.getPaint().getColor();
final int currentColor = mColorStateList.getColorForState(states, mUserColor);
if (currentColor != previousColor) {
mLayout.getPaint().setColor(currentColor);
invalidateSelf();
}
}
return super.onStateChange(states);
}
@Override
public boolean onTouchEvent(MotionEvent event, View view) {
final int action = event.getActionMasked();
if (action == ACTION_CANCEL) {
clearSelection();
return false;
}
final Rect bounds = getBounds();
final int x = (int) event.getX() - bounds.left;
final int y = (int) event.getY() - bounds.top;
float touchRadius = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
DEFAULT_TOUCH_RADIUS_IN_SP,
view.getResources().getDisplayMetrics());
ClickableSpan clickedSpan = getClickableSpanInCoords(x, y);
if (clickedSpan == null) {
clickedSpan = getClickableSpanInProximityToClick(x, y, touchRadius);
}
if (clickedSpan != null) {
if (action == ACTION_UP) {
clearSelection();
clickedSpan.onClick(view);
} else if (action == ACTION_DOWN) {
setSelection(clickedSpan);
}
return true;
}
clearSelection();
return false;
}
@Override
public boolean shouldHandleTouchEvent(MotionEvent event) {
final int action = event.getActionMasked();
boolean isWithinBounds = getBounds().contains((int) event.getX(), (int) event.getY());
boolean isUpOrDown = action == ACTION_UP || action == ACTION_DOWN;
return (mShouldHandleTouch && isWithinBounds && isUpOrDown) || action == ACTION_CANCEL;
}
public void mount(
CharSequence text,
Layout layout,
int userColor,
ClickableSpan[] clickableSpans) {
mount(text, layout, 0, null, userColor, 0, clickableSpans, null);
}
public void mount(CharSequence text, Layout layout, int userColor, int highlightColor) {
mount(text, layout, 0, null, userColor, highlightColor, null, null);
}
public void mount(
CharSequence text,
Layout layout,
float layoutTranslationY,
ColorStateList colorStateList,
int userColor,
int highlightColor,
ClickableSpan[] clickableSpans) {
mount(text, layout, 0, null, userColor, highlightColor, clickableSpans, null);
}
public void mount(
CharSequence text,
Layout layout,
float layoutTranslationY,
ColorStateList colorStateList,
int userColor,
int highlightColor,
ClickableSpan[] clickableSpans,
ImageSpan[] imageSpans) {
mLayout = layout;
mLayoutTranslationY = layoutTranslationY;
mText = text;
mClickableSpans = clickableSpans;
mShouldHandleTouch = (clickableSpans != null && clickableSpans.length > 0);
mHighlightColor = highlightColor;
if (userColor != 0) {
mColorStateList = null;
mUserColor = userColor;
} else {
mColorStateList = colorStateList != null ? colorStateList : TextSpec.textColorStateList;
mUserColor = mColorStateList.getDefaultColor();
if (mLayout != null) {
mLayout.getPaint().setColor(mColorStateList.getColorForState(getState(), mUserColor));
}
}
if (imageSpans != null) {
for (ImageSpan imageSpan: imageSpans) {
imageSpan.getDrawable().setCallback(this);
}
}
invalidateSelf();
}
public void unmount() {
mLayout = null;
mLayoutTranslationY = 0;
mText = null;
mClickableSpans = null;
mShouldHandleTouch = false;
mHighlightColor = 0;
mColorStateList = null;
mUserColor = 0;
}
public ClickableSpan[] getClickableSpans() {
return mClickableSpans;
}
@Override
public void setAlpha(int alpha) {
}
@Override
public void setColorFilter(ColorFilter cf) {
}
@Override
public int getOpacity() {
return 0;
}
public CharSequence getText() {
return mText;
}
public int getColor() {
return mLayout.getPaint().getColor();
}
@Override
public List<CharSequence> getTextItems() {
return mText != null ? Collections.singletonList(mText) : Collections.<CharSequence>emptyList();
}
/**
* Get the clickable span that is at the exact coordinates
* @param x x-position of the click
* @param y y-position of the click
* @return a clickable span that's located where the click occurred,
* or: {@code null} if no clickable span was located there
*/
@Nullable
private ClickableSpan getClickableSpanInCoords(int x, int y) {
final int line = mLayout.getLineForVertical(y);
float start = mLayout.getPrimaryHorizontal(mLayout.getLineStart(line));
float end = mLayout.getPrimaryHorizontal(mLayout.getLineVisibleEnd(line));
if (start > end) {
// In RTL scenario
float temp = start;
start = end;
end = temp;
}
if (x >= start && x <= end) {
final int offset = mLayout.getOffsetForHorizontal(line, x);
final ClickableSpan[] clickableSpans = ((Spanned) mText).getSpans(
offset,
offset,
ClickableSpan.class);
if (clickableSpans != null && clickableSpans.length > 0) {
return clickableSpans[0];
}
}
return null;
}
/**
* Get the clickable span that's close to where the view was clicked.
* @param x x-position of the click
* @param y y-position of the click
* @return a clickable span that's close the click position,
* or: {@code null} if no clickable span was close to the click,
* or if a link was directly clicked or if more than one clickable
* span was in proximity to the click.
*/
@Nullable
private ClickableSpan getClickableSpanInProximityToClick(
float x,
float y,
float tapRadius) {
final Region touchAreaRegion= new Region();
final Region clipBoundsRegion = new Region();
if (mTouchAreaPath == null) {
mTouchAreaPath = new Path();
}
clipBoundsRegion.set(
0,
0,
LayoutMeasureUtil.getWidth(mLayout),
LayoutMeasureUtil.getHeight(mLayout));
mTouchAreaPath.reset();
mTouchAreaPath.addCircle(x, y, tapRadius, Path.Direction.CW);
touchAreaRegion.setPath(mTouchAreaPath, clipBoundsRegion);
ClickableSpan result = null;
for (ClickableSpan span : mClickableSpans) {
if (!isClickCloseToSpan(span, (Spanned) mText, mLayout, touchAreaRegion, clipBoundsRegion)) {
continue;
}
if (result != null) {
// This is the second span that's close to the tap, so we don't have a definitive answer
return null;
}
result = span;
}
return result;
}
private Path getSelectionPath() {
if (mSelectionStart == mSelectionEnd) {
return null;
}
if (Color.alpha(mHighlightColor) == 0) {
return null;
}
if (mSelectionPathNeedsUpdate) {
if (mSelectionPath == null) {
mSelectionPath = new Path();
}
mLayout.getSelectionPath(mSelectionStart, mSelectionEnd, mSelectionPath);
mSelectionPathNeedsUpdate = false;
}
return mSelectionPath;
}
private void setSelection(ClickableSpan span) {
final Spanned text = (Spanned) mText;
setSelection(text.getSpanStart(span), text.getSpanEnd(span));
}
/**
* Updates selection to [selectionStart, selectionEnd] range.
* @param selectionStart
* @param selectionEnd
*/
private void setSelection(int selectionStart, int selectionEnd) {
if (Color.alpha(mHighlightColor) == 0 ||
(mSelectionStart == selectionStart && mSelectionEnd == selectionEnd)) {
return;
}
mSelectionStart = selectionStart;
mSelectionEnd = selectionEnd;
if (mHighlightPaint == null) {
mHighlightPaint = new Paint();
mHighlightPaint.setColor(mHighlightColor);
} else {
mHighlightPaint.setColor(mHighlightColor);
}
mSelectionPathNeedsUpdate = true;
invalidateSelf();
}
private void clearSelection() {
setSelection(0, 0);
}
private boolean isClickCloseToSpan(
ClickableSpan span,
Spanned buffer,
Layout layout,
Region touchAreaRegion,
Region clipBoundsRegion) {
final Region clickableSpanAreaRegion = new Region();
final Path clickableSpanAreaPath = new Path();
layout.getSelectionPath(
buffer.getSpanStart(span),
buffer.getSpanEnd(span),
clickableSpanAreaPath);
clickableSpanAreaRegion.setPath(clickableSpanAreaPath, clipBoundsRegion);
return clickableSpanAreaRegion.op(touchAreaRegion, Region.Op.INTERSECT);
}
@Override
public void invalidateDrawable(Drawable drawable) {
invalidateSelf();
}
@Override
public void scheduleDrawable(Drawable drawable, Runnable runnable, long l) {
scheduleSelf(runnable, l);
}
@Override
public void unscheduleDrawable(Drawable drawable, Runnable runnable) {
unscheduleSelf(runnable);
}
}