Android自定义view之ViewPager指示器——1

来源:互联网 发布:单片机如何烧程序 编辑:程序博客网 时间:2024/05/17 23:02

Android自定义view之ViewPager指示器——1

在上两篇文章《Android自定义view之measure、layout、draw三大流程》以及《Android自定义view之事件传递机制》中,我们主要讲解了Android中视图的一些理论上的东西,为我们的自定义view打下基础。本次我们就将之前的理论应用到实际中动手自定义一个view。

ViewPager应该是我们平日里接触很多的控件,它允许我们在一个Activity中容纳多个界面,可以来回切换。ViewPager这个是在support v4包中,但是我们切换页面的时候总想知道我们现在在哪个页面、或者当前页面的标题,也就是我们需要一个指示器(Indicator)。可是官方好像一直都没有一个像样的这样的控件来做这件事。其实要实现的方法也很多。完全可以自己用xml布局文件写出来,不过这样就不好动态改变页面了,或者干脆指示器也用一个ViewPager来写,但ViewPager一次只显示一个界面也有点别扭。那我们就自己动手实现一个吧。

首先来看一下效果:
ViewPagerIndicator效果
可以看到,指示器中的横线以及文字是会随着ViewPager的变化而变化,并且指示器可以容纳超出自己边界的tags,并在适当的时候移动。点击对应的tag,ViewPager也会发生对应的变化。
接下来我们一步一步开始写。

1. 制定参数

每个View都有可以设置的参数,来设置View的外观和行为。对于一个指示器,我们应该有以下一些可自行定制的参数:
(1) 指示器的文字和横线颜色(文字颜色包括被选中的和未被选中的)
(2) 文字大小以及横线的高度
(3) tag之间的距离
(4) 布局模式,分为平衡模式和间距布局模式。平衡模式是为了所有tag的长度和不足以填满指示器时,将tags平均地进行分布
(5) 除此之外,还包括其他的一些常规view的熟悉,比如padding等
接下来我们就可以制定View的属性了,首先在values文件夹下新建attrs_text_indicator.xml。内容如下:

<?xml version="1.0" encoding="utf-8"?><resources>    <declare-styleable name="TextViewPagerIndicator">        <attr name="textColor" format="color"/>        <attr name="selectedTextColor" format="color"/>        <attr name="backgroundColor" format="color"/>        <attr name="textSize" format="dimension"/>        <attr name="indicatorColor" format="color"/>        <attr name="indicatorHeight" format="dimension"/>        <attr name="intervalBetweenTags" format="dimension"/>        <attr name="textPadding" format="dimension"/>        <attr name="textPaddingTop" format="dimension"/>        <attr name="textPaddingBottom" format="dimension"/>        <attr name="textPaddingLeft" format="dimension"/>        <attr name="textPaddingRight" format="dimension"/>        <attr name="indicatorStyle" format="enum">            <enum name="line" value="0"/>            <enum name="background" value="1"/>        </attr>        <attr name="indicatorDrawable" format="reference"/>        <attr name="balanceLayout" format="boolean"/>    </declare-styleable></resources>

注意的是,某些属性由于时间的关系,只是想到了但并没有实现它,日后有空了再完善。
另外很中要的是我们指示器的布局原则:(1)指示器横线紧贴指示器底部,不考虑指示器的paddingBottom影响。(2)显示文字的TextView宽和高都是WRAP_CONTENT,并且在指示横线的顶部到指示器的顶部这片空间中居中布局。(3)指示器中的横线的宽度应该和当前对应的tag宽度一致。(4)平衡布局应用的情况应该是子View的宽度和比指示器的宽度小,此时子view的左右边界不会超出指示器左右边界。如果在相反的情况下仍然应用平衡布局,子view会紧挨着彼此,并且会超出边界进行布局,有可能会出现不期望的行为,因此不应该在这种情况下使用平衡布局。

2. 开始编写

