简单定制Android控件(2) - 点赞列表控件

来源:互联网 发布:淘宝卖家怎么发布宝贝 编辑:程序博客网 时间:2024/05/01 19:32

国际惯例,先放github:

https://github.com/razerdp/PraiseWidget


放上效果图:


很多时候我们都看到点赞列表这个东东的存在,最典型的就是微信的赞。而最近我司也在做圈,无可避免的涉及到这个赞的控件。本来打算上网找个控件改改就算了,然而。。。也许我的搜索技术太渣,木有找到合适的。

于是懒惰的我,只好勉强唤醒脑里面睡着的那个勤奋的家伙,动手开工了。。。。。

开工之前当然需要了解下需求了,这次我司的需求跟普通的点赞有那么一丢丢的不同,废话不多说,直接上:

  1. 点赞嘛,当然要可以显示用户名,同时点击用户名可以做对应的操作(比如跳到他的个人主页?)
  2. 我司需求:要求点赞用户超过XX行,就显示等XX人,而不要完全显示
  3. 嗯。。。大概就这两点了- -


开工之前想了下关于这个点赞控件的难点:

  1. 用户点击的实现,以及用户名排序的问题
  2. 超过行数后显示等XX人
一开始,针对这两个问题感觉最快捷的方法不过于 FlowLayout+TextView,简单又快捷。然而用这个方法就会有一个问题,就是行数的问题,因为我们需要在绘制之前就要做到这些操作,也就是在控件被绘制之前得到行数,再显示,否则我们得到的行数永远都只能为0,因而无法做到第二点。

ps:后来想了一下,既然我们无法在当前控件绘制前得到一些数值,那我们可以直接new当前控件(父)对象,再得到需要的数值,然后把数值应用到当前的控件
pss:也许上面说的有点乱,这里打个比喻,比如说我们要设计一个控件A,而A继承B,通过重载某些方法来实现我们的控件。然而有一些数值需要在B的onDraw()里面得到,但我们需要A在onDraw()前就拿到这个数值,那么我们可以直接new出B对象,把数据塞进去,然后得到需要的数据,再把这个数据用在A身上,达到同样的效果。

回到正题,上面的两个ps都是我想到的笨方法,如果有更好的方法,求告知-V- 

当时没想到这些,于是放弃用FlowLayout,而且FlowLayout也有些不方便,于是就换了一种方法:TextView+Span。

Span这个东东可牛逼了,可以说对于文字的操作都可以用到这个神器来实现。而今天的这个控件,正使用该神器实现。
那么下面开工:

首先针对第一点:显示用户名同时可以进行点击跳转。

