一、前言
液晶体数字实际上有很多用途,特别是智能手表的普及,为了美观需要,往往要自定义一些数字风格。
实际上,本篇可以看作一种自定义字体的文章,在开发中,因为字体本身的设计与本篇大差不差,只不过通常意义上的自己使用了更多贝塞尔曲线,而本篇仅仅使用了普通的Path设计。
效果预览:
电子表数字 ui 样式
下图是手表案例
二、字体原理
2.1 常用字体原理
作为矢量图形,字体本身就是预制好的图形文件集合,任何一个文字或者数字本身就是定型的,只不过必要时放大或者缩小以适应显示要求。
这里我们通过定义液晶体的方式,希望对开发者启发,或许后续我们没必要去引入字体文件,同样也可以实现字体的展示,当然这里也仅仅是抛砖引玉。
通常意义上来说,常用的希腊字符
ƒ、Θ、Ψ、ϑ、∑、∩、∫、⊄、≈、Π ...
这些可以随便打出来的字符,Android系统本身也是支持的,当然我们也可直接从下面网址中复制即可,但是对于特殊的表达式,比如求和公式等,需要专门去进行绘制
当然,常用的字体中融合了大量的贝塞尔曲线公式,比如下图的文字效果
本篇我们通过定义数字,未来实际上可以通过类似的原理实现特殊的文字。
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,但是美观方面需要熟练掌握杯赛尔取消。