在app首次打开的指导页面和app内一些左右翻动的列表里经常会需要的一个页面指示器,像一般手机桌面也会有一个翻页的页面指示器,这次我们就来定制一个。
基本的逻辑来自JakeWharton/ViewPagerIndicator
他里面包含很多的样式,是一个很好的学习资料
不过他是纯英文的,也不维护了。但是很多人都还有在用,我也经常用在项目上,但是只会用是没办法解决心里头的疑惑。很想把它的源码拆出来瞧瞧,学习一下思维也是很棒的,这对于我们学习自定义控件有着非常大帮助,毕竟已经维护这么久了,也有很多人在用,全面性还是足够的。下面我就挑了圆形指示器进行解析和翻译。学习自定义控件的朋友应该能从中收获不少。
下图为GIF动图,点击查看
定下要实现的功能
做了这么多次自定义控件了,流程都应该熟读于心,这样子对于我们去看别人的自定义控件代码也更加的清晰,不会觉得一脸茫然,知道哪一步再到哪一步是非常重要的。
以上的黄色部分内容是自定义控件里最常见的,也是一般必定会有的,当我们查看别人的自定义控件时,一般也按照这个流程来整理。
文件路径 /res/values/attrs.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="CirclePageIndicator"> <!-- Whether or not the indicators should be centered. --> <!-- 指示器是否居中显示 --> <attr name="centered" format="boolean" /> <!-- Color of the filled circle that represents the current page. --> <!-- 当前页面指示器颜色 --> <attr name="selectColor" format="color"/> <!-- Color of the filled circles that represents pages. --> <!-- 全部页面指示器颜色 --> <attr name="pageColor" format="color"/> <!-- Radius of the circles. This is also the spacing between circles. --> <!-- 指示器半径 --> <attr name="radius" format="dimension"/> <!-- Whether or not the selected indicator snaps to the circles. --> <!-- 指示器是否锁定位置 --> <attr name="snap" format="boolean"/> <!-- Color of the open circles. --> <!-- 全部页面指示器空心圆边框颜色 --> <attr name="strokeColor" format="color"/> <!-- Width of the stroke used to draw the circles. --> <!-- 全部页面指示器空心圆边框宽度 --> <attr name="strokeWidth" format="dimension" /> <!-- View background --> <!-- 页面指示器背景 --> <attr name="android:background"/> </declare-styleable> </resources>
文件路径 /res/values/defaults.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <!-- 默认资源集 --> <!-- 默认指示器是否位于中间 --> <bool name="default_circle_indicator_centered">true</bool> <!-- 默认指示器全部页面实心圆颜色 --> <color name="default_circle_indicator_page_color">#00000000</color> <!-- 默认指示器当前页面实心圆颜色 --> <color name="default_circle_indicator_select_color">#FFFFFFFF</color> <!-- 默认指示器全部页面空心圆边框颜色 --> <color name="default_circle_indicator_stroke_color">#FFDDDDDD</color> <!-- 默认指示器全部页面空心圆边框宽度 --> <dimen name="default_circle_indicator_stroke_width">1dp</dimen> <!-- 默认指示器圆形半径 --> <dimen name="default_circle_indicator_radius">4dp</dimen> <!-- 默认指示器是否锁定在位置上,设置为false的话,指示器就会跟着手指划动而移动 --> <bool name="default_circle_indicator_snap">false</bool> </resources>
新建文件 PageIndicator.class 继承页面改变监听器,这样我们除了自己自定义的接口外也能复写页面改变事件的接口
/** * 接口 * A PageIndicator is responsible to show an visual indicator on the total views * number and the current visible view. */ public interface PageIndicator extends ViewPager.OnPageChangeListener { /** * Bind the indicator to a ViewPager. * 绑定指示器和viewpager * @param view */ void setViewPager(ViewPager view); /** * Bind the indicator to a ViewPager. * 绑定指示器和viewpager * @param view * @param initialPosition */ void setViewPager(ViewPager view, int initialPosition); /** * <p>Set the current page of both the ViewPager and indicator.</p> * <p/> * <p>This <strong>must</strong> be used if you need to set the page before * the views are drawn on screen (e.g., default start page).</p> * 跳到当前选项页面 * @param item */ void setCurrentItem(int item); /** * Set a page change listener which will receive forwarded events. * 设置监听器 * @param listener */ void setOnPageChangeListener(ViewPager.OnPageChangeListener listener); /** * Notify the indicator that the fragment list has changed. * 更新 */ void notifyDataSetChanged(); }
/** * Draws circles (one for each view). The current view position is filled and * others are only stroked. * 页面指示器 */ public class CirclePageIndicator extends View implements PageIndicator { public CirclePageIndicator(Context context) { super(context); } public CirclePageIndicator(Context context, @Nullable AttributeSet attrs) { super(context, attrs); } @Override public void setViewPager(ViewPager view) { } @Override public void setViewPager(ViewPager view, int initialPosition) { } @Override public void setCurrentItem(int item) { } @Override public void setOnPageChangeListener(ViewPager.OnPageChangeListener listener) { } @Override public void notifyDataSetChanged() { } @Override public void onPageScrolled(int i, float v, int i1) { } @Override public void onPageSelected(int i) { } @Override public void onPageScrollStateChanged(int i) { } }
/** 无效手指指示 */ private static final int INVALID_POINTER = -1; /** 上下文 */ private Context mContext; /** 圆形指示器半径 */ private float mRadius; /** 所有页面圆形指示器实心圆画笔 */ private final Paint mPaintPageFill = new Paint(ANTI_ALIAS_FLAG); /** 所有页面圆形指示器边框空心圆画笔 */ private final Paint mPaintStroke = new Paint(ANTI_ALIAS_FLAG); /** 当前页面圆形指示器实心圆画笔 */ private final Paint mPaintSelect = new Paint(ANTI_ALIAS_FLAG); /** 页面管理控件 */ private ViewPager mViewPager; /** 页面改变监听器 */ private ViewPager.OnPageChangeListener mListener; /** 当前页面号码 */ private int mCurrentPage; /** 保存页面号码 */ private int mSnapPage; /** 页面划动时的偏移距离 */ private float mPageOffset; /** 页面划动条状态 */ private int mScrollState; /** 指示器是否居中显示 */ private boolean mCentered; /** 指示器是否锁定在位置上,设置为false的话,指示器就会跟着手指划动而移动 */ private boolean mSnap; /** 控件最小可触发响应的划动距离 */ private int mTouchSlop; /** 手指按下位置 */ private float mLastMotionX = -1; /** 响应的手指ID */ private int mActivePointerId = INVALID_POINTER; /** 手指是否在拖动中 */ private boolean mIsDragging;
public CirclePageIndicator(Context context) { super(context); mContext = context; init(null); } public CirclePageIndicator(Context context, AttributeSet attrs){ super(context, attrs); mContext = context; init(attrs); } /** * 初始化 * @param attrs */ private void init(AttributeSet attrs) { // Load defaults from resources // 加载默认资源 final Resources res = getResources(); // 默认当前页面指示器实心圆颜色 final int defaultPageColor = res.getColor(R.color.default_circle_indicator_page_color); // 默认全部页面指示器实心圆颜色 final int defaultFillColor = res.getColor(R.color.default_circle_indicator_select_color); // 默认全部页面指示器空心圆边框颜色 final int defaultStrokeColor = res.getColor(R.color.default_circle_indicator_stroke_color); // 默认全部页面指示器空心圆边框宽度 final float defaultStrokeWidth = res.getDimension(R.dimen.default_circle_indicator_stroke_width); // 默认指示器圆形半径 final float defaultRadius = res.getDimension(R.dimen.default_circle_indicator_radius); // 默认指示器是否居中 final boolean defaultCentered = res.getBoolean(R.bool.default_circle_indicator_centered); // 默认指示器是否锁定在位置上 final boolean defaultSnap = res.getBoolean(R.bool.default_circle_indicator_snap); // Retrieve styles attributes // 读取配置信息 TypedArray a = mContext.obtainStyledAttributes(attrs, R.styleable.CirclePageIndicator); mCentered = a.getBoolean(R.styleable.CirclePageIndicator_centered, defaultCentered); mPaintPageFill.setStyle(Style.FILL); mPaintPageFill.setColor(a.getColor(R.styleable.CirclePageIndicator_pageColor, defaultPageColor)); mPaintStroke.setStyle(Style.STROKE); mPaintStroke.setColor(a.getColor(R.styleable.CirclePageIndicator_strokeColor, defaultStrokeColor)); mPaintStroke.setStrokeWidth(a.getDimension(R.styleable.CirclePageIndicator_strokeWidth, defaultStrokeWidth)); mPaintSelect.setStyle(Style.FILL); mPaintSelect.setColor(a.getColor(R.styleable.CirclePageIndicator_selectColor, defaultFillColor)); mRadius = a.getDimension(R.styleable.CirclePageIndicator_radius, defaultRadius); mSnap = a.getBoolean(R.styleable.CirclePageIndicator_snap, defaultSnap); Drawable background = a.getDrawable(R.styleable.CirclePageIndicator_android_background); if (null != background) { setBackgroundDrawable(background); } // 释放内存,回收资源 a.recycle(); // 拿到控件的最小划动距离 final ViewConfiguration configuration = ViewConfiguration.get(mContext); mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration); }
/** * 测量 * @param widthMeasureSpec * @param heightMeasureSpec */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 保存测量数据 setMeasuredDimension(measureLong(widthMeasureSpec), measureShort(heightMeasureSpec)); } /** * Determines the width of this view * * @param measureSpec A measureSpec packed into an int * @return The width of the view, honoring constraints from measureSpec */ private int measureLong(int measureSpec) { int result; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); if ((specMode == MeasureSpec.EXACTLY) || (mViewPager == null)) { // We were told how big to be // 给定数据 result = specSize; } else { // Calculate the width according the views count // 根据页面数量来计算宽度,最后加1为了保证后面绘制时数据计算成float也能有数据冗余 final int count = mViewPager.getAdapter().getCount(); result = (int) (getPaddingLeft() + getPaddingRight() + (count * 2 * mRadius) + (count - 1) * mRadius + 1); //Respect AT_MOST value if that was what is called for by measureSpec if (specMode == MeasureSpec.AT_MOST) { result = Math.min(result, specSize); } } return result; } /** * Determines the height of this view * * @param measureSpec A measureSpec packed into an int * @return The height of the view, honoring constraints from measureSpec */ private int measureShort(int measureSpec) { int result; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); if (specMode == MeasureSpec.EXACTLY) { // We were told how big to be // 给定数据 result = specSize; } else { // Measure the height result = (int) (2 * mRadius + getPaddingTop() + getPaddingBottom() + 1); //Respect AT_MOST value if that was what is called for by measureSpec if (specMode == MeasureSpec.AT_MOST) { result = Math.min(result, specSize); } } return result; }
/** * 绘制 * @param canvas */ @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 绑定的页面管理控件不能为空 if (mViewPager == null) { return; } // 绑定的页面数量也不能为0 final int count = mViewPager.getAdapter().getCount(); if (count == 0) { return; } // 如果当前页的页码大于全部页面数量,就要修改当前页为最后一页 if (mCurrentPage >= count) { setCurrentItem(count - 1); return; } // 声明变量 int longSize = getWidth(); // 整体长度 int longPaddingBefore = getPaddingLeft(); // 距离前面的长度 int longPaddingAfter = getPaddingRight(); // 距离后面的长度 int shortPaddingBefore = getPaddingTop(); // 距离上面的长度 // 一个指示器占据的位置,一个圆形再加上左边边界半个圆和右边边界半个圆 final float fourRadius = mRadius * 4; // 短边偏移量,举例:横着排列,那就是顶部距离到指示器圆中心点 final float shortOffset = shortPaddingBefore + mRadius; // 长边偏移量,举例:横着排列,那就是左边距离到指示器圆中心点 float longOffset = longPaddingBefore + fourRadius / 2.0f; if (mCentered) { // 如果指示器居中显示,计算中间开始位置 longOffset += ((longSize - longPaddingBefore - longPaddingAfter) / 2.0f) - ((count * fourRadius) / 2.0f); } // 圆心X轴 float dX; // 圆心Y轴 float dY; // 全部页面指示器实心圆半径 float pageFillRadius = mRadius; if (mPaintStroke.getStrokeWidth() > 0) { // 全部页面指示器实心圆半径 为 设定圆半径减去空心圆宽度的一半 pageFillRadius -= mPaintStroke.getStrokeWidth() / 2.0f; } // 循环绘制全部页面指示器 for (int iLoop = 0; iLoop < count; iLoop++) { // 记录XY轴位置 dX = longOffset + (iLoop * fourRadius); dY = shortOffset; // Only paint fill if not completely transparent if (mPaintPageFill.getAlpha() > 0) { // 绘制全部页面指示器实心圆 canvas.drawCircle(dX, dY, pageFillRadius, mPaintPageFill); } // Only paint stroke if a stroke width was non-zero if (pageFillRadius != mRadius) { // 绘制全部页面指示器空心圆外框 canvas.drawCircle(dX, dY, mRadius, mPaintStroke); } } // Draw the filled circle according to the current scroll // 绘制当前页面页面指示器 float cx = (mSnap ? mSnapPage : mCurrentPage) * fourRadius; if (!mSnap) { // 如果不锁定位置,那么就要在划动时记录页面间偏移量,然后在这里加上 cx += mPageOffset * fourRadius; } // 记录XY轴位置 dX = longOffset + cx; dY = shortOffset; // 绘制当前页面实心圆 canvas.drawCircle(dX, dY, mRadius, mPaintSelect); }
/** * 拦截触控事件 * @param ev * @return */ @Override public boolean onTouchEvent(MotionEvent ev) { if (super.onTouchEvent(ev)) { return true; } // 绑定的页面管理控件不能为空并且页面数量不能为0 if ((mViewPager == null) || (mViewPager.getAdapter().getCount() == 0)) { return false; } // 拿到当前动作 final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; switch (action) { case MotionEvent.ACTION_DOWN: // 手指按下时 // 拿到手指ID mActivePointerId = MotionEventCompat.getPointerId(ev, 0); // 将放下手指的位置记录下来 mLastMotionX = ev.getX(); break; case MotionEvent.ACTION_MOVE: { // 手指在屏幕上移动时 // 根据手指按下时记录的手指ID去获得活动手指的Index final int activePointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); // 拿到这个手指当前所在的X轴位置 final float x = MotionEventCompat.getX(ev, activePointerIndex); // 计算移动的距离 final float deltaX = x - mLastMotionX; // 如果上一次记录的没在移动中 if (!mIsDragging) { // 那么这一次就要看移动距离的绝对值(Math.abs)有没有大过最小识别的移动距离 if (Math.abs(deltaX) > mTouchSlop) { // 有的话,就记录为移动中 mIsDragging = true; } } // 如果在移动中 if (mIsDragging) { // 记录手指当前位置 mLastMotionX = x; // 如果是虚假的移动 if (mViewPager.isFakeDragging() || mViewPager.beginFakeDrag()) { // 使用fakeDragBy将页面偏移手指移动的距离 mViewPager.fakeDragBy(deltaX); } } break; } case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: // 手指离开屏幕时 // 上一个状态不是移动中 if (!mIsDragging) { final int count = mViewPager.getAdapter().getCount(); final int width = getWidth(); final float halfWidth = width / 2f; final float sixthWidth = width / 6f; if ((mCurrentPage > 0) && (ev.getX() < halfWidth - sixthWidth)) { // 当前页面不是第一页、上一个状态是划动中、最后手指的位置在屏幕的左边1/3的位置内,就当作要往后翻一页 if (action != MotionEvent.ACTION_CANCEL) { mViewPager.setCurrentItem(mCurrentPage - 1); } return true; // 当前页面不是最后一页、上一个状态是划动中、最后手指的位置在屏幕的右边1/3的位置内,就当作要往前翻一页 } else if ((mCurrentPage < count - 1) && (ev.getX() > halfWidth + sixthWidth)) { if (action != MotionEvent.ACTION_CANCEL) { mViewPager.setCurrentItem(mCurrentPage + 1); } return true; } } // 移动状态归否 mIsDragging = false; // 手指头ID清空 mActivePointerId = INVALID_POINTER; // 结束页面管理器的假动作 if (mViewPager.isFakeDragging()) mViewPager.endFakeDrag(); break; case MotionEventCompat.ACTION_POINTER_DOWN: { final int index = MotionEventCompat.getActionIndex(ev); mLastMotionX = MotionEventCompat.getX(ev, index); mActivePointerId = MotionEventCompat.getPointerId(ev, index); break; } case MotionEventCompat.ACTION_POINTER_UP: final int pointerIndex = MotionEventCompat.getActionIndex(ev); final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); if (pointerId == mActivePointerId) { final int newPointerIndex = pointerIndex == 0 ? 1 : 0; mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex); } mLastMotionX = MotionEventCompat.getX(ev, MotionEventCompat.findPointerIndex(ev, mActivePointerId)); break; } return true; } /** * 复现 * @param state */ @Override public void onRestoreInstanceState(Parcelable state) { SavedState savedState = (SavedState) state; super.onRestoreInstanceState(savedState.getSuperState()); mCurrentPage = savedState.currentPage; mSnapPage = savedState.currentPage; requestLayout(); } /** * 保存 * @return */ @Override public Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState savedState = new SavedState(superState); savedState.currentPage = mCurrentPage; return savedState; } /** * 保存状态类 */ static class SavedState extends BaseSavedState { int currentPage; public SavedState(Parcelable superState) { super(superState); } private SavedState(Parcel in) { super(in); currentPage = in.readInt(); } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeInt(currentPage); } @SuppressWarnings("UnusedDeclaration") public static final Creator<SavedState> CREATOR = new Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; }
/** * 设置页面管理器 * @param view */ @Override public void setViewPager(ViewPager view) { if (mViewPager == view) { return; } if (mViewPager != null) { mViewPager.setOnPageChangeListener(null); } if (view.getAdapter() == null) { throw new IllegalStateException("ViewPager does not have adapter instance."); } mViewPager = view; mViewPager.setOnPageChangeListener(this); invalidate(); } /** * 设置页面管理器,同时定义了当前页面 * @param view * @param initialPosition */ @Override public void setViewPager(ViewPager view, int initialPosition) { setViewPager(view); setCurrentItem(initialPosition); } /** * 设置当前页面 * @param item */ @Override public void setCurrentItem(int item) { if (mViewPager == null) { throw new IllegalStateException("ViewPager has not been bound."); } mViewPager.setCurrentItem(item); mCurrentPage = item; invalidate(); } /** * 设置页面改变监听器 * @param listener */ @Override public void setOnPageChangeListener(ViewPager.OnPageChangeListener listener) { mListener = listener; } /** * 请求重新加载 */ @Override public void notifyDataSetChanged() { invalidate(); } /** * 页面滚动时 * @param i 位置 * @param v 偏移距离 * @param i1 偏移像素距离 */ @Override public void onPageScrolled(int i, float v, int i1) { mCurrentPage = i; mPageOffset = v; invalidate(); if (mListener != null) { mListener.onPageScrolled(i, v, i1); } } /** * 页面选择 * @param i 位置 */ @Override public void onPageSelected(int i) { if (mSnap || mScrollState == ViewPager.SCROLL_STATE_IDLE) { mCurrentPage = i; mSnapPage = i; invalidate(); } if (mListener != null) { mListener.onPageSelected(i); } } /** * 页面划动状态改变 * @param i 状态 */ @Override public void onPageScrollStateChanged(int i) { mScrollState = i; if (mListener != null) { mListener.onPageScrollStateChanged(i); } }
在布局文件中添加:
<com.dlong.rep.dlflipviewpage.indicator.CirclePageIndicator android:id="@+id/indicator" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:paddingBottom="10dp" app:pageColor="@color/colorAccent" app:radius="6dp" app:selectColor="@color/colorPrimary" app:strokeWidth="0dp" />
在活动中使用
private ViewPager view_pager; private CirclePageIndicator indicator;
获得控件,指示器要和ViewPager一起使用
private void initview() { view_pager = (ViewPager) findViewById(R.id.viewPager); indicator = (CirclePageIndicator) findViewById(R.id.indicator); }
indicator.setViewPager(view_pager);
下一篇介绍ViewPager+GridView的组合使用。