很多app的首页都会有一个用于显示热点消息的banner,通过垂直切换文本的方式动态展示消息。垂直切换的方式可以有效利用空间显示更多的内容,动态的效果也更能吸引用户的注意力。
实现这个效果,我能想到的方式大概有两种:
1、继承一个LineLayout,在里面添加两个TextView,通过动画实现TextView的移动、显示、隐藏。
2、继承TextView,手动去绘制文字,然后动态的改变文字的绘制,以实现切换的动效。
相比之下,第一种方式要简单一些,而且方法1不只可以添加TextVIew,还可以添加两个ViewGroup,然后构建更加复杂的布局,如添加图片等。为了体现自定义控件的特点,这里使用第二种方式来实现这个功能。具体看下代码的实现:
private static final int DEFAULT_SWITCH_DURATION = 500; private static final int DEFAULT_IDLE_DURATION = 2000; private Context mContext; private List<String> lists;//会循环显示的文本内容 private int contentSize; private String outStr;//当前滑出的文本内容 private String inStr;//当前滑入的文本内容 private float textBaseY;//文本显示的baseline private int currentIndex = 0;//当前显示到第几个文本 private int switchDuaration = DEFAULT_SWITCH_DURATION;//切换时间 private int idleDuaration = DEFAULT_IDLE_DURATION;//间隔时间 private int switchOrientation = 0; private float currentAnimatedValue = 0.0f; private ValueAnimator animator; private int verticalOffset = 0; private int mWidth; private int mHeight; private int paddingLeft = 0; private int paddingBottom = 0; private int paddingTop = 0; private Paint mPaint; //回调接口,用来通知调用者控件当前的状态 public VerticalSwitchTextViewCbInterface cbInterface; public VerticalSwitchTextView(Context context) { this(context, null); } public VerticalSwitchTextView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public VerticalSwitchTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); mContext = context; TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.VerticalSwitchTextView); try { switchDuaration = array.getInt(R.styleable.VerticalSwitchTextView_switchDuaration, DEFAULT_SWITCH_DURATION); idleDuaration = array.getInt(R.styleable.VerticalSwitchTextView_idleDuaration, DEFAULT_IDLE_DURATION); switchOrientation = array.getInt(R.styleable.VerticalSwitchTextView_switchOrientation, 0); } finally { array.recycle(); } init(); }
首先定义一些常量和变量,并实现了三个构造方法。
常量的含义可以直接看代码的标注,构造方法中只有一个参数的方法是在代码里使用new生成View的时候调用的,两个参数的构造方法是在xml中定义View的时候调用,三个参数的构造方法不常用,当有自定义style的时候会用到。构造方法中对自定义属性进行了解析,后面会用到。
<resources> <declare-styleable name="VerticalSwitchTextView"> <attr name="switchDuaration" format="integer"/> <attr name="idleDuaration" format="integer"/> <attr name="switchOrientation"> <enum name="up" value="0"/> <enum name="down" value="1"/> </attr> </declare-styleable> </resources>
上面是自定义属性的内容,分别对应于切换时长、切换间隔和切换方向。
private void init() { setOnClickListener(this); mPaint = getPaint(); mPaint.setColor(getCurrentTextColor()); animator = ValueAnimator.ofFloat(0f, 1f).setDuration(switchDuaration); animator.setStartDelay(idleDuaration); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { currentAnimatedValue = (float) animation.getAnimatedValue(); if (currentAnimatedValue < 1.0f) { invalidate(); } } }); animator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { currentIndex = (++currentIndex) % contentSize; if (cbInterface != null) { cbInterface.showNext(currentIndex); } outStr = lists.get(currentIndex); inStr = lists.get((currentIndex + 1) % contentSize); animator.setStartDelay(idleDuaration); animator.start(); } @Override public void onAnimationCancel(Animator animation) { } @Override public void onAnimationRepeat(Animator animation) { } }); }
init()方法中定义了一个属性动画,通过属性值的更新控制绘制的进度,在AnimatorUpdateListener的onAnimationUpdate()方法中,不断调用invalidate(),从而触发View的onDraw()方法回调。在AnimatorListener的onAnimationEnd()方法中,对要显示的内容进行更新,同时延时一定间隔再循环执行动画。
/** * 设置循环显示的文本内容 * * @param content 内容list */ public void setTextContent(List<String> content) { lists = content; if (lists == null || lists.size() == 0) { return; } contentSize = lists.size(); outStr = lists.get(0); if (contentSize > 1) { inStr = lists.get(1); } else { inStr = lists.get(0); } if (contentSize > 0) { animator.start(); } }
setTextContent()方法用于动态设置切换的文本列表,考虑到大部分应用场景下内容都是动态设置的,这里没有自定义属性提供xml文件的静态设置,如果有需求可以另行优化。
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); mWidth = MeasureSpec.getSize(widthMeasureSpec); Rect bounds = new Rect(); if (contentSize <= 0) { return; } String text = lists.get(0); mPaint.getTextBounds(text, 0, text.length(), bounds); int textHeight = bounds.height(); Log.d("viclee", "onMeasure height is " + mHeight); paddingLeft = getPaddingLeft(); paddingBottom = getPaddingBottom(); paddingTop = getPaddingTop(); mHeight = textHeight + paddingBottom + paddingTop; Paint.FontMetrics fontMetrics = mPaint.getFontMetrics(); //计算文字高度 float fontHeight = fontMetrics.bottom - fontMetrics.top; //计算文字的baseline textBaseY = mHeight - (mHeight - fontHeight) / 2 - fontMetrics.bottom; setMeasuredDimension(mWidth, mHeight); }
onMeasure()方法中主要做了两件事:根据文本的高度、padding设置View的高度和确定文本内容绘制的baseline。具体baseline的原理和计算方法请自行google。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (contentSize <= 0) { return; } //直接使用mHeight控制文本绘制,会因为text的baseline的问题不能居中显示 verticalOffset = Math.round(2 * textBaseY * (0.5f - currentAnimatedValue)); Log.d("viclee", "verticalOffset is " + verticalOffset); if (switchOrientation == 0) {//向上滚动切换 if (verticalOffset > 0) { canvas.drawText(outStr, paddingLeft, verticalOffset, mPaint); } else { canvas.drawText(inStr, paddingLeft, 2 * textBaseY + verticalOffset, mPaint); } } else { if (verticalOffset > 0) {//向下滚动切换 canvas.drawText(outStr, paddingLeft, 2 * textBaseY - verticalOffset, mPaint); } else { canvas.drawText(inStr, paddingLeft, -verticalOffset, mPaint); } } }
onDraw()中调用Canvas的drawText()方法绘制文本,根据我们设置的滚动方向的不同,绘制的y坐标的计算方式有所差别,向上滚动时,y值变小,向下滚动时,y值变大。需要注意的是,在调用drawText()的时候,x坐标为设置的左边距paddingLeft,y坐标不是控件的高度mHeight,也不能是字体的高度textHeight,只能是text的baseline,这个大家可以自己验证一下。
//回调接口,用来通知调用者控件当前的状态,index表示开始显示哪一个文本内容 public interface VerticalSwitchTextViewCbInterface { void showNext(int index); void onItemClick(int index); } public void setCbInterface(VerticalSwitchTextViewCbInterface cb) { cbInterface = cb; }
最后提供了一个接口,通过回调通知调用者View的状态更新(切换到新的内容或者当前内容被点击等)。如果需要其他功能,可以根据自定义View的原理自行扩展。
================================
2016.8.16更新:
增加gravity属性,水平方向的gravity属性可以设置为left、right、center,分别表示文字左对齐、右对齐和居中对齐。
增加ellipsis属性,可以设置为start、end、middle,分别表示当文字长度超出View长度时,在头部、尾部、中间显示省略号。
核心代码分析:
private void generateEllipsisText() { if (ellipsisLists != null) {//防止重复计算 return; } ellipsisLists = new ArrayList<>(); if (lists != null && lists.size() != 0) { for (String item : lists) { int avail = mWidth - paddingLeft - paddingRight; float remaining = avail - ellipsisLen; if (avail <= 0) { ellipsisLists.add(""); } else { float itemWidth = mPaint.measureText(item, 0, item.length()); if (itemWidth < avail) { ellipsisLists.add(item); } else if (remaining <= 0) { ellipsisLists.add(ellipsis); } else { int len = item.length(); float[] widths = new float[len]; mPaint.getTextWidths(item, 0, item.length(), widths); if (mEllipsize == TextUtils.TruncateAt.END) { float blockWidth = 0f; for (int i = 0; i < len; i++) { blockWidth += widths[i]; if (blockWidth > remaining) { ellipsisLists.add(item.substring(0, i) + ellipsis); break; } } } else if (mEllipsize == TextUtils.TruncateAt.START) { float blockWidth = 0f; for (int i = len - 1; i >= 0; i--) { blockWidth += widths[i]; if (blockWidth > remaining) { ellipsisLists.add(ellipsis + item.substring(i, len - 1)); break; } } } else if (mEllipsize == TextUtils.TruncateAt.MIDDLE) { float blockWidth = 0f; for (int i = 0, j = len - 1; i < j; i++, j--) { blockWidth += (widths[i] + widths[j]); if (blockWidth > remaining) { if (blockWidth - widths[j] < remaining) { ellipsisLists.add(item.substring(0, i + 1) + ellipsis + item.substring(j, len - 1)); } else { ellipsisLists.add(item.substring(0, i) + ellipsis + item.substring(j, len - 1)); } break; } } } } } } } lists = ellipsisLists; }
generateEllipsisText()方法用来对文字内容进行处理,当文字超过View长度时,多余的部分用省略号“...”代替。ellipsisLists就是用来存储处理后的文字的。avail表示可以显示文字的区域宽度,remaining是去除省略号“...”的宽度后,可以显示文字内容的宽度。使用Paint的measureText()方法可以获得字符串的宽度,这个宽度是对应于某一种字体和字号的,字体和字号不同,获得的宽度也就不同。Paint的getTextWidths()方法可以获得一个数组,数组中存储的是每一个字符的宽度。然后根据当前的ellipsize,通过计算字符长度与View宽度的关系,来确定最终要显示的文字内容。
//计算绘制的文字中心位置 switch (alignment) { case TEXT_ALIGN_CENTER: inTextCenterX = outTextCenterX = (mWidth - paddingLeft - paddingRight) / 2 + paddingLeft; break; case TEXT_ALIGN_LEFT: inTextCenterX = paddingLeft + mPaint.measureText(inStr) / 2; outTextCenterX = paddingLeft + mPaint.measureText(outStr) / 2; break; case TEXT_ALIGN_RIGHT: inTextCenterX = mWidth - paddingRight - mPaint.measureText(inStr) / 2; outTextCenterX = mWidth - paddingRight - mPaint.measureText(outStr) / 2; break; }
上面的代码根据当前的gravity来计算文字绘制的x轴的中心位置,注意由于每项文字的长度不同,他们x轴的中心位置也不相同。
效果图如下
带有gravity属性和ellipsis属性的效果:
普通效果:
作者:viclee108