在之前的文章中,我们已经谈到过,对于自定义一个View,我们大概能有4种选择。1:完全重写一个View。2:继承特定的View并重写其中的一些方法。3:完全重写一个ViewGroup。4:继承特定的ViewGroup并重写某些方法。以我们目前的需求,是对文字的操作,并且其中还涉及到对一条横线的操作。显然不需要用View,我们就继承ViewGroup。思路就是将文字放在TextView中,然后将TextView按照要求在这个View中布局。
新建一个类:

public class TextViewPagerIndicator extends ViewGroup{    MyLog log = new MyLog("TextViewPagerIndicator", true);    /**     * 布局显示相关的参数     * */    private int textColor = Color.parseColor("#000000");    private int backgroundColor = Color.parseColor("#ffffff");    private int textSize = 10;    private int indicatorColor = Color.parseColor("#ffffff");    private int indicatorHeight = 3;    /*tag之间的间隔*/    private int interval = 10;    private int textPadding = 0;    private int textPaddingTop = 0;    private int textPaddingBottom = 0;    private int textPaddingLeft = 0;    private int textPaddingRight = 0;    private int selectedTextColor;    private boolean balanceLayout = false;    private enum IndicatorStyle    {        line, background    }    private IndicatorStyle indicatorStyle = IndicatorStyle.line;    private int indicatorDrawable = -1;    private ArrayList<String> tags = new ArrayList<>();    private HashMap<String, TextView> tagMap = new HashMap<>();    private ImageView indicatorLine;    /*目前选中的位置*/    private int currentPosition = 0;    private boolean expanded = false;    /*最前面的TextView的左边与指示器左边的偏离值,为0时代表对准指示器左侧,小于0代表在指示器左侧的左边,大于0时相反*/    private int textOffset = 0;    /*tag的点击监听器*/    private ArrayList<OnTagClickedListener> onTagClickedListeners = new ArrayList<>();    /**     * 事件相关参数     * */    private float newX, newY, lastX, lastY, dx, dy, downX, downY;    /*判断是否是滑动的阈值*/    private int touchSlop = 5;}

注意有一些属性并没有应用,是为以后升级所预留的。比如IndicatorStyle等。
然后是构造函数,自定义View时是要求自己写构造函数的,而在构造函数中,我们就可以将用户在布局xml文件中设置的属性拿到手,然后对我们的属性进行赋值或初始化工作。

    public TextViewPagerIndicator(Context context) {        this(context, null);    }    public TextViewPagerIndicator(Context context, AttributeSet attrs) {        this(context, attrs, 0);    }    public TextViewPagerIndicator(Context context, AttributeSet attrs, int defStyleAttr) {        this(context, attrs, defStyleAttr, 0);    }    public TextViewPagerIndicator(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {        super(context, attrs, defStyleAttr, defStyleRes);        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TextViewPagerIndicator);        textColor = a.getColor(R.styleable.TextViewPagerIndicator_textColor, textColor);        backgroundColor = a.getColor(R.styleable.TextViewPagerIndicator_backgroundColor, backgroundColor);        indicatorColor = a.getColor(R.styleable.TextViewPagerIndicator_indicatorColor, indicatorColor);        textSize = a.getDimensionPixelSize(R.styleable.TextViewPagerIndicator_textSize, textSize);        indicatorHeight = a.getDimensionPixelSize(R.styleable.TextViewPagerIndicator_indicatorHeight, indicatorHeight);        interval = a.getDimensionPixelSize(R.styleable.TextViewPagerIndicator_intervalBetweenTags, interval);        selectedTextColor = a.getColor(R.styleable.TextViewPagerIndicator_selectedTextColor, indicatorColor);        balanceLayout = a.getBoolean(R.styleable.TextViewPagerIndicator_balanceLayout, balanceLayout);        a.recycle();        indicatorLine = new ImageView(context);        indicatorLine.setBackgroundColor(indicatorColor);        this.addView(indicatorLine);        touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();        this.setClickable(true);        textOffset = getPaddingLeft();        if(isInEditMode())        {            for(int i = 0; i < 5; i++)            {                addTag("item" + i);            }        }    }

