前面我们介绍了利用View和Android已有的控件RLF...(RelativeLayout、LinearLayout、FrameLayout...)实践自定义UI,感兴趣的小伙伴请移步:
实践自定义UI—RLF...(RelativeLayout LinearLayout FrameLayout....)
接下来我们将利用ViewGroup实践自定义UI,首先还是看看效果图:
这个效果是来源于Keep_Growing群里面的一个小伙伴,好像是在项目中需要,问有没有开源的,后来我发现好像还真的没有(如果你知道,请告诉我,当然目前实现的功能还没有达到像ViewPager那么牛,这里主要是想让大家对利用ViewGroup自定义UI有个很好的认识),所有就想着自己利用ViewGroup实现这个效果。这里利用ViewGroup自定义UI控件,我们主要是注意一下下面两点:
1.定义规则、属性:定义一下布局规则,类似于LinearLayout中的orientation、RelativeLayout中的alignParentLeft等。这些规则主要是告诉我们这些子View如何放置他们的位置,以及如何设置大小等属性。
2.处理交互事件:主要是触摸事件的处理。
分解效果图
我们从上面的效果图可以很清晰的发现,ViewGroup的子child在滑动的时候,是可以放大和缩小的。那么我们的主要任务之一就是解决这个放大和缩小的效果。我们看一下进入界面的效果如下图:
从这个静态的页面可以看到,就是两个View,其中第二个View我们可以认为只是按照一定的比例缩小了。根据上面的分析,我们可以这么想象,在ViewGroup中我们添加的一定数量的子View,并且第一个View保持原始大小,剩下的View按一定比例缩小。他们的布局如下图所示:
在滑动的过程中,假如从右向左滑动,那么当前的View会逐渐缩小,下一个View会逐渐放大;假如从左向右滑动,当前的View会逐渐缩小,上一个View会逐渐放大(可以参考效果图理解)。
实现分解效果图
根据上面的分解我们来一步一步实现。
1.测量大小和布局
为了布局和设置大小的需要,这里我们定义两个属性:marginLeftRight和gutterSize,其中marginLeftRight是确定子View与left和right的间距,gutterSize是确定原始大小View与缩小View之间的距离。知道这两个属性后我们首先要确定每个View的大小,我们知道这个过程是在onMeasure()方法中完成的(其实onMeasure()方法就是确定当前ViewGroup和子View大小的地方,我们自定义View和ViewGroup都是一样的),这里还是直接看代码吧:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//设置默认大小,让当前的ViewGroup大小为充满屏幕
setMeasuredDimension(getDefaultSize(0, widthMeasureSpec),
getDefaultSize(0, heightMeasureSpec));
int measuredWidth = getMeasuredWidth();
int measuredHeight = getMeasuredHeight();
int childCount = getChildCount();
//每个子child的宽度为屏幕的宽度减去与两边的间距
int width = measuredWidth - (int) (mMarginLeftRight * 2);
int height = measuredHeight;
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY);
int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY);
for (int i = 0; i < childCount; i++) {
getChildAt(i).measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
//切换一个page需要移动的距离为一个page的宽度
mSwitchSize = width;
//确定缩放比例
confirmScaleRatio(width, mGutterSize);
}
这里首先设置的当前ViewGroup的大小,然后确定每个子View的大小。子View的高度是和ViewGroup的高度相同的,子View的宽度是需要减去刚才设置与两边的间距,并调用child.measure()方法确定子View的大小。
当前ViewGroup的大小和每个子View的大小确定了,接下来的工作就是确定他们在当前ViewGroup中的位置,这个工作当然由onLayout()方法来确定啦,还是直接看代码吧:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int originLeft = (int) mMarginLeftRight;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
int left = originLeft + child.getMeasuredWidth() * i;
int right = originLeft + child.getMeasuredWidth() * (i + 1);
int bottom = child.getMeasuredHeight();
child.layout(left, 0, right, bottom);
if (i != 0) {
child.setScaleX(SCALE_RATIO);
child.setScaleY(SCALE_RATIO);
}
}
}
其实这个位置确定的过程可以参考上面的示意图,首先按照原始的大小将每个子View通过调用child.layout()方法告诉他们在当前ViewGroup中的位置,他们在绘制自己的时候就会在给定的区域内绘制。当这些子View都确定位置时,他们是一个挨着一个的(结合上面的示意图就可以理解了),并没有缩小的效果图,我们调用child.setScaleX()和child.setScaleY()两个方法设置缩放的大小,那么当child在绘制的时候就会缩小。这里我们怎么知道缩小多少呢,还是看看代码:
private void confirmScaleRatio(int width, float gutterSize) {
SCALE_RATIO = (width - gutterSize * 2) / width;
}
这里是根据gutterSize的大小占用整个子View宽度大小的比例,就是缩小的比例,如果不是很理解这个计算方法,可以参考下图理解一下(这里我们原始大小的和缩小的叠加到了一起):
2.滑动效果
上面我们简单的将测量大小和布局的过程介绍了一下,接下来的工作就是左右滑动的效果实现了,以及处理好滑动过程中的放大和缩小的效果。为了会实现这个效果我们这里简单的介绍一下需要使用到的类和方法。
(1) Scroller
滑动的过程我们用到了Scroller这个类,它的主要作用是配合computeScroll(),让子View滑动到固定的位置。我们先看看Scoller中我们需要使用的方法:
startScroll(int startX, int startY, int dx, int dy, int duration)
这个方法主要的功能是模拟在duration的时间内,在X轴方向上从startX的位置(这里我们只关心X方向,Y方向类似)移动了dx的距离。在这个模拟移动的过程中通过getCurrX() 获取当前移动到的位置(其实这里大家可以自己查一下这个类的具体用法)。
(2) VelocityTracker
这个类的主要作用就是检测手势滑动的速度。我们滑动View的时候会有一定的速率,当达到一定的速率时我们切换子View。
(3) scrollBy(int x, int y)方法、scrollTo(int x, int y)方法和computeScroll()方法
scrollBy()方法是在X轴上移动距离为x和Y轴上移动距离为y;scrollTo()方法是移动到(x, y)的位置;computeScroll()方法在我们需要View进行重绘时,就会触发该方法。当我们需要在规定时间内将View从某个位置滑动到某个固定位置时,可以通过Scroller类模拟这个过程,并通过scrollTo方法配合使用,就可以达到View移动的效果。
接下来我们将利用上面介绍的方法实现滑动的效果。实现滑动的效果,肯定是对Touch事件的处理,还是直接看代码:
@Override
public boolean onTouchEvent(MotionEvent event) {
LogUtils.LogD(TAG, " onInterceptTouchEvent hit touch event");
final int actionIndex = MotionEventCompat.getActionIndex(event);
mActivePointerId = MotionEventCompat.getPointerId(event, 0);
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(event);
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
mDownX = event.getRawX();
if (mScroller != null && !mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
//calculate moving distance
float distance = -(event.getRawX() - mDownX);
mDownX = event.getRawX();
LogUtils.LogD(TAG, " current distance == " + distance);
performDrag((int)distance);
break;
case MotionEvent.ACTION_UP:
releaseViewForTouchUp();
cancel();
break;
}
return true;
}
private void performDrag(int distance) {
if (mOnPagerChangeListener != null){
mOnPagerChangeListener.onPageScrollStateChanged(SCROLL_STATE_DRAGGING);
}
LogUtils.LogD(TAG, " perform drag distance == " + distance);
scrollBy(distance, 0);
if (distance < 0) {
dragScaleShrinkView(mCurrentPosition, LEFT_TO_RIGHT);
} else {
LogUtils.LogD(TAG, " current direction is right to left and current child position = " + mCurrentPosition);
dragScaleShrinkView(mCurrentPosition, RIGHT_TO_LEFT);
}
}
这里处理的是在手指按住滑动的时候,child的变化,当然最主要的就是放大缩小的变化,由于draScaleShrinkView()方法的代码比较多,这里就不贴了,我们只要知道该方法就是处理按住左右滑动时child的放大和缩小。我们知道放大过程就是放大比例是从SCALE_RATIO变化到1.0,缩小的过程就是缩小比例从1.0变化到SCALE_RATIO。而且放大的过程是在SCALE_RATIO的基础上增加的,缩小的过程是在1.0的基础上减少的。所以移动过程中计算方法如下:
scaleRatio = SCALE_RATIO + (1.0f - SCALE_RATIO) * ratio;
shrinkRatio = 1.0f - (1.0f - SCALE_RATIO) * ratio;
我们在切换一个页面时需要移动的距离为mSwitchSize(这个值我们在前面设置的),那么切换完成后放大或者缩小都变化了(1.0-SCALE_RATIO)。那么在切换的过程中移动的距离与mSwitch的比值我们设为ratio,这个值的变化范围为:0-1。定义切换一个页面需要移动的距离为mSwitchSize,当前处于原始大小child的位置为position,当我们向左滑动的时候(向右滑动的过程大家可以试着算一下),计算的过程为:
int moveSize = getScrollX() - position * mSwitchSize;
float ratio = (float) moveSize / mSwitchSize;
这个计算的过程估计会有点难理解,大家还是自己想象一下滑动的过程,或者自己比划一下,这样便于理解(这里确实比较难理解,我也花了很长时间写着点内容,希望小伙伴们能自己比划一下_)。这个比例算好后直接调用下面的代码就可以实现缩放的效果了:
//放大
ViewCompat.setScaleX(scaleView, scaleRatio);
ViewCompat.setScaleY(scaleView, scaleRatio);
scaleView.invalidate();
//缩小
ViewCompat.setScaleX(shrinkView,shrinkRatio);
ViewCompat.setScaleY(shrinkView, shrinkRatio);
shrinkView.invalidate();
以上是滑动过程中的变化,用户一直处于按住拖动的状态。当用户松手之后,那么我们需要根据滑动的速率和当前移动的距离是否超过mSwitchSize(也就是页面的大小)的一半,判断是否切换页面。
private void releaseViewForTouchUp() {
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int initialVelocity = (int) VelocityTrackerCompat.getXVelocity(
velocityTracker, mActivePointerId);
float xVel = mVelocityTracker.getXVelocity();
//向左滑动,速度大于限定的值滑动到下一个页面
if (xVel > SNAP_VELOCITY && mCurrentPosition > 0) {
smoothScrollToItemView(mCurrentPosition - 1, true);
//向右滑动时,速度为负数,所以当小于限定值的负数滑动到上一个页面
} else if (xVel < -SNAP_VELOCITY && mCurrentPosition < getChildCount() - 1) {
smoothScrollToItemView(mCurrentPosition + 1, true);
} else {
//没有达到一定的速度,根据移动的距离确定滑动到哪个页面
smoothScrollToDes();
}
setScrollState(SCROLL_STATE_SETTLING);
}
private void smoothScrollToDes() {
//整个ViewGroup已经滑动的距离
int scrollX = getScrollX();
//确定滑动到哪个页面,mSwitchSize是切换一个页面ViewGroup需要滑动的距离
int position = (scrollX + mSwitchSize / 2) / mSwitchSize;
LogUtils.LogD(TAG, " smooth scroll to des position == before =" + mCurrentPosition
+ " scroll X = " + scrollX + " switch size == " + mSwitchSize + " position == " + position);
smoothScrollToItemView(position, mCurrentPosition == position);
}
private void smoothScrollToItemView(int position, boolean pageSelected) {
mCurrentPosition = position;
if (mCurrentPosition > getChildCount() - 1) {
mCurrentPosition = getChildCount() - 1;
}
if (mOnPagerChangeListener != null && pageSelected){
mOnPagerChangeListener.onPageSelected(position);
}
//确定滑动的距离
int dx = position * (getMeasuredWidth() - (int) mMarginLeftRight * 2) - getScrollX();
mScroller.startScroll(getScrollX(), 0, dx, 0, Math.min(Math.abs(dx) * 2, MAX_SETTLE_DURATION));
invalidate();
}
当调用Scroller.startScroll方法后会调用invalidate()方法,这个过程就会触发computeScroll()方法,我们看看在该方法中我们怎么处理滑动的效果吧,直接看代码:
@Override
public void computeScroll() {
if (!mScroller.isFinished() && mScroller.computeScrollOffset()) {
dragScaleShrinkView(mCurrentPosition, mCurrentDir);
scrollTo(mScroller.getCurrX(), 0);
}
}
上面我们说了,Scroller.startScroll方法只是模拟移动的过程,通过模拟的过程我们可以在duration的时间内获取移动到的位置(getCurrX()方法获取),正真的移动效果还是通过scrollTo()方法实现的,由于我们需要不停的获取和移动,所以就需要在模拟的时间内不停的调用scrollTo方法,该方法会触发整个View重绘,会再次调用computeScroll()方法,而我们通过调用Scroller.computeScollOffset()和Scroller.isFinished()方法检测模拟移动是否结束,从而达到平滑滑动的效果,这个过程中同时要实现放大缩小的效果,上面已经分析了,我就不详细的介绍了。
好了,上面我基本上把需要实现了滑屏以及滑动过程中放大缩小的效果了,这个过程其实涉及的东西还是蛮多的,也比较繁琐,不过不是非常的难。只要仔细的理解每一个过程,还是比较容易理解的,最主要还是多多练习!这里写的比较多,有可能看的比较晕,如果有兴趣的话可以看看源码吧!
总结
到此,把自定义UI的三种方法都一一进行了实践,相信对自定义UI应该有一个感性的认识了。其实更多的时候还是靠自己的练习,只有不断的实践才能提高。好了,就写这么多,如果有不明白的小伙伴,可以随时交流!
PS
在此感谢程序亦非猿_对 实践自定义UI三篇文章的促成,本来只是想写一些开源的控件,但是在他的鼓励下,最终写了这个系列的博客。
希望在Android学习的路上,大家共同成长!