最近看了Canvas画图中的Path,不由感叹其功能之强大,同时脑海中也产生了很多飞舞的线条,近日将其整理出来,最终归结到一个游乐场中的四个项目:
1,摩天轮
2,滑梯
3,冲浪
4,射击
闲话少说,先上个图:
由于总体内容较多,下面只捡关键点进行说明:
一、摩天轮(FerrisWheel)
将摩天轮拆解,分为4部分:支架、辐条、外轮,以及靠在外轮上的人(这里我是简单的用字来表示了,或者你也可以理解为是游客说的话)。
这里要注意,支架是不动的,而另外3部分是运动的,所以在绘图时需要注意各部分的关联。
由于辐条、外轮、以及外轮上的字需要同步转动,这里采用了旋转画布来实现这个转动。
使用画布旋转时要注意,要先保存画布的状态,然后设置旋转角度:
canvas.save();
canvas.rotate(progressValue*360/100, circleCenter.x, circleCenter.y);
在完成旋转绘图后,要进行画布状态的恢复:
canvas.restore();
在轮上写字,我采用了 canvas.drawTextOnPath()方法,其原型如下:
void drawTextOnPath (String text, Path path, float hOffset, float vOffset, Paint paint)
通过调整第3个参数 hOffset 来调整文字沿着path的位置,使初始出现时文字在最底部(我们总是从摩天轮的底部登上去,然后才能体验高处的刺激)。
然后调整第四个参数 vOffset 来控制文字与线的垂直位置,使字既挨着线又能完整显示。
最后,还有一个问题,我写出来的文字,是朝圆外的,而我希望字是朝圆内的,怎么办?
原来在画圆的时候,还有个参数是表示绘制的方向的,我们可以指定是按顺时针方向绘制还是逆时针绘制。调整为逆时针方向绘制(使用参数 Path.Direction.CCW),文字方向就如我所愿了,如下:
path.addCircle(circleCenter.x, circleCenter.y, radius, Path.Direction.CCW);
具体实现如下:
package com.customview.view;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Shader;
import android.text.StaticLayout;
import android.graphics.Paint.Cap;
import android.graphics.Paint.FontMetrics;
import android.graphics.Paint.Style;
import android.graphics.Path;
import android.graphics.Point;
import android.graphics.PointF;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import com.customview.LogUtil;
import com.customview.R;
public class FerrisWheelView extends View
{
private Paint mPaint;//画笔
private int progressValue=0;//进度值
public FerrisWheelView(Context context, AttributeSet attrs)
{
this(context, attrs, 0);
}
public FerrisWheelView(Context context)
{
this(context, null);
}
public FerrisWheelView(Context context, AttributeSet attrs, int defStyle)
{
super(context, attrs, defStyle);
mPaint = new Paint();
progressValue=0;
}
public void setProgressValue(int progressValue){
this.progressValue = progressValue;
postInvalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int mWidth = getMeasuredWidth();
int mHeight = getMeasuredHeight();
//按比例计算View各部分的值
float unit = Math.min(((float)mWidth)/300, ((float)mHeight)/300);
float lineWidth = 5*unit;//线粗
//初始化画笔
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth((float) lineWidth );
mPaint.setStyle(Style.STROKE);
mPaint.setStrokeCap(Cap.ROUND);
mPaint.setShader(null);
mPaint.setColor(Color.RED);
//准备上变化的字
String text ;
if(progressValue%100 < 100/4){
text="哦";
} else if(progressValue%100 < 100/2){
text="耶";
} else if(progressValue%100 < 100*3/4){
text="哇";
} else {
text="噢";
}
Path path = new Path();
path.reset();
int radius = mWidth/2*4/5;//设置半径
PointF circleCenter = new PointF(mWidth/2, mHeight/2 );//中心点
mPaint.setColor(Color.BLACK);
mPaint.setStrokeWidth(2*unit );
//支架
for(int i=1;i<3;i++){
canvas.drawLine(circleCenter.x, circleCenter.y,
circleCenter.x-radius*i/10, circleCenter.y+radius*11/10,
mPaint);
canvas.drawLine(circleCenter.x, circleCenter.y,
circleCenter.x+radius*i/10, circleCenter.y+radius*11/10,
mPaint);
}
canvas.drawLine(circleCenter.x-radius*2/4, circleCenter.y+radius*11/10,
circleCenter.x+radius*2/4, circleCenter.y+radius*11/10,
mPaint);
//进行画布旋转,影响后面的绘制
canvas.save();
canvas.rotate(progressValue*360/100, circleCenter.x, circleCenter.y);
//轮辐
mPaint.setColor(Color.LTGRAY);
mPaint.setStrokeWidth(1*unit );
for(int i=0;i<12;i++){
float offsetX = radius*(float)Math.sin(3.14159*i/12);//3.14 360
float offsetY = radius*(float)Math.cos(3.14159*i/12);
canvas.drawLine(circleCenter.x-offsetX, circleCenter.y-offsetY,
circleCenter.x+offsetX, circleCenter.y+offsetY,
mPaint);
}
//外轮
mPaint.setColor(Color.RED);
mPaint.setStrokeWidth((float) lineWidth );
path.addCircle(circleCenter.x, circleCenter.y, radius, Path.Direction.CCW);//顺时针 CW CCW
canvas.drawPath(path, mPaint);
//文字
mPaint.setTextSize(20*unit);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawTextOnPath(text, path, radius*3*(float)1.57-20, -15, mPaint);
//旋转效果取消
canvas.restore();
}
}
二、滑梯(Sliding)
将滑梯分解开,是一个Path,由四条线组成:滑梯的曲面,另外3条直线进行连接。
这个曲线,这里采用了3阶贝塞尔曲线,通过调整两个控制点,就画出了一条平滑的曲线。
怎么让文字沿着这条曲线进行运动?
动态调整drawTextOnPath()的第3个参数 hOffset 来调整文字沿着path的位置。
另外还要考虑到滑动的速度是逐渐增加的,这就是调整 hOffset 变化的速度了。
还有个核心问题,我希望文字运行到曲线的终点就停下来。可是,哪里是曲线的终点呢?换个问题,曲线的长度是多少?
这真是个麻烦的问题呢,我的数学学得不好,要是谷歌提供个方法能获取曲线长度就好了。上网搜了一下,原来谷歌早就考虑到了我们的需求,提供了一个便利的途径:使用PathMeasure。
获取曲线的长度很简单,两句话搞定:
PathMeasure pathMeasure = new PathMeasure(path, false);
float length = pathMeasure.getLength();
PathMeasure(path, false)中第一个参数好理解,就是路径,第二个参数,是指这个路径是否闭合。
具体实现如下:
package com.customview.view;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.Cap;
import android.graphics.Paint.Style;
import android.graphics.Path;
import android.graphics.PathMeasure;
import android.graphics.PointF;
import android.util.AttributeSet;
import android.view.View;
import com.customview.LogUtil;
public class SlidingView extends View
{
private Paint mPaint;//画笔
private int progressValue=0;//进度值
private float offsetTextH=0;
public SlidingView(Context context, AttributeSet attrs)
{
this(context, attrs, 0);
}
public SlidingView(Context context)
{
this(context, null);
}
public SlidingView(Context context, AttributeSet attrs, int defStyle)
{
super(context, attrs, defStyle);
mPaint = new Paint();
progressValue=0;
}
public void setProgressValue(int progressValue){
//进度重置时,字的位置也重置
if(progressValue==0){
offsetTextH=0;
}
this.progressValue = progressValue;
postInvalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int mWidth = getMeasuredWidth();
int mHeight = getMeasuredHeight();
//按比例计算View各部分的值
float unit = Math.min(((float)mWidth)/300, ((float)mHeight)/300);
float lineWidth = 5*unit;//线粗
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth((float) lineWidth );
mPaint.setStyle(Style.STROKE);
mPaint.setStrokeCap(Cap.ROUND);
mPaint.setShader(null);
mPaint.setColor(Color.RED);
String text = "滑";
Path path = new Path();
//画出滑梯的曲线
PointF start, control1, control2, end;
start = new PointF(10, 200);
end = new PointF(mWidth-10, mHeight-100);
control1 = new PointF((start.x+end.x)/2-50, start.y);
control2 = new PointF((start.x+end.x)/2+50, end.y);
path.reset();
path.moveTo(start.x, start.y);
path.cubicTo(control1.x, control1.y, control2.x, control2.y, end.x, end.y);
//获取曲线的长度
PathMeasure pathMeasure = new PathMeasure(path, false);
float length = pathMeasure.getLength();
//画出3条连接线,将区域封闭,填充颜色
PointF end1 = new PointF(end.x, mHeight-10);
PointF end2 = new PointF(start.x, end1.y);
path.lineTo(end1.x, end1.y);
path.lineTo(end2.x, end2.y);
path.lineTo(start.x, start.y);
mPaint.setColor(0xff478EEB);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth((float) lineWidth );
canvas.drawPath(path, mPaint);
//沿曲线写字,其位置动态变化
float acceleration=0;
LogUtil.logWithMethod(new Exception(),"progressValue="+progressValue);
if(progressValue>20){
acceleration = ((float)progressValue-20)/10;
LogUtil.logWithMethod(new Exception(),"acceleration="+acceleration);
}
offsetTextH += 3*(1+acceleration);
if(offsetTextH>(length-40)){
offsetTextH=length-40;
}
LogUtil.logWithMethod(new Exception(),"offsetTextH="+offsetTextH);
mPaint.setStrokeWidth(1*unit);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.BLACK);
mPaint.setTextSize(18*unit);
canvas.drawTextOnPath(text, path, offsetTextH, -2, mPaint);
}
}
三、冲浪(Surfing)
这个画面与滑梯的画面看起来比较类似,最初,我的想法也是用一个3阶贝塞尔曲线,通过动态调整起点、终点、控制点,来模拟波浪的起伏效果,但是效果总是不太好,特别是对于波峰的移动,没有找到办法来实现。
后来参考网上的方法,使用了四个二阶贝塞尔曲线来实现,两个在屏幕中显示,两个在屏幕之外,通过整体的移动,从而实现波浪的起伏效果。
另外还有一个问题,就是文字的位置如何控制?
我们可以通过PathMeasure的getLength()方法,获取4个贝塞尔曲线的总长度,但是随着曲线的整体移动,怎么保证文字在画面的中央呢?
肯定是调整drawTextOnPath()的第3个参数 hOffset 来调整文字沿着path的位置,具体如何设置呢?
我开始是这样设置的:
hOffset=length*3/4 - start.x;
说明一下,length是测量的曲线总长度,其左边的一半,在初始状态,是在屏幕之外的左侧,其右边的一半,是恰好完整显示在屏幕中的。所以其3/4位置,就是屏幕的中央。
start.x,是显示在屏幕上的左侧那个贝塞尔曲线的起点的x坐标。
看运行效果,文字基本居中,随波浪起伏,可是有个小问题,就是完成一次整屏幕的移动后,进行画面回归初始状态,准备开始下一轮移动的时刻,发现文字有一个明显的跳动移位。
不知道聪明的你有没有猜到问题出在哪里。
我是后来经过打印数据分析,发现是我没有区分清楚length与start.x的意义,从而导致的问题。
length是曲线长度,start.x是坐标。若计算4条贝塞尔曲线的x坐标的跨度,是2倍的屏幕宽度,而曲线长度,肯定是要大于2倍屏幕宽度的(两点之间,直线最短)。
所以,直接使用x坐标是不行的,我就将坐标与长度按照线性的比例修改了,如下:
hOffset=length*3/4 - length*start.x/(2*mWidth);
最终发现效果是非常好的,文字没有了那个切换时的跳动。
其实仔细分析的话,曲线与坐标,肯定不是线性的,只是切换时刻,恰好是移动了一个屏幕的距离,而这正好对应length的一半,从而实现平滑切换。
具体实现如下:
package com.customview.view;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Shader;
import android.text.StaticLayout;
import android.graphics.Paint.Cap;
import android.graphics.Paint.FontMetrics;
import android.graphics.Paint.Style;
import android.graphics.Path;
import android.graphics.PathMeasure;
import android.graphics.PointF;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import com.customview.LogUtil;
import com.customview.R;
public class SurfingView extends View
{
private Paint mPaint;//画笔
private int progressValue=0;//进度值
private float offsetHorizontal=0,offsetVertical=0;
public SurfingView(Context context, AttributeSet attrs)
{
this(context, attrs, 0);
}
public SurfingView(Context context)
{
this(context, null);
}
public SurfingView(Context context, AttributeSet attrs, int defStyle)
{
super(context, attrs, defStyle);
mPaint = new Paint();
progressValue=0;
}
public void setProgressValue(int progressValue){
this.progressValue = progressValue;
postInvalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int mWidth = getMeasuredWidth();
int mHeight = getMeasuredHeight();
//按比例计算View各部分的值
float unit = Math.min(((float)mWidth)/300, ((float)mHeight)/300);
float lineWidth = 5*unit;//线粗
mPaint.setAntiAlias(true);
mPaint.setStyle(Style.STROKE);
mPaint.setStrokeWidth((float) lineWidth );
mPaint.setColor(Color.RED);
String text = "冲浪";
Path path = new Path();
//水平方向移动的偏移量,渐变
if(offsetHorizontal>=mWidth){
offsetHorizontal = 0;
} else {
offsetHorizontal+=30;
}
//垂直方向的幅度值,固定
offsetVertical = mHeight*1/12;//1/10
//画出左侧的波浪线
PointF startLeft, controlLeft1, controlLeft2, endLeft;
startLeft = new PointF(-mWidth+offsetHorizontal, (mHeight*2/5));
endLeft = new PointF(0+offsetHorizontal, startLeft.y);//start.y
controlLeft1 = new PointF(startLeft.x+(endLeft.x-startLeft.x)*1/4, startLeft.y-offsetVertical);
controlLeft2 = new PointF(startLeft.x+(endLeft.x-startLeft.x)*3/4, endLeft.y+offsetVertical);
path.reset();
path.moveTo(startLeft.x, startLeft.y);
path.quadTo(controlLeft1.x, controlLeft1.y, (startLeft.x+endLeft.x)*1/2, (startLeft.y+endLeft.y)*1/2);
path.quadTo(controlLeft2.x, controlLeft2.y, endLeft.x, endLeft.y);
//画出波浪线
PointF start, control1, control2, end;
start = new PointF(0+offsetHorizontal, (mHeight*2/5));
end = new PointF(mWidth+offsetHorizontal, start.y);//start.y
control1 = new PointF(start.x+(end.x-start.x)*1/4, start.y-offsetVertical);
control2 = new PointF(start.x+(end.x-start.x)*3/4, end.y+offsetVertical);
path.quadTo(control1.x, control1.y, (start.x+end.x)*1/2, (start.y+end.y)*1/2);
path.quadTo(control2.x, control2.y, end.x, end.y);
//获取波浪线的长度
PathMeasure pathMeasure = new PathMeasure(path, false);
float length = pathMeasure.getLength();
//再画出3条连接线
PointF end1 = new PointF(end.x, mHeight);
PointF end2 = new PointF(startLeft.x, end1.y);
path.lineTo(end1.x, end1.y);
path.lineTo(end2.x, end2.y);
path.lineTo(startLeft.x, startLeft.y);
//波浪线与3条连接线,形成封闭区域,填充颜色
mPaint.setColor(Color.BLUE);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth((float) lineWidth );
canvas.drawPath(path, mPaint);
//写字,其位置逐渐移动
float offsetTextH=length-200-progressValue;
if(offsetTextH<200){
offsetTextH=200;
}
offsetTextH=length*3/4 - length*start.x/(2*mWidth);
// LogUtil.logWithMethod(new Exception(),"offsetTextH="+offsetTextH+" start.x="+start.x);
mPaint.setStrokeWidth(1*unit);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.BLACK);
mPaint.setTextSize(18*unit);
canvas.drawTextOnPath(text, path, offsetTextH, -2, mPaint);
}
}
相关源码地址:
http://download.youkuaiyun.com/detail/lintax/9646000