既然我们选择加工TextView,那么关于显示用户名这个东东就很好解决了,问题在于第二个点击跳转,这里我们使用ClickableSpan解决,ClickableSpan里面只有两个方法,一个onClick,一个updateDrawState,看着方法名,很容易就知道这两个东东是干嘛用的,很明显,我们需要用到onClick来做到点击事件,那么下面就先上ClickableSpan的代码
public class PraiseClick extends ClickableSpan{    private static final int DEFAULT_COLOR=0xff517fae;    private int color;    private int userID;    private String userNick;    private Context mContext;    public PraiseClick(Context context, String userNick, int userID, int color) {        mContext = context;        this.userNick = userNick;        this.userID = userID;        this.color = color;    }    public PraiseClick(Context context, int userID, int color) {       this(context,"",userID,color);    }    public PraiseClick(Context context, int userID) {       this(context,"",userID,0);    }    public PraiseClick(Context context, String userNick, int userID) {       this(context,userNick,userID,0);    }    @Override    public void onClick(View widget) {        Toast.makeText(mContext,"当前用户名是: "+userNick+"   它的ID是: "+userID,Toast.LENGTH_SHORT).show();    }    @Override    public void updateDrawState(TextPaint ds) {        super.updateDrawState(ds);        //去掉下划线        if (color == 0) {            ds.setColor(DEFAULT_COLOR);        } else {            ds.setColor(color);        }        ds.setUnderlineText(false);    }}
在onClick里面目前为了展示,我只用了个Toast,如果需要别的事件,可以自己重载这里。
现在ClickableSpan已经有了,问题在于我们如何应用到String里面呢,这就不得不用到SpannablestringBuilder这个东东了

SpannablestringBuilder也是一个神器,通过名字我们可以知道这个东东可以构造出一个SpanString,至于详细的请查看API,这次我们主要用到的一个方法是
  public SpannableStringBuilder append(CharSequence text, Object what, int flags)
这个方法是在API21以后加入的,看了下源码,该方法实际上依然是调用本来就存在的setSpan和append方法,因此我们可以抽出来用于使用所有的版本。
于是就有了以下的SpannableStringBuilderAllVer
/** * Created by 大灯泡 on 2015/9/30. */public class SpannableStringBuilderAllVer extends SpannableStringBuilder {    public SpannableStringBuilderAllVer() {        super("");    }    public SpannableStringBuilderAllVer(CharSequence text) {        super(text, 0, text.length());    }    public SpannableStringBuilderAllVer(CharSequence text, int start, int end){        super(text,start,end);    }    public SpannableStringBuilderAllVer append(CharSequence text) {        if (text == null) return this;        int length = length();        return (SpannableStringBuilderAllVer)replace(length, length, text, 0, text.length());    }    /**该方法在原API里面只支持API21或者以上,这里抽取出来以适应低版本*/    public SpannableStringBuilderAllVer append(CharSequence text, Object what, int flags) {        if (text == null) return this;        int start = length();        append(text);        setSpan(what, start, length(), flags);        return this;    }}
这个方法的第二个参数通过官方代码注释(我的是API23版本的SpannableStringBuilder第266行),我们可以得知第二个参数对象会作为一个span覆盖在原有的text之上
 /**     * Appends the character sequence {@code text} and spans {@code what} over the appended part.     * See {@link Spanned} for an explanation of what the flags mean.     * @param text the character sequence to append.     * @param what the object to be spanned over the appended text.     * @param flags see {@link Spanned}.     * @return this {@code SpannableStringBuilder}.     */    public SpannableStringBuilder append(CharSequence text, Object what, int flags) {        int start = length();        append(text);        setSpan(what, start, length(), flags);        return this;    }
那么我们可以构造出一个对象,而这个span是可点击的,并且将它覆盖在我们需要覆盖的文字上,那样就可以做到点击文字跳转了,而clickableSpan简直就是为此而生,于是乎我们实现颜色改变而且可以点击响应的代码就出炉了
 // 构建 builder        SpannableStringBuilderAllVer spanBuilder = new SpannableStringBuilderAllVer(spanStr);        for (int i = 0; i <= LastPos; i++) {            PraiseBean bean = mBeans.get(i);            if (i == 0) {                spanBuilder.append("  " + bean.userNick,                    new PraiseClick(mContext, bean.userNick, bean.userId,color), 0);            } else {                spanBuilder.append(mBeans.get(i).userNick,                    new PraiseClick(mContext, bean.userNick, bean.userId,color), 0);            }            if (i != LastPos) spanBuilder.append(", ");        }

通过上面的我们构造出的PraiseClick,传入context,和用户名和ID信息,点击它们就会把他们Toast出来。这就初步实现了我们需要的功能。至于上面代码中的LastPos,以及这个for循环是用来干嘛的,待会再说。

现在我们初步解决了第一点,然而第二点需求就比较麻烦,前文说过,因为我们是在绘制UI前就需要将数据获取,所以我们只能利用一个新的对象,代替我们实现这些数据的获取再用到我们的控件中。

而这次,我们需要获取的就是行数,根据上面的思路,我们就有了以下的方法:
  int lastPos = 0;//最后一个位置            curLine = 0;            int maxLine = mMaxLine;            int beanSize = mBeans.size();            String peopleCount =                mContext.getResources().getString(R.string.praise_zan, mBeans.size());            StringBuilder stringBuilder = new StringBuilder();            stringBuilder.append("like  ");//预留位置给点赞的心,防止超出指定行数行            for (int i = 0; i < beanSize && curLine <= maxLine; i++) {                stringBuilder.append(mBeans.get(i).userNick);                /**测量当前文字的所属行数(加上“等xxx人测量,保证最后一个可以被顶替掉”)*/                curLine = createWorkingLayout(stringBuilder.toString() + peopleCount,                    textTotalWidth).getLineCount();                if (curLine <= maxLine) {                    lastPos = i;                    stringBuilder.append(", ");                } else {                    break;                }            }

textview有一个方法是getLineCount(),这个方法用来得出当前textView内文字的行数,而这个方法只能够在绘制UI后才能得到,否则永远为0,而当我们进去看源码,会发现getLineCount()无一例外都指向于这么一段东东:
mLayout.getLineCount()
当再进入这个方法,我们就发现这个方法实质上是属于layout的方法,因此我们可以大胆猜测,textview放置文字其实是与layout有关,当文字宽度超过一定宽度,则会新建一行,再填充下去。直到文字放置完全位置(当然,也许控件也是这样。)

接下来我们到TextView搜索(mLayout == null),如果搜不到,我们可以转过来null==mLayout,毕竟谷歌老大们怎么写的我们也不知道,但关于空对象空引用判断是必须会有的。很快,我们就搜到了这个
protected void makeNewLayout(int wantWidth, int hintWidth,                                 BoringLayout.Metrics boring,                                 BoringLayout.Metrics hintBoring,                                 int ellipsisWidth, boolean bringIntoView)
然后找到该方法下的这一段:
  mLayout = makeSingleLayout(wantWidth, boring, ellipsisWidth, alignment, shouldEllipsize,                effectiveEllipsize, effectiveEllipsize == mEllipsize);
最后我们可以轻易的找到这么一段:
 Layout result = null;        if (mText instanceof Spannable) {            result = new DynamicLayout(mText, mTransformed, mTextPaint, wantWidth,                    alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad,                    mBreakStrategy, mHyphenationFrequency,                    getKeyListener() == null ? effectiveEllipsize : null, ellipsisWidth);        }
上网找了一下,同时看源码注释,不难发现,这个Layout的用处就是用于textView计算宽高和自动换行的,

/** * DynamicLayout is a text layout that updates itself as the text is edited. * <p>This is used by widgets to control text layout. You should not need * to use this class directly unless you are implementing your own widget * or custom display object, or need to call * {@link android.graphics.Canvas#drawText(java.lang.CharSequence, int, int, float, float, android.graphics.Paint) *  Canvas.drawText()} directly.</p> */

于是,就有了我们的第二段代码:
<pre name="code" class="java" style="color: rgb(51, 51, 51); line-height: 26px;">private Layout createWorkingLayout(String workingText, int textTotalWidth) {        /**         *  float spacingmult:相对行间距,相对字体大小,1.5f表示行间距为1.5倍的字体高度。         *  float spacingadd:在基础行距上添加多少         */        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {            LineSpacingMultiplier = getLineSpacingMultiplier();            LineSpacingExtra = getLineSpacingExtra();        } else {            if (LineSpacingMultiplier == 0.0f && LineSpacingExtra == 0.0f) {                try {                    Field Multiplier = TextView.class.getDeclaredField("mSpacingMult");                    Multiplier.setAccessible(true);                    LineSpacingMultiplier = Multiplier.getFloat(this);                    Field SpacingExtra = TextView.class.getDeclaredField("mSpacingAdd");                    SpacingExtra.setAccessible(true);                    LineSpacingExtra = SpacingExtra.getFloat(this);                } catch (Exception e) {                    e.printStackTrace();                    LineSpacingMultiplier = 1.0f;                    LineSpacingExtra = 3.0f;                }            }        }        return new DynamicLayout(workingText, getPaint(),            (textTotalWidth == 0 ? getScreenPixWidth(mContext) : textTotalWidth),            Layout.Alignment.ALIGN_NORMAL, LineSpacingMultiplier, LineSpacingExtra, false);    }
再结合之前的测量方法,我们的操作也很明显:
通过不断的new出DynamicLayout,逐步的将文字放进去测量,当超出我们指定的行数就停止,同时记录下超出我们指定行数的bean所处于list中的位置,然后通过spannedstringbuilder重新将最后一个位置前的文字拼凑起来,最后setText就可以达到我们的需求了。
如果上面的描述有点难理解,就可以看看下面的描述。。。。
比如咱们现在的点咱用户是这样的:{bean1,bean2,bean3,bean4,bean5......,bean50}(左边是一个list),因为我们需要测量行数,所以我们需要不断的new DynamicLayout出来,并调用getLineCount,也就是我们测量第一个方的数据是bean1,这时候没有超出,于是继续放bean2,也就是我们这时测量的是bean1,bean2,没有超出,于是我们继续测量。。。。。。假设我们在bean16的时候超出了,也就是我们测量bean1,bean2,bean3....bean16,在bean17超出了指定行数,那么就不再测量,记录下此时的bean在list的位置,进行下面的拼凑文字的操作,最后再setText就完成了。
最后放上完整代码
/** * Created by 大灯泡 on 2015/9/25. * 这是实现点赞显示的控件<br> * <strong>请用setDataByArray(PraiseBean数组)来绑定数据</strong> * <br> * * <br> * <strong> * smaple:<br> * </strong> * <p> *  * <l>android:id="@+id/test_friend"</l><br> * <l>android:layout_width="250dp"//可以是match</l><br> * <l>android:maxLines="3"//最大显示行数</l><br> * <l>android:lineSpacingExtra="2.5px" //行距</l><br> * <l>android:lineSpacingMultiplier="1"//行距倍数</l><br> * <l>android:layout_height="wrap_content"</l><br> * <l>app:font_size="@dimen/sp_16"//内部字体大小</l><br> * <l>app:font_color="#ff259cf8"//内部字体颜色</l><br> * <l>app:zan_icon="@drawable/ba_zan"//赞图标</l><br> * </p> * <l>/></l><br> */public class PraiseWidget extends TextView {    private static final String TAG = "PraiseWidget";    //===================参数定义====================    private int color = 0xff517fae;    private int size = 16;    private Context mContext;    private int iconID = R.drawable.ic_moment_liked;    private List<PraiseBean> mBeans;    private int curLine;//渲染当前文本的行数    private int mMaxLine = 3;    float LineSpacingMultiplier = 0.0f;    float LineSpacingExtra = 0.0f;    //缓存    private static LruCache<String, SpannableStringBuilderAllVer> mCache =        new LruCache<String, SpannableStringBuilderAllVer>(50) {            @Override            protected int sizeOf(String key, SpannableStringBuilderAllVer value) {                return 1;            }        };    //销毁窗口记得清除缓存,清掉对context的引用    public static void clearPraiseWidgetCache() {        if (mCache != null) mCache.evictAll();    }    public static int getPraiseWidgetCacheEvictionCount() {        if (mCache != null) {            return mCache.evictionCount();        } else {            return -1;        }    }    public PraiseWidget(Context context) {        this(context, null);    }    public PraiseWidget(Context context, AttributeSet attrs) {        this(context, attrs, 0);    }    public PraiseWidget(Context context, AttributeSet attrs, int defStyleAttr) {        super(context, attrs, defStyleAttr);        this.mContext = context;        TypedArray attr = context.obtainStyledAttributes(attrs, R.styleable.PraiseWidget);        this.color = attr.getColor(R.styleable.PraiseWidget_font_color, 0xff517fae);        this.size = attr.getDimensionPixelSize(R.styleable.PraiseWidget_font_size, 16);        this.iconID =            attr.getResourceId(R.styleable.PraiseWidget_zan_icon, R.drawable.ic_moment_liked);        TypedArray systemAttr =            context.obtainStyledAttributes(attrs, new int[] { android.R.attr.maxLines });        this.mMaxLine=systemAttr.getInt(0,3);        attr.recycle();        systemAttr.recycle();        //如果不设置,clickableSpan不能响应点击事件        this.setMovementMethod(LinkMovementMethod.getInstance());    }    @Override    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        super.onMeasure(widthMeasureSpec, heightMeasureSpec);        if (getMeasuredWidth() > 0) {            renderView();        }    }    //------------------------------------------传参-----------------------------------------------    public void setDataByArray(List<PraiseBean> list) {        this.mBeans = list;        if (getMeasuredWidth() > 0) {            renderView();        } else {            requestLayout();        }    }    private void renderView() {        if (mBeans == null || mBeans.size() == 0) {            setText("");            return;        }        int textTotalWidth = getMeasuredWidth();        //从缓存读取,避免重复测量导致的过多对象被创建问题        String key = Integer.toString(mBeans.hashCode()) + mBeans.size() + textTotalWidth;        SpannableStringBuilderAllVer spannable = mCache.get(key);        if (spannable != null) {            setText(spannable);        } else {            int lastPos = 0;//最后一个位置            curLine = 0;            int maxLine = mMaxLine;            int beanSize = mBeans.size();            String peopleCount =                mContext.getResources().getString(R.string.praise_zan, mBeans.size());            StringBuilder stringBuilder = new StringBuilder();            stringBuilder.append("like  ");//预留位置给点赞的心,防止超出指定行数行            for (int i = 0; i < beanSize && curLine <= maxLine; i++) {                stringBuilder.append(mBeans.get(i).userNick);                /**测量当前文字的所属行数(加上“等xxx人测量,保证最后一个可以被顶替掉”)*/                curLine = createWorkingLayout(stringBuilder.toString() + peopleCount,                    textTotalWidth).getLineCount();                if (curLine <= maxLine) {                    lastPos = i;                    stringBuilder.append(", ");                } else {                    break;                }            }            spannable = addClickablePart(lastPos);            setText(spannable);            mCache.put(key, spannable);        }    }    private SpannableStringBuilderAllVer addClickablePart(int LastPos) {        // 第一个心心图标        CustomImageSpan span = new CustomImageSpan(mContext, iconID);        //空字符,保证有一个位置        SpannableString spanStr = new SpannableString(" ");        spanStr.setSpan(span, 0, 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);        // 构建 builder        SpannableStringBuilderAllVer spanBuilder = new SpannableStringBuilderAllVer(spanStr);        for (int i = 0; i <= LastPos; i++) {            PraiseBean bean = mBeans.get(i);            if (i == 0) {                spanBuilder.append("  " + bean.userNick,                    new PraiseClick(mContext, bean.userNick, bean.userId,color), 0);            } else {                spanBuilder.append(mBeans.get(i).userNick,                    new PraiseClick(mContext, bean.userNick, bean.userId,color), 0);            }            if (i != LastPos) spanBuilder.append(", ");        }        if (LastPos < mBeans.size() - 1) {            //等xxx人            return spanBuilder.append(                mContext.getResources().getString(R.string.praise_zan, mBeans.size()-LastPos));        } else {            return spanBuilder;        }    }    private Layout createWorkingLayout(String workingText, int textTotalWidth) {        /**         *  float spacingmult:相对行间距,相对字体大小,1.5f表示行间距为1.5倍的字体高度。         *  float spacingadd:在基础行距上添加多少         */        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {            LineSpacingMultiplier = getLineSpacingMultiplier();            LineSpacingExtra = getLineSpacingExtra();        } else {            if (LineSpacingMultiplier == 0.0f && LineSpacingExtra == 0.0f) {                try {                    Field Multiplier = TextView.class.getDeclaredField("mSpacingMult");                    Multiplier.setAccessible(true);                    LineSpacingMultiplier = Multiplier.getFloat(this);                    Field SpacingExtra = TextView.class.getDeclaredField("mSpacingAdd");                    SpacingExtra.setAccessible(true);                    LineSpacingExtra = SpacingExtra.getFloat(this);                } catch (Exception e) {                    e.printStackTrace();                    LineSpacingMultiplier = 1.0f;                    LineSpacingExtra = 3.0f;                }            }        }        return new DynamicLayout(workingText, getPaint(),            (textTotalWidth == 0 ? getScreenPixWidth(mContext) : textTotalWidth),            Layout.Alignment.ALIGN_NORMAL, LineSpacingMultiplier, LineSpacingExtra, false);    }    /** 获取屏幕分辨率:宽 */    public int getScreenPixWidth(Context context) {        return context.getResources().getDisplayMetrics().widthPixels;    }}


LruCache是我后来加入的,因为我们做的是个圈,做个xx圈肯定用到listView,因此我们测量一次就放到lru里面,如果内容无变化,我们就不需要重复的测量(也就是不需要重复的进行new操作),避免了短时间内创建大量对象。

Demo并没有用listview,所以lrucache几乎没啥用。

最后如果对这个控件有更高的改进方法(个人觉得不断的new对象那里可以优化一下),欢迎评论区留下您的脚印哦-V-






   

1 0
原创粉丝点击