Android翻页效果原理实现之引入折线

尊重原创转载请注明:From AigeStudio(http://blog.csdn.net/aigestudio)Power by Aige 侵权必究!

炮兵镇楼

PS:写得太嗨忘了说明一点,下面文章中提到的“长边”(也就是代码部分中出现的sizeLong)指的是折叠区域直角三角形中与控件右边相连的边,而“短边”(也就是代码部分中出现的sizeShort)则指的是折叠区域直角三角形中与控件底边相连的边。两者术语并非指的是较长的边和较短的边,这点要注意。其命名来源于My参考图…………囧……

上一节中我们讲了翻页的原理实现,说白了就是Canvas中clip方法的使用,而现实生活中的翻页必然不是像我们上节demo那样左右切换的,我们总是会在看书翻页的时候掀起纸张的一角拉向书的另一侧实现翻页,翻页的过程对纸张来说是一个曲度和形状改变的过程,这一节我们先不讲曲度的实现,我们先假设翻页的过程是一个折页的过程,类似下图:

Android翻页效果原理实现之引入折线_第1张图片

先以折页的方式对翻页过程进行一个细致的分析,然后再在下一节将折线变为曲线。折页的实现可分为两种方式,一种是纯计算,我们利用已知的条件根据各类公式定理计算出未知的值,第二种呢则是通过图形的组合巧妙地去获取图形的交并集来实现,第二种方式需要很好的空间想象力这里就先不说了,而第一种纯计算的方式呢又可以分为使用高等数学和解三角形两种方法,前者对于数学不好的童鞋来说不易理解,这里我们选择后者使用解三角形来计算,首先我们先来搞个简单的辅助图:

Android翻页效果原理实现之引入折线_第2张图片

图很简单,一看就懂,大家可以拿个本子或者书尝试折页,不管你如何折,折叠区域AOB和下一页显示的区域APB必定是完全相等的对吧,那么我们就可以得到一个惊人的事实:角AOB恒为直角,这时我们来添加一些辅助线便于理解:

Android翻页效果原理实现之引入折线_第3张图片

我们设折叠后的三角形AOB的短边长度为x而长边长度为y,由图可以得出以下运算:

Android翻页效果原理实现之引入折线_第4张图片

我们可以使用相同的方法去解得y的值,这里我使用的是等面积法,由图可知梯形MOBP的面积是三角形MOA、AOB、APB面积之和:

Android翻页效果原理实现之引入折线_第5张图片

这样我们可以根据任意一点得出两边边长,我们来代码中实践一下看看是不是这样的呢?为了便于理解,这里我重新使用了一个新的FoldView:

public class FoldView extends View {
	public FoldView(Context context, AttributeSet attrs) {
		super(context, attrs);
	}
}
那么尝试根据我们以上分析的原理来绘制这么一个折页的效果,获取事件点、获取控件宽高就不说了,我们重点来看看onDraw中的计算:

@Override
protected void onDraw(Canvas canvas) {
	// 重绘时重置路径
	mPath.reset();

	// 绘制底色
	canvas.drawColor(Color.WHITE);

	/*
	 * 如果坐标点在右下角则不执行绘制
	 */
	if (pointX == 0 && pointY == 0) {
		return;
	}

	/*
	 * 额,这个该怎么注释好呢……根据图来
	 */
	float mK = mViewWidth - pointX;
	float mL = mViewHeight - pointY;

	// 需要重复使用的参数存值避免重复计算
	float temp = (float) (Math.pow(mL, 2) + Math.pow(mK, 2));

	/*
	 * 计算短边长边长度
	 */
	float sizeShort = temp / (2F * mK);
	float sizeLong = temp / (2F * mL);

	/*
	 * 生成路径
	 */
	mPath.moveTo(pointX, pointY);
	mPath.lineTo(mViewWidth, mViewHeight - sizeLong);
	mPath.lineTo(mViewWidth - sizeShort, mViewHeight);
	mPath.close();

	// 绘制路径
	canvas.drawPath(mPath, mPaint);
}
每次绘制的时候我们需要重置Path不然上一次的Path就会跟这一次叠加在一起,效果如下:

Android翻页效果原理实现之引入折线_第6张图片

效果是大致出来了,但是我们发现有一处不对的地方,当我们非常靠左或非常靠下地折叠时:

Android翻页效果原理实现之引入折线_第7张图片

如果再往下折

Android翻页效果原理实现之引入折线_第8张图片

如果再往左折

此时我们的Path就会消失掉,其实这跟我们我们现实中的折页是一样的,折页的过程是有限制的,如下图:

Android翻页效果原理实现之引入折线_第9张图片

右下角点P因为受装订线的制约,其半径最大只能为纸张的宽度,如果我们始终以该宽度为半径折页,那么点P的轨迹就可以形成曲线Q,图中半透明红色区域为一个半圆形,也就是说,我们的点P只能在该范围内才应当有效对吧,那么该如何做限制呢?很简单,我们只需在计算长短边长之前判断触摸点是否在该区域即可:

/**
 * 计算短边的有效区域
 */
private void computeShortSizeRegion() {
	// 短边圆形路径对象
	Path pathShortSize = new Path();

	// 用来装载Path边界值的RectF对象
	RectF rectShortSize = new RectF();

	// 添加圆形到Path
	pathShortSize.addCircle(0, mViewHeight, mViewWidth, Path.Direction.CCW);

	// 计算边界
	pathShortSize.computeBounds(rectShortSize, true);

	// 将Path转化为Region
	mRegionShortSize.setPath(pathShortSize, new Region((int) rectShortSize.left, (int) rectShortSize.top, (int) rectShortSize.right, (int) rectShortSize.bottom));
}
同样计算有效区域这个过程是在onSizeChanged中进行,我们说过尽量不要在一些重复调用的方法内执行没必要的计算,在onDraw里我们只需在绘制钱判断下当前触摸点是否在该区域内,如果不在,那么我们通过坐标x轴重新计算坐标y轴:

/*
 * 判断触摸点是否在短边的有效区域内
 */
if (!mRegionShortSize.contains((int) mPointX, (int) mPointY)) {
	// 如果不在则通过x坐标强行重算y坐标
	mPointY = (float) (Math.sqrt((Math.pow(mViewWidth, 2) - Math.pow(mPointX, 2))) - mViewHeight);

	// 精度附加值避免精度损失
	mPointY = Math.abs(mPointY) + mValueAdded;
}
那么如何来计算y坐标呢?很简单,我们只需根据圆的方程求解即可,因为P的轨迹是个圆~在得到新的y坐标mPointY后我们还应该为其加上一点点的精度值mValueAdded来挽回因浮点计算而损失的精度。而对于过分往下折出现的问题我们使用限制下折最大值的方法来避免:

/*
 * 缓冲区域判断
 */
float area = mViewHeight - mBuffArea;
if (mPointY >= area) {
	mPointY = area;
}
在控件下方接近底部的地方我们设定一个缓冲区域,触摸点永远不能到达该区域,因为没有必要也没有意义,再往下就要划出控件了,别浪费多余的计算,运行效果大致如下:

Android翻页效果原理实现之引入折线_第10张图片

大致的效果出来了,我们还需要做一些补充工作,当触摸点在右下角某个区域时如果我们抬起手指,那么就让“纸张”自动滑下去,同理当触摸点在左边某个区域时我们让“纸张”自动翻过去,这里我们约定这两个区域分别是控件右下角宽高四分之一的区域和控件左侧八分之一的区域(当然你可以约定你自己的控件行为,这里我就哪简单往哪走了~):

Android翻页效果原理实现之引入折线_第11张图片

那么在上一节中我们也有类似的效果,这里我们依葫芦画瓢,当手指抬起时判断当前事件点是否位于右下角自滑区域内,如果在那么以当前事件点为坐标点A右下角为坐标点B根据两点式我们可以获得一条直线方程:

Android翻页效果原理实现之引入折线_第12张图片

此后根据不断自加递增的x坐标不断计算对应的y坐标直至点滑至右下角为止,既然涉及到事件,So我们在onTouchEvent处理:

case MotionEvent.ACTION_UP:// 手指抬起时候
	/*
	 * 获取当前事件点
	 */
	float x = event.getX();
	float y = event.getY();

	/*
	 * 如果当前事件点位于右下自滑区域
	 */
	if (x > mAutoAreaRight && y > mAutoAreaButtom) {
		// 获取当前点为直线方程坐标之一
		float startX = x, startY = y;

		/*
		 * 当x坐标小于控件宽度时
		 */
		while (x < mViewWidth) {
			// 不断让x自加
			x++;

			// 重置当前点的值
			mPointX = x;
			mPointY = startY + ((x - startX) * (mViewHeight - startY)) / (mViewWidth - startX);

			// 重绘视图
			invalidate();
		}
	}
	break;
OK,我们来看看效果:

Android翻页效果原理实现之引入折线_第13张图片

大家看到当手指弹起时如果触摸点在右下角的自滑区域内的话就会自动“滑动”到右下角去,可是大家细心的话会发现效果好像不太对啊!怎么一下子就到右下角了?说好的“滑动”呢?好像毫无滑动效果啊!!!!为什么会这样?其实如果你细心就会发现上一节我们在讲图片左右两侧自滑的时候也是一样的效果!根本就没有什么滑动!为什么?难道在我们的while循环中没有执行invalidate吗?大家可以尝试在View中重写invalidate()方法Log一些信息看看invalidate()是否没有没执行。这里鉴于篇幅我就直接简单地说一下了,具体的我们会在《自定义控件其实很简单》系列文章讲到View绘制流程的时候详细阐述。这里我先可以告诉大家的是invalidate()方法即便你调用了也不会马上执行,invalidate()的作用更准确地说是将我们的View标记为无效,当View被标记为无效后Android就会尝试去调用onDraw()对其重绘,如果大家曾翻阅过API 文档就会看到在invalidate()方法中Google给出了这么一句话:


我们知道UI的刷新需要在UI Thread也就是主线程中进行,这里会涉及到一个叫做message和message queue的东西,message你可以见文知意地称其为消息而message queue则为消息队列,我们将一个message压入message queue后UI Thread会处理它,而我们刷新UI也需要有message作为载体去告诉UI Thread诶需要更新UI了哦,而当我们在UI Thread中去做一个loop不断地往message queue中压入消息时,我们的UI Thread是不会去处理这些message的,直到loop结束为止,这就是为什么我们在while中不断调用invalidate()的时候你只会看到最后的结果而不会得到中间过程的变化。这里我只阐述了一个很浅显能懂的原因,更深入的原因涉及到View中各种标识位的运算如上所说篇幅过长就不多说了。那么知道了原因该如何去处理呢?message和message queue如果大家对Handler有一定的了解一定不陌生,没错,这里我们也将使用Handler来实现我们的滑动,首先,在我们的View中创建一个内部类,该内部类是Handler的一个子类,我们将使用它来更新View实现滑动效果:

/**
 * 处理滑动的Handler
 */
@SuppressLint("HandlerLeak")
private class SlideHandler extends Handler {
	@Override
	public void handleMessage(Message msg) {
		// 循环调用滑动计算
		FoldView.this.slide();

		// 重绘视图
		FoldView.this.invalidate();
	}

	/**
	 * 延迟向Handler发送消息实现时间间隔
	 * 
	 * @param delayMillis
	 *            间隔时间
	 */
	public void sleep(long delayMillis) {
		this.removeMessages(0);
		sendMessageDelayed(obtainMessage(0), delayMillis);
	}
}
我们额外提供一个slide()方法来对参数值进行更新:

/**
 * 计算滑动参数变化
 */
private void slide() {
	/*
	 * 如果x坐标恒小于控件宽度
	 */
	if (isSlide && mPointX < mViewWidth) {
		// 则让x坐标自加
		mPointX++;

		// 并根据x坐标的值重新计算y坐标的值
		mPointY = mStart_BR_Y + ((mPointX - mStart_BR_X) * (mViewHeight - mStart_BR_Y)) / (mViewWidth - mStart_BR_X);

		// 让SlideHandler处理重绘
		mSlideHandler.sleep(1);
	}
}
而在onTouchEvent中我们则不在处理参数的计算和重绘,仅需简单调用slide()方法即可:

case MotionEvent.ACTION_UP:// 手指抬起时候
	/*
	 * 获取当前事件点
	 */
	float x = event.getX();
	float y = event.getY();

	/*
	 * 如果当前事件点位于右下自滑区域
	 */
	if (x > mAutoAreaRight && y > mAutoAreaButtom) {
		// 获取并设置直线方程的起点
		mStart_BR_X = x;
		mStart_BR_Y = y;

		// OK要开始滑动了哦~
		isSlide = true;

		// 滑动
		slide();
	}
	break;
注:mStart_BR_X和mStart_BR_Y为直线方程的一点,这里我单独使用两个引用存值便于大家理解,如果各位数学基础好完全可以将其并入到slide()方法中一并计算并省去这两个引用的声明。

这里我们定义了一个boolean类型的isSlide标识值,目的是方便控制动画,我们对外提供一个slideStop方法便于其他组件对动画的控制(当然你可以提供更多方法来控制slide,这里就不多说了),例如当Activity的onDestroy被调用时让动画停止:

/**
 * 为isSlide提供对外的停止方法便于必要时释放滑动动画
 */
public void slideStop() {
	isSlide = false;
}
在这一过程中,我们在事件手指抬起时判断点的所在,如果在滑动区域我们则触发slide()方法的执行,在slide()方法中我们重新计算坐标值并调用SlideHandler的sleep(long delayMillis)方法,sleep(long delayMillis)的处理逻辑也很简单,根据delayMillis延时向SlideHandler发送obtainMessage,在SlideHandler的handleMessage方法中再次调用slide()方法重新计算参数值并刷新界面。看到这里你可能会问为什么slide()和sleep()没有死循环而?Don't worry!我们来细致分析一下我们到底干了什么,首先我们创建了一个Handler的子类SlideHandler与当前Thread绑定在一起由此我们才可以直接给Thread发送并处理message。因为Handler对message的处理都是异步的,所以在我们自定的SlideHandler中sleep()方法也是个异步方法,所以slide()和sleep()之间的相互调用才没有构成死循环。

好了,分析归分析,我们还是要看实际效果的对吧,执行一下看看:

Android翻页效果原理实现之引入折线_第14张图片

是不是有“滑动”的效果了?之前我们在处理MOVE的时候在onDraw中定义了一个底部的缓冲区:

float area = mViewHeight - mBuffArea;
if (mPointY >= area) {
	mPointY = area;
}
而在自滑的时候我们是不需要去判断它的,So~我们改改:

/*
 * 缓冲区域判断
 */
float area = mViewHeight - mBuffArea;
if (!isSlide && mPointY >= area) {
	mPointY = area;
}
只有当没有产生滑动动画时才去判断缓冲区~

至此,从《自定义控件其实很简单》系列开始我们已经学会三种对View进行刷新的方式:第一种是在刚开始讲《自定义控件其实很简单1/12》让View作为Runnable的实现类,在run方法中更新,另一种是我们后来用的比较多的直接在onDraw方法中invalidate(),最后一种呢则是上面我们讲的Handler来处理绘制逻辑,这三种方法虽说本质一样但是实现方式各不相同且应用场景也不尽相同~第一种更倾向于多种状态进行同时重绘,第二种局限性很大虽说常见但能实现的功能很弱,第三种可以应用到绝大多数的重绘情景且不受不同状态的影响自由度更大。

好了,我们继续修改下代码让左侧也实现自滑的功能:

Android翻页效果原理实现之引入折线_第15张图片

如图所示,当我们的触摸点x坐标落于控件左侧1/8处弹起手指时,我们让该点与“上一页”的左下角相连构成一条直线,让点沿着该直线不断下滑直至“上一页”的左下角,鉴于我们要分开对左下和右下滑动进行处理,这里我定义一个枚举内部类:

/**
 * 枚举类定义滑动方向
 */
private enum Slide {
	LEFT_BOTTOM, RIGHT_BOTTOM
}
对应地我们就需要一个成员变量来存值咯:

private Slide mSlide;// 定义当前滑动是往左下滑还是右下滑
重新整理onTouchEvent处理逻辑:

case MotionEvent.ACTION_UP:// 手指抬起时候
	/*
	 * 获取当前事件点
	 */
	float x = event.getX();
	float y = event.getY();

	/*
	 * 如果当前事件点位于右下自滑区域
	 */
	if (x > mAutoAreaRight && y > mAutoAreaButtom) {
		// 当前为往右下滑
		mSlide = Slide.RIGHT_BOTTOM;

		// 摩擦吧骚年!
		justSlide(x, y);
	}

	/*
	 * 如果当前事件点位于左侧自滑区域
	 */
	if (x < mAutoAreaLeft) {
		// 当前为往左下滑
		mSlide = Slide.LEFT_BOTTOM;

		// 摩擦吧骚年!
		justSlide(x, y);
	}
	break;
我们将一些相同的方法封装在justSlide中:

/**
 * 在这光滑的地板上~
 * 
 * @param x
 *            当前触摸点x
 * @param y
 *            当前触摸点y
 */
private void justSlide(float x, float y) {
	// 获取并设置直线方程的起点
	mStart_X = x;
	mStart_Y = y;

	// OK要开始滑动了哦~
	isSlide = true;

	// 滑动
	slide();
}
slide()中的处理则会根据滑动方向来计算参数值:

/**
 * 计算滑动参数变化
 */
private void slide() {
	/*
	 * 如果滑动标识值为false则返回
	 */
	if (!isSlide) {
		return;
	}

	/*
	 * 如果当前滑动标识为向右下滑动x坐标恒小于控件宽度
	 */
	if (mSlide == Slide.RIGHT_BOTTOM && mPointX < mViewWidth) {
		// 则让x坐标自加
		mPointX += 10;

		// 并根据x坐标的值重新计算y坐标的值
		mPointY = mStart_Y + ((mPointX - mStart_X) * (mViewHeight - mStart_Y)) / (mViewWidth - mStart_X);

		// 让SlideHandler处理重绘
		mSlideHandler.sleep(25);
	}

	/*
	 * 如果当前滑动标识为向左下滑动x坐标恒大于控件宽度的负值
	 */
	if (mSlide == Slide.LEFT_BOTTOM && mPointX > -mViewWidth) {
		// 则让x坐标自减
		mPointX -= 20;

		// 并根据x坐标的值重新计算y坐标的值
		mPointY = mStart_Y + ((mPointX - mStart_X) * (mViewHeight - mStart_Y)) / (-mViewWidth - mStart_X);

		// 让SlideHandler处理重绘
		mSlideHandler.sleep(25);
	}
}
看看效果:

Android翻页效果原理实现之引入折线_第16张图片

好像一切都很完美,但是我们发现当Path绘制到快结束时卡了两下,同时我们的LogCat也出现了如下警告:


说是我们的Path太大了已经超出了texture的渲染范围,什么是texture这要涉及到GL等Android底层对图形绘制的过程,我们不需要理解,但是要知道的是,从API 11开始Android开始支持HW硬件加速绘制视图,硬件加速对texture是有限制的,这个限制值因机而异,如上面我们在警告信息的最后看到的max = 16384 x 16384,如何解决呢?最简单的方法当然是直接关闭硬件加速咯,关于关闭硬件加速的两种方法在《自定义控件其实很简单》中我们已经说过:

setLayerType(LAYER_TYPE_SOFTWARE, null);
这样我们再次运行:

Android翻页效果原理实现之引入折线_第17张图片

诶~Good,很顺畅也没出现警告了对吧,但是Path的大小依然是没有改变的,依然是灰常灰常大,超出控件上方的部分依旧被绘制而且很大很大,其实这部分绘制是完全没必要的,而且为此我们还关闭了HW更可怕的是还将APP的最低支持版本升到了11……我们可不可以通过其他方式来避免呢?答案是肯定的,方法很多,但是最简明扼要的还是Calculation~~~~~我们尝试去判断折叠后长边的的长度,如果长边的长度大于控件的高度则我们折叠的分部就不是一个三角形而是一个四边形了:

Android翻页效果原理实现之引入折线_第18张图片

如上图中的四边形OBQM,那么如何来生成这个四边形的区域呢?我们曾约定在手指MOVE的过程中控件下方有一个“缓冲区域”,也就是说我们的触摸点Y坐标永远不可能在MOVE的过程中与控件底部重合,这个约定给我们在计算四边形区域的时候带来一个好处:如上图所示OA边总会与PN的延长线有交点,那样计算就非常简单了,我们过点O作一条垂直于PN边的垂线与PN边相交于点D:

Android翻页效果原理实现之引入折线_第19张图片

那么我么就会有:

Android翻页效果原理实现之引入折线_第20张图片

由此很容易得出

MN = largerTrianShortSize = an / (sizeLong - (mViewHeight - mPointY)) * (mViewWidth - mPointX);
QN = smallTrianShortSize = an / sizeLong * sizeShort;
在onDraw中我们在计算出sizeLong和sizeShort后加一步判断:

/*
 * 计算短边长边长度
 */
float sizeShort = temp / (2F * mK);
float sizeLong = temp / (2F * mL);

// 移动路径起点至触摸点
mPath.moveTo(mPointX, mPointY);

if (sizeLong > mViewHeight) {
	// 计算……额……按图来AN边~
	float an = sizeLong - mViewHeight;

	// 三角形AMN的MN边
	float largerTrianShortSize = an / (sizeLong - (mViewHeight - mPointY)) * (mViewWidth - mPointX);

	// 三角形AQN的QN边
	float smallTrianShortSize = an / sizeLong * sizeShort;

	/*
	 * 生成四边形路径
	 */
	mPath.lineTo(mViewWidth - largerTrianShortSize, 0);
	mPath.lineTo(mViewWidth - smallTrianShortSize, 0);
	mPath.lineTo(mViewWidth - sizeShort, mViewHeight);
	mPath.close();
} else {
	/*
	 * 生成三角形路径
	 */
	mPath.lineTo(mViewWidth, mViewHeight - sizeLong);
	mPath.lineTo(mViewWidth - sizeShort, mViewHeight);
	mPath.close();
}

// 绘制路径
canvas.drawPath(mPath, mPaint);
来看看具体效果:

Android翻页效果原理实现之引入折线_第21张图片

挺不错的感觉,好,继续!

折线是OK了,剩下的问题是如何将我们的图片显示,也就是上一节的内容融合进来呢?在此之前,我们要知道的是折叠区域、当前页和下一页这三部分显示的是不同的内容,如文章开头的示图:

Android翻页效果原理实现之引入折线_第22张图片

那么如何显示不同的图片就必须要先将这三部分表达出来对吧,我们之前曾学过Region区域对象,在这里就可以派上用场。首先,我们可以考虑将整个控件分为三个区域:显示当前页的区域、显示折叠的区域(也就是当前页的背面)、显示下一页的区域。与上图类似,我们可以利用图层的功能使用三个图层来模拟。第一步我们声明一个Region类型的引用来定义当前页的区域:

private Region mRegionCurrent;// 当前页区域,其实就是控件的大小
因为当前页的区域其实就是控件大小(置于最底层无所谓了~),我们直接就可以在onSizeChanged中完成对象的生成:

// 计算当前页区域
mRegionCurrent.set(0, 0, mViewWidth, mViewHeight);
尔后我们需要计算折叠区域和下一页的路径:

Android翻页效果原理实现之引入折线_第23张图片

如图红色线条所示路径,我们需要这部分区域来计算下一页的区域,即:下一页区域=线条部分区域-折叠区域对吧,同样我们声明一个Path类型的引用来定义该部分Path:

private Path mPathFoldAndNext;// 一个包含折叠和下一页区域的Path
在onDraw中在计算折叠区域的同时计算该部分区域:

/*
 * 计算短边长边长度
 */
float sizeShort = temp / (2F * mK);
float sizeLong = temp / (2F * mL);

// 移动路径起点至触摸点
mPath.moveTo(mPointX, mPointY);
mPathFoldAndNext.moveTo(mPointX, mPointY);

if (sizeLong > mViewHeight) {
	// 计算……额……按图来AN边~
	float an = sizeLong - mViewHeight;

	// 三角形AMN的MN边
	float largerTrianShortSize = an / (sizeLong - (mViewHeight - mPointY)) * (mViewWidth - mPointX);

	// 三角形AQN的QN边
	float smallTrianShortSize = an / sizeLong * sizeShort;

	/*
	 * 计算参数
	 */
	float topX1 = mViewWidth - largerTrianShortSize;
	float topX2 = mViewWidth - smallTrianShortSize;
	float btmX2 = mViewWidth - sizeShort;

	/*
	 * 生成四边形路径
	 */
	mPath.lineTo(topX1, 0);
	mPath.lineTo(topX2, 0);
	mPath.lineTo(btmX2, mViewHeight);
	mPath.close();

	/*
	 * 生成包含折叠和下一页的路径
	 */
	mPathFoldAndNext.lineTo(topX1, 0);
	mPathFoldAndNext.lineTo(mViewWidth, 0);
	mPathFoldAndNext.lineTo(mViewWidth, mViewHeight);
	mPathFoldAndNext.lineTo(btmX2, mViewHeight);
	mPathFoldAndNext.close();
} else {
	/*
	 * 计算参数
	 */
	float leftY = mViewHeight - sizeLong;
	float btmX = mViewWidth - sizeShort;

	/*
	 * 生成三角形路径
	 */
	mPath.lineTo(mViewWidth, leftY);
	mPath.lineTo(btmX, mViewHeight);
	mPath.close();

	/*
	 * 生成包含折叠和下一页的路径
	 */
	mPathFoldAndNext.lineTo(mViewWidth, leftY);
	mPathFoldAndNext.lineTo(mViewWidth, mViewHeight);
	mPathFoldAndNext.lineTo(btmX, mViewHeight);
	mPathFoldAndNext.close();
}
将路径转换为Region我们独立一个方法来调用:

/**
 * 通过路径计算区域
 * 
 * @param path
 *            路径对象
 * @return 路径的Region
 */
private Region computeRegion(Path path) {
	Region region = new Region();
	RectF f = new RectF();
	path.computeBounds(f, true);
	region.setPath(path, new Region((int) f.left, (int) f.top, (int) f.right, (int) f.bottom));
	return region;
}
之后我们就可以计算并绘制这三部分区域:

/*
 * 定义区域
 */
Region regionFold = null;
Region regionNext = null;

/*
 * 通过路径成成区域
 */
regionFold = computeRegion(mPath);
regionNext = computeRegion(mPathFoldAndNext);

/*
 * 计算当前页的区域
 */
canvas.save();
canvas.clipRegion(mRegionCurrent);
canvas.clipRegion(regionNext, Region.Op.DIFFERENCE);
canvas.drawColor(0xFFF4D8B7);
canvas.restore();

/*
 * 计算折叠页的区域
 */
canvas.save();
canvas.clipRegion(regionFold);
canvas.drawColor(0xFF663C21);
canvas.restore();

/*
 * 计算下一页的区域
 */
canvas.save();
canvas.clipRegion(regionNext);
canvas.clipRegion(regionFold, Region.Op.DIFFERENCE);
canvas.drawColor(0xFF9596C4);
canvas.restore();
我们看看效果是否与我们期待的一致:

Android翻页效果原理实现之引入折线_第24张图片

差不多对吧,只是有点小问题,我们在没触摸之前显示的是空白这很好解决,下面我们结合上一节的内容把图片的效果也整合进来,该部分全部的代码如下,我就直接贴出来了:

package com.aigestudio.pagecurl.views;

import java.util.ArrayList;
import java.util.List;

import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Region;
import android.os.Handler;
import android.os.Message;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.Toast;

/**
 * 折叠View
 * 
 * @author AigeStudio {@link http://blog.csdn.net/aigestudio}
 * @version 1.0.0
 * @since 2014/12/27
 */
public class FoldView extends View {
	private static final float VALUE_ADDED = 1 / 500F;// 精度附加值占比
	private static final float BUFF_AREA = 1 / 50F;// 底部缓冲区域占比
	private static final float AUTO_AREA_BUTTOM_RIGHT = 3 / 4F, AUTO_AREA_BUTTOM_LEFT = 1 / 8F;// 右下角和左侧自滑区域占比
	private static final float AUTO_SLIDE_BL_V = 1 / 25F, AUTO_SLIDE_BR_V = 1 / 100F;// 滑动速度占比
	private static final float TEXT_SIZE_NORMAL = 1 / 40F, TEXT_SIZE_LARGER = 1 / 20F;// 标准文字尺寸和大号文字尺寸的占比

	private List<Bitmap> mBitmaps;// 位图数据列表

	private SlideHandler mSlideHandler;// 滑动处理Handler
	private Paint mPaint;// 画笔
	private TextPaint mTextPaint;// 文本画笔
	private Context mContext;// 上下文环境引用

	private Path mPath;// 折叠路径
	private Path mPathFoldAndNext;// 一个包含折叠和下一页区域的Path

	private Region mRegionShortSize;// 短边的有效区域
	private Region mRegionCurrent;// 当前页区域,其实就是控件的大小

	private int mViewWidth, mViewHeight;// 控件宽高
	private int mPageIndex;// 当前显示mBitmaps数据的下标

	private float mPointX, mPointY;// 手指触摸点的坐标
	private float mValueAdded;// 精度附减值
	private float mBuffArea;// 底部缓冲区域
	private float mAutoAreaButtom, mAutoAreaRight, mAutoAreaLeft;// 右下角和左侧自滑区域
	private float mStart_X, mStart_Y;// 直线起点坐标
	private float mAutoSlideV_BL, mAutoSlideV_BR;// 滑动速度
	private float mTextSizeNormal, mTextSizeLarger;// 标准文字尺寸和大号文字尺寸
	private float mDegrees;// 当前Y边长与Y轴的夹角

	private boolean isSlide, isLastPage, isNextPage;// 是否执行滑动、是否已到最后一页、是否可显示下一页的标识值

	private Slide mSlide;// 定义当前滑动是往左下滑还是右下滑

	/**
	 * 枚举类定义滑动方向
	 */
	private enum Slide {
		LEFT_BOTTOM, RIGHT_BOTTOM
	}

	private Ratio mRatio;// 定义当前折叠边长

	/**
	 * 枚举类定义长边短边
	 */
	private enum Ratio {
		LONG, SHORT
	}

	public FoldView(Context context, AttributeSet attrs) {
		super(context, attrs);
		mContext = context;

		/*
		 * 实例化文本画笔并设置参数
		 */
		mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG | Paint.LINEAR_TEXT_FLAG);
		mTextPaint.setTextAlign(Paint.Align.CENTER);

		/*
		 * 实例化画笔对象并设置参数
		 */
		mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
		mPaint.setStyle(Paint.Style.STROKE);
		mPaint.setStrokeWidth(2);

		/*
		 * 实例化路径对象
		 */
		mPath = new Path();
		mPathFoldAndNext = new Path();

		/*
		 * 实例化区域对象
		 */
		mRegionShortSize = new Region();
		mRegionCurrent = new Region();

		// 实例化滑动Handler处理器
		mSlideHandler = new SlideHandler();
	}

	@Override
	protected void onSizeChanged(int w, int h, int oldw, int oldh) {
		/*
		 * 获取控件宽高
		 */
		mViewWidth = w;
		mViewHeight = h;

		// 初始化位图数据
		if (null != mBitmaps) {
			initBitmaps();
		}

		// 计算文字尺寸
		mTextSizeNormal = TEXT_SIZE_NORMAL * mViewHeight;
		mTextSizeLarger = TEXT_SIZE_LARGER * mViewHeight;

		// 计算精度附加值
		mValueAdded = mViewHeight * VALUE_ADDED;

		// 计算底部缓冲区域
		mBuffArea = mViewHeight * BUFF_AREA;

		/*
		 * 计算自滑位置
		 */
		mAutoAreaButtom = mViewHeight * AUTO_AREA_BUTTOM_RIGHT;
		mAutoAreaRight = mViewWidth * AUTO_AREA_BUTTOM_RIGHT;
		mAutoAreaLeft = mViewWidth * AUTO_AREA_BUTTOM_LEFT;

		// 计算短边的有效区域
		computeShortSizeRegion();

		/*
		 * 计算滑动速度
		 */
		mAutoSlideV_BL = mViewWidth * AUTO_SLIDE_BL_V;
		mAutoSlideV_BR = mViewWidth * AUTO_SLIDE_BR_V;

		// 计算当前页区域
		mRegionCurrent.set(0, 0, mViewWidth, mViewHeight);
	}

	/**
	 * 初始化位图数据
	 * 缩放位图尺寸与屏幕匹配
	 */
	private void initBitmaps() {
		List<Bitmap> temp = new ArrayList<Bitmap>();
		for (int i = mBitmaps.size() - 1; i >= 0; i--) {
			Bitmap bitmap = Bitmap.createScaledBitmap(mBitmaps.get(i), mViewWidth, mViewHeight, true);
			temp.add(bitmap);
		}
		mBitmaps = temp;
	}

	/**
	 * 计算短边的有效区域
	 */
	private void computeShortSizeRegion() {
		// 短边圆形路径对象
		Path pathShortSize = new Path();

		// 用来装载Path边界值的RectF对象
		RectF rectShortSize = new RectF();

		// 添加圆形到Path
		pathShortSize.addCircle(0, mViewHeight, mViewWidth, Path.Direction.CCW);

		// 计算边界
		pathShortSize.computeBounds(rectShortSize, true);

		// 将Path转化为Region
		mRegionShortSize.setPath(pathShortSize, new Region((int) rectShortSize.left, (int) rectShortSize.top, (int) rectShortSize.right, (int) rectShortSize.bottom));
	}

	@Override
	protected void onDraw(Canvas canvas) {
		/*
		 * 如果数据为空则显示默认提示文本
		 */
		if (null == mBitmaps || mBitmaps.size() == 0) {
			defaultDisplay(canvas);
			return;
		}

		// 重绘时重置路径
		mPath.reset();
		mPathFoldAndNext.reset();

		// 绘制底色
		canvas.drawColor(Color.WHITE);

		/*
		 * 如果坐标点在原点(即还没发生触碰时)则绘制第一页
		 */
		if (mPointX == 0 && mPointY == 0) {
			canvas.drawBitmap(mBitmaps.get(mBitmaps.size() - 1), 0, 0, null);
			return;
		}

		/*
		 * 判断触摸点是否在短边的有效区域内
		 */
		if (!mRegionShortSize.contains((int) mPointX, (int) mPointY)) {
			// 如果不在则通过x坐标强行重算y坐标
			mPointY = (float) (Math.sqrt((Math.pow(mViewWidth, 2) - Math.pow(mPointX, 2))) - mViewHeight);

			// 精度附加值避免精度损失
			mPointY = Math.abs(mPointY) + mValueAdded;
		}

		/*
		 * 缓冲区域判断
		 */
		float area = mViewHeight - mBuffArea;
		if (!isSlide && mPointY >= area) {
			mPointY = area;
		}

		/*
		 * 额,这个该怎么注释好呢……根据图来
		 */
		float mK = mViewWidth - mPointX;
		float mL = mViewHeight - mPointY;

		// 需要重复使用的参数存值避免重复计算
		float temp = (float) (Math.pow(mL, 2) + Math.pow(mK, 2));

		/*
		 * 计算短边长边长度
		 */
		float sizeShort = temp / (2F * mK);
		float sizeLong = temp / (2F * mL);

		/*
		 * 根据长短边边长计算旋转角度并确定mRatio的值
		 */
		if (sizeShort < sizeLong) {
			mRatio = Ratio.SHORT;
			float sin = (mK - sizeShort) / sizeShort;
			mDegrees = (float) (Math.asin(sin) / Math.PI * 180);
		} else {
			mRatio = Ratio.LONG;
			float cos = mK / sizeLong;
			mDegrees = (float) (Math.acos(cos) / Math.PI * 180);
		}

		// 移动路径起点至触摸点
		mPath.moveTo(mPointX, mPointY);
		mPathFoldAndNext.moveTo(mPointX, mPointY);

		if (sizeLong > mViewHeight) {
			// 计算……额……按图来AN边~
			float an = sizeLong - mViewHeight;

			// 三角形AMN的MN边
			float largerTrianShortSize = an / (sizeLong - (mViewHeight - mPointY)) * (mViewWidth - mPointX);

			// 三角形AQN的QN边
			float smallTrianShortSize = an / sizeLong * sizeShort;

			/*
			 * 计算参数
			 */
			float topX1 = mViewWidth - largerTrianShortSize;
			float topX2 = mViewWidth - smallTrianShortSize;
			float btmX2 = mViewWidth - sizeShort;

			/*
			 * 生成四边形路径
			 */
			mPath.lineTo(topX1, 0);
			mPath.lineTo(topX2, 0);
			mPath.lineTo(btmX2, mViewHeight);
			mPath.close();

			/*
			 * 生成包含折叠和下一页的路径
			 */
			mPathFoldAndNext.lineTo(topX1, 0);
			mPathFoldAndNext.lineTo(mViewWidth, 0);
			mPathFoldAndNext.lineTo(mViewWidth, mViewHeight);
			mPathFoldAndNext.lineTo(btmX2, mViewHeight);
			mPathFoldAndNext.close();
		} else {
			/*
			 * 计算参数
			 */
			float leftY = mViewHeight - sizeLong;
			float btmX = mViewWidth - sizeShort;

			/*
			 * 生成三角形路径
			 */
			mPath.lineTo(mViewWidth, leftY);
			mPath.lineTo(btmX, mViewHeight);
			mPath.close();

			/*
			 * 生成包含折叠和下一页的路径
			 */
			mPathFoldAndNext.lineTo(mViewWidth, leftY);
			mPathFoldAndNext.lineTo(mViewWidth, mViewHeight);
			mPathFoldAndNext.lineTo(btmX, mViewHeight);
			mPathFoldAndNext.close();
		}

		drawBitmaps(canvas);
	}

	/**
	 * 绘制位图数据
	 * 
	 * @param canvas
	 *            画布对象
	 */
	private void drawBitmaps(Canvas canvas) {
		// 绘制位图前重置isLastPage为false
		isLastPage = false;

		// 限制pageIndex的值范围
		mPageIndex = mPageIndex < 0 ? 0 : mPageIndex;
		mPageIndex = mPageIndex > mBitmaps.size() ? mBitmaps.size() : mPageIndex;

		// 计算数据起始位置
		int start = mBitmaps.size() - 2 - mPageIndex;
		int end = mBitmaps.size() - mPageIndex;

		/*
		 * 如果数据起点位置小于0则表示当前已经到了最后一张图片
		 */
		if (start < 0) {
			// 此时设置isLastPage为true
			isLastPage = true;

			// 并显示提示信息
			showToast("This is fucking lastest page");

			// 强制重置起始位置
			start = 0;
			end = 1;
		}

		/*
		 * 定义区域
		 */
		Region regionFold = null;
		Region regionNext = null;

		/*
		 * 通过路径成成区域
		 */
		regionFold = computeRegion(mPath);
		regionNext = computeRegion(mPathFoldAndNext);

		/*
		 * 计算当前页的区域
		 */
		canvas.save();
		canvas.clipRegion(mRegionCurrent);
		canvas.clipRegion(regionNext, Region.Op.DIFFERENCE);
		canvas.drawBitmap(mBitmaps.get(end - 1), 0, 0, null);
		canvas.restore();

		/*
		 * 计算折叠页的区域
		 */
		canvas.save();
		canvas.clipRegion(regionFold);

		canvas.translate(mPointX, mPointY);

		/*
		 * 根据长短边标识计算折叠区域图像
		 */
		if (mRatio == Ratio.SHORT) {
			canvas.rotate(90 - mDegrees);
			canvas.translate(0, -mViewHeight);
			canvas.scale(-1, 1);
			canvas.translate(-mViewWidth, 0);
		} else {
			canvas.rotate(-(90 - mDegrees));
			canvas.translate(-mViewWidth, 0);
			canvas.scale(1, -1);
			canvas.translate(0, -mViewHeight);
		}

		canvas.drawBitmap(mBitmaps.get(end - 1), 0, 0, null);
		canvas.restore();

		/*
		 * 计算下一页的区域
		 */
		canvas.save();
		canvas.clipRegion(regionNext);
		canvas.clipRegion(regionFold, Region.Op.DIFFERENCE);
		canvas.drawBitmap(mBitmaps.get(start), 0, 0, null);
		canvas.restore();
	}

	/**
	 * 默认显示
	 * 
	 * @param canvas
	 *            Canvas对象
	 */
	private void defaultDisplay(Canvas canvas) {
		// 绘制底色
		canvas.drawColor(Color.WHITE);

		// 绘制标题文本
		mTextPaint.setTextSize(mTextSizeLarger);
		mTextPaint.setColor(Color.RED);
		canvas.drawText("FBI WARNING", mViewWidth / 2, mViewHeight / 4, mTextPaint);

		// 绘制提示文本
		mTextPaint.setTextSize(mTextSizeNormal);
		mTextPaint.setColor(Color.BLACK);
		canvas.drawText("Please set data use setBitmaps method", mViewWidth / 2, mViewHeight / 3, mTextPaint);
	}

	/**
	 * 通过路径计算区域
	 * 
	 * @param path
	 *            路径对象
	 * @return 路径的Region
	 */
	private Region computeRegion(Path path) {
		Region region = new Region();
		RectF f = new RectF();
		path.computeBounds(f, true);
		region.setPath(path, new Region((int) f.left, (int) f.top, (int) f.right, (int) f.bottom));
		return region;
	}

	/**
	 * 计算滑动参数变化
	 */
	private void slide() {
		/*
		 * 如果滑动标识值为false则返回
		 */
		if (!isSlide) {
			return;
		}

		/*
		 * 如果当前页不是最后一页
		 * 如果是需要翻下一页
		 * 并且上一页已被做掉
		 */
		if (!isLastPage && isNextPage && (mPointX - mAutoSlideV_BL <= -mViewWidth)) {
			mPointX = -mViewWidth;
			mPointY = mViewHeight;
			mPageIndex++;
			invalidate();
		}

		/*
		 * 如果当前滑动标识为向右下滑动x坐标恒小于控件宽度
		 */
		else if (mSlide == Slide.RIGHT_BOTTOM && mPointX < mViewWidth) {
			// 则让x坐标自加
			mPointX += mAutoSlideV_BR;

			// 并根据x坐标的值重新计算y坐标的值
			mPointY = mStart_Y + ((mPointX - mStart_X) * (mViewHeight - mStart_Y)) / (mViewWidth - mStart_X);

			// 让SlideHandler处理重绘
			mSlideHandler.sleep(25);
		}

		/*
		 * 如果当前滑动标识为向左下滑动x坐标恒大于控件宽度的负值
		 */
		else if (mSlide == Slide.LEFT_BOTTOM && mPointX > -mViewWidth) {
			// 则让x坐标自减
			mPointX -= mAutoSlideV_BL;

			// 并根据x坐标的值重新计算y坐标的值
			mPointY = mStart_Y + ((mPointX - mStart_X) * (mViewHeight - mStart_Y)) / (-mViewWidth - mStart_X);

			// 让SlideHandler处理重绘
			mSlideHandler.sleep(25);
		}
	}

	/**
	 * 为isSlide提供对外的停止方法便于必要时释放滑动动画
	 */
	public void slideStop() {
		isSlide = false;
	}

	/**
	 * 提供对外的方法获取View内Handler
	 * 
	 * @return mSlideHandler
	 */
	public SlideHandler getSlideHandler() {
		return mSlideHandler;
	}

	@Override
	public boolean onTouchEvent(MotionEvent event) {
		isNextPage = true;

		/*
		 * 获取当前事件点
		 */
		float x = event.getX();
		float y = event.getY();

		switch (event.getAction() & MotionEvent.ACTION_MASK) {
		case MotionEvent.ACTION_UP:// 手指抬起时候
			if (isNextPage) {
				/*
				 * 如果当前事件点位于右下自滑区域
				 */
				if (x > mAutoAreaRight && y > mAutoAreaButtom) {
					// 当前为往右下滑
					mSlide = Slide.RIGHT_BOTTOM;

					// 摩擦吧骚年!
					justSlide(x, y);
				}

				/*
				 * 如果当前事件点位于左侧自滑区域
				 */
				if (x < mAutoAreaLeft) {
					// 当前为往左下滑
					mSlide = Slide.LEFT_BOTTOM;

					// 摩擦吧骚年!
					justSlide(x, y);
				}
			}
			break;
		case MotionEvent.ACTION_DOWN:
			isSlide = false;
			/*
			 * 如果事件点位于回滚区域
			 */
			if (x < mAutoAreaLeft) {
				// 那就不翻下一页了而是上一页
				isNextPage = false;
				mPageIndex--;
				mPointX = x;
				mPointY = y;
				invalidate();
			}
			downAndMove(event);
			break;
		case MotionEvent.ACTION_MOVE:
			downAndMove(event);
			break;
		}
		return true;
	}

	/**
	 * 处理DOWN和MOVE事件
	 * 
	 * @param event
	 *            事件对象
	 */
	private void downAndMove(MotionEvent event) {
		if (!isLastPage) {
			mPointX = event.getX();
			mPointY = event.getY();

			invalidate();
		}
	}

	/**
	 * 在这光滑的地板上~
	 * 
	 * @param x
	 *            当前触摸点x
	 * @param y
	 *            当前触摸点y
	 */
	private void justSlide(float x, float y) {
		// 获取并设置直线方程的起点
		mStart_X = x;
		mStart_Y = y;

		// OK要开始滑动了哦~
		isSlide = true;

		// 滑动
		slide();
	}

	/**
	 * 设置位图数据
	 * 
	 * @param bitmaps
	 *            位图数据列表
	 */
	public synchronized void setBitmaps(List<Bitmap> bitmaps) {
		/*
		 * 如果数据为空则抛出异常
		 */
		if (null == bitmaps || bitmaps.size() == 0)
			throw new IllegalArgumentException("no bitmap to display");

		/*
		 * 如果数据长度小于2则GG思密达
		 */
		if (bitmaps.size() < 2)
			throw new IllegalArgumentException("fuck you and fuck to use imageview");

		mBitmaps = bitmaps;
		invalidate();
	}

	/**
	 * Toast显示
	 * 
	 * @param msg
	 *            Toast显示文本
	 */
	private void showToast(Object msg) {
		Toast.makeText(mContext, msg.toString(), Toast.LENGTH_SHORT).show();
	}

	/**
	 * 处理滑动的Handler
	 */
	@SuppressLint("HandlerLeak")
	public class SlideHandler extends Handler {
		@Override
		public void handleMessage(Message msg) {
			// 循环调用滑动计算
			FoldView.this.slide();

			// 重绘视图
			FoldView.this.invalidate();
		}

		/**
		 * 延迟向Handler发送消息实现时间间隔
		 * 
		 * @param delayMillis
		 *            间隔时间
		 */
		public void sleep(long delayMillis) {
			this.removeMessages(0);
			sendMessageDelayed(obtainMessage(0), delayMillis);
		}
	}
}
以上代码就是本节的全部内容,运行的效果如下:


代码部分除了折叠区域图像的生成外都是直接从上一节COPY过来,而折叠区域图像的生成为了便于大家的理解我分为了两种情况计算(如果你的图形学屌可以将其并入一个计算方式)即当长边大于短边和短边大于长边两种情况(注意文章开头我们的长短边声明),而相等的情况我们并入其中一种一并计算即可。具体的计算过程很简单,首先我们必定要移动Canvas使其原点与我们的触摸点对应:

canvas.translate(mPointX, mPointY);//386行
然后就是分情况了,第一种情况短边小于长边:

//392-395行
canvas.rotate(90 - mDegrees);
canvas.translate(0, -mViewHeight);
canvas.scale(-1, 1);
canvas.translate(-mViewWidth, 0);
这个过程图解的话就是:

Android翻页效果原理实现之引入折线_第25张图片

而另一种情况短边大于长边:

//397-400行
canvas.rotate(-(90 - mDegrees));
canvas.translate(-mViewWidth, 0);
canvas.scale(1, -1);
canvas.translate(0, -mViewHeight);
该过程其实与上面类似、就不在画图了,图解很累的……

其他的跟上一节我们所讲的没有太大的出入。

OK,这一节到此为止,下一节我们将去尝试曲线的生成,如何将折线变为曲线并简单地实现扭曲的效果,thx all 谢谢大家、、

本例源码:传送门

你可能感兴趣的:(android,view,custom,Android翻页效果)