ScFlowLayout一款Android基于ViewGroup实现的流式布局

ScFlowLayout一款Android基于ViewGroup实现的流式布局

本文原创,转载请注明出处。欢迎关注我的 简书
安利一波我写的开发框架:MyScFrame喜欢的话就给个Star
ScFlowLayout已经加入到该框架内

场景

最近在做一个聊天功能,其中需要给对方打标签,第一时间想到的就是流式布局,目前项目上用的是鸿洋大神的FlowLayout,功能很强大,不过我项目上只用到了展示效果,读了大神的源码,给了我一些灵感,这里我也写一个FlowLayout,并且参考了一些Recycler.Adapter的做法。

参考资料

hongyangAndroid/FlowLayout
Android流式布局(FlowLayout)
自定义View、动画

实现功能

  • 使用adapter的形式绑定并处理数据
  • 支持多种布局一同展示
  • 支持多行,单行,指定显示行数
  • 支持Item左对齐,居中对齐,右对齐
  • 支持行布局顶部对齐,居中对齐,底部对齐
  • 支持选中状态
  • 支持设置行间距
  • 支持设置item间距

ScFlowLayout

1.思路

使用SparseArray<LineDes> mLineDesArray;保存每行的数据,其中包括行高,行宽以及该行中包含的View的集合。
使用BaseTagFlowAdapter mAdapter;来管理数据加载,点击事件,选中事件。

2.onMeasure()中的业务逻辑

onMeasure()
首先遍历测量子View,将子View的顶点坐标通过view.setTag()方法保存,同时把每行的数据保存在LineDes中,这样写是为了后续在onLayout()好处理,不用重复计算。
接下来我们在调用setMeasuredDimension()方法之前需要给出布局的宽跟高,我这边是通过getLayoutParams().widthgetLayoutParams().height来判断布局的宽高,至于为什么要这样写大家可以参考这篇文章,如果想要实现指定行数的话需要遍历每行高度,然后累加到mMeasuredHeight中。
在计算高度的时候,由于我这里实现了自定义行间距,因此实际计算高度的时候还需要加上行间距的高度。

        //由于计算子view所占宽度
        Map<String, Integer> compute = compute(widthSize, widthMeasureSpec, heightMeasureSpec);
        mMeasuredWidth = widthSize;
        mMeasuredHeight = heightSize;
        if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
            mMeasuredWidth = compute.get(ALL_CHILD_WIDTH);
        }
        if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
            mMeasuredHeight = compute.get(ALL_CHILD_HEIGHT);
            if (mLineDesArray.size() > 1) {
                //加上行间距
                mMeasuredHeight += mLineSpace * (mLineDesArray.size() - 1);
            }
        }
        if (mMaxShowRow != 0) {
            mMeasuredHeight = 0;
            int lineCount = Math.min(mLineDesArray.size(), mMaxShowRow);
            for (int i = 0; i < lineCount; i++) {
                mMeasuredHeight += mLineDesArray.get(i).rowsMaxHeight;
            }
            mMeasuredHeight += getPaddingBottom();
            if (lineCount > 1) {
                //加上行间距
                mMeasuredHeight += mLineSpace * (lineCount - 1);
            }
        }

Map<String, Integer> compute(int flowWidth, int widthMeasureSpec, int heightMeasureSpec)是遍历子View的方法(整个控件都靠它了)
我们先要设置几个参数:

int lineIndex行数
int rowsWidth当前行已占宽度
int columnHeight当前行顶部已占高度
int rowsMaxHeight当前行所有子元素的最大高度(用于换行累加高度)
LineDes lineDes保存每行数据的bean类

思路是先遍历所有子View,然后计算出每个子View所占用的宽高,child.getMeasuredWidth()计算出来的是包含子View中的Padding参数,但是不包含Margin,所以这里实际宽高还需要加上Margin不然会导致实际大小与计算出来的不符

            //遍历去调用所有子元素的measure方法(child.getMeasuredHeight()才能获取到值,否则为0)
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            //获取元素测量宽度和高度
            int measuredWidth = child.getMeasuredWidth();
            int measuredHeight = child.getMeasuredHeight();
            //获取元素的margin
            marginParams = (MarginLayoutParams) child.getLayoutParams();
            //子元素所占宽度 = MarginLeft+ child.getMeasuredWidth+MarginRight  注意此时不能child.getWidth,因为界面没有绘制完成,此时wdith为0
            int childWidth = marginParams.leftMargin + marginParams.rightMargin + measuredWidth;
            int childHeight = marginParams.topMargin + marginParams.bottomMargin + measuredHeight;

