Android 自定义理化表达式View

一、前言

在 Android 中实现上下标我们一般使用 SpannableString 去完成,需要计算开始位置和结束位置,也要设置各种 Span,而且动态性不是很好,因为无法做到规则统一约束,因此有必要进行专有规则设定,提高代码使用的灵活程度。

当然,也有很多开源的项目,但是对于简单的数学和化学表达式,大多都缺少通用性,仅限于项目本身使用,这也是本篇实现的主要目的之一。对于其他类型如求和公式、平方根公式、分子分母其实也可以通过本篇的思想,进行一些列改造即可,当然也可以借助语法树,实现自己的公式编辑器。

二、效果预览

三、实现

实现其实很简单,本身就是借助Canvas#drawTextXXX实现,但是我们这里仍然需要回顾的问题是字体测量和基线计算问题。

3.1 字体测量

常用的宽高测量如下

arduino 复制代码
        //获取文本最小宽度(真实宽度)
        private static int getTextRealWidth(String text, Paint paint) {
            if (TextUtils.isEmpty(text)) return 0;
            Rect rect = new Rect(); // 文字所在区域的矩形
            paint.getTextBounds(text, 0, text.length(), rect);
            //获取最小矩形,该矩形紧贴文字笔画开始的位置
            return rect.width();
        }

        //获取文本最小高度(真实高度)
        private static int getTextRealHeight(String text, Paint paint) {
            if (TextUtils.isEmpty(text)) return 0;
            Rect rect = new Rect(); // 文字所在区域的矩形
            paint.getTextBounds(text, 0, text.length(), rect);
            //获取最小矩形,该矩形紧贴文字笔画开始的位置
            return rect.height();
        }

        //真实宽度 + 笔画左右两侧间隙(一般绘制的的时候建议使用这种,左右两侧的间隙和字形有关)
        private static int getTextWidth(String text, Paint paint) {
            if (TextUtils.isEmpty(text)) return 0;
            return (int) paint.measureText(text);
        }

        //真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
        private static int getTextHeight(Paint paint) {
            Paint.FontMetricsInt fm = paint.getFontMetricsInt();
            int textHeight = ~fm.top - (~fm.top - ~fm.ascent) - (fm.bottom - fm.descent);
            return textHeight;
        }

3.2基线计算

在Canvas 绘制,实际上Html中的Canvas一样都需要计算意义,因为文字的受到不同文化的影响,表现形式不同,另外音标等问题存在,所以使用基线来绘制更合理。

推导算法如下

scss 复制代码
       /**
         * 基线到中线的距离=(Descent+Ascent)/2-Descent
         * 注意,实际获取到的Ascent是负数。公式推导过程如下:
         * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
         */
        public static float getTextPaintBaseline(Paint p) {
            Paint.FontMetrics fontMetrics = p.getFontMetrics();
            return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
        }

3.3 全部代码

scss 复制代码
public class MathExpressTextView extends View {

    private final List<TextInfo> TEXT_INFOS = new ArrayList<>();
    private int textSpace = 15;
    private String TAG = "MathExpressTextView";
    protected Paint mTextPaint;
    protected Paint mSubTextPaint;
    protected Paint mMarkTextPaint;
    protected float mContentWidth = 0f;
    protected float mContentHeight = 0f;
    protected float mMaxSize = 0;


    public void setMaxTextSize(float sizePx) {
        mMaxSize = sizePx;

        mTextPaint.setTextSize(mMaxSize);
        mSubTextPaint.setTextSize(mMaxSize / 3f);
        mMarkTextPaint.setTextSize(mMaxSize / 2f);

        invalidate();
    }

    public void setTextSpace(int textSpace) {
        this.textSpace = textSpace;
    }

    public void setContentHeight(float height) {
        mContentHeight = height;
        invalidate();
    }