拿到布局文件中设置的属性,我们需要TypedArray对象。然后按照我们之前的attrs中定义的属性来获取属性值,R.styleable.TextViewPagerIndicator就是我们之前的attrs中定义的。然后就如同取普通的键值对存储一样,传入属性名称和默认值。如果在布局文件中设置了,那我们就能拿到,否则就返回我们传入的默认值。
isInEditMode()这是自定义View的一个预览方法。返回true代表当前是在布局的编辑模式中。自定义的View中如果没有正确处理这个流程,就无法在编辑的时候实时预览。我们在这里添加了5个tag,在编辑的时候就能够实时查看效果了。
touchSlop是一个阈值。在处理触摸事件时有用。之前已经说过,单指触摸事件是由ACTION_DOWN开始,中间有若干ACTION_MOVE,结尾是ACTION_UP。基本所有的触摸事件,哪怕是很快地点击屏幕,都会有ACTION_MOVE事件发生。所以不能单纯地以ACTION的类型来判断事件性质。因此设置一个阈值,当ACTION_MOVE发生时,我们判断移动距离是否超过阈值,如果是,则代表用户现在确实要进行滑动操作。否则我们就认为这个ACTION_MOVE是意外发生的,用户没有要滑动。而这个阈值不可过大也不可过小,过小会导致作用不明显,过大则会导致滑动时有卡顿感(尤其是在慢速滑动时)。因此一般设置为4或5即可。

3. Measure过程

measure过程的主要作用就是测量自己和子view的大小,而自身的大小又和子view的大小息息相关。

    @Override    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        log.v("onMeasure");        /*如果这个View不进行任何绘制操作,则设置为true,以便系统进行优化*/        setWillNotDraw(true);        /*获取父view传递给我们的宽和高的SpecMode和SpecSize*/        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);        int widthSize = MeasureSpec.getSize(widthMeasureSpec);        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);        int heightSize = MeasureSpec.getSize(heightMeasureSpec);        /*设置用来测量TextView宽度的MeasureSpec,由于tag是一定要完整的单行显示,因此我们将宽度的SpecMode设置为UNSPECIFIED,即        * 要多大给多大*/        int unspecifiedWidthMeasureSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.UNSPECIFIED);        /*设置用来测量TextView高度的MeasureSpec,不同于宽度,高度上TextView不能比我们指示器的高度更大,还要减去padding值和预留给横线的空间*/        int atMostHeightMeasureSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.AT_MOST);        /*宽度结果,要考虑paddingLeft和paddingRight*/        int resultWidthSize = getPaddingLeft() + getPaddingRight();        /*高度结果,要考虑paddingTop和paddingBottom,还有给横线预留的位置*/        int resultHeightSize = getPaddingTop() + getPaddingBottom() + indicatorHeight;        for(String tag : tags)        {            /*依次对每一个TextView进行布局,并将宽度累加到宽度结果里*/            TextView child = tagMap.get(tag);            ViewGroup.LayoutParams childLayoutParams = child.getLayoutParams();            int childWidthSpec = getChildMeasureSpec(unspecifiedWidthMeasureSpec, resultWidthSize, childLayoutParams.width);            int childHeightSpec = getChildMeasureSpec(atMostHeightMeasureSpec, resultHeightSize, childLayoutParams.height);            child.measure(childWidthSpec, childHeightSpec);            resultWidthSize += child.getMeasuredWidth();        }        /*最终完全确定宽度和高度结果。注意到如果我们不是平衡布局,那么宽度结果还要加上tag之间的距离。对于高度结果,我们只要随便        * 取一个已经测量过的TextView将其高度加进去即可*/        if(tags != null && tags.size() != 0)        {            resultHeightSize += tagMap.get(tags.get(0)).getMeasuredHeight();            if(!balanceLayout)            {                resultWidthSize += (tags.size() - 1) * interval;            }        }        /*结合父view传给我们的SpecMode来确定我们这个指示器layout的最终大小。如果是AT_MOST,那大小不能超过父View传给我们的SpecSize,        * 如果是EXACTLY,那就直接将SpecSize作为我们的结果,而不管我们之前测量的宽度和高度结果*/        if(widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST)        {            setMeasuredDimension(resultWidthSize > widthSize ? widthSize : resultWidthSize,                    resultHeightSize > heightSize ? heightSize : resultHeightSize);        }else if(widthMeasureSpec == MeasureSpec.AT_MOST)        {            setMeasuredDimension(resultWidthSize > widthSize ? widthSize : resultWidthSize, heightSize);        }else if(heightMeasureSpec == MeasureSpec.AT_MOST)        {            setMeasuredDimension(widthSize, resultHeightSize > heightSize ? heightSize : resultHeightSize);        }else        {            setMeasuredDimension(widthSize, heightSize);        }    }

