MagicIndicator系列之三 —— MagicIndicator原理浅析及扩展MagicIndicator的4种方式
来源:互联网 发布:日本殖民地知乎美国 编辑:程序博客网 时间:2024/04/29 23:32
这是 MagicIndicator 系列的第三篇文章,如果你没有看过前两篇,建议出门先看一下。当然你不看也没关系,我用一句话来介绍它: MagicIndicator 是一个可定制、易扩展的页面指示器框架,使用它可极大的简化页面指示器的开发。
本文将给大家简单阐述 MagicIndicator 的原理,并介绍 4 种扩展 MagicIndicator 的方式,分别是:
- 继承 IPagerNavigator 打造任意的指示效果
- 继承 IPagerTitleView 打造任意效果的指示器标题
- 继承 IPagerIndicator 打造任意效果的指示器
- 使用 CommonPagerTitleView 加载自定义布局
使用这四种方法,基本可以搞定所有的指示器效果,没有做不到,只有想不到!
原理浅析
MagicIndicator 其实非常简单。和其它所有指示器框架一样,也是通过监听 ViewPager.OnPageChangeListener 来实现切换效果的。但 MagicIndicator 有两点明显不同:
MagicIndicator 不提供 setViewPager 方法来和 ViewPager 强绑定,因此在不使用 ViewPager 的情况下(比如手动切换 Fragment,轻量级的广告轮播控件,ViewFlipper 等),也是可以使用 MagicIndicator 的,只需要你手动调用 onPageXXX 系列方法。
MagicIndicator 将指示器进行了抽象,意在通过扩展来实现不同的切换效果,而不是像其他所有指示器框架那样,提供了一大堆的 setter 方法,却只能实现很有限的切换效果。
在布局文件中使用的 标签,本质上就是一个 FrameLayout:
public class MagicIndicator extends FrameLayout { private IPagerNavigator mNavigator; public MagicIndicator(Context context) {...} public MagicIndicator(Context context, AttributeSet attrs) {...} public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { if (mNavigator != null) { mNavigator.onPageScrolled(position, positionOffset, positionOffsetPixels); } } public void onPageSelected(int position) { if (mNavigator != null) { mNavigator.onPageSelected(position); } } public void onPageScrollStateChanged(int state) { if (mNavigator != null) { mNavigator.onPageScrollStateChanged(state); } } public void setNavigator(IPagerNavigator navigator) {...}}
在 MagicIndicator 中,指示器(也许叫导航器更为恰当)被抽象成了 IPagerNavigator,设置到 MagicIndicator 类中的 IPagerNavigator 被作为唯一的子元素添加到其中。onPageXXX 系列回调原封不动的传递给了 IPagerNavigator。因此,要想实现不同的指示器效果,只需继承任意的 View 并实现 IPagerNavigator 接口即可。
考虑大多数情况下的指示器(导航器)都类似下面的效果:
MagicIndicator 中内置了一个 CommonNavigator 来简化这样的指示器(导航器)的开发,CommonNavigator 继承了 FrameLayout 并实现了 IPagerNavigator 接口,并根据指示器标题是否可变(数目是否可变,比如新闻应用的频道数就可变)来加载不同的子元素(布局文件),如下:
指示器标题可变,可滚动
<?xml version="1.0" encoding="utf-8"?><HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/scroll_view" android:layout_width="match_parent" android:layout_height="match_parent" android:fadingEdge="none" android:scrollbars="none"> <FrameLayout android:layout_width="wrap_content" android:layout_height="match_parent"> <LinearLayout android:id="@+id/indicator_container" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal" /> <LinearLayout android:id="@+id/title_container" android:layout_width="wrap_content" android:layout_height="match_parent" android:orientation="horizontal" /> </FrameLayout></HorizontalScrollView>
指示器标题不可变
<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:id="@+id/indicator_container" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal" /> <LinearLayout android:id="@+id/title_container" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal" /></FrameLayout>
这两个布局中都有两个 LinearLayout,分别是 indicator_container 和 title_container,它俩被放在一个 FrameLayout 中,我想你已经明白了:指示器标题在指示器的上方,分别位于两层,互不影响。indicator_container 的宽高和 title_container 相等。
title_container 的子元素被抽象成了 IPagerTitleView,如下:
public interface IPagerTitleView { /** * 被选中 */ void onSelected(int index, int totalCount); /** * 未被选中 */ void onDeselected(int index, int totalCount); /** * 离开 * * @param leavePercent 离开的百分比, 0.0f - 1.0f * @param leftToRight 从左至右离开 */ void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight); /** * 进入 * * @param enterPercent 进入的百分比, 0.0f - 1.0f * @param leftToRight 从左至右进入 */ void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight);}
onPageXXX 系列回调被 NavigatorHelper 转换成了 onEnter、onLeave、onSelected、onDeselected 4 个回调传递给 IPagerTitleView。通过这 4 个回调,可实现各种各样炫酷的效果。关于 onEnter 和 onLeave 回调,我打个比方:从 ViewPager 的第 2 页切换到第 3 页过程中,第 2 个 IPagerTitleView 会不断收到 onLeave 回调,leavePerent 从 0.0f 渐变为 1.0f,leftToRight 始终为 true,第 3 个 IPagerTitleView 会不断收到 onEnter 回调, enterPercent 从 0.0f 渐变成 1.0f,leftToRight 始终为 true。
indicator_container 仅有一个子元素且它被抽象成了 IPagerIndicator:
public interface IPagerIndicator { void onPageScrolled(int position, float positionOffset, int positionOffsetPixels); void onPageSelected(int position); void onPageScrollStateChanged(int state); void onPositionDataProvide(List<PositionData> dataList);}
onPageXXX 系列回调原封不动的传递给了它,此外,还有个最重要的 onPositionDataProvide 回调,这个是干嘛的呢?试想一下,如果要使得扩展 IPagerIndicator 可以实现任意的切换效果,那最起码应该把每一个 IPagerTitleView 的位置信息传递给 IPagerIndicator 吧,有了这些位置信息,继承 View 并实现 IPagerIndicator 后,不论是画圆还是画直线,或是画上图中的贝塞尔吸附式效果,才有坐标可循啊。
我们看一下 PositionData 类:
public class PositionData { public int mLeft; public int mTop; public int mRight; public int mBottom; public int mContentLeft; public int mContentTop; public int mContentRight; public int mContentBottom; public int width() { return mRight - mLeft; } public int height() { return mBottom - mTop; } public int contentWidth() { return mContentRight - mContentLeft; } public int contentHeight() { return mContentBottom - mContentTop; } public int horizontalCenter() { return mLeft + width() / 2; } public int verticalCenter() { return mTop + height() / 2; }}
PositionData 中不仅封装了 IPagerTitleView 上下左右的位置,还封装了其内容区域的位置,有内容区域的位置,我们才可能实现上图中第三个指示器效果:不论 IPagerTitleView 的宽度如何变化,直线宽度始终和内容宽度相等。
由于 IPagerTitleView 是抽象的,CommonNavigator 不可能知道其内容区域的边界到底在哪里,因此还得我们告诉它,要提供内容边界给 CommonNavigator,实现 IMeasurablePagerTitleView 即可:
public interface IMeasurablePagerTitleView extends IPagerTitleView { int getContentLeft(); int getContentTop(); int getContentRight(); int getContentBottom();}
如果不实现 IMeasuablePagerTitleView,则默认内容区域边界就是 IPagerTitleView 的边界(mLeft,mTop,mRight,mBottom)。
继承 IPagerNavigator
一般情况下,使用 CommonNavigator 就能满足需求。但是当遇到一些明显 CommonNavigator 搞不定的情况,比如 Smartisan OS 桌面的指示器效果:
就需要继承 View,实现 IPagerNavigator 接口,拿起手里的 Canvas 开画吧!
额,今天就不去实现这个效果了,因为需要处理的细节比较多,后面我处理好后会把这个效果上传到 demo 中,我们来个简单的,效果如下:
这个效果没有跟随手指的过渡,看起来比较呆板,我就叫它 DummyCircleNavigator 吧:
public class DummyCircleNavigator extends View implements IPagerNavigator { public DummyCircleNavigator(Context context) { super(context); } @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { } @Override public void onPageSelected(int position) { } @Override public void onPageScrollStateChanged(int state) { } // 被添加到 magicindicator 时调用 @Override public void onAttachToMagicIndicator() { } // 从 magicindicator 上移除时调用 @Override public void onDetachFromMagicIndicator() { } // 当指示数目改变时调用 @Override public void notifyDataSetChanged() { }}
除了实现 onPageXXX 系列回调,还需要实现 onAttachToMagicIndicator、onDetachFromMagicIndicator、notifyDataSetChanged 三个方法。
我们需要让外部来配置圆的半径、颜色、数量,圆之间的间距以及圆的描边宽度。同时,我们需要一个变量来表示当前选中了哪一个圆,当然,画笔也必不可少:
private int mRadius;private int mCircleColor;private int mStrokeWidth;private int mCircleSpacing;private int mCurrentIndex;private int mCircleCount;private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
根据用户设置的 mCircleSpacing,mRadius,mCircleCount,结合当前的宽度,我们可以计算出每一个圆的圆心位置:
private List<PointF> mCirclePoints = new ArrayList<PointF>();@Overrideprotected void onLayout(boolean changed, int left, int top, int right, int bottom) { prepareCirclePoints();}private void prepareCirclePoints() { mCirclePoints.clear(); if (mCircleCount > 0) { int y = getHeight() / 2; int measureWidth = mCircleCount * mRadius * 2 + (mCircleCount - 1) * mCircleSpacing; int centerSpacing = mRadius * 2 + mCircleSpacing; int startX = (getWidth() - measureWidth) / 2 + mRadius; for (int i = 0; i < mCircleCount; i++) { PointF pointF = new PointF(startX, y); mCirclePoints.add(pointF); startX += centerSpacing; } }}
圆心位置已准备就绪,那就开画吧:
@Overrideprotected void onDraw(Canvas canvas) { drawDeselectedCircles(canvas); drawSelectedCircle(canvas);}private void drawDeselectedCircles(Canvas canvas) { mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(mStrokeWidth); mPaint.setColor(mCircleColor); for (int i = 0, j = mCirclePoints.size(); i < j; i++) { PointF pointF = mCirclePoints.get(i); canvas.drawCircle(pointF.x, pointF.y, mRadius, mPaint); }}private void drawSelectedCircle(Canvas canvas) { mPaint.setStyle(Paint.Style.FILL); if (mCirclePoints.size() > 0) { float selectedCircleX = mCirclePoints.get(mCurrentIndex).x; canvas.drawCircle(selectedCircleX, getHeight() / 2, mRadius, mPaint); }}
最后,不要忘了给 mCurrentIndex 赋值,同时,mCircleCount 变化时需要重新计算圆心位置:
@Overridepublic void onPageSelected(int position) { mCurrentIndex = position; invalidate();}public void setCircleCount(int circleCount) { mCircleCount = circleCount;}@Overridepublic void notifyDataSetChanged() { prepareCirclePoints(); invalidate();}
注意,setCircleCount 方法中,并没有重新计算圆心位置,而是希望外部调用 notifyDataSetChanged 来计算并刷新。希望自定义的 IPagerNavigator 都应该遵守此约定。
好了,大功告成了,是不是很容易!
继承 IPagerTitleView
如果你使用了 CommonNavigator,但是内置的 IPagerTitleView 无法满足需求,那就自定义 IPagerTitleView 吧。比如,简书的这种效果靠内置的 IPagerTitleView 是 hold 不住的:
因为它既不是跟随手指渐变,也不是抬起手指(onPageSelected)才去改变颜色。而是在滑动一段距离后且手指未抬起时去改变颜色。
我们来实现这种效果:直接继承 TextView 并实现 IPagerTitleView,在 onEnter 回调中做判断,如果 enterPercent 大于设定的阈值,就将文字颜色设为选中颜色,否则,设为未选中颜色,代码如下:
public class ColorFlipPagerTitleView extends TextView implements IPagerTitleView { private int mNormalColor; private int mSelectedColor; private float mChangePercent = 0.45f; public ColorFlipPagerTitleView(Context context) { super(context); setGravity(Gravity.CENTER); int padding = UIUtil.dip2px(context, 10); setPadding(padding, 0, padding, 0); setSingleLine(); setEllipsize(TextUtils.TruncateAt.END); } @Override public void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight) { if (leavePercent >= mChangePercent) { setTextColor(mNormalColor); } else { setTextColor(mSelectedColor); } } @Override public void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight) { if (enterPercent >= mChangePercent) { setTextColor(mSelectedColor); } else { setTextColor(mNormalColor); } } // 部分 setter、getter 略}
如果你还想提供内容的边界,那就继承 IMeasuablePagerTitleView 吧,并实现以下方法:
@Overridepublic int getContentLeft() { Rect bound = new Rect(); getPaint().getTextBounds(getText().toString(), 0, getText().length(), bound); int contentWidth = bound.width(); return getLeft() + getWidth() / 2 - contentWidth / 2;}@Overridepublic int getContentTop() { Paint.FontMetrics metrics = getPaint().getFontMetrics(); float contentHeight = metrics.bottom - metrics.top; return (int) (getHeight() / 2 - contentHeight / 2);}@Overridepublic int getContentRight() { Rect bound = new Rect(); getPaint().getTextBounds(getText().toString(), 0, getText().length(), bound); int contentWidth = bound.width(); return getLeft() + getWidth() / 2 + contentWidth / 2;}@Overridepublic int getContentBottom() { Paint.FontMetrics metrics = getPaint().getFontMetrics(); float contentHeight = metrics.bottom - metrics.top; return (int) (getHeight() / 2 + contentHeight / 2);}
效果如下:
继承 IPagerIndicator
如果你使用了 CommonNavigator,但是内置的 IPagerIndicator hold不住你的需求,那就自定义吧。
目前内置的 IPagerIndicator 全是跟随手指滑动的,我们来打造一个简单的、不跟随的指示器。这个指示器会在被选中的 IPagerTitleView 下方显示一个小点。
我们继承 View 并实现 IPagerIndicator,代码很短,我就全贴代码了:
public class DotPagerIndicator extends View implements IPagerIndicator { private List<PositionData> mDataList; private float mRadius; private float mYOffset; private float mCircleCenterX; private int mDotColor; private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); public DotPagerIndicator(Context context) { super(context); } @Override public void onPageSelected(int position) { if (mDataList == null || mDataList.isEmpty()) { return; } PositionData data = mDataList.get(position); mCircleCenterX = data.mLeft + data.width() / 2; invalidate(); } @Override public void onPositionDataProvide(List<PositionData> dataList) { mDataList = dataList; } @Override protected void onDraw(Canvas canvas) { mPaint.setColor(mDotColor); canvas.drawCircle(mCircleCenterX, getHeight() - mYOffset - mRadius, mRadius, mPaint); } // 一些 getter、setter 略}
效果如下:
是不是很简单!
使用 CommonPagerTitleView 加载自定义布局
每当内置的 IPagerTitleView 不满足需求时,你可以选择扩展它,但更好的方式是使用 CommonPagerTitleView。CommonPagerTitleView 继承 FrameLayout 并实现了 IMeasurablePagerTitleView,它支持将自定义的布局文件设置进来,并且把 onEnter、onLeave … getContentLeft、getContentTop 等方法都回调出去,交给外面去实现,代码如下:
public class CommonPagerTitleView extends FrameLayout implements IMeasurablePagerTitleView { private OnPagerTitleChangeListener mOnPagerTitleChangeListener; private ContentPositionDataProvider mContentPositionDataProvider; public CommonPagerTitleView(Context context) { super(context); } public void setContentView(int layoutId) { View child = LayoutInflater.from(getContext()).inflate(layoutId, null); setContentView(child, null); } @Override public void onSelected(int index, int totalCount) { if (mOnPagerTitleChangeListener != null) { mOnPagerTitleChangeListener.onSelected(index, totalCount); } } // 省略一部分方法 public interface OnPagerTitleChangeListener { void onSelected(int index, int totalCount); void onDeselected(int index, int totalCount); void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight); void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight); } public interface ContentPositionDataProvider { int getContentLeft(); int getContentTop(); int getContentRight(); int getContentBottom(); }}
上面的大图中的最后一个效果就是这么做的。我们先定义一个布局文件:
<?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:orientation="vertical" android:paddingLeft="10dp" android:paddingRight="10dp"> <ImageView android:id="@+id/title_img" android:layout_width="20dp" android:layout_height="20dp" /> <TextView android:id="@+id/title_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="5dp" /></LinearLayout>
再将布局文件设置到 CommonPagerTitleView 并进行初始化:
@Overridepublic IPagerTitleView getTitleView(Context context, final int index) { CommonPagerTitleView commonPagerTitleView = new CommonPagerTitleView(MainActivity.this); commonPagerTitleView.setContentView(R.layout.simple_pager_title_layout); // 初始化 final ImageView titleImg = (ImageView) commonPagerTitleView.findViewById(R.id.title_img); titleImg.setImageResource(R.mipmap.ic_launcher); final TextView titleText = (TextView) commonPagerTitleView.findViewById(R.id.title_text); titleText.setText(mDataList.get(index)); commonPagerTitleView.setOnPagerTitleChangeListener(new CommonPagerTitleView.OnPagerTitleChangeListener() { @Override public void onSelected(int index, int totalCount) { titleText.setTextColor(Color.RED); } @Override public void onDeselected(int index, int totalCount) { titleText.setTextColor(Color.BLACK); } @Override public void onLeave(int index, int totalCount, float leavePercent, boolean leftToRight) { titleImg.setScaleX(1.3f + (0.8f - 1.3f) * leavePercent); titleImg.setScaleY(1.3f + (0.8f - 1.3f) * leavePercent); } @Override public void onEnter(int index, int totalCount, float enterPercent, boolean leftToRight) { titleImg.setScaleX(0.8f + (1.3f - 0.8f) * enterPercent); titleImg.setScaleY(0.8f + (1.3f - 0.8f) * enterPercent); } }); return commonPagerTitleView;}
通过设置一个 OnPagerTitleChangeListener 来实现切换效果。我们再回顾一下效果图:
结合代码,我相信你已经完全掌握 CommonPagerTitleView 啦。
结语
今天就是这些。写长文好累,给个 star 呗,地址:
https://github.com/hackware1993/MagicIndicator。对 MagicIndicator 还有疑问,欢迎加QQ群:373360748
- MagicIndicator系列之三 —— MagicIndicator原理浅析及扩展MagicIndicator的4种方式
- MagicIndicator系列之二 —— MagicIndicator使用指南
- MagicIndicator
- MagicIndicator
- MagicIndicator系列之一 —— 使用MagicIndicator打造千变万化的ViewPager指示器
- MagicIndicator系列之一 —— 使用MagicIndicator打造千变万化的ViewPager指示器
- MagicIndicator系列之一 —— 使用MagicIndicator打造千变万化的ViewPager指示器
- MagicIndicator系列之一 —— 使用MagicIndicator打造千变万化的ViewPager指示器
- MagicIndicator的日常使用笔记
- net.lucode.hackware.magicindicator使用及修改
- 使用MagicIndicator打造千变万化的ViewPager指示器
- magicIndicator使用简记
- MagicIndicator源码学习
- android MagicIndicator 指示器简单使用
- MagicIndicator入门 (标签,指示器) -- 引入并初步使用
- Android中ViewPager常用功能5----MagicIndicator(框架)打造ViewPager指示器
- “Ceph浅析”系列之五——Ceph的工作原理及流程
- “Ceph浅析”系列之(四)——Ceph的工作原理及流程
- MagicIndicator系列之二 —— MagicIndicator使用指南
- Python控制Ubuntu1404关机、重启
- HDU2030汉字统计
- 关于Android app 国际化 中英文翻译的细节处理
- GlobalAlloc 分配的内存上限
- MagicIndicator系列之三 —— MagicIndicator原理浅析及扩展MagicIndicator的4种方式
- adb 查看最上层成activity名字
- 单元测试:Mockito
- python 3.4 在windows64下安装模块error: Microsoft Visual C++ 10.0 is required(Unable to find vcvarsall.bat)
- RF source code test in windows
- 配置远程服务器(记录一些以前的误解)
- LIGHToj 1282 - Leading and Trailing 【对数+快速幂取模】
- 企业网站在新窗口打开网页链接好不好?
- UICollectionView高度宽度自适应缓存框架