    public MathExpressTextView(Context context){
        this(context,null);
    }
    public MathExpressTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
        setEditDesignTextInfos();
    }

    public MathExpressTextView setText(String text, String subText, String supText, float space) {
        this.TEXT_INFOS.clear();
        TextInfo.Builder tb = new TextInfo.Builder(text, mTextPaint, mSubTextPaint)
                .subText(subText)
                .supText(supText)
                .textSpace(space);

        this.TEXT_INFOS.add(tb.build());
        return this;
    }

    public MathExpressTextView appendMarkText(String text) {
        TextInfo.Builder tb = new TextInfo.Builder(text, mMarkTextPaint, mMarkTextPaint);
        this.TEXT_INFOS.add(tb.build());
        return this;
    }

    public MathExpressTextView appendText(String text, String subText, String supText, float space) {
        TextInfo.Builder tb = new TextInfo.Builder(text, mTextPaint, mSubTextPaint)
                .subText(subText)
                .supText(supText)
                .textSpace(space);

        this.TEXT_INFOS.add(tb.build());
        return this;
    }

    private void setEditDesignTextInfos() {

        if (!isInEditMode()) return;
//        setText("2H", "2", "", 10)
//				.appendMarkText("+");
//        		appendText("O", "2", "", 10);
//        		appendMarkText("=");
//        		appendText("2H", "2", "", 10);
//        		appendText("O", "", "", 10);

//		setText("sin(Θ+α)", "", "", 10)
//				.appendMarkText("=");
//		appendText("sinΘcosα", "", "", 10);
//		appendMarkText("+");
//		appendText("cosΘsinα", "", "", 10);

		setText("cos2Θ", "1", "", 10)
				.appendMarkText("=");
		appendText("cos", "", "2", 10);
		appendText("Θ", "1", "", 10);
		appendMarkText("-");
        appendText("sin", "", "2", 10);
        appendText("Θ", "1", "", 10);

    }
    public Paint getTextPaint() {
        return mTextPaint;
    }

    public Paint getSubTextPaint() {
        return mSubTextPaint;
    }

    public Paint getMarkTextPaint() {
        return mMarkTextPaint;
    }

    private float dpTopx(int dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
    }


    private void init() {

        mTextPaint = new Paint();
        mTextPaint.setColor(Color.WHITE);
        mTextPaint.setAntiAlias(true);
        mTextPaint.setStyle(Paint.Style.STROKE);

        mMarkTextPaint = new Paint();
        mMarkTextPaint.setColor(Color.WHITE);
        mMarkTextPaint.setAntiAlias(true);
        mMarkTextPaint.setStyle(Paint.Style.STROKE);

        mSubTextPaint = new Paint();
        mSubTextPaint.setColor(Color.WHITE);
        mSubTextPaint.setAntiAlias(true);
        mSubTextPaint.setStyle(Paint.Style.STROKE);

        setMaxTextSize(dpTopx(30));

    }


    private void setSubTextShader() {
        if (this.colors != null) {
            float textHeight = mSubTextPaint.descent() - mSubTextPaint.ascent();
            float textOffset = (textHeight / 2) - mSubTextPaint.descent();
            Rect bounds = new Rect();
            mSubTextPaint.getTextBounds("%", 0, 1, bounds);
            mSubTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f, 0,
                    mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
        } else {
            mSubTextPaint.setShader(null);
        }

    }

    private void setMarTextShader() {
        if (this.colors != null) {
            float textHeight = mMarkTextPaint.descent() - mMarkTextPaint.ascent();
            float textOffset = (textHeight / 2) - mMarkTextPaint.descent();
            Rect bounds = new Rect();
            mMarkTextPaint.getTextBounds("%", 0, 1, bounds);
            mMarkTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f, 0,
                    mContentHeight / 2 + textOffset - mMaxSize / 100 * 22f - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
        } else {
            mMarkTextPaint.setShader(null);
        }

    }

    private void setTextShader() {
        if (this.colors != null) {
            float textHeight = mTextPaint.descent() - mTextPaint.ascent();
            float textOffset = (textHeight / 2) - mTextPaint.descent();
            Rect bounds = new Rect();
            mTextPaint.getTextBounds("A", 0, 1, bounds);
            mTextPaint.setShader(new LinearGradient(0, mContentHeight / 2 + textOffset, 0, mContentHeight / 2 + textOffset - bounds.height(), colors, positions, Shader.TileMode.CLAMP));
        } else {
            mTextPaint.setShader(null);
        }
    }


    public void setColor(int unitColor, int numColor) {
        mSubTextPaint.setColor(unitColor);
        mTextPaint.setColor(numColor);
    }

    RectF contentRect = new RectF();

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        if (mContentWidth <= 0) {
            mContentWidth = getWidth();
        }
        if (mContentHeight <= 0) {
            mContentHeight = getHeight();
        }

        if (mContentWidth == 0 || mContentHeight == 0) return;

        setTextShader();
        setSubTextShader();
        setMarTextShader();

        if (TEXT_INFOS.size() == 0) return;

        int width = getWidth();
        int height = getHeight();


        contentRect.left = (width - mContentWidth) / 2f;
        contentRect.top = (height - mContentHeight) / 2f;
        contentRect.right = contentRect.left + mContentWidth;
        contentRect.bottom = contentRect.top + mContentHeight;

        int id = canvas.save();
        float centerX = contentRect.centerX();
        float centerY = contentRect.centerY();
        canvas.translate(centerX, centerY);

        contentRect.left = -centerX;
        contentRect.right = centerX;
        contentRect.top = -centerY;
        contentRect.bottom = centerY;


        float totalTextWidth = 0l;
        int textCount = TEXT_INFOS.size();

        for (int i = 0; i < textCount; i++) {
            totalTextWidth += TEXT_INFOS.get(i).getTextWidth();
            if (i < textCount - 1) {
                totalTextWidth += textSpace;
            }
        }

        drawGuideBaseline(canvas, contentRect, totalTextWidth);

        float startOffsetX = -(totalTextWidth) / 2f;
        for (int i = 0; i < textCount; i++) {
            TEXT_INFOS.get(i).draw(canvas, startOffsetX, contentRect.centerY());
            startOffsetX += TEXT_INFOS.get(i).getTextWidth() + textSpace;
        }

        canvas.restoreToCount(id);

    }

    private void drawGuideBaseline(Canvas canvas, RectF contentRect, float totalTextWidth) {

        if (!isInEditMode()) return;

        Paint guidelinePaint = new Paint();
        guidelinePaint.setAntiAlias(true);
        guidelinePaint.setStrokeWidth(0);
        guidelinePaint.setStyle(Paint.Style.FILL);

        RectF hline = new RectF();
        hline.top = -1;
        hline.bottom = 1;
        hline.left = -totalTextWidth / 2;
        hline.right = totalTextWidth / 2;
        canvas.drawRect(hline, guidelinePaint);

        RectF vline = new RectF();
        hline.left = -1;
        vline.top = contentRect.top;
        vline.bottom = contentRect.bottom;
        vline.right = 1;

        canvas.drawRect(vline, guidelinePaint);
    }


    private static class TextInfo {
        Paint subOrSupTextPaint = null;
        String subText = null;
        String supText = null;
        Paint textPaint = null;
        String text;
        float space;

        private TextInfo(String text, String subText, String supText, Paint textPaint, Paint subOrSupTextPaint, float space) {
            this.text = text;
            if (this.text == null) {
                this.text = "";
            }
            this.subText = subText;
            this.supText = supText;
            this.space = space;
            this.textPaint = textPaint;
            this.subOrSupTextPaint = subOrSupTextPaint;
        }

        public void draw(Canvas canvas, float startX, float startY) {

            if (this.textPaint == null) {
                return;
            }

            canvas.drawText(this.text, startX, startY + getTextPaintBaseline(this.textPaint), this.textPaint);

            if (this.subOrSupTextPaint == null) {
                return;
            }
            if (this.supText != null) {
                RectF rect = new RectF();
                rect.left = startX + space + getTextWidth(this.text, this.textPaint);
                rect.top = -getTextHeight(this.textPaint) / 2;
                rect.bottom = 0;
                rect.right = rect.left + getTextWidth(supText, this.subOrSupTextPaint);
                canvas.drawText(supText, rect.left, rect.centerY() + getTextPaintBaseline(this.subOrSupTextPaint), this.subOrSupTextPaint);
            }


            if (this.subText != null) {
                RectF rect = new RectF();
                rect.left = startX + space + getTextWidth(this.text, this.textPaint);
                rect.top = 0;
                rect.bottom = getTextHeight(this.textPaint) / 2;
                rect.right = rect.left + getTextWidth(subText, this.subOrSupTextPaint);
                canvas.drawText(subText, rect.left, rect.centerY() + getTextPaintBaseline(this.subOrSupTextPaint), this.subOrSupTextPaint);
            }

        }

        /**
         * 基线到中线的距离=(Descent+Ascent)/2-Descent
         * 注意,实际获取到的Ascent是负数。公式推导过程如下:
         * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
         */
        public static float getTextPaintBaseline(Paint p) {
            Paint.FontMetrics fontMetrics = p.getFontMetrics();
            return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
        }


        public float getTextWidth() {

            if (textPaint == null) {
                return 0;
            }

            float width = 0;

            width = getTextWidth(this.text, textPaint);

            float subTextWidth = 0;
            if (this.subText != null && subOrSupTextPaint != null) {
                subTextWidth = getTextWidth(this.subText, subOrSupTextPaint) + space;
            }

            float supTextWidth = 0;
            if (this.supText != null && subOrSupTextPaint != null) {
                supTextWidth = getTextWidth(this.supText, subOrSupTextPaint) + space;
            }
            return width + Math.max(subTextWidth, supTextWidth);
        }


        //获取文本最小宽度(真实宽度)
        private static int getTextRealWidth(String text, Paint paint) {
            if (TextUtils.isEmpty(text)) return 0;
            Rect rect = new Rect(); // 文字所在区域的矩形
            paint.getTextBounds(text, 0, text.length(), rect);
            //获取最小矩形,该矩形紧贴文字笔画开始的位置
            return rect.width();
        }

        //获取文本最小高度(真实高度)
        private static int getTextRealHeight(String text, Paint paint) {
            if (TextUtils.isEmpty(text)) return 0;
            Rect rect = new Rect(); // 文字所在区域的矩形
            paint.getTextBounds(text, 0, text.length(), rect);
            //获取最小矩形,该矩形紧贴文字笔画开始的位置
            return rect.height();
        }

        //真实宽度 + 笔画左右两侧间隙(一般绘制的的时候建议使用这种,左右两侧的间隙和字形有关)
        private static int getTextWidth(String text, Paint paint) {
            if (TextUtils.isEmpty(text)) return 0;
            return (int) paint.measureText(text);
        }

        //真实宽度 + 笔画上下两侧间隙(符合文本绘制基线)
        private static int getTextHeight(Paint paint) {
            Paint.FontMetricsInt fm = paint.getFontMetricsInt();
            int textHeight = ~fm.top - (~fm.top - ~fm.ascent) - (fm.bottom - fm.descent);
            return textHeight;
        }


        private static class Builder {

            Paint subOrSupTextPaint = null;
            Paint textPaint = null;
            String subText = null;
            String supText = null;
            String text;
            float space;

            public Builder(String text, Paint textPaint, Paint subOrSupTextPaint) {
                this.text = text;
                this.textPaint = textPaint;
                this.subOrSupTextPaint = subOrSupTextPaint;
            }

            public Builder subText(String subText) {
                this.subText = subText;
                return this;
            }

            public Builder supText(String supText) {
                this.supText = supText;
                return this;
            }

            public Builder textSpace(float space) {
                this.space = space;
                return this;
            }

            public TextInfo build() {
                return new TextInfo(text, this.subText, this.supText, this.textPaint, this.subOrSupTextPaint, this.space);
            }
        }

    }
    private int[] colors = new int[]{
            0xC0FFFFFF, 0x9fFFFFFF,
            0x98FFFFFF, 0xA5FFFFFF,
            0xB3FFFFFF, 0xBEFFFFFF,
            0xCCFFFFFF, 0xD8FFFFFF,
            0xE5FFFFFF, 0xFFFFFFFF};
    private float[] positions = new float[]{
            0f, 0.05f,
            0.3f, 0.4f,
            0.5f, 0.6f,
            0.7f, 0.8f,
            0.9f, 1f};

    public void setShaderColors(int[] colors) {
        this.colors = colors;
    }

    public void setShaderColors(int c) {
        this.colors = new int[]{c, c, c, c,
                c, c, c, c, c, c};
    }

}

3.4 使用

ini 复制代码
        MathExpressTextView m1 = findViewById(R.id.math_exp_1);
        MathExpressTextView m2 = findViewById(R.id.math_exp_2);
        MathExpressTextView m3 = findViewById(R.id.math_exp_3);
        MathExpressTextView m4 = findViewById(R.id.math_exp_4);


        m1.setShaderColors(0xffFF4081);
        m1.setText("2H","2","",10)
                .appendMarkText("+")
                .appendText("O","2","",10)
                .appendMarkText("=")
                .appendText("2H","2","",10)
                .appendText("O","","",10);

        m2.setShaderColors(0xffff9922);
        m2.setText("2","","2",10)
                .appendMarkText("+")
                .appendText("5","","-1",10)
                .appendMarkText("=")
                .appendText("4.2","","",10);

        m3.setShaderColors(0xffFFEAC4);
        m3.setText("H","2","0",10)
                .appendMarkText("+")
                .appendText("Cu","","+2",10)
                .appendText("O","","-2",10)
                .appendMarkText("==")
                .appendText("Cu","","0",10)
                .appendText("H","2","+1",10)
                .appendText("O","","-2",10);


        m4.setText("985","","GB",10)
                .appendMarkText("+")
                .appendText("211","","MB",10);

四、总结

相对来说本篇相对简单,没有过多复杂的计算。但是对于打算实现公式编辑器的项目,可参考本方案的设计思想:

  • 组合化:通过大公式,组合小公式,这样也方便使用语法树,提高通用性。
  • 对象化:单独描述单独片段
  • 规则化:对不同的片段进行规则化绘制,如appendMarkText方法
相关推荐
福大大架构师每日一题18 分钟前
文心一言 VS 讯飞星火 VS chatgpt (396)-- 算法导论25.2 1题
算法·文心一言
Myli_ing23 分钟前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
EterNity_TiMe_33 分钟前
【论文复现】(CLIP)文本也能和图像配对
python·学习·算法·性能优化·数据分析·clip
dr李四维40 分钟前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
机器学习之心44 分钟前
一区北方苍鹰算法优化+创新改进Transformer!NGO-Transformer-LSTM多变量回归预测
算法·lstm·transformer·北方苍鹰算法优化·多变量回归预测·ngo-transformer
yyt_cdeyyds1 小时前
FIFO和LRU算法实现操作系统中主存管理
算法
雯0609~1 小时前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ1 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z1 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
alphaTao1 小时前
LeetCode 每日一题 2024/11/18-2024/11/24
算法·leetcode