得到每个子View的宽高后就要开始计算行数以及每行所存放的View的数量了
我们之前已经有了一个rowsWidth参数,默认值是getPaddingLeft(),然后加上childWidth看是否会超过父布局的宽度,这边还需要减去一个getPaddingRight()切记切记!,如果超了,表示这个View已经无法存放在该行,需要换行。最后使用Rect把子View的宽高赋值进去,然后保存在tag中,方便后续使用。

            //该布局添加进去后会超过总宽度->换行
            if (rowsWidth + childWidth > flowWidth - getPaddingRight()) {
                getLineDesArray().put(lineIndex, lineDes);
                lineDes = new LineDes();
                lineIndex++;
                //重置行宽度
                rowsWidth = getPaddingLeft();
                //累加上该行子元素最大高度
                columnHeight += rowsMaxHeight;
                //重置该行最大高度
                rowsMaxHeight = childHeight;
            } else {
                rowsMaxHeight = Math.max(rowsMaxHeight, childHeight);
            }
            //累加上该行子元素宽度
            rowsWidth += childWidth;
            // 判断时占的宽段时加上margin计算,设置顶点位置时不包括margin位置,
            // 不然margin会不起作用,这是给View设置tag,在onlayout给子元素设置位置再遍历取出
            Rect rect = new Rect(
                    rowsWidth - childWidth + marginParams.leftMargin,
                    columnHeight + marginParams.topMargin,
                    rowsWidth - marginParams.rightMargin,
                    columnHeight + childHeight - marginParams.bottomMargin);
            child.setTag(rect);
            lineDes.rowsMaxHeight = rowsMaxHeight;
            lineDes.rowsMaxWidth = rowsWidth;
            lineDes.views.add(child);
            //累加上item间距
            rowsWidth += mItemSpace;

3.onLayout()中的业务逻辑

onLayout()中,通过所需要实现的类型去做不同的排版
因为这里我们实现了行间上,中,下与Item间的左,中,右对齐因此,这里需要有针对行与Item做两次处理

我们先设置一个diffvalue用于存放位移参数。
为了让行内所有布局都居中对齐或下对齐,那么我们要先知道每行有多少个元素,以及行高与元素高度,这个时候LineDes就派上用场了,之前在onMeasure()中我们已经计算并保存了LineDes,现在只需要遍历LineDes即可,由于系统在绘制的时候就是使用顶部对齐,因此LINE_GRAVITY_TOP不需要做处理,我们只需要处理LINE_GRAVITY_CENTERLINE_GRAVITY_BOTTOM即可

LINE_GRAVITY_CENTERdiffvalue = (lineDes.rowsMaxHeight - childWidth) / 2;
LINE_GRAVITY_BOTTOMdiffvalue = lineDes.rowsMaxHeight - childWidth;

再来说一下Item间的排版,同样的TAG_GRAVITY_LEFT可以不做处理

LINE_GRAVITY_CENTERdiffvalue = (mMeasuredWidth - getPaddingRight() - lineDes.rowsMaxWidth) / 2;
LINE_GRAVITY_BOTTOMdiffvalue = mMeasuredWidth - lineDes.rowsMaxWidth - getPaddingRight();

改完重新写入Rect中并传入子View的layout()中即可。

    private synchronized void formatAboveLine(int lineGravity) {
        int lineIndex = getLineDesArray().size();
        for (int i = 0; i < lineIndex; i++) {
            LineDes lineDes = getLineDesArray().get(i);
            List<View> views = lineDes.views;
            int viewIndex = views.size();
            for (int j = 0; j < viewIndex; j++) {
                View child = views.get(j);
                Rect rect = (Rect) child.getTag();
                int childWidth = (rect.bottom - rect.top);
                //如果是当前行的高度大于了该view的高度话,此时需要重新放该view了
                int diffvalue = 0;
                if (childWidth < lineDes.rowsMaxHeight) {
                    switch (lineGravity) {
                        case LINE_GRAVITY_TOP:
                            break;
                        case LINE_GRAVITY_CENTER:
                            diffvalue = (lineDes.rowsMaxHeight - childWidth) / 2;
                            rect.top += diffvalue;
                            rect.bottom += diffvalue;
                            break;
                        case LINE_GRAVITY_BOTTOM:
                            diffvalue = lineDes.rowsMaxHeight - childWidth;
                            rect.top += diffvalue;
                            rect.bottom += diffvalue;
                            break;
                        default:
                            break;
                    }
                }
                switch (mTagGravity) {
                    case TAG_GRAVITY_LEFT:
                        break;
                    case TAG_GRAVITY_CENTER:
                        diffvalue = (mMeasuredWidth - getPaddingRight() - lineDes.rowsMaxWidth) / 2;
                        if (diffvalue > 0) {
                            rect.left += diffvalue;
                            rect.right += diffvalue;
                        }
                        break;
                    case TAG_GRAVITY_RIGHT:
                        diffvalue = mMeasuredWidth - lineDes.rowsMaxWidth - getPaddingRight();
                        rect.left += diffvalue;
                        rect.right += diffvalue;
                        break;
                    default:
                        break;
                }
                //加上行间距
                rect.top += mLineSpace * i;
                rect.bottom += mLineSpace * i;
                child.layout(rect.left, rect.top, rect.right, rect.bottom);
            }
        }
        getLineDesArray().clear();
    }

适配器

参考BaseRecyclerViewAdapterHelper实现的一个AdapterViewHolder用于绑定相关数据,并处理点击,选中等事件。

1.思路

使用SparseIntArray mLayoutResIds保存layoutId,实现多布局样式。
使用SparseArray<ArrayList<Integer>> mCheckedStateViewResIds保存需要实现选中状态的子ViewId
使用HashMap<Integer, TagView> mCheckedPosList保存选中的View,实现单选,多选等功能

2.加载布局

RecyclerView.Adapter一样,我们把data传进来,然后遍历数据,通过ViewType来判断到底使用mLayoutResIds中的哪个布局,并且遍历 mCheckedStateViewResIds对需要做选中状态变更的view设置setDuplicateParentStateEnabled(true),然后把实例出来的View传入ViewHolder最后加载出来。

    private void addNewView() {
        mFlowLayout.removeAllViews();
        mCheckedPosList.clear();
        TagView tagViewContainer = null;
        K baseViewHolder = null;
        T data = null;
        int viewType = DEFAULT_VIEW_TYPE;
        for (int i = 0; i < getCount(); i++) {
            data = getItem(i);
            viewType = getDefItemViewType(data);
            baseViewHolder = onCreateViewHolder(mFlowLayout, viewType, i);
            tagViewContainer = new TagView(mContext);
            //关键代码,使得内部View可以使用TagView的状态
            if (mCheckedStateViewResIds != null) {
                ArrayList<Integer> viewResId = mCheckedStateViewResIds.get(viewType, new ArrayList<Integer>());
                for (Integer stateViewId : viewResId) {
                    View stateView = baseViewHolder.getView(stateViewId.intValue());
                    if (stateView != null) {
                        stateView.setDuplicateParentStateEnabled(true);
                    }
                }
            }
            baseViewHolder.itemView.setDuplicateParentStateEnabled(true);
            if (baseViewHolder.itemView.getLayoutParams() != null) {
                tagViewContainer.setLayoutParams(baseViewHolder.itemView.getLayoutParams());
            } else {
                ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams(
                        ViewGroup.LayoutParams.WRAP_CONTENT,
                        ViewGroup.LayoutParams.WRAP_CONTENT);
                lp.setMargins(leftMargin, topMargin, rightMargin, bottomMargin);
                tagViewContainer.setLayoutParams(lp);
            }
            ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
            baseViewHolder.itemView.setLayoutParams(lp);
            tagViewContainer.addView(baseViewHolder.itemView);

            //处理选中与非选中逻辑
            if (setDefSelected(data, i)) {
                if (mSelectedMax == 1 && mCheckedPosList.size() > 0) {
                    int oldSelected = 0;
                    TagView oldTagView;
                    for (Map.Entry<Integer, TagView> entry : mCheckedPosList.entrySet()) {
                        oldSelected = entry.getKey();
                        oldTagView = entry.getValue();
                        setChildUnChecked(oldSelected, oldTagView);
                    }
                    mCheckedPosList.clear();
                }
                mCheckedPosList.put(i, tagViewContainer);
                setChildChecked(i, tagViewContainer);
            }

            mFlowLayout.addView(tagViewContainer);
            convert(baseViewHolder, data);
            bindViewClickListener(tagViewContainer, baseViewHolder);
        }
    }

3.ViewHolder

ViewHolder里面只是保存一些常用数据,方便在使用的时候调用

    private final SparseArray<View> views;
    private final LinkedHashSet<Integer> childClickViewIds;//需要添加点击事件的子View
    private final LinkedHashSet<Integer> itemChildLongClickViewIds;//需要添加点击事件的子View
    private final HashSet<Integer> nestViews;//需要添加两种点击事件的子View
    public final View itemView;
    private BaseTagFlowAdapter adapter;
    private int position = -1;
    private int viewType = BaseTagFlowAdapter.DEFAULT_VIEW_TYPE;

    public BaseTagFlowViewHolder(final View view) {
        this.itemView = view;
        this.views = new SparseArray<>();
        this.childClickViewIds = new LinkedHashSet<>();
        this.itemChildLongClickViewIds = new LinkedHashSet<>();
        this.nestViews = new HashSet<>();
    }

欢迎大家留言指出我的不足。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容