/* * Copyright 2017 zengp * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.devilist.advancedtextview; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.os.Vibrator; import android.text.TextPaint; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; import android.util.SparseArray; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.WindowManager; import android.widget.PopupWindow; import android.widget.TextView; import android.widget.Toast; import java.lang.reflect.Field; import java.util.regex.Matcher; import java.util.regex.Pattern; import static android.content.Context.VIBRATOR_SERVICE; import static android.view.MotionEvent.ACTION_MOVE; /** * VerticalTextView ———— 实现文字竖排的TextView。 * <p> 功能: * <p> 1.文字从上到下竖排。 * <p> 2.文字阅读方向可选择 从右向左 和 从左向右。 * <p> 3.长按可选择文本,并弹出自定义的可定制化的菜单ActionMenu * <p> * Created by zengpu on 2017/1/20. */ public class VerticalTextView extends TextView { private static String TAG = VerticalTextView.class.getSimpleName(); private final int TRIGGER_LONGPRESS_TIME_THRESHOLD = 300; // 触发长按事件的时间阈值 private final int TRIGGER_LONGPRESS_DISTANCE_THRESHOLD = 10; // 触发长按事件的位移阈值 private Context mContext; private int mScreenWidth; // 屏幕宽度 private int mScreenHeight; // 屏幕高度 // attrs private boolean isLeftToRight; // 竖排方向,是否从左到右;默认从右到左 private float mLineSpacingExtra; // 行距 默认 6px private float mCharSpacingExtra; // 字符间距 默认 6px private boolean isUnderLineText; // 是否需要下划线,默认false private int mUnderLineColor; // 下划线颜色 默认 Color.RED private float mUnderLineWidth; // 下划线线宽 默认 1.5f private float mUnderLineOffset; // 下划线偏移 默认 3px private boolean isShowActionMenu; // 是否显示ActionMenu,默认true private int mTextHighlightColor; // 选中文字背景高亮颜色 默认0x60ffeb3b // onMeasure相关 private int[] mTextAreaRoughBound; // 粗略计算的文本最大显示区域(包含padding),用于view的测量和不同Gravity情况下文本的绘制 private int[] mMeasureMode; // 宽高的测量模式 private SparseArray<Float[]> mLinesOffsetArray; // 记录每一行文字的X,Y偏移量 private SparseArray<int[]> mLinesTextIndex; // 记录每一行文字开始和结束字符的index private int mMaxTextLine = 0; // 最大行数 private int mStatusBarHeight; // 状态栏高度 private int mActionMenuHeight; // 弹出菜单ActionMenu高度 private String mSelectedText; // 选择的文字 // onTouchEvent相关 private float mTouchDownX = 0; private float mTouchDownY = 0; private float mTouchDownRawY = 0; private boolean isLongPress = false; // 是否发触了长按事件 private boolean isLongPressTouchActionUp = false; // 长按事件结束后,标记该次事件,防止手指抬起后view没有重绘 private boolean isVibrator = false; // 是否触发过长按震动 private boolean isActionSelectAll = false; // 是否触发全选事件 private int mStartLine; // 长按触摸事件 所选文字的起始行 private int mCurrentLine; // 长按触摸事件 移动过程中手指所在行 private float mStartTextOffset; // 长按触摸事件 所选文字开始位置的Y向偏移值 private float mCurrentTextOffset; // 长按触摸事件 移动过程中所选文字结束位置的Y向偏移值 private Vibrator mVibrator; private PopupWindow mActionMenuPopupWindow; // 长按弹出菜单 private ActionMenu mActionMenu = null; // ActionMenu private OnClickListener mOnClickListener; private CustomActionMenuCallBack mCustomActionMenuCallBack; public VerticalTextView(Context context) { this(context, null); } public VerticalTextView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public VerticalTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); this.mContext = context; TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.VerticalTextView); mLineSpacingExtra = mTypedArray.getDimension(R.styleable.VerticalTextView_lineSpacingExtra, 6); mCharSpacingExtra = mTypedArray.getDimension(R.styleable.VerticalTextView_charSpacingExtra, 6); isLeftToRight = mTypedArray.getBoolean(R.styleable.VerticalTextView_textLeftToRight, false); isUnderLineText = mTypedArray.getBoolean(R.styleable.VerticalTextView_underLineText, false); mUnderLineColor = mTypedArray.getColor(R.styleable.VerticalTextView_underLineColor, Color.RED); mUnderLineWidth = mTypedArray.getFloat(R.styleable.VerticalTextView_underLineWidth, 1.5f); mUnderLineOffset = mTypedArray.getDimension(R.styleable.VerticalTextView_underlineOffset, 3); mTextHighlightColor = mTypedArray.getColor(R.styleable.VerticalTextView_textHeightLightColor, 0x60ffeb3b); isShowActionMenu = mTypedArray.getBoolean(R.styleable.VerticalTextView_showActionMenu, false); mTypedArray.recycle(); mLineSpacingExtra = Math.max(6, mLineSpacingExtra); mCharSpacingExtra = Math.max(6, mCharSpacingExtra); if (isUnderLineText) { mUnderLineWidth = Math.abs(mUnderLineWidth); mUnderLineOffset = Math.min(Math.abs(mUnderLineOffset), Math.abs(mLineSpacingExtra) / 2); } init(); } private void init() { WindowManager wm = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); mScreenWidth = wm.getDefaultDisplay().getWidth(); mScreenHeight = wm.getDefaultDisplay().getHeight(); setTextIsSelectable(false); mLinesOffsetArray = new SparseArray<>(); mLinesTextIndex = new SparseArray<>(); mTextAreaRoughBound = new int[]{0, 0}; mStatusBarHeight = Utils.getStatusBarHeight(mContext); mActionMenuHeight = Utils.dp2px(mContext, 45); mVibrator = (Vibrator) mContext.getSystemService(VIBRATOR_SERVICE); } public VerticalTextView setLeftToRight(boolean leftToRight) { isLeftToRight = leftToRight; return this; } public VerticalTextView setLineSpacingExtra(float lineSpacingExtra) { this.mLineSpacingExtra = Utils.dp2px(mContext, lineSpacingExtra); return this; } public VerticalTextView setCharSpacingExtra(float charSpacingExtra) { this.mCharSpacingExtra = Utils.dp2px(mContext, charSpacingExtra); return this; } public VerticalTextView setUnderLineText(boolean underLineText) { isUnderLineText = underLineText; return this; } public VerticalTextView setUnderLineColor(int underLineColor) { this.mUnderLineColor = underLineColor; return this; } public VerticalTextView setUnderLineWidth(float underLineWidth) { this.mUnderLineWidth = underLineWidth; return this; } public VerticalTextView setUnderLineOffset(float underLineOffset) { this.mUnderLineOffset = Utils.dp2px(mContext, underLineOffset); return this; } public VerticalTextView setShowActionMenu(boolean showActionMenu) { isShowActionMenu = showActionMenu; return this; } public VerticalTextView setTextHighlightColor(int color) { this.mTextHighlightColor = color; String color_hex = String.format("%08X", color); color_hex = "#40" + color_hex.substring(2); setHighlightColor(Color.parseColor(color_hex)); return this; } @Override public void setOnClickListener(OnClickListener l) { super.setOnClickListener(l); if (null != l) { mOnClickListener = l; } } /** * 设置ActionMenu菜单内容监听 * * @param callBack */ public void setCustomActionMenuCallBack(CustomActionMenuCallBack callBack) { this.mCustomActionMenuCallBack = callBack; } /* ***************************************************************************************** */ // view测量部分 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // view的初始测量宽高(包含padding) int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); Log.d(TAG, "widthSize " + widthSize); Log.d(TAG, "heightSize " + heightSize); // 粗略计算文字的最大宽度和最大高度,用于修正最后的测量宽高 mTextAreaRoughBound = getTextRoughSize(heightSize == 0 ? mScreenHeight : heightSize, mLineSpacingExtra, mCharSpacingExtra); int measuredWidth; int measureHeight; // if (widthSize == 0) { // // 当嵌套在HorizontalScrollView时,MeasureSpec.getSize(widthMeasureSpec)返回0,因此需要特殊处理 // measuredWidth = mTextAreaRoughBound[0]; // } else if (widthSize <= mScreenWidth) { // measuredWidth = mTextAreaRoughBound[0] <= mScreenWidth ? // Math.max(widthSize, mTextAreaRoughBound[0]) : widthSize; // } else { // measuredWidth = mTextAreaRoughBound[0] <= mScreenWidth ? // mScreenWidth : Math.min(widthSize, mTextAreaRoughBound[0]); // } if (widthSize == 0) { // 当嵌套在HorizontalScrollView时,MeasureSpec.getSize(widthMeasureSpec)返回0,因此需要特殊处理 measuredWidth = mTextAreaRoughBound[0]; } else { measuredWidth = widthMode == MeasureSpec.AT_MOST || widthMode == MeasureSpec.UNSPECIFIED ? mTextAreaRoughBound[0] : widthSize; } if (heightSize == 0) { // 当嵌套在ScrollView时,MeasureSpec.getSize(widthMeasureSpec)返回0,因此需要特殊处理 measureHeight = mScreenHeight; } else { measureHeight = heightMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.UNSPECIFIED ? mTextAreaRoughBound[1] : heightSize; } setMeasuredDimension(measuredWidth, measureHeight); Log.d(TAG, "measuredWidth " + measuredWidth); Log.d(TAG, "measureHeight " + measureHeight); } /** * 粗略计算文本的宽度和高度(包含padding),用于修正最后的测量宽高 * * @param oriHeightSize 初始测量高度 必须大于0。当等于0时,用屏幕高度代替 * @param lineSpacingExtra * @param charSpacingExtra * @return int[textWidth, textHeight] */ private int[] getTextRoughSize(int oriHeightSize, float lineSpacingExtra, float charSpacingExtra) { // 将文本用换行符分隔,计算粗略的行数 String[] subTextStr = getText().toString().split("\n"); int textLines = 0; // 用于计算最大高度的目标子段落 String targetSubPara = ""; int tempLines = 1; float tempLength = 0; // 计算每个段落的行数,然后累加 for (String aSubTextStr : subTextStr) { // 段落的粗略长度(字符间距也要考虑进去) float subParagraphLength = aSubTextStr.length() * (getTextSize() + charSpacingExtra); // 段落长度除以初始测量高度,得到粗略行数 int subLines = (int) Math.ceil(subParagraphLength / Math.abs(oriHeightSize - getPaddingTop() - getPaddingBottom())); if (subLines == 0) subLines = 1; textLines += subLines; // 如果所有子段落的行数都为1,则最大高度为长度最长的子段落长度;否则最大高度为oriHeightSize; if (subLines == 1 && tempLines == 1) { if (subParagraphLength > tempLength) { tempLength = subParagraphLength; targetSubPara = aSubTextStr; } } tempLines = subLines; } // 计算文本粗略高度,包括padding int textHeight = getPaddingTop() + getPaddingBottom(); if (textLines > subTextStr.length) textHeight = oriHeightSize; else { // 计算targetSubPara长度作为高度 for (int i = 0; i < targetSubPara.length(); i++) { String char_i = String.valueOf(getText().toString().charAt(i)); // 区别标点符号 和 文字 if (isUnicodeSymbol(char_i)) { textHeight += 1.4f * getCharHeight(char_i, getTextPaint()) + charSpacingExtra; } else { textHeight += getTextSize() + charSpacingExtra; } } } // 计算文本的粗略宽度,包括padding, int textWidth = getPaddingLeft() + getPaddingRight() + (int) ((textLines + 1) * getTextSize() + lineSpacingExtra * (textLines - 1)); Log.d(TAG, "textRoughLines " + textLines); Log.d(TAG, "textRoughWidth " + textWidth); Log.d(TAG, "textRoughHeight " + textHeight); return new int[]{textWidth, textHeight}; } /* ***************************************************************************************** */ // 触摸事件部分。处理onclick事件,长按选择文字事件 和 弹出ActionMenu事件 @Override public boolean onTouchEvent(MotionEvent event) { int action = event.getAction(); int currentLine; // 当前所在行 switch (action) { case MotionEvent.ACTION_DOWN: Log.d(TAG, "ACTION_DOWN"); // 每次按下时,创建ActionMenu菜单,创建不成功,屏蔽长按事件 if (null == mActionMenu) { mActionMenu = createActionMenu(); } mTouchDownX = event.getX(); mTouchDownY = event.getY(); mTouchDownRawY = event.getRawY(); isLongPress = false; isVibrator = false; isLongPressTouchActionUp = false; break; case ACTION_MOVE: Log.d(TAG, "ACTION_MOVE"); // 先判断是否禁用了ActionMenu功能,以及ActionMenu是否创建失败, // 二者只要满足了一个条件,退出长按事件 if (isShowActionMenu || mActionMenu.getChildCount() == 0) { // 手指移动过程中的字符偏移 currentLine = getCurrentTouchLine(event.getX(), isLeftToRight); float mWordOffset_move = event.getY(); // 判断是否触发长按事件 判断条件为: // 1.超过时间阈值;2.且超过手指移动的最小位移阈值;3.且未超过边界,在padding内 boolean isTriggerTime = event.getEventTime() - event.getDownTime() >= TRIGGER_LONGPRESS_TIME_THRESHOLD; boolean isTriggerDistance = Math.abs(event.getX() - mTouchDownX) < TRIGGER_LONGPRESS_DISTANCE_THRESHOLD && Math.abs(event.getY() - mTouchDownY) < TRIGGER_LONGPRESS_DISTANCE_THRESHOLD; int[] drawPadding = getDrawPadding(isLeftToRight); boolean isInBound = event.getX() >= drawPadding[0] && event.getX() <= getWidth() - drawPadding[2] && event.getY() >= drawPadding[1] && event.getY() <= getHeight() - drawPadding[3]; if (isTriggerTime && isTriggerDistance && isInBound) { Log.d(TAG, "ACTION_MOVE 长按"); isLongPress = true; isLongPressTouchActionUp = false; mStartLine = currentLine; mStartTextOffset = mWordOffset_move; // 每次触发长按时,震动提示一次 if (!isVibrator) { mVibrator.vibrate(60); isVibrator = true; } } if (isLongPress) { mCurrentLine = currentLine; mCurrentTextOffset = mWordOffset_move; // 通知父布局不要拦截触摸事件 getParent().requestDisallowInterceptTouchEvent(true); // 通知view绘制所选文字背景色 invalidate(); } } break; case MotionEvent.ACTION_UP: Log.d(TAG, "ACTION_UP"); // 处理长按事件 if (isLongPress) { currentLine = getCurrentTouchLine(event.getX(), isLeftToRight); float mWordOffsetEnd = event.getY(); mCurrentLine = currentLine; mCurrentTextOffset = mWordOffsetEnd; // 手指抬起后选择文字 selectText(mStartTextOffset, mCurrentTextOffset, mStartLine, mCurrentLine, mCharSpacingExtra, isLeftToRight); if (!TextUtils.isEmpty(mSelectedText)) { // 计算菜单显示位置 int mPopWindowOffsetY = calculatorActionMenuYPosition((int) mTouchDownRawY, (int) event.getRawY()); // 弹出菜单 showActionMenu(mPopWindowOffsetY, mActionMenu); } isLongPressTouchActionUp = true; isLongPress = false; } else if (event.getEventTime() - event.getDownTime() < TRIGGER_LONGPRESS_TIME_THRESHOLD) { // 由于onTouchEvent最终返回了true,onClick事件会被屏蔽掉,因此在这里处理onClick事件 if (null != mOnClickListener) mOnClickListener.onClick(this); } // 通知父布局继续拦截触摸事件 getParent().requestDisallowInterceptTouchEvent(false); break; } return true; } /** * 计算触摸位置所在行,最小值为1 * * @param offsetX * @param isLeftToRight * @return */ private int getCurrentTouchLine(float offsetX, boolean isLeftToRight) { int currentLine = 1; float lineWidth = getTextSize() + mLineSpacingExtra; int[] drawPadding = getDrawPadding(isLeftToRight); if (isLeftToRight) { // 边界控制 if (offsetX >= getWidth() - drawPadding[2]) currentLine = mMaxTextLine; else currentLine = (int) Math.ceil((offsetX - drawPadding[0]) / lineWidth); } else { if (offsetX <= drawPadding[0]) currentLine = mMaxTextLine; else currentLine = (int) Math.ceil((getWidth() - offsetX - drawPadding[2]) / lineWidth); } currentLine = currentLine <= 0 ? 1 : (currentLine > mMaxTextLine ? mMaxTextLine : currentLine); Log.d(TAG, "touch line is: " + currentLine); return currentLine; } /** * 选择选中的字符 * * @param startOffsetY * @param endOffsetY * @param startLine * @param endLine * @param charSpacingExtra */ private void selectText(float startOffsetY, float endOffsetY, int startLine, int endLine, float charSpacingExtra, boolean isLeftToRight) { // 计算开始和结束的字符index int index_start = getSelectTextIndex(startOffsetY, startLine, charSpacingExtra, isLeftToRight); int index_end = getSelectTextIndex(endOffsetY, endLine, charSpacingExtra, isLeftToRight); if (index_start == index_end) mSelectedText = ""; else { String textAll = getText().toString(); if (TextUtils.isEmpty(textAll)) mSelectedText = ""; else mSelectedText = textAll.substring(Math.min(index_start, index_end), Math.max(index_start, index_end)); } Log.d(TAG, "mSelectedText " + mSelectedText); } /** * 计算所选文字起始或结束字符对应的index * * @param offsetY * @param targetLine * @param charSpacingExtra 字符间距 */ private int getSelectTextIndex(float offsetY, int targetLine, float charSpacingExtra, boolean isLeftToRight) { int[] drawPadding = getDrawPadding(isLeftToRight); // 该行文字的起始和结束位置 int[] lineIndex = mLinesTextIndex.get(targetLine); // 目标位置 int targetIndex = lineIndex[1]; float tempY = drawPadding[1]; // 边界控制 if (offsetY < drawPadding[1]) { return lineIndex[0]; } else if (offsetY > getHeight() - drawPadding[3]) { return lineIndex[1]; } /* * 循环累加每一个字符的高度,一直到tempY > offsetY 时停止,然后根据行首或行末计算index; * 如果循环完成后依然未触发tempY >= offsetY条件,返回该行的最后一个字符index,即lineIndex[1]; */ for (int i = lineIndex[0]; i < lineIndex[1]; i++) { String char_i = String.valueOf(getText().toString().charAt(i)); // 区别换行符,标点符号 和 文字 if (char_i.equals("\n")) { tempY = drawPadding[1]; } else if (isUnicodeSymbol(char_i)) { tempY += 1.4f * getCharHeight(char_i, getTextPaint()) + charSpacingExtra; } else { tempY += getTextSize() + charSpacingExtra; } // 触发停止的条件 if (tempY >= offsetY) { targetIndex = i; break; } } Log.d(TAG, "target index " + targetIndex); return targetIndex; } /** * 计算弹出菜单相对于父布局的Y向偏移 * * @param yOffsetStart 所选字符的起始位置相对屏幕的Y向偏移 * @param yOffsetEnd 所选字符的结束位置相对屏幕的Y向偏移 * @return */ private int calculatorActionMenuYPosition(int yOffsetStart, int yOffsetEnd) { if (yOffsetStart > yOffsetEnd) { int temp = yOffsetStart; yOffsetStart = yOffsetEnd; yOffsetEnd = temp; } int actionMenuOffsetY; if (yOffsetStart < mActionMenuHeight * 3 / 2 + mStatusBarHeight) { if (yOffsetEnd > mScreenHeight - mActionMenuHeight * 3 / 2) { // 菜单显示在屏幕中间 actionMenuOffsetY = mScreenHeight / 2 - mActionMenuHeight / 2; } else { // 菜单显示所选文字下方 actionMenuOffsetY = yOffsetEnd + mActionMenuHeight / 2; } } else { // 菜单显示所选文字上方 actionMenuOffsetY = yOffsetStart - mActionMenuHeight * 3 / 2; } return actionMenuOffsetY; } /* ***************************************************************************************** */ // 创建ActionMenu部分 /** * 创建ActionMenu菜单 * * @return */ private ActionMenu createActionMenu() { // 创建菜单 ActionMenu actionMenu = new ActionMenu(mContext); // 是否需要移除默认item boolean isRemoveDefaultItem = false; if (null != mCustomActionMenuCallBack) { isRemoveDefaultItem = mCustomActionMenuCallBack.onCreateCustomActionMenu(actionMenu); } if (!isRemoveDefaultItem) actionMenu.addDefaultMenuItem(); // 添加默认item actionMenu.addCustomItem(); // 添加自定义item actionMenu.setFocusable(true); // 获取焦点 actionMenu.setFocusableInTouchMode(true); if (actionMenu.getChildCount() != 0) { // item监听 for (int i = 0; i < actionMenu.getChildCount(); i++) { actionMenu.getChildAt(i).setOnClickListener(mMenuClickListener); } } return actionMenu; } /** * 长按弹出菜单 * * @param offsetY * @param actionMenu * @return 菜单创建成功,返回true */ private void showActionMenu(int offsetY, ActionMenu actionMenu) { mActionMenuPopupWindow = new PopupWindow(actionMenu, WindowManager.LayoutParams.WRAP_CONTENT, mActionMenuHeight, true); mActionMenuPopupWindow.setFocusable(true); mActionMenuPopupWindow.setOutsideTouchable(false); mActionMenuPopupWindow.setBackgroundDrawable(new ColorDrawable(0x00000000)); mActionMenuPopupWindow.showAtLocation(this, Gravity.TOP | Gravity.CENTER_HORIZONTAL, 0, offsetY); mActionMenuPopupWindow.setOnDismissListener(new PopupWindow.OnDismissListener() { @Override public void onDismiss() { // 清理已选的文字 clearSelectedTextBackground(); } }); } /** * 隐藏菜单 */ private void hideActionMenu() { if (null != mActionMenuPopupWindow) { mActionMenuPopupWindow.dismiss(); mActionMenuPopupWindow = null; } } /** * 菜单点击事件监听 */ private OnClickListener mMenuClickListener = new OnClickListener() { @Override public void onClick(View v) { String menuItemTitle = (String) v.getTag(); if (menuItemTitle.equals(ActionMenu.DEFAULT_MENU_ITEM_TITLE_SELECT_ALL)) { //全选事件 mSelectedText = getText().toString(); int[] drawPadding = getDrawPadding(isLeftToRight); mStartLine = 1; mCurrentLine = mMaxTextLine; mStartTextOffset = drawPadding[1]; mCurrentTextOffset = getHeight() - drawPadding[3]; isLongPressTouchActionUp = true; invalidate(); } else if (menuItemTitle.equals(ActionMenu.DEFAULT_MENU_ITEM_TITLE_COPY)) { // 复制事件 Utils.copyText(mContext, mSelectedText); Toast.makeText(mContext, "复制成功!", Toast.LENGTH_SHORT).show(); hideActionMenu(); } else { // 自定义事件 if (null != mCustomActionMenuCallBack) { mCustomActionMenuCallBack.onCustomActionItemClicked(menuItemTitle, mSelectedText); } hideActionMenu(); } } }; /* ***************************************************************************************** */ // 绘制部分 @Override protected void onDraw(Canvas canvas) { // 绘制竖排文字 drawVerticalText(canvas, mLineSpacingExtra, mCharSpacingExtra, isLeftToRight); // 绘制下划线 drawTextUnderline(canvas, isLeftToRight, mUnderLineOffset, mCharSpacingExtra); // 绘制选中文字的背景,触发条件: // 1.长按事件 2.全选事件 3.手指滑动过快时,进入ACTION_UP事件后,可能会出现背景未绘制的情况 if (isLongPress | isActionSelectAll | isLongPressTouchActionUp) { drawSelectedTextBackground(canvas, mStartLine, mCurrentLine, mStartTextOffset, mCurrentTextOffset, mLineSpacingExtra, mCharSpacingExtra, isLeftToRight); isActionSelectAll = false; isLongPressTouchActionUp = false; } } /** * 绘制竖排文字 * * @param canvas * @param lineSpacingExtra 行距 * @param charSpacingExtra 字符间距 * @param isLeftToRight 文字方向 */ private void drawVerticalText(Canvas canvas, float lineSpacingExtra, float charSpacingExtra, boolean isLeftToRight) { // 文字画笔 TextPaint textPaint = getTextPaint(); int textStrLength = getText().length(); if (textStrLength == 0) return; // 每次绘制时初始化参数 mMaxTextLine = 1; int currentLineStartIndex = 0; // 行首位置标记 mLinesOffsetArray.clear(); mLinesTextIndex.clear(); int[] drawPadding = getDrawPadding(isLeftToRight); // 绘制文字的padding // 当前竖行的XY向偏移初始值 float currentLineOffsetX = isLeftToRight ? drawPadding[0] : getWidth() - drawPadding[2] - getTextSize(); float currentLineOffsetY = drawPadding[1] + getTextSize(); for (int j = 0; j < textStrLength; j++) { String char_j = String.valueOf(getText().charAt(j)); /* 换行条件为: * 1:遇到换行符; * 2:该竖行是否已经写满。 * * 该竖行是否已经写满,判定条件为: * 1.y向剩余的空间已经不够填下一个文字; * 2.且当前要绘制的文字不是标点符号; * 3.或当前要绘制的文字是标点符号,但标点符号的高度大于y向剩余的空间 * 注意:文字是从左下角开始向上绘制的 */ boolean isLineBreaks = char_j.equals("\n"); boolean isCurrentLineFinish = currentLineOffsetY > getHeight() - drawPadding[3] && (!isUnicodeSymbol(char_j) || (isUnicodeSymbol(char_j) && currentLineOffsetY + getCharHeight(char_j, textPaint) > getHeight() - drawPadding[3] + getTextSize())); if (isLineBreaks || isCurrentLineFinish) { // 记录记录偏移量,和行首行末字符的index;然后另起一行, mLinesOffsetArray.put(mMaxTextLine, new Float[]{currentLineOffsetX, currentLineOffsetY}); mLinesTextIndex.put(mMaxTextLine, new int[]{currentLineStartIndex, j}); // 另起一竖行,更新偏移量 currentLineOffsetX = isLeftToRight ? currentLineOffsetX + getTextSize() + lineSpacingExtra : currentLineOffsetX - getTextSize() - lineSpacingExtra; currentLineOffsetY = drawPadding[1] + getTextSize(); mMaxTextLine++; } // 判断是否是行首,记录行首字符位置; // 判断行首的条件为:currentLineOffsetY == drawPadding[1]+getTextSize() if (currentLineOffsetY == drawPadding[1] + getTextSize()) { currentLineStartIndex = j; } // 绘制第j个字符. if (isLineBreaks) { // 如果是换行符,do nothing //char_j = ""; //canvas.drawText(char_j, currentLineOffsetX, currentLineOffsetY, textPaint); } else if (isUnicodeSymbol(char_j)) { // 如果是Y向需要补偿标点符号,加一个补偿 getTextSize() - getCharHeight. // 注意:如果该竖行第一个字符是标点符号的话,不加补偿; // 判断是否是第一个字符的条件为:offsetY == drawPadding[1] + getTextSize() float drawOffsetY = currentLineOffsetY; if (isSymbolNeedOffset(char_j)) drawOffsetY = drawOffsetY - (getTextSize() - 1.4f * getCharHeight(char_j, textPaint)); // 文字从左向右,标点符号靠右绘制,竖排标点除外 float drawOffsetX = currentLineOffsetX; if (isLeftToRight && !isVerticalSymbol(char_j)) drawOffsetX = drawOffsetX + getTextSize() / 2; canvas.drawText(char_j, drawOffsetX, drawOffsetY, textPaint); currentLineOffsetY += 1.4f * getCharHeight(char_j, textPaint) + charSpacingExtra; } else { canvas.drawText(char_j, currentLineOffsetX, currentLineOffsetY, textPaint); currentLineOffsetY += getTextSize() + charSpacingExtra; } // 最后一行的偏移量和行首行末字符的index; if (j == textStrLength - 1) { mLinesOffsetArray.put(mMaxTextLine, new Float[]{currentLineOffsetX, currentLineOffsetY}); mLinesTextIndex.put(mMaxTextLine, new int[]{currentLineStartIndex, textStrLength}); } } Log.d(TAG, "mMaxTextLine is : " + mMaxTextLine); } /** * 绘制下划线 * * @param canvas * @param isLeftToRight 文字方向 * @param underLineOffset 下划线偏移量 >0 * @param charSpacingExtra */ private void drawTextUnderline(Canvas canvas, boolean isLeftToRight, float underLineOffset, float charSpacingExtra) { if (!isUnderLineText || mUnderLineWidth == 0) return; // 下划线paint Paint underLinePaint = getPaint(); underLinePaint.setColor(mUnderLineColor); underLinePaint.setAntiAlias(true); underLinePaint.setStyle(Paint.Style.FILL); underLinePaint.setStrokeWidth(mUnderLineWidth); int[] drawPadding = getDrawPadding(isLeftToRight); // 绘制文字的padding for (int i = 0; i < mMaxTextLine; i++) { // Y向开始和结束位置 float yStart = drawPadding[1]; float yEnd = mLinesOffsetArray.get(i + 1)[1] - getTextSize(); // 如果end <= start 或者 该行字符为换行符,则不绘制下划线 int[] lineIndex = mLinesTextIndex.get(i + 1); String lineText = getText().toString().substring(lineIndex[0], lineIndex[1]); if (yEnd <= yStart || (lineText.equals("\n"))) continue; // Y向边界处理 if (yEnd > getHeight() - drawPadding[3] - getTextSize()) yEnd = getHeight() - drawPadding[3]; // 首行缩进处理 int spaceNum = getLineStartSpaceNumber(lineText); if (spaceNum > 0) { yStart = yStart + (getTextSize() + charSpacingExtra) * spaceNum; } // X向;注意不同的文字方向和下划线偏移 float xStart = mLinesOffsetArray.get(i + 1)[0]; if (isLeftToRight) xStart += getTextSize() + underLineOffset; else xStart -= underLineOffset; float xEnd = xStart; canvas.drawLine(xStart, yStart, xEnd, yEnd, underLinePaint); } } /** * 绘制所选文字高亮色 * * @param canvas * @param startLine 开始行 * @param endLine 结束行 * @param startOffsetY 开始字符Y向偏移 * @param endOffsetY 结束文字Y向偏移 * @param lineSpacingExtra * @param charSpacingExtra * @param isLeftToRight */ private void drawSelectedTextBackground(Canvas canvas, int startLine, int endLine, float startOffsetY, float endOffsetY, float lineSpacingExtra, float charSpacingExtra, boolean isLeftToRight) { if (startLine == endLine && Math.abs(endOffsetY - startOffsetY) == 0) { return; } // 文字背景高亮画笔 Paint highlightPaint = new Paint(Paint.ANTI_ALIAS_FLAG); highlightPaint.setStyle(Paint.Style.FILL); highlightPaint.setColor(mTextHighlightColor); highlightPaint.setAlpha(60); int[] drawPadding = getDrawPadding(isLeftToRight); // 绘制文字的padding // 预处理,如果startLine > endLine,交换二者 if (startLine > endLine) { startLine = startLine + endLine; endLine = startLine - endLine; startLine = startLine - endLine; startOffsetY = startOffsetY + endOffsetY; endOffsetY = startOffsetY - endOffsetY; startOffsetY = startOffsetY - endOffsetY; } // 行宽 int lineWidth = (int) (getTextSize() + lineSpacingExtra); // 开始行和结束行所选文字的y向偏移量 int startLineOffsetY = getSelectTextPreciseOffsetY(startOffsetY, startLine, charSpacingExtra, true, isLeftToRight); int endLineOffsetY = getSelectTextPreciseOffsetY(endOffsetY, endLine, charSpacingExtra, false, isLeftToRight); // 围绕所选的文字创建一个Path闭合路径,一共八个点 Path path_all = new Path(); if (isLeftToRight) { // 往左偏移半个行距 int offsetLeftPadding = (int) (drawPadding[0] - lineSpacingExtra / 2); path_all.moveTo(offsetLeftPadding + (startLine - 1) * lineWidth, startLineOffsetY); path_all.lineTo(offsetLeftPadding + startLine * lineWidth, startLineOffsetY); path_all.lineTo(offsetLeftPadding + startLine * lineWidth, drawPadding[1]); path_all.lineTo(offsetLeftPadding + endLine * lineWidth, drawPadding[1]); path_all.lineTo(offsetLeftPadding + endLine * lineWidth, endLineOffsetY); path_all.lineTo(offsetLeftPadding + (endLine - 1) * lineWidth, endLineOffsetY); path_all.lineTo(offsetLeftPadding + (endLine - 1) * lineWidth, getHeight() - drawPadding[3] + charSpacingExtra); path_all.lineTo(offsetLeftPadding + (startLine - 1) * lineWidth, getHeight() - drawPadding[3] + charSpacingExtra); path_all.close(); } else { // 往右偏移半个行距 int offsetRightPadding = (int) (getWidth() - drawPadding[2] + lineSpacingExtra / 2); path_all.moveTo(offsetRightPadding - (startLine - 1) * lineWidth, startLineOffsetY); path_all.lineTo(offsetRightPadding - startLine * lineWidth, startLineOffsetY); path_all.lineTo(offsetRightPadding - startLine * lineWidth, drawPadding[1]); path_all.lineTo(offsetRightPadding - endLine * lineWidth, drawPadding[1]); path_all.lineTo(offsetRightPadding - endLine * lineWidth, endLineOffsetY); path_all.lineTo(offsetRightPadding - (endLine - 1) * lineWidth, endLineOffsetY); path_all.lineTo(offsetRightPadding - (endLine - 1) * lineWidth, getHeight() - drawPadding[3] + charSpacingExtra); path_all.lineTo(offsetRightPadding - (startLine - 1) * lineWidth, getHeight() - drawPadding[3] + charSpacingExtra); path_all.close(); } canvas.drawPath(path_all, highlightPaint); canvas.save(); canvas.restore(); } /** * 根据文本的Gravity计算文字绘制时的padding * * @param isLeftToRight 文字阅读方向 * @return [left, top, right, bottom] */ private int[] getDrawPadding(boolean isLeftToRight) { int textBoundWidth = mTextAreaRoughBound[0]; int textBoundHeight = mTextAreaRoughBound[1]; int left, right, top, bottom; int gravity; if (textBoundWidth < getWidth()) { // 先把水平方向的gravity解析出来 gravity = getGravity() & Gravity.HORIZONTAL_GRAVITY_MASK; if (gravity == Gravity.CENTER || gravity == Gravity.CENTER_HORIZONTAL) { left = getPaddingLeft() + (getWidth() - textBoundWidth) / 2; right = getPaddingRight() + (getWidth() - textBoundWidth) / 2; } else if (gravity == Gravity.RIGHT && isLeftToRight) { left = getPaddingLeft() + getWidth() - textBoundWidth; right = getPaddingRight(); } else if (gravity == Gravity.LEFT && !isLeftToRight) { left = getPaddingLeft(); right = getPaddingRight() + getWidth() - textBoundWidth; } else { left = isLeftToRight ? getPaddingLeft() : getPaddingLeft() + getWidth() - textBoundWidth; right = isLeftToRight ? getPaddingRight() + getWidth() - textBoundWidth : getPaddingRight(); } } else { left = getPaddingLeft(); right = getPaddingRight(); } if (textBoundHeight < getHeight()) { // 先把垂直方向的gravity解析出来 gravity = getGravity() & Gravity.VERTICAL_GRAVITY_MASK; if (gravity == Gravity.CENTER || gravity == Gravity.CENTER_VERTICAL) { top = getPaddingTop() + (getHeight() - textBoundHeight) / 2; bottom = getPaddingBottom() + (getHeight() - textBoundHeight) / 2; } else if (gravity == Gravity.BOTTOM) { top = getPaddingTop() + getHeight() - textBoundHeight; bottom = getPaddingBottom(); } else { top = getPaddingTop(); bottom = getPaddingBottom() + getHeight() - textBoundHeight; } } else { top = getPaddingTop(); bottom = getPaddingBottom(); } return new int[]{left, top, right, bottom}; } /** * 获取所选文字的精确Y向偏移 * * @param offsetY * @param targetLine * @param charSpacingExtra * @return */ private int getSelectTextPreciseOffsetY(float offsetY, int targetLine, float charSpacingExtra, boolean isStart, boolean isLeftToRight) { int[] drawPadding = getDrawPadding(isLeftToRight); // 绘制文字的padding // 该行文字的起始和结束位置 int[] lineIndex = mLinesTextIndex.get(targetLine); // 目标位置 int targetOffset = drawPadding[1]; int tempY = drawPadding[1]; // 边界控制 if (offsetY < drawPadding[1]) { return drawPadding[1]; } else if (offsetY > getHeight() - drawPadding[3]) { return getHeight() - drawPadding[3]; } /* * 循环累加每一个字符的高度,一直到tempY > offsetY 时停止,然后根据行首或行末计算精确的偏移量; * 如果循环完成后依然未触发tempY >= offsetY条件,返回该行的最大长度; */ for (int i = lineIndex[0]; i < lineIndex[1]; i++) { String char_i = String.valueOf(getText().toString().charAt(i)); // 区别换行符,标点符号 和 文字 if (char_i.equals("\n")) { tempY = drawPadding[1]; } else if (isUnicodeSymbol(char_i)) { tempY += 1.4f * getCharHeight(char_i, getTextPaint()) + charSpacingExtra; } else { tempY += getTextSize() + charSpacingExtra; } if (tempY <= offsetY) { targetOffset = tempY; } // 触发暂停条件 if (tempY > offsetY) { break; } } return Math.max(targetOffset, drawPadding[1]); } /** * 清除所选文字的背景 */ private void clearSelectedTextBackground() { mSelectedText = ""; mStartLine = mCurrentLine = 0; mStartTextOffset = mCurrentTextOffset = 0; invalidate(); } /** * 文字画笔 * * @return */ private TextPaint getTextPaint() { // 文字画笔 TextPaint textPaint = getPaint(); textPaint.setColor(getCurrentTextColor()); return textPaint; } /** * 计算首行缩进的空格数 * * @param lineText * @return */ private int getLineStartSpaceNumber(String lineText) { if (lineText.startsWith(" ")) { return 4; } else if (lineText.startsWith(" ") || lineText.startsWith(" ")) { return 3; } else if (lineText.startsWith(" ") || lineText.startsWith(" ")) { return 2; } else if (lineText.startsWith(" ") || lineText.startsWith(" ")) { return 1; } else return 0; } /** * 获取一个字符的高度 * * @param target_char * @param paint * @return */ private float getCharHeight(String target_char, Paint paint) { Rect rect = new Rect(); paint.getTextBounds(target_char, 0, 1, rect); return rect.height(); } /** * 获取一个字符的宽度 * * @param target_char * @param paint * @return */ private float getCharWidth(String target_char, Paint paint) { Rect rect = new Rect(); paint.getTextBounds(target_char, 0, 1, rect); return rect.width(); } /** * 判断是否是标点符号 * - - —— = + ~ 这几个不做判断 * * @param str * @return */ private boolean isUnicodeSymbol(String str) { String regex = ".*[_\"`!@#$%^&*()|{}':;,\\[\\].<>/?!¥…()【】‘’;:”“。,、?︵ ︷︿︹︽﹁﹃︻︶︸﹀︺︾ˉ﹂﹄︼]$+.*"; Matcher m = Pattern.compile(regex).matcher(str); return m.matches(); } /** * 需要补偿的标点符号 * - - —— = + ~ 这几个不做补偿 * * @param str * @return */ private boolean isSymbolNeedOffset(String str) { String regex = ".*[_!@#$%&()|{}:;,\\[\\].<>/?!¥…()【】;:。,、?︵ ︷︿︹︽﹁﹃︻]$+.*"; Matcher m = Pattern.compile(regex).matcher(str); return m.matches(); } /** * 是否是竖排标点符号 * * @param str * @return */ private boolean isVerticalSymbol(String str) { String regex = ".*[︵ ︷︿︹︽﹁﹃︻︶︸﹀︺︾ˉ﹂﹄︼|]$+.*"; Matcher m = Pattern.compile(regex).matcher(str); return m.matches(); } }