测量的主要思路就是先测量所有子view的大小,并且计算所有子view的宽度和、tag之间的距离以及指示器的padding值等。由于我们指示器暂时只支持横向布局,因此高度上我们只要考虑任意一个子view的高、指示器横线的高度以及指示器本身的padding值即可。
需要注意的是getChildMeasureSpec(int spec, int padding, int childDimension)这个方法,它是ViewGroup的静态方法,可以依据父View传递给我们的MeasureSpec、padding值以及childDimension(子view的宽或高)来生成子view的MeasureSpec。此处我们通过伪造了父View传递给我们(就是指示器本身,要注意到在measure方法里会有两种MeasureSpec,一种是父View传给我们的,另一种是我们生产的用于测量子View的)的Spec来测量子view,宽度伪造成UNSPECIFIED的,而高度伪造成AT_MOST。其实对于UNSPECIFIED的MeasureSpec,SpecSize传入多少都没关系,因为它就代表子View想要多大要多大,不必理会父容器(也就是我们的指示器本身)的尺寸。padding值其实并不是单纯的padding值,而是父容器在这个方向上已经被用掉的尺寸,比如高度上,我们除了考虑paddingTop和paddingBottom,还要考虑到横线的高度。而childDimension是我们取自TextView的LayoutParams中的值,此时它并不是具体的尺寸,而是WRAP_CONTENT,即-2,因为我们在new一个TextView时给它设置的就是WRAP_CONTENT。这个在后面会看到。关于getChildMeasureSpec(int spec, int padding, int childDimension)这个方法更多的说明以及它如何生产子View的MeasureSpec,可以看我的文章《Android自定义view之measure、layout、draw三大流程 》。
如果已经了解了Measure的详细流程,其实这里生产子View的MeasureSpec压根不用这么麻烦,也不用伪造指示器的MeasureSpec,只要直接制造子View的MeasureSpec即可。宽度上我们TextView一定要单行完整显示,那么可以直接写childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);高度上就比较麻烦了,因为我们对于TextView的高度是有限制的,即childHeight <= height - paddingTop - paddingBottom - indicatorLine.getHeight(),由于后面3个属于已知的值,因此我们主要要确定height值。这要根据父View传给指示器的heightMeasureSpec来确定。可分为两种情况:
1. SpecMode == UNSPECIFIED,此时我们也无法确定指示器的高度,所以直接构造TextView的spec为childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)即可。
2. SpecMode == AT_MOST 或 SpecMode == EXACTLY:这个时候我们可以知道指示器最大的高度值就是父View传递给指示器的MeasureSpec中的SpecSize,而TextView的高都是WRAP_CONTENT的,因此只要设置子View的SpecMode为AT_MOST即可。即childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(remainingSize, MeasureSpec.AT_MOST),其中remainingSize是指示器在高度上能留给TextView空间。在这里,可以设置为remainingSize = height - paddingTop - paddingBottom - indicatorLine.getHeight()

