关闭

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

标签: android控件
1949人阅读 评论(7) 收藏 举报
分类:

国际惯例,先放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

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:12896次
    • 积分:248
    • 等级:
    • 排名:千里之外
    • 原创:11篇
    • 转载:0篇
    • 译文:0篇
    • 评论:23条
    文章分类
    最新评论