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一次只显示一个界面也有点别扭。那我们就自己动手实现一个吧。
首先来看一下效果:
可以看到,指示器中的横线以及文字是会随着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来确定横线的布局,而后者则对横线的布局进行了更精准的控制,可以操作具体的位置和长度。
至此,布局工作就做完了。接下来就是事件响应了。由于本篇篇幅有些过长,接下来的事件相应和其他一些完善工作将会放在下篇去讲。
- Android自定义view之ViewPager指示器——1
- Android自定义view之ViewPager指示器——2
- Android 自定义View实现ViewPager指示器
- Android自定义View--Flyme6的Viewpager指示器
- Android——自定义ViewPager指示器
- Android自定义ViewPager指示器
- Android-自定义ViewPager指示器
- Android自定义ViewPager指示器
- 自定义view实现ViewPager指示器
- Android自定义View——自定义ViewPager
- Android自定义View——自定义ViewPager
- Android自定义View——滑动变色指示器
- 【笔记】自定义控件——ViewPager指示器
- 自定义View——图片指示器
- Android自定义View之超简单圆形数字指示器
- Android 自定义控件之圆点指示器 View (IndicateDotView)
- Android自定义Viewpager指示器PagerIndicator-仿微博头条效果
- Android---自定义ViewPager指示器(一)
- Android基础总结二:Intent总结二(Intent传递数据的几种类型)
- Android设备获取默认的启用数据卡的SubId
- ios-程序中模拟GET和POST请求登录
- python基础--换行
- Linux进程(二)
- Android自定义view之ViewPager指示器——1
- redis--Sentinel
- JS中从Array.slice()与Array.splice()的底层实现原理分析区别
- HTTP协议理解
- 字母次数
- 如何编写测试计划
- 加农炮
- 观察者模式
- 41. First Missing Positive