Android 自定义控件之圆形页面指示器CirclePageIndicator带划动效果
前言
在app首次打开的指导页面和app内一些左右翻动的列表里经常会需要的一个页面指示器,像一般手机桌面也会有一个翻页的页面指示器,这次我们就来定制一个。
感谢
基本的逻辑来自JakeWharton/ViewPagerIndicator
他里面包含很多的样式,是一个很好的学习资料
不过他是纯英文的,也不维护了。但是很多人都还有在用,我也经常用在项目上,但是只会用是没办法解决心里头的疑惑。很想把它的源码拆出来瞧瞧,学习一下思维也是很棒的,这对于我们学习自定义控件有着非常大帮助,毕竟已经维护这么久了,也有很多人在用,全面性还是足够的。下面我就挑了圆形指示器进行解析和翻译。学习自定义控件的朋友应该能从中收获不少。
效果图
下图为GIF动图,点击查看
目标
定下要实现的功能
- 可以修改圆形指示器的大小
- 可以修改圆形指示器当前页的颜色
- 可以修改圆形指示器所有页面的颜色
- 可以修改圆形指示器所有页面的边框大小
- 可以修改圆形指示器所有页面的边框颜色
- 可以修改圆形指示器是否居中显示
- 可以修改圆形指示器是否支持拖动跟随效果
流程
做了这么多次自定义控件了,流程都应该熟读于心,这样子对于我们去看别人的自定义控件代码也更加的清晰,不会觉得一脸茫然,知道哪一步再到哪一步是非常重要的。
- 自定义属性
- 自定义默认属性
- 自定义接口
- 创建控件类继承View
- 声明属性变量
- 初始化属性信息
- 测量布局
- 绘制布局
- 记录变化信息
- 复写自定义接口
以上的黄色部分内容是自定义控件里最常见的,也是一般必定会有的,当我们查看别人的自定义控件时,一般也按照这个流程来整理。
自定义属性
文件路径 /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();
}
创建控件类继承View
/**
* 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的组合使用。