這是 MagicIndicator 系列的第三篇文章,如果你沒有看過前兩篇,建議出門先看一下。當然你不看也沒關系,我用一句話來介紹它: MagicIndicator 是一個可定制、易擴展的頁面指示器框架,使用它可極大的簡化頁面指示器的開發。
本文將給大家簡單闡述 MagicIndicator 的原理,并介紹 4 種擴展 MagicIndicator 的方式,分別是:
- 繼承 IPagerNavigator 打造任意的指示效果
- 繼承 IPagerTitleView 打造任意效果的指示器標題
- 繼承 IPagerIndicator 打造任意效果的指示器
- 使用 CommonPagerTitleView 加載自定義布局
使用這四種方法,基本可以搞定所有的指示器效果,沒有做不到,只有想不到!
原理淺析
MagicIndicator 其實非常簡單。和其它所有指示器框架一樣,也是通過監聽 ViewPager.OnPageChangeListener 來實現切換效果的。但 MagicIndicator 有兩點明顯不同:
MagicIndicator 不提供 setViewPager 方法來和 ViewPager 強綁定,因此在不使用 ViewPager 的情況下(比如手動切換 Fragment,輕量級的廣告輪播控件,ViewFlipper 等),也是可以使用 MagicIndicator 的,只需要你手動調用 onPageXXX 系列方法。
MagicIndicator 將指示器進行了抽象,意在通過擴展來實現不同的切換效果,而不是像其他所有指示器框架那樣,提供了一大堆的 setter 方法,卻只能實現很有限的切換效果。
在布局文件中使用的 <MagicIndicator/> 標簽,本質上就是一個 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>();
@Override
protected 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;
}
}
}
圓心位置已準備就緒,那就開畫吧:
@Override
protected 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 變化時需要重新計算圓心位置:
@Override
public void onPageSelected(int position) {
mCurrentIndex = position;
invalidate();
}
public void setCircleCount(int circleCount) {
mCircleCount = circleCount;
}
@Override
public 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 吧,并實現以下方法:
@Override
public int getContentLeft() {
Rect bound = new Rect();
getPaint().getTextBounds(getText().toString(), 0, getText().length(), bound);
int contentWidth = bound.width();
return getLeft() + getWidth() / 2 - contentWidth / 2;
}
@Override
public int getContentTop() {
Paint.FontMetrics metrics = getPaint().getFontMetrics();
float contentHeight = metrics.bottom - metrics.top;
return (int) (getHeight() / 2 - contentHeight / 2);
}
@Override
public int getContentRight() {
Rect bound = new Rect();
getPaint().getTextBounds(getText().toString(), 0, getText().length(), bound);
int contentWidth = bound.width();
return getLeft() + getWidth() / 2 + contentWidth / 2;
}
@Override
public 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 并進行初始化:
@Override
public 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