之后我们就可以使用刚制作的子view的宽度和高度的MeasureSpec来测量子view了,调用TextView的measure方法,并将MeasureSpec传入即可。

接下来TextView有了测量宽和高(getMeasuredHeight()和getMeasuredWidth()可以取到有效值了)。然后每测量一个TextView,我们就把它的measuredWidth累加到resultWidth中。直到测量完所有的TextView,现在resultWidth是所有TextView的宽度之和,另外再加上指示器的paddingLeft和paddingRight。然后就要看是否是平衡布局,如果不是平衡布局,那么我们还要加上tag间的间距。高度就简单得多,因为是横向布局的,所以高度就是paddingTop、paddingBottom、一个TextView的高和横线高度之和。

现在已经得到了resultWidth和resultHeight,接下来就是对指示器的大小做最终的决定,这时我们要结合指示器的SpecMode来决定。详细的流程已经在以前文章中measure那一节讲过了,代码里也很清楚。最后别忘了调用setMeasuredDimension将结果应用到View。

4. Layout过程

对于ViewGroup来说,最重要的就是layout过程,因为布局涉及到视图表现以及动画效果等。

    @Override    protected void onLayout(boolean changed, int l, int t, int r, int b) {        if(changed)        {            layoutChildren(textOffset, currentPosition);            tagMap.get(tags.get(currentPosition)).setTextColor(selectedTextColor);        }    }

在layout函数里我们调用了layoutChildren(int textOffset, int index),这个函数是用于立马完成布局的,也就是根据textOffset和目前所选中的tag值的index立即完成布局,不存在中间状态。关于layout在前面的文章中也说过,layout函数在布局发生改变时是会调用的,而changed则只有在该view的位置或大小发生变化时才会为true,在第一次布局时是为true的。这里的结构表明了它只会在第一次布局时走if语句里的流程。
接下来是layoutChildren(int textOffset, int index)

    private void layoutChildren(int textOffset, int index)    {        if(tags.size() != 0)        {            /*计算TextView的顶部到指示器顶部的距离和底部到横线顶部的距离。由于我们的TextView是居中显示的(不),所以如下计算*/            int padding = (getMeasuredHeight() - getPaddingBottom() - getPaddingTop() - indicatorHeight - tagMap.get(tags.get(0)).getMeasuredHeight()) / 2;            if(padding < 0)            {                padding = 0;            }            if(balanceLayout)            {                int availableWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();                /*如果是平衡布局,我们就要计算横向所剩余的空间,再将这些空间平分,作为tag之间的间距和tag与指示器前端和后端的距离*/                int totalItemWidth = 0;                for(int i = 0; i < tags.size(); i++)                {                    totalItemWidth += tagMap.get(tags.get(i)).getMeasuredWidth();                }                int space = (availableWidth - totalItemWidth) / (tags.size() + 1);                space = space < 0 ? 0 : space;                /*根据paddingLeft决定第一个TextView的偏离值*/                textOffset = getPaddingLeft() + space;                /*对子view进行布局*/                for(int i = 0; i < tags.size(); i++)                {                    TextView child = tagMap.get(tags.get(i));                    int left = textOffset;                    int right = textOffset + child.getMeasuredWidth();                    child.layout(left,                            getPaddingTop() + padding, right,                            getPaddingTop() + child.getMeasuredHeight() + padding);                    /*更新offset*/                    textOffset += space + child.getMeasuredWidth();                }            }else            {                /*如果不是平衡布局,直接按照传入的textOffset来布局,此时更新textOffset时使用tag之间的间距,即interval*/                for(String s : tags)                {                    TextView child = tagMap.get(s);                    child.layout(textOffset, getPaddingTop() + padding, textOffset + child.getMeasuredWidth(), getPaddingTop() + child.getMeasuredHeight() + padding);                    textOffset += child.getMeasuredWidth() + interval;//                log.v(s + ": left=" + child.getLeft() + ", right=" + child.getRight() + ", top=" + child.getTop() + ", bottom=" + child.getBottom());                }            }            /*最后对横线进行布局*/            ViewGroup.LayoutParams layoutParams = indicatorLine.getLayoutParams();            layoutParams.width = tagMap.get(tags.get(index)).getMeasuredWidth();            indicatorLine.setLayoutParams(layoutParams);            int indicatorOffset = tagMap.get(tags.get(index)).getLeft();            indicatorLine.layout(indicatorOffset, getMeasuredHeight() - getPaddingBottom() - indicatorHeight ,                    indicatorOffset + layoutParams.width, getMeasuredHeight() - getPaddingBottom());        }    }

但是上面的只能用于布局确定状态,无法布局中间状态,就是说我原来对应的是tag1,接下来转换到tag2时,调用这个函数就会立马对应的tag2,不能对中间渐变的过程进行布局。接下来我们还需要一个能对中间状态布局的函数layoutChildren(int textOffset, int indicatorOffset, int indicatorLength)

    private void layoutChildren(int textOffset, int indicatorOffset, int indicatorLength)    {        log.v("layout children, textOffset = " + textOffset + ", indicatorOffset = " + indicatorOffset + ", indicatorLength = " + indicatorLength);        if(tags.size() != 0)        {            /*计算TextView的顶部到指示器顶部的距离和底部到横线顶部的距离。由于我们的TextView是居中显示的(不),所以如下计算*/            int padding = (getMeasuredHeight() - getPaddingBottom() - getPaddingTop() - indicatorHeight - tagMap.get(tags.get(0)).getMeasuredHeight()) / 2;            if(padding < 0)            {                padding = 0;            }            if(balanceLayout)            {                /*如果是平衡布局,我们就要计算横向所剩余的空间,再将这些空间平分,作为tag之间的间距和tag与指示器前端和后端的距离*/                int availableWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();                int totalItemWidth = 0;                for(int i = 0; i < tags.size(); i++)                {                    totalItemWidth += tagMap.get(tags.get(i)).getMeasuredWidth();                }                int space = (availableWidth - totalItemWidth) / (tags.size() + 1);                space = space < 0 ? 0 : space;                textOffset = getPaddingLeft() + space;                /*布局子view*/                for(int i = 0; i < tags.size(); i++)                {                    TextView child = tagMap.get(tags.get(i));                    int left = textOffset;                    int right = textOffset + child.getMeasuredWidth();                    child.layout(left,                            getPaddingTop() + padding, right,                            getPaddingTop() + child.getMeasuredHeight() + padding);                    textOffset += space + child.getMeasuredWidth();                }            }else            {                for(String s : tags)                {                    TextView child = tagMap.get(s);                    child.layout(textOffset, getPaddingTop() + padding, textOffset + child.getMeasuredWidth(), getPaddingTop() + child.getMeasuredHeight() + padding);                    textOffset += child.getMeasuredWidth() + interval;//                log.v(s + ": left=" + child.getLeft() + ", right=" + child.getRight() + ", top=" + child.getTop() + ", bottom=" + child.getBottom());                }            }        }        /*布局指示器的横线*/        ViewGroup.LayoutParams layoutParams = indicatorLine.getLayoutParams();        layoutParams.width = indicatorLength;        indicatorLine.setLayoutParams(layoutParams);        indicatorLine.layout(indicatorOffset, getMeasuredHeight() - getPaddingBottom() - indicatorHeight,                indicatorOffset + indicatorLength, getMeasuredHeight() - getPaddingBottom());    }

这个函数和上面那个相比,其实大体差不多,只不过前者可以自动根据当前对应的tag来确定横线的布局,而后者则对横线的布局进行了更精准的控制,可以操作具体的位置和长度。

至此,布局工作就做完了。接下来就是事件响应了。由于本篇篇幅有些过长,接下来的事件相应和其他一些完善工作将会放在下篇去讲。