本文主要是对TextView 上的一些操作,适用于android 客户端开发的论坛app 中使用到的功能,比如显示居中显示emoji,居中显示gif 表情(可以扩展显示网络图片需要自己修改源码),给文字添加剧透(筛选任意一段文字添加一层有色的遮盖层),效果图
效果图
注:本项目使用到很多的TextView 的修饰类 诸如:SpannableStringBuilder,Spannable,ImageSpan,用法可以自行百度,很简答
所有的文件结构
1.文字中显示gif的做法是使用大佬的轮子 '
传送门 在这个基础上修改的
2.TextView 显示混合图片显示时,文字要居中,所以用到自定义 ImageSpan
public class VerticalImageSpan extends ImageSpan {
public VerticalImageSpan(Bitmap b) {
super(b);
}
public VerticalImageSpan(Drawable drawable) {
super(drawable);
}
public VerticalImageSpan(Context context, int resourceId) {
super(context, resourceId);
}
public VerticalImageSpan(Context context, Bitmap resourceId) {
super(context, resourceId);
}
public VerticalImageSpan(Drawable d, String source) {
super(d, source);
}
@Override
public int getSize(Paint paint, CharSequence text, int start, int end,
Paint.FontMetricsInt fontMetricsInt) {
Drawable drawable = getDrawable();
Rect rect = drawable.getBounds();
if (fontMetricsInt != null) {
Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt();
int fontHeight = fmPaint.bottom - fmPaint.top;
int drHeight = rect.bottom - rect.top;
/**对于这里我表示,我不知道为啥是这样。不应该是fontHeight/2?但是只有fontHeight/4才能对齐
难道是因为TextView的draw的时候top和bottom是大于实际的?具体请看下图
所以fontHeight/4是去除偏差?*/
int top = drHeight / 2 - fontHeight / 4;
int bottom = drHeight / 2 + fontHeight / 4;
fontMetricsInt.ascent = -bottom;
fontMetricsInt.top = -bottom;
fontMetricsInt.bottom = top;
fontMetricsInt.descent = top;
}
return rect.right;
}
@Override
public void draw(Canvas canvas, CharSequence text, int start, int end,
float x, int top, int y, int bottom, Paint paint) {
Paint.FontMetricsInt fm = paint.getFontMetricsInt();
Drawable drawable = getDrawable();
int transY = (y + fm.descent + y + fm.ascent) / 2
- drawable.getBounds().bottom / 2;
canvas.save();
canvas.translate(x, transY);
drawable.draw(canvas);
canvas.restore();
}
}
3.SelectionBean 这个类的主要作用是用来记录需要一段字符串中存在些什么,便于文字的点击效果,剧透的点击效果等的处理
public class SelectionBean {
private String id;
private String name;
private int start;
private int end;
private int type;//1 自定义的需要点击的文本 2 @用户 3标签 4 网址 跳webView 5其它类型 6 其它类型 这是收录的类型分类
public SelectionBean(String id, String name, int start, int end, int type) {
this.id = id;
this.name = name;
this.start = start;
this.end = end;
this.type = type;
}
public SelectionBean(String name, int start, int end, int type) {
this.name = name;
this.start = start;
this.end = end;
this.type = type;
}
public int getType() {
return type;
}
public void setType(int type) {
this.type = type;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getStart() {
return start;
}
public void setStart(int start) {
this.start = start;
}
public int getEnd() {
return end;
}
public void setEnd(int end) {
this.end = end;
}}
4.FaceData 这个类主要是用来记录Emoji gif表情的名字 很重要不能丢失,
5.AppTools 一个简答的工具类,
class AppTools {
companion object {
fun dp2px(context: Context, value: Int): Int {
return (context.resources.displayMetrics.density * value).toInt()
}
}
}
6.GlideImageGetter 这个类主要做用是展示Drawable,gif 和 静态emoji,这个地方,我们要注意这是一个无限循环的去绘制gif,所以要监听View 是否在桌面上显示,添加OnAttachStateChangeListener, 要控制Animatable 的开始和停止
这个地方不处理非常的消耗资源。
public class GlideImageGetter implements Html.ImageGetter, Drawable.Callback {
private final Context mContext;
private final TextView mTextView;
private final Set<ImageGetterViewTarget> mTargets;
public static GlideImageGetter get(View view) {
return (GlideImageGetter) view.getTag(R.id.drawable_callback_tag);
}
public void clear() {
GlideImageGetter prev = get(mTextView);
if (prev == null) return;
for (ImageGetterViewTarget target : prev.mTargets) {
Glide.clear(target);
}
}
public GlideImageGetter(Context context, TextView textView) {
this.mContext = context;
this.mTextView = textView;
// clear(); 屏蔽掉这句在TextView中可以加载多张图片
mTargets = new HashSet<>();
mTextView.setTag(R.id.drawable_callback_tag, this);
}
@Override
public Drawable getDrawable(String url) {
final UrlDrawable_Glide urlDrawable = new UrlDrawable_Glide();
Glide.with(mContext)
.load(url)
.override(1, 1)
.diskCacheStrategy(DiskCacheStrategy.SOURCE)
.into(new ImageGetterViewTarget(mTextView, urlDrawable));
return urlDrawable;
}
@Override
public void invalidateDrawable(Drawable who) {
mTextView.invalidate();
}
@Override
public void scheduleDrawable(Drawable who, Runnable what, long when) {
}
@Override
public void unscheduleDrawable(Drawable who, Runnable what) {
}
private class ImageGetterViewTarget extends ViewTarget<TextView, GlideDrawable> {
private final UrlDrawable_Glide mDrawable;
private ImageGetterViewTarget(TextView view, UrlDrawable_Glide drawable) {
super(view);
mTargets.add(this);
this.mDrawable = drawable;
}
@Override
public void onResourceReady(final GlideDrawable resource, GlideAnimation<? super GlideDrawable> glideAnimation) {
Rect rect;
if (resource.getIntrinsicWidth() > 100) {
float width;
float height;
System.out.println("Image width is " + resource.getIntrinsicWidth());
System.out.println("View width is " + view.getWidth());
if (resource.getIntrinsicWidth() >= getView().getWidth()) {
float downScale = (float) resource.getIntrinsicWidth() / getView().getWidth();
width = (float) resource.getIntrinsicWidth() / (float) downScale;
height = (float) resource.getIntrinsicHeight() / (float) downScale;
} else {
float multiplier = (float) getView().getWidth() / resource.getIntrinsicWidth();
width = (float) resource.getIntrinsicWidth() * (float) multiplier;
height = (float) resource.getIntrinsicHeight() * (float) multiplier;
}
System.out.println("New Image width is " + width);
rect = new Rect(8, 0, AppTools.Companion.dp2px(mContext, 15), AppTools.Companion.dp2px(mContext, 15));
} else {
rect = new Rect(8, 0, AppTools.Companion.dp2px(mContext, 15) + 8, AppTools.Companion.dp2px(mContext, 15));
}
resource.setBounds(rect);
mDrawable.setBounds(rect);
mDrawable.setDrawable(resource);
if (resource.isAnimated()) {
mDrawable.setCallback(get(getView()));
resource.setLoopCount(GlideDrawable.LOOP_FOREVER);
resource.start();
}
// 这个非常重要
getView().addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
/**
* 添加视图
*/
if (resource != null)
resource.start();
}
@Override
public void onViewDetachedFromWindow(View v) {
if (resource != null)
if (resource.isRunning())
resource.stop();
}
});
getView().setText(getView().getText());
getView().invalidate();
}
private Request request;
@Override
public Request getRequest() {
return request;
}
@Override
public void setRequest(Request request) {
this.request = request;
}
}
}
- AppConfig 配置文件,主要是一些正则表达式,表达式用户可以根据自己的情况修改,
public class AppConfig {
public static final String AT = "@[\\w\\p{InCJKUnifiedIdeographs}-]{1,26}";// @人
private static final String TOPIC = "#[\\p{Print}\\p{InCJKUnifiedIdeographs}&&[^#]]+#";// ##话题
public static final String SPOLIER = "\\[剧透:[\\s\\S]*?]";
public static final String EMOJI = ":[0-9a-zA-Z_]+:";//
//这个可以用系统自带的表达式
public static final String URLPATH = "((http|https)://)(([a-zA-Z0-9\\._-]+\\.[a-zA-Z]{2,6})|([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}))(:[0-9]{1,4})*(/[a-zA-Z0-9\\&%_\\./-~-]*)?";
public static final String ALL = "(" + AT + ")" + "|" + "(" + TOPIC + ")" + "|" + "(" + URLPATH + ")";
private static Pattern mPattern = Pattern.compile(EMOJI);
public static String getImageHtml(String text) {
String result = text;
try {
Matcher matcher = mPattern.matcher(text);
while (matcher.find()) {
String group = matcher.group();
if (group != null) {
String faceId = null;
if ((faceId = FaceData.gifFaceInfo.get(group)) != null) {
result = result.replaceFirst(group, "<img src=\"file:///android_asset/" + faceId + "\">");
} /*else if ((faceId = FaceData.staticFaceInfo.get(group)) != null) {
result = result.replaceFirst(group, *//*"<img src=\"file:///android_asset/" + faceId + "\">"*//*faceId);
}*/
}
}
return result.replace("\n", "<br/>");
} catch (Exception e) {
return result;
}
}
}
8.SpolierTextView 主要是绘制剧透层(灰色遮盖层),和绘制选中文字的前景,点击的背景,数字的点击事件等
这里我要学习一个类 import android.text.Layout 下的Layout,这个类记录了TextView 的所有位置信息,这些方法可以去了解一波
public class SpolierTextView extends AppCompatTextView {
private List<Point> arrays = new ArrayList<>();
private List<SelectionBean> selects = new ArrayList<>();
private Paint mPaint;
private Context mContext;
private boolean isSpoiler = false;
private boolean isAttchWindows = false;
public SpolierTextView(Context context) {
this(context, null, 0);
}
public SpolierTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SpolierTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mContext = context;
initView();
}
private void initView() {
mPaint = new Paint();
mPaint.setColor(Color.parseColor("#6d6d6d"));
mPaint.setStyle(Paint.Style.FILL);
// mPaint.setStrokeWidth(10);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
/**
* 绘制一层阴影
*/
if (isAttchWindows)
getMeasureCoondiration(canvas);
/**
* 获取第一行换行的角标
*/
}
/**
* 获取字符的 rect
* 判断当前文字在第几行
* 同一行 直接绘制黑色块
* 如果是已经换行 判断换行 且获取换行的矩形
*/
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private void getMeasureCoondiration(Canvas canvas) {
Layout layout = getLayout();
if (arrays.size() > 0) {
for (int a = 0; a < arrays.size(); a++) {
if (!isAttchWindows)
break;
/**
* 获取一行的字数
*/
Rect bound = new Rect();
/**
*当前文本所在行数
*/
int startLine = layout.getLineForOffset(arrays.get(a).x);
/**
* 文本结束的行数
*/
int endLine = layout.getLineForOffset(arrays.get(a).y);
int lineCount = layout.getLineCount();
float lineSpacingExtra = getLineSpacingExtra();
float lineSpacingMultiplier = getLineSpacingMultiplier();
layout.getLineBounds(startLine, bound);
// int yAxisTop = bound.top;//字符顶部y坐标
int yAxisBottom = bound.bottom;//字符底部y坐标
int lineHeight = (int) ((getMeasuredHeight() - getPaddingTop() - getPaddingBottom()) * 1f / lineCount + 0.5f);//计算单行的高度
int yAxisTop = startLine * lineHeight;//字符顶部y坐标
int xAxisLeft = (int) layout.getPrimaryHorizontal(arrays.get(a).x);//字符左边x坐标
int xAxisRight = (int) layout.getSecondaryHorizontal(arrays.get(a).y);//偏移量
if (false) {
continue;
}
if (startLine == endLine) {//只有一行的时候
canvas.drawRect(new RectF(xAxisLeft, lineHeight * startLine + 3, (int) layout.getSecondaryHorizontal(arrays.get(a).y), yAxisTop + lineHeight - 3), mPaint);
} else {//多行
/**
* 换行绘制 需要绘制两行 黑色区域
*/
canvas.drawRect(new RectF(xAxisLeft, yAxisTop + 3, bound.right, yAxisTop + lineHeight - 3), mPaint);
/**
* 循环绘制存在剧透的行数
*/
if (endLine - startLine > 1) {
for (int i = startLine + 1; i < endLine; i++) {
if (!isAttchWindows)
return;
canvas.drawRect(new RectF(0, lineHeight * i + 3, bound.right, lineHeight * (i + 1) - 3), mPaint);
}
}
canvas.drawRect(new RectF(0, lineHeight * endLine + 3, xAxisRight, lineHeight * (endLine + 1) - 3), mPaint);
}
}
}
}
public void setData(String text, String... tag) {
arrays.clear();
selects.clear();
SpannableStringBuilder htmlStr = (SpannableStringBuilder) Html.fromHtml(text.toString());
Pattern pattern = Pattern.compile(SPOLIER);
Matcher matcher = pattern.matcher(htmlStr);
while (matcher.find()) {//替换需要更改的文本
final String at = matcher.group();
if (at != null) {
int start = matcher.start();
int end = start + at.length();
int orignalX = start - 5 * arrays.size();
int orignalY = end - 5 * (arrays.size() + 1);
arrays.add(new Point(orignalX, orignalY));
htmlStr.delete(orignalX, orignalX + "[剧透:".length());
htmlStr.delete(orignalY, orignalY + 1);
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
// setTextIsSelectable(true);
}
setText(htmlStr);
setMovementMethod(LinkMovementMethod.getInstance());
final SpannableString tmep = (SpannableString) getText();
if (tmep instanceof Spannable) {
final int end = tmep.length();
final Spannable sp = (Spannable) getText();
ImageSpan[] imgs = tmep.getSpans(0, end, ImageSpan.class);
for (ImageSpan url : imgs) {
VerticalImageSpan span = new VerticalImageSpan(getUrlDrawable(url.getSource(), this), url.getSource());
tmep.setSpan(span, tmep.getSpanStart(url), tmep.getSpanEnd(url), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
pattern = Pattern.compile(ALL);
matcher.reset();
matcher = pattern.matcher(tmep);
while (matcher.find()) {
String at = matcher.group(1);
String topic = matcher.group(2);
String urlPath = matcher.group(3);
if (at != null) {
int start = matcher.start(1);
int endSpoiler = start + at.length();
selects.add(new SelectionBean(at, start, endSpoiler, 2));
ForegroundColorSpan colorSpan = new ForegroundColorSpan(Color.parseColor("#2d7dd2"));
tmep.setSpan(colorSpan, start, endSpoiler, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
}
if (topic != null) {
int start = matcher.start(2);
int endSpoiler = start + topic.length();
selects.add(new SelectionBean(topic, topic, start, endSpoiler, 3));
ForegroundColorSpan colorSpan = new ForegroundColorSpan(Color.parseColor("#2d7dd2"));
tmep.setSpan(colorSpan, start, endSpoiler, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
}
if (urlPath != null) {
int start = matcher.start(3);
int endUrl = start + urlPath.length();
selects.add(new SelectionBean(urlPath, start, endUrl, 4));
ForegroundColorSpan colorSpan = new ForegroundColorSpan(Color.parseColor("#2d7dd2"));
tmep.setSpan(colorSpan, start, endUrl, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
}
}
pattern = Pattern.compile(EMOJI);
matcher.reset();
matcher = pattern.matcher(tmep);
while (matcher.find()) {
String emoji = matcher.group();
if (emoji != null) {
int start = matcher.start();
int emojiEnd = start + emoji.length();
String emojiPath = null;
if ((emojiPath = FaceData.staticFaceInfo.get(emoji)) != null) {
try {
InputStream open = mContext.getAssets().open(emojiPath);
BitmapDrawable drawable = new BitmapDrawable(open);
drawable.setBounds(0, 0, AppTools.Companion.dp2px(mContext, 16), AppTools.Companion.dp2px(mContext, 16));
VerticalImageSpan span = new VerticalImageSpan(drawable);
tmep.setSpan(span, start, emojiEnd, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
if (tag != null && tag.length > 0) {
for (String str : tag)
if (!TextUtils.isEmpty(str)) {
int start = tmep.toString().indexOf(str);
int end = tmep.toString().indexOf(str) + str.length();
ForegroundColorSpan colorSpan = new ForegroundColorSpan(Color.parseColor("#2d7dd2"));
try {
tmep.setSpan(colorSpan, start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
} catch (Exception e) {
e.printStackTrace();
}
selects.add(new SelectionBean("", start, end, 1));
}
}
setText(tmep);
final BackgroundColorSpan span = new BackgroundColorSpan(Color.parseColor("#31000000"));
final int slop = ViewConfiguration.get(mContext).getScaledTouchSlop();
setOnTouchListener(new OnTouchListener() {
int downX, downY;
int id;
SelectionBean downSection = null;
@Override
public boolean onTouch(View v, MotionEvent event) {
int action = MotionEventCompat.getActionMasked(event);
Layout layout = getLayout();
if (layout == null) {
return false;
}
int line = 0;
int index = 0;
switch (action) {
case MotionEvent.ACTION_DOWN://TODO 最后一行点击问题 网址链接
int actionIndex = event.getActionIndex();
id = event.getPointerId(actionIndex);
downX = (int) event.getX(actionIndex);
downY = (int) event.getY(actionIndex);
line = layout.getLineForVertical(getScrollY() + (int) event.getY());
index = layout.getOffsetForHorizontal(line, (int) event.getX());
int lastRight = (int) layout.getLineRight(line);
if (lastRight < event.getX()) { //文字最后为话题时,如果点击在最后一行话题之后,也会造成话题被选中效果
return false;
}
Point clickPoint = null;
for (Point point : arrays) {//判断是否存在点击剧透 取消剧透
if (index >= point.x && index <= point.y) {
clickPoint = point;
}
}
if (clickPoint != null) {
arrays.remove(clickPoint);
invalidate();
}
for (SelectionBean section : selects) {
if (index >= section.getStart() && index <= section.getEnd()) {
tmep.setSpan(span, section.getStart(), section.getEnd(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
downSection = section;
setText(tmep);
getParent().requestDisallowInterceptTouchEvent(true);//不允许父view拦截
return true;
}
}
return false;
case MotionEvent.ACTION_MOVE:
int indexMove = event.findPointerIndex(id);
/**
* 会出现的异常 pointerIndex out of range
*/
int currentX = 0;
int currentY = 0;
try {
currentX = (int) event.getX(indexMove);
currentY = (int) event.getY(indexMove);
} catch (Exception e) {
e.printStackTrace();
}
if (Math.abs(currentX - downX) < slop && Math.abs(currentY - downY) < slop) {
if (downSection == null) {
getParent().requestDisallowInterceptTouchEvent(false);//允许父view拦截
return false;
}
break;
}
downSection = null;
getParent().requestDisallowInterceptTouchEvent(false);//允许父view拦截
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
int indexUp = event.findPointerIndex(id);
tmep.removeSpan(span);
setText(tmep);
int upX = (int) event.getX(indexUp);
int upY = (int) event.getY(indexUp);
if (Math.abs(upX - downX) < slop && Math.abs(upY - downY) < slop) {
//TODO startActivity or whatever
if (downSection != null) {
if (downSection.getType() == 3) {//跳转搜索
} else if (downSection.getType() == 4) {
} else {
if (mTextTouchListener != null) {
mTextTouchListener.touch(downSection);
}
}
downSection = null;
} else {
return false;
}
} else {
downSection = null;
return false;
}
break;
}
return true;
}
});
}
public interface OnTextTouchListener {
void touch(SelectionBean bean);
}
private OnTextTouchListener mTextTouchListener;
public void setOnTextTouchListener(OnTextTouchListener listener) {
mTextTouchListener = listener;
}
public static Drawable getUrlDrawable(String source, TextView mTextView) {
GlideImageGetter imageGetter = new GlideImageGetter(mTextView.getContext(), mTextView);
return imageGetter.getDrawable(source);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
isAttchWindows = true;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
isAttchWindows = false;
// arrays.clear();
// selects.clear();
}
}
好了所有的要点都是这里了,可以直接复制进项目,调用的话,就这样,
String text = "[剧透:这是一段剧透文字] 动态表情:teasing::teasing::teasing: emoji :anguished::apple::art: #我是标签可以点击的# @王昭君 https://www.baidu.com";
SpolierTextView spoiler_Text = findViewById(R.id.tv_Spoiler);
spoiler_Text.setData(AppConfig.getImageHtml(text));