本文是Mr.Huang同學投稿的一篇文章,也是本公衆号接受的第一篇觀衆投稿。再此非常感謝Mr.Huang同學。也歡迎其他同學踴躍投稿!這篇文章可讀性高,非常值得一讀。因為,我們知道随着Android的發展,對android開發者的要求也越來越高,公司和使用者都希望開發出酷炫的界面和動畫。而要想實作很多酷炫的界面和動畫,Bezier曲線是基石。是以,快來分享和收藏吧!
前端時間公司項目中有用到Bezier曲線的知識,在熟悉Bezier曲線原理和實作方式後,我突發奇想在Android用戶端實作Bezier曲線的建構動畫,于是有了BezierMaker這個項目。在講解代碼前,我們先了解一下Bezier曲線的原理。 項目源碼:https://github.com/venshine/BezierMaker
什麼是Bezier曲線?
貝塞爾曲線于1962年,由法國工程師皮埃爾·貝塞爾(Pierre Bézier)所廣泛發表,他運用貝塞爾曲線來為汽車的主體進行設計。貝塞爾曲線最初由Paul de Casteljau于1959年運用de Casteljau算法開發,以穩定數值的方法求出貝塞爾曲線。
Bezier曲線的原理
Bezier曲線是用一系列點來控制曲線狀态的,我們将這些點簡單分為兩類:
- 資料點:确定曲線的起始和結束位置
- 控制點:确定曲線的彎曲程度
線性Bezier曲線
給定資料點P0、P1,線性Bezier曲線隻是一條兩點之間的直線。
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLi0zaHRGcWdUYuVzVa9GczoVdG1mWfVGc5RHLwIzX39GZhh2csATMflHLwEzX4xSZz91ZsAzMfRHLGZkRGZkRfJ3bs92YskmNhVTYykVNQJVMRhXVEF1X0hXZ0xiNx8VZ6l2cssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49jZpdmLxUGN2cTZ3QjZlhDZ1kjNjlTM1EGZ0MzN2UjNlJzM5YmNjdDZ4EzLcRDMyIDMy8CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.gif)
二階Bezier曲線
二階Bezier曲線的路徑由資料點 P 0、P 2和控制點P 1的函數B(t)追蹤。
為建構二階Bezier曲線,可以中介點Q 0和Q 1作為由0至1的t: 由P 0至P 1的連續點Q0,描述一條線性Bezier曲線。 由P 1至P 2的連續點Q 1,描述一條線性Bezier曲線。 由Q 0至Q 1的連續點B(t),描述一條二階Bezier曲線。
三階Bezier曲線
P 0、P 1、P 2、P 3四個點在平面或在三維空間中定義了三階Bezier曲線。曲線起始于P0走向P 1,并從P 2的方向來到P 3。一般不會經過P 1或P 2;這兩個點隻是在那裡提供方向。P 0和P 1之間的間距,決定了曲線在轉而趨進P 2之前,走向P 1方向的“長度有多長”。
高階Bezier曲線
N 階貝塞爾曲線可如下推斷。給定點P 0、P 1、…、P n,其Bezier曲線即
上面的公式可用如下遞歸表達,即N 階貝塞爾曲線是雙N-1 階貝塞爾曲線之間的插值。
![M(k)]]
注解:
- 開始于P 0并結叢于P n的曲線,即所謂的端點插值法屬性。
- 曲線是直線的充分必要條件是所有的控制點都位在曲線上。同樣的,貝塞爾曲線是直線的充分必要條件是控制點共線。
- 曲線的起始點(結叢點)相切于貝塞爾多邊形的第一節(最後一節)。
- 一條曲線可在任意點切割成兩條或任意多條子曲線,每一條子曲線仍是貝塞爾曲線。
Bezier曲線的作用
Bezier曲線的作用十分廣泛,在Android移動應用開發中有很多應用的場景。例如QQ小紅點拖拽效果、閱讀軟體的翻書效果、平滑折線圖的制作、很多炫酷的動畫效果等等。
BezierView實戰講解
看到這裡,大部分人應該都了解了Bezier曲線的原理。上面說到了Bezier曲線能夠實作很多炫酷的效果,是不是有點躍躍欲試了? 先欣賞一下最終的實作效果:
下面講解一下BezierView這個類的主要代碼,這個自定義類包含了實作Bezier曲線動畫的主要代碼。首先看一下init()初始化方法,代碼如下所示:
init()方法主要作用是初始化一些必要的資訊,包括繪制Bezier曲線的畫筆、資料點和控制點的坐标,繪制路徑以及狀态變量。可以看到,我們在程式中初始化了兩個資料點和一個控制點,代碼中我們把資料點也作為控制點處理。由上面講解的Bezier曲線的原理可以知道,一階(線性)Bezier曲線包含兩個控制點,二階Bezier曲線包含三個控制點,三階Bezier曲線包含四個控制點,可以依次類推N階Bezier曲線包含N+1個控制點。
private ArrayList<PointF> buildBezierPoints() {
ArrayList<PointF> points = new ArrayList<>();
int order = mControlPoints.size() - 1;
float delta = 1.0f / FRAME;
for (float t = 0; t <= 1; t += delta) {
// Bezier點集
points.add(new PointF(deCasteljauX(order, 0, t),
deCasteljauY(order, 0, t)));
}
return points;
}
這段代碼主要用于建立Bezier點集。熟悉Android Path用法的同學應該知道Path類中有兩個方法quadTo和cubicTo,通過這兩個方法可以畫出二階和三階Bezier曲線。因為我們這裡需要建立更高階的Bezier曲線,是以我采用德卡斯特裡奧算法(De Casteljau's Algorithm)來實作Bezier曲線。 不熟悉德卡斯特裡奧算法的同學可以參考我翻譯的一篇文章《德卡斯特裡奧算法——找到Bezier曲線上的一個點》。 在上面的方法中,我通過deCasteljauX和deCasteljauY方法建立某一時刻Bezier曲線上的點,再通過把參數t等分1000份,每個時刻的點連接配接在一起就能形成一條完整的Bezier曲線。有的同學看到這裡可能會問,為什麼要等分1000份啊?其實t等分多少取決于曲線的平滑程度。1000隻是一個相對值,t等分越多,曲線越平滑,反之亦然。
private float deCasteljauX(int i, int j, float t) {
if (i == 1) {
return (1 - t) * mControlPoints.get(j).x + t * mControlPoints.get(j + 1).x;
}
return (1 - t) * deCasteljauX(i - 1, j, t) + t * deCasteljauX(i - 1, j + 1, t);
}
下面看一下德卡斯特裡奧算法的實作程式,通過遞歸實作N階Bezier曲線上的點。上面短短幾行代碼就能實作Bezier,是不是很簡單。在這裡我就不解釋這段代碼了,不懂的同學可以參考上一段解釋,看我翻譯的德卡斯特裡奧算法。
private ArrayList<ArrayList<ArrayList<PointF>>> buildTangentPoints() {
ArrayList<PointF> points; // 1條線點集
ArrayList<ArrayList<PointF>> morepoints; // 多條線點集
ArrayList<ArrayList<ArrayList<PointF>>> allpoints = new ArrayList<>(); // 所有點集
PointF point;
int order = mControlPoints.size() - 1;
float delta = 1.0f / FRAME;
for (int i = 0; i < order - 1; i++) {
int size = allpoints.size();
morepoints = new ArrayList<>();
for (int j = 0; j < order - i; j++) {
points = new ArrayList<>();
for (float t = 0; t <= 1; t += delta) {
float p0x = 0;
float p1x = 0;
float p0y = 0;
float p1y = 0;
int z = (int) (t * FRAME);
if (size > 0) {
p0x = allpoints.get(i - 1).get(j).get(z).x;
p1x = allpoints.get(i - 1).get(j + 1).get(z).x;
p0y = allpoints.get(i - 1).get(j).get(z).y;
p1y = allpoints.get(i - 1).get(j + 1).get(z).y;
} else {
p0x = mControlPoints.get(j).x;
p1x = mControlPoints.get(j + 1).x;
p0y = mControlPoints.get(j).y;
p1y = mControlPoints.get(j + 1).y;
}
float x = (1 - t) * p0x + t * p1x;
float y = (1 - t) * p0y + t * p1y;
point = new PointF(x, y);
points.add(point);
}
morepoints.add(points);
}
allpoints.add(morepoints);
}
return allpoints;
}
這段代碼不長,但是乍一看有三重for循環,很多人可能頭大了。其實聽我慢慢分析,你會感覺很簡單。這段代碼就是用來實作建立Bezier曲線過程中的折線點集。通過德卡斯特裡奧算法我們知道,二階Bezier曲線有三個控制點和一條折線,在t時刻通過三個點和一條折線确定曲線上唯一的點。接下來三階Bezier曲線在t時刻通過四個點和二條折線确定曲線上唯一的點。以此類推,N階Bezier曲線在t時刻通過N+1個點和N-1條折線确定曲線上唯一的點。了解上面的話,我們就能通過程式來實作折線點集。
ArrayList<PointF> points;
定義一段折線的點集,
ArrayList<ArrayList<PointF>> morepoints;
定義一條折線的點集,
ArrayList<ArrayList<ArrayList<PointF>>> allpoints = new ArrayList<>();
定義多條折線的點集。那麼如何确定折線段上的點呢?我們可以通過下面的公式來擷取:
B(t) = (1-t) * P0 + t * P1;
,其中P0為一條折線段的起點坐标,P1為終點坐标。這個公式的證明參考德卡斯特裡奧算法。看最裡層for循環,擷取t時刻每一段折線的點集。其中,如果還沒有一條折線産生,則取控制點坐标實作最外層折線,當最外層折線形成後,裡層的折線坐标依次取外層t時刻的折線上的點。中間層for循環實作每一條折線的點集,最外層for循環控制折線的層樹。例如三階Bezier曲線有兩層折線,外層折線包含兩條線段,裡層折線包含一條線段。
@Override
protected void onDraw(Canvas canvas) {
if (isRunning() && !isTouchable()) {
if (mBezierPoint == null) {
mBezierPath.reset();
mBezierPoint = mBezierPoints.get(0);
mBezierPath.moveTo(mBezierPoint.x, mBezierPoint.y);
}
// 控制點和控制點連線
int size = mControlPoints.size();
PointF point;
for (int i = 0; i < size; i++) {
point = mControlPoints.get(i);
if (i > 0) {
// 控制點連線
canvas.drawLine(mControlPoints.get(i - 1).x, mControlPoints.get(i - 1).y, point.x, point.y,
mLinePaint);
}
// 控制點
canvas.drawCircle(point.x, point.y, CONTROL_RADIUS, mControlPaint);
// 控制點文本
canvas.drawText("p" + i, point.x + CONTROL_RADIUS * 2, point.y + CONTROL_RADIUS * 2, mTextPointPaint);
// 控制點文本展示
canvas.drawText("p" + i + " ( " + new DecimalFormat("##0.0").format(point.x) + " , " + new DecimalFormat
("##0.0").format(point.y) + ") ", REGION_WIDTH, mHeight - (size - i) * TEXT_HEIGHT, mTextPaint);
}
// 切線
if (mTangent && mInstantTangentPoints != null && !isStop()) {
int tsize = mInstantTangentPoints.size();
ArrayList<PointF> tps;
for (int i = 0; i < tsize; i++) {
tps = mInstantTangentPoints.get(i);
int tlen = tps.size();
for (int j = 0; j < tlen - 1; j++) {
mTangentPaint.setColor(Color.parseColor(TANGENT_COLORS[i]));
canvas.drawLine(tps.get(j).x, tps.get(j).y, tps.get(j + 1).x, tps.get(j + 1).y,
mTangentPaint);
canvas.drawCircle(tps.get(j).x, tps.get(j).y, CONTROL_RADIUS, mTangentPaint);
canvas.drawCircle(tps.get(j + 1).x, tps.get(j + 1).y, CONTROL_RADIUS, mTangentPaint);
}
}
}
// Bezier曲線
mBezierPath.lineTo(mBezierPoint.x, mBezierPoint.y);
canvas.drawPath(mBezierPath, mBezierPaint);
// Bezier曲線起始移動點
canvas.drawCircle(mBezierPoint.x, mBezierPoint.y, CONTROL_RADIUS, mMovingPaint);
// 時間展示
canvas.drawText("t:" + (new DecimalFormat("##0.000").format((float) mR / FRAME)), mWidth - TEXT_HEIGHT *
3, mHeight - TEXT_HEIGHT, mTextPaint);
mHandler.removeMessages(HANDLER_WHAT);
mHandler.sendEmptyMessage(HANDLER_WHAT);
}
if (isTouchable()) {
// 控制點和控制點連線
int size = mControlPoints.size();
PointF point;
for (int i = 0; i < size; i++) {
point = mControlPoints.get(i);
if (i > 0) {
canvas.drawLine(mControlPoints.get(i - 1).x, mControlPoints.get(i - 1).y, point.x, point.y,
mLinePaint);
}
canvas.drawCircle(point.x, point.y, CONTROL_RADIUS, mControlPaint);
canvas.drawText("p" + i, point.x + CONTROL_RADIUS * 2, point.y + CONTROL_RADIUS * 2, mTextPointPaint);
canvas.drawText("p" + i + " ( " + new DecimalFormat("##0.0").format(point.x) + " , " + new DecimalFormat
("##0.0").format(point.y) + ") ", REGION_WIDTH, mHeight - (size - i) * TEXT_HEIGHT, mTextPaint);
}
}
}
下面我們來繪制Bezier曲線。這段代碼很長,但是很簡單。首先判斷狀态,隻有運作且非觸摸狀态時,才可以繪制Bezier曲線。觸摸狀态時僅繪制控制點和控制點之間的連線。運作狀态,如果目前Bezier曲線移動起始點為空,則重置Path,重新設定Path的起始點。接下來for循環繪制控制點和控制點連線以及文本。接下來我們解析瞬時折線點,通過雙重for循環取出mInstantTangentPoints中的點,兩兩連接配接繪制折線。接下來通過Path連接配接Bezier曲線上某一時刻的點,繪制該點的路徑。其實onDraw裡的代碼隻繪制了某一時刻的點,我們在最後通過handler方式改變t時刻,并重複調用onDraw繪制,一直到曲線繪制周期結束。這樣,一條完美的Bezier曲線就繪制好了。
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (msg.what == HANDLER_WHAT) {
mR += mRate;
if (mR >= mBezierPoints.size()) {
removeMessages(HANDLER_WHAT);
mR = 0;
mState &= ~STATE_RUNNING;
mState &= ~STATE_STOP;
mState |= STATE_READY | STATE_TOUCH;
if (mLoop) {
start();
}
return;
}
if (mR != mBezierPoints.size() - 1 && mR + mRate >= mBezierPoints.size()) {
mR = mBezierPoints.size() - 1;
}
// Bezier點
mBezierPoint = new PointF(mBezierPoints.get(mR).x, mBezierPoints.get(mR).y);
// 切線點
if (mTangent) {
int size = mTangentPoints.size();
ArrayList<PointF> instantpoints;
mInstantTangentPoints = new ArrayList<>();
for (int i = 0; i < size; i++) {
int len = mTangentPoints.get(i).size();
instantpoints = new ArrayList<>();
for (int j = 0; j < len; j++) {
float x = mTangentPoints.get(i).get(j).get(mR).x;
float y = mTangentPoints.get(i).get(j).get(mR).y;
instantpoints.add(new PointF(x, y));
}
mInstantTangentPoints.add(instantpoints);
}
}
if (mR == mBezierPoints.size() - 1) {
mState |= STATE_STOP;
}
invalidate();
}
}
};
再來看一下handler子產品。這段代碼通過一定速率控制并擷取曲線上的點以及折線上的點,保證曲線最後一幀繪制完成後結束調用。我們再來欣賞一下Bezier曲線的繪制效果,如下圖所示:
以上就是繪制Bezier曲線代碼的核心部分,有疑問的朋友可以在下面留言。
關于Java和Android大牛頻道
Java和Android大牛頻道是一個數萬人關注的探讨Java和Android開發的公衆号,分享最有價值的幹貨文章,讓你成為這方面的大牛!
歡迎關注我們,一起讨論技術,掃描和長按下方的二維碼可快速關注我們
或搜尋微信公衆号:JANiubility,關注我們。