Android 自定义液晶体数字

842 阅读2分钟

一、前言

液晶体数字实际上有很多用途,特别是智能手表的普及,为了美观需要,往往要自定义一些数字风格。

实际上,本篇可以看作一种自定义字体的文章,在开发中,因为字体本身的设计与本篇大差不差,只不过通常意义上的自己使用了更多贝塞尔曲线,而本篇仅仅使用了普通的Path设计。

效果预览:

电子表数字 ui 样式

下图是手表案例

二、字体原理

2.1 常用字体原理

作为矢量图形,字体本身就是预制好的图形文件集合,任何一个文字或者数字本身就是定型的,只不过必要时放大或者缩小以适应显示要求。

这里我们通过定义液晶体的方式,希望对开发者启发,或许后续我们没必要去引入字体文件,同样也可以实现字体的展示,当然这里也仅仅是抛砖引玉。

通常意义上来说,常用的希腊字符

ƒ、Θ、Ψ、ϑ、∑、∩、∫、⊄、≈、Π ...

这些可以随便打出来的字符,Android系统本身也是支持的,当然我们也可直接从下面网址中复制即可,但是对于特殊的表达式,比如求和公式等,需要专门去进行绘制

htmlhelp.com/reference/h…

当然,常用的字体中融合了大量的贝塞尔曲线公式,比如下图的文字效果

本篇我们通过定义数字,未来实际上可以通过类似的原理实现特殊的文字。

2.2 点阵字体原理

不同与液晶体和一般字体,点阵体有个明显的缺陷就是,不支持缩放,其展示是固定的。不过。在我之前的一篇博客中,通过《Android 实现LED 展示效果》的方式,是可以把普通字体转为点阵体,可以达到缩放的目的,不过如果要真正使用,细节部分还需要一些调整。

2.3 液晶体字体原理

液晶体字体实际上也是矢量的,不同于LED点阵效果,本篇也会使用大量的Path去实现笔划,但我们这里并不涉及贝塞尔曲线,但是如果要设计特殊的字体,贝塞尔曲线的知识是必须要掌握的。

2.4 为什么要使用Path

我们上面说了,液晶体会使用Path实现大量笔划,但是为什么要这么做呢,重要的原因是Path具备矢量性质,因为其可以进行缩放,而不会出现变形,不过本篇没有利用这种特性,因为要设计字体,需要统一化字体显示的区域大小。

Path#transform方法

public void transform(@NonNull Matrix matrix) {    
    isSimplePath = false;    
    nTransform(mNativePath, matrix.native_instance);
}

三、液晶体实现步骤

3.1 实现思路

如果是单独的字体,往往都是紧凑在一起的,同样数字也一样,每个数字之间需要保留一定的padding,当然术语不叫padding。

实际上,任何字体都是具备“笔划”的,特别是非拉丁字母,通常是多个”笔划“组成,这里我们要拆分“笔划”。这里我们以数字“8”作为完整基准,对笔画拆分。

3.2 利用二进制映射

为了实现更好的可扩展性,我们这里不写死每个笔画,而是对笔画进行编码,在java中,二进制的表示形式是0B开头。

液晶体相对简单,我们只需要把不同的笔划组合起来即可,最终形成字体映射。

   private void initStrokePath() {
           
//对笔划进行编码
            StrokePath[] paths = {
                    getLeftTopLine(),    //0b0000001   //8字左上笔划
                    getTopLine(),        //0b0000010    //8字顶部笔划
                    getRightTopLine(),   //0b0000100   //8字右上笔划
                    getRightBottomLine(),//0b0001000 //8字右下笔划
                    getBottomLine(),     //0b0010000 //8字底部笔划
                    getLeftBottomLine(), //0b0100000  //8字左下笔划
                    getCenterLine()      //0b1000000  // 8字中间笔划
            };

            for(int i=0;i<paths.length;i++){
                paths[i].flag = 1 << i;
                strokePaths.add(paths[i]);
            }

        }

3.2 将笔划组合为数字

为了完整表示文字,我们需要将文字组合为数字,基本上是二进制的计算,当然,如果你想定义更多字体,使用十六进制也是可以的。

//建立数字
            numberTables.put(-1,0b1000000);  // “负数符号”
            numberTables.put(0,0b0111111);   //数字0
            numberTables.put(1,0b0001100);    //数字1
            numberTables.put(2,0b1110110);   //数字2
            numberTables.put(3,0b1011110);  //数字3
            numberTables.put(4,0b1001101);  //数字4
            numberTables.put(5,0b1011011);  //数字5
            numberTables.put(6,0b1111011);  // 数字6
            numberTables.put(7,0b0001110);  //数字7
            numberTables.put(8,0b1111111);  //数字8
            numberTables.put(9,0b1011111);  //数字9

3.3 通过数字读取笔划

上面我们通过笔划组合了数字,接下来我们需要将每种数字对应的笔画映射的壁画组合获取到。下面是要通过数字将笔划读取出来方法,这样可以使得我们更加方便渲染。

 List<StrokePath> getStorkePaths(int num){
        Integer integer = numberTables.get(num);
        if(integer==null) return null;

        List<StrokePath> strokeNumPaths = new ArrayList<>();
        for (int i=0;i<strokePaths.size();i++){
             
            StrokePath sp = strokePaths.get(i);
             if((sp.flag & integer) != 0){  //判断笔划是否在组合中
                  strokeNumPaths.add(sp);
             }

         }

         if(strokeNumPaths.isEmpty()){
                return null;
         }
        return strokeNumPaths;
 }

以上就是基本逻辑了,整个流程就是对笔画进行组合、编码和提取。

3.4 绘制字体

绘制字体的逻辑就是位运算了,strokeIsInPath用来匹配是否对区域绘制黑色,基本都是Canvas与Path相关的操作。

        List<StrokePath> strokeNumberPaths =  numberStroke.getStorkePaths(number);

        if(strokeNumberPaths==null) return;

        List<StrokePath> strokePaths  =  numberStroke.getStrokePaths();

        int restoreId = canvas.save();
        canvas.translate(w/2,h/2);

        for (int i=0;i<strokePaths.size();i++){
            StrokePath sp = strokePaths.get(i);

            if(strokeIsInPath(sp,strokeNumberPaths)){
                mPaint.setColor(Color.DKGRAY);
                canvas.drawPath(sp,mPaint);
            }else{
                mPaint.setColor(0xeeeeeeee);
                canvas.drawPath(sp,mPaint);
            }
        }

        canvas.restoreToCount(restoreId);

3.5 一些扩展

本篇,我们了解了字体绘制方法,实际上我们可以通过Path定义一些我们需要的中文字体,或者尝试使用笔画进行组合。

四、完整逻辑

4.1 完整代码

下面是完整的实现逻辑,基本上是Canvas绘制那一套流程。这里要注意,我们实现的View只能展示单个数字,为什么这么做呢?主要原因是为了提高View自身的灵活性。

public class ClockNumberView extends View {
    private DisplayMetrics displayMetrics;
    private TextPaint mPaint;
    private float lineWidth = 0l;
    private int number = 8;
    private NumberStroke numberStroke;

    public ClockNumberView(Context context) {
        this(context, null);
    }

    public ClockNumberView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ClockNumberView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        displayMetrics = context.getResources().getDisplayMetrics();
        initPaint();

        if (isInEditMode()) {
            number = 8;
        }
    }

    public void setNumber(int number) {
        if (number < -1 || number > 10) {
            throw new IllegalArgumentException("number should be between -1 and 10,current value " + number);
        }
        this.number = number;
        if (Looper.myLooper() == Looper.getMainLooper()) {
            invalidate();
        } else {
            postInvalidate();
        }
    }

    public void setLineWidth(float lineWidth) {
        this.lineWidth = lineWidth;
        postInvalidate();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (numberStroke == null) {
            numberStroke = new NumberStroke(w, h, lineWidth);
            return;
        }

        if (numberStroke.shouldResize(w, h, lineWidth)) {
            numberStroke.update(w, h, lineWidth);
        }
    }

    private void initPaint() {
        // 实例化画笔并打开抗锯齿
        mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setAntiAlias(true);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setStrokeCap(Paint.Cap.SQUARE);
        mPaint.setDither(true);
        lineWidth = dpTopx(6);
    }

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


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            widthSize = displayMetrics.widthPixels / 3;
        }

        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (heightMode != MeasureSpec.EXACTLY) {
            heightSize = displayMetrics.widthPixels / 3;
        }

        setMeasuredDimension(widthSize, heightSize);
    }


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

        int w = getWidth();
        int h = getHeight();

        if (w == 0 || h == 0) return;
        if (numberStroke == null) return;

        if (numberStroke.shouldResize(w, h, lineWidth)) {
            numberStroke.update(w, h, lineWidth);
        }

        List<StrokePath> strokeNumberPaths = numberStroke.getStorkePaths(number);
        if (strokeNumberPaths == null) return;
        List<StrokePath> strokePaths = numberStroke.getStrokePaths();

        int restoreId = canvas.save();
        canvas.translate(w / 2f, h / 2f);

        for (int i = 0; i < strokePaths.size(); i++) {
            StrokePath sp = strokePaths.get(i);
            if (strokeIsInPath(sp, strokeNumberPaths)) {
                mPaint.setColor(Color.DKGRAY);
                canvas.drawPath(sp, mPaint);
            } else {
                mPaint.setColor(0xeeeeeeee);
                canvas.drawPath(sp, mPaint);
            }
        }
        canvas.restoreToCount(restoreId);
    }

    private boolean strokeIsInPath(StrokePath sp, List<StrokePath> strokeNumberPaths) {
        if (strokeNumberPaths == null || strokeNumberPaths.size() == 0) {
            return false;
        }
        for (int i = 0; i < strokeNumberPaths.size(); i++) {
            if (strokeNumberPaths.get(i) == sp) {
                return true;
            }
        }
        return false;
    }


    public static class NumberStroke {

        private int width;
        private int height;
        private float xLineLength = 0;
        private float yLineLength = 0;
        private float lineWidth = 0l;
        private final SparseArray<Integer> numberTables = new SparseArray<>();
        private final List<StrokePath> strokePaths = new ArrayList<StrokePath>();

        public NumberStroke(int width, int height, float lineWidth) {
            this.update(width, height, lineWidth);

        }

        List<StrokePath> getStorkePaths(int num) {
            Integer integer = numberTables.get(num);
            if (integer == null) return null;

            List<StrokePath> strokeNumPaths = new ArrayList<>();
            for (int i = 0; i < strokePaths.size(); i++) {
                StrokePath sp = strokePaths.get(i);
                if ((sp.flag & integer) != 0) {
                    strokeNumPaths.add(sp);
                }

            }

            if (strokeNumPaths.isEmpty()) {
                return null;
            }
            return strokeNumPaths;
        }

        private void initStrokePath() {
            strokePaths.clear();

            StrokePath[] paths = {
                    getLeftTopLine(),    //0b0000001
                    getTopLine(),        //0b0000010
                    getRightTopLine(),   //0b0000100
                    getRightBottomLine(),//0b0001000
                    getBottomLine(),     //0b0010000
                    getLeftBottomLine(), //0b0100000
                    getCenterLine()      //0b1000000
            };

            for (int i = 0; i < paths.length; i++) {
                paths[i].flag = 1 << i;
                strokePaths.add(paths[i]);
            }

            numberTables.put(-1, 0b1000000);
            numberTables.put(0, 0b0111111);
            numberTables.put(1, 0b0001100);
            numberTables.put(2, 0b1110110);
            numberTables.put(3, 0b1011110);
            numberTables.put(4, 0b1001101);
            numberTables.put(5, 0b1011011);
            numberTables.put(6, 0b1111011);
            numberTables.put(7, 0b0001110);
            numberTables.put(8, 0b1111111);
            numberTables.put(9, 0b1011111);

        }


        private StrokePath getLeftTopLine() {
            StrokePath path = new StrokePath();
            path.moveTo(-xLineLength / 2, -yLineLength + lineWidth / 2);
            path.lineTo(-xLineLength / 2, -lineWidth / 2);
            path.lineTo(-xLineLength / 2 + lineWidth, -lineWidth);
            path.lineTo(-xLineLength / 2 + lineWidth, -yLineLength + lineWidth + lineWidth / 2);
            path.close();

            return path;
        }

        private StrokePath getLeftBottomLine() {
            StrokePath path = new StrokePath();
            path.moveTo(-xLineLength / 2, yLineLength - lineWidth / 2);
            path.lineTo(-xLineLength / 2, lineWidth / 2);
            path.lineTo(-xLineLength / 2 + lineWidth, lineWidth);
            path.lineTo(-xLineLength / 2 + lineWidth, yLineLength - lineWidth - lineWidth / 2);
            path.close();

            return path;
        }


        private StrokePath getTopLine() {
            StrokePath path = new StrokePath();
            path.moveTo(-xLineLength / 2, -yLineLength);
            path.lineTo(xLineLength / 2, -yLineLength);
            path.lineTo(xLineLength / 2 - lineWidth, -yLineLength + lineWidth);
            path.lineTo(-xLineLength / 2 + lineWidth, -yLineLength + lineWidth);
            path.close();

            return path;
        }


        private StrokePath getCenterLine() {

            StrokePath path = new StrokePath();
            path.moveTo(-xLineLength / 2, 0);
            path.lineTo(-xLineLength / 2 + lineWidth, -lineWidth / 2);
            path.lineTo(xLineLength / 2 - lineWidth, -lineWidth / 2);

            path.lineTo(xLineLength / 2, 0);
            path.lineTo(xLineLength / 2 - lineWidth, lineWidth / 2);
            path.lineTo(-xLineLength / 2 + lineWidth, lineWidth / 2);

            path.close();

            return path;
        }

        private StrokePath getBottomLine() {
            StrokePath path = new StrokePath();
            path.moveTo(-xLineLength / 2, yLineLength);
            path.lineTo(xLineLength / 2, yLineLength);
            path.lineTo(xLineLength / 2 - lineWidth, yLineLength - lineWidth);
            path.lineTo(-xLineLength / 2 + lineWidth, yLineLength - lineWidth);
            path.close();

            return path;
        }


        private StrokePath getRightBottomLine() {
            StrokePath path = new StrokePath();
            path.moveTo(xLineLength / 2, yLineLength - lineWidth / 2);
            path.lineTo(xLineLength / 2, lineWidth / 2);
            path.lineTo(xLineLength / 2 - lineWidth, lineWidth);
            path.lineTo(xLineLength / 2 - lineWidth, yLineLength - lineWidth - lineWidth / 2);
            path.close();

            return path;
        }


        private StrokePath getRightTopLine() {
            StrokePath path = new StrokePath();
            path.moveTo(xLineLength / 2, -yLineLength + lineWidth / 2);
            path.lineTo(xLineLength / 2, -lineWidth / 2);
            path.lineTo(xLineLength / 2 - lineWidth, -lineWidth);
            path.lineTo(xLineLength / 2 - lineWidth, -yLineLength + lineWidth + lineWidth / 2);
            path.close();

            return path;
        }


        public void update(int w, int h, float lineWidth) {
            this.width = w;
            this.height = h;

            this.xLineLength = w;
            this.yLineLength = h / 2;
            this.lineWidth = lineWidth;

            initStrokePath();
        }


        public boolean shouldResize(int w, int h, float lineWidth) {

            return w != this.width || h != this.height || lineWidth != this.lineWidth;
        }

        public List<StrokePath> getStrokePaths() {
            return strokePaths;
        }
    }

    static class StrokePath extends Path {
        private int flag = 0;

        public void setFlag(int flag) {
            this.flag = flag;
        }

        public int getFlag() {
            return flag;
        }
    }

}

4.2 使用方式

其实使用方式很简单,我们每个数字对应一个View,这里我们定义布局,通过两个View展示,从而达到开头的效果。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center_horizontal"
    android:id="@+id/clock_number_panel"
    android:padding="10dp"
    >

    <com.android.m3d.camera.ClockNumberView
        android:layout_margin="5dp"
        android:layout_width="80dp"
        android:layout_height="wrap_content" />
    <com.android.m3d.camera.ClockNumberView
        android:layout_margin="5dp"
        android:layout_width="80dp"
        android:layout_height="wrap_content" />


</LinearLayout>

下面是测试代码

我们用动画来实现数字变动

    ValueAnimator  animator = null;
    public void show(){
             if(animator!=null){
                animator.cancel();
            }
            clockPanel = findViewById(R.id.clock_number_panel);

            final ClockNumberView firstNum = (ClockNumberView) clockPanel.getChildAt(0);
            final ClockNumberView secondNum = (ClockNumberView) clockPanel.getChildAt(1);

            animator =  ValueAnimator.ofInt(-9,99).setDuration(20000);
            animator.setInterpolator(new LinearInterpolator());
            animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    Integer i = (Integer) animation.getAnimatedValue();
                    if(i==null){
                        i = 0;
                    }

                    if(i<0){
                        firstNum.setNumber(-1);
                        secondNum.setNumber(Math.abs(i));
                        return;
                    }

                    if(i<10){
                        firstNum.setNumber(0);
                        secondNum.setNumber(i);
                        return;
                    }

                    if(i>10){
                        firstNum.setNumber(i/10);
                        secondNum.setNumber(i-i/10*10);
                        return;
                    }

                }
            });
            animator.start();    
}

以上就是完整代码逻辑了,目前手表开发中会经常遇到这种UI,另外对于需要自定义字体的需求,本篇也提供了思路,因此掌握这种开发技能也是必要的。

五、总结

本篇到这里就结束了,从本篇我们可以了解到,字体实现的一些逻辑,如贝塞尔曲线方式、点阵字体等。本篇通过Path用于字体笔画编码,组合等方法,很容易实现了液晶体数字,当然如果实现更复杂的字体,矢量化方向我们可以使用Path,但是美观方面需要熟练掌握杯赛尔取消。