Android自定义频道选择器、频道定制

1.频道选择器,频道定制

  现在市场上的新闻软件中,绝大多数都会有频道选择器,比如腾讯新闻、网易新闻、今日头条等,频道选择器可以帮助用户定制自己想要的新闻板块,给用户更好的体验。我们的项目正好也是一个新闻类APP,为了更好的符合我们的产品,我们需要自己实现一套频道选择器,项目地址ChannelView,如果有需要的朋友可以看一下,先来看一下效果图。

  从效果上看,我们的频道选择器已经完全不弱于市面上的大多数主流应用的选择器,频道拖动、频道删除、频道添加,动画效果都已经包含,并且十分流畅没有卡顿,下面我们就一起看看这款自定义View是如何实现的吧。

2.View结构

  想实现这个选择器不难,因为它只是一些对子View的布局和位置调整,所以我们的重点就是确定每个子View的位置,并保存它的坐标,然后用动画让子View之间可以交换位置也就是交换坐标,这些核心地方实现了,其他的一些拖动、增删功能也就不是问题了,都是在它的基础上实现的。

  现在,我们都知道要实现一个自定义View我们需要继承View或者ViewGroup,这里我们一看拥有这么多子View就知道肯定要继承ViewGroup了。但问题又来了,在那么的多的ViewGroup中我们需要使用哪个呢?其实有很多的选择,关键是哪个更方便,先让我们来考虑一下选择哪个吧,这是任何一个自定义View的开始,选合适了我们可以事半功倍。

  如果我们的View继承LinearLayout,虽然子View有一定的顺序让我们不用覆盖它的onLayout()方法重写,但由于横向、竖向都有所以我们要嵌套的使用LinarLayout,这会让View过度绘制。如果继承RelativeLayout的话,它的子View似乎需要我们自己确定位置,我们需要在onLayout()里面进行计算每个View,这似乎还不如直接继承ViewGroup,其实我的第一个频道选择器就是继承的ViewGroup实现的,功能效果跟现在的几乎一样,但代码实现上惨不忍睹,所以又重新写了现在这个。好了,我们的这个频道选择器是继承GridLayout实现的,其实一看它的布局就应该能想到,结果我第一次实现的时候却没想到它,使用GridLayout的好处是我们不用自己去实现子View的位置,只需要添加子View后它就会根据根据我们对GridLayout的属性设置自动布局好每个子View的位置,然后我们只需要在onLayout()方法中遍历每个子View得到它的坐标位置保存就OK了,我们看一下整个选择器的布局情况

  整个自定义View只有3层,最外层是继承ScrollView的ChannelView,中间是继承GridLayout的ChannelLayout,最里面一层是并列的TextView频道,下面我们进入正题吧。

3.代码结构

  我们的自定义View命名为ChannelView,包括两个内部类和一个接口,其中ChannelView继承ScrollView,可以实现上下滑动,ChannelAttr是频道属性,ChannelLayout继承GridLayout是整个频道选择器的核心类。

4.具体实现

  • 初始化数据
public ChannelLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init();
}

private void init() {
    setColumnCount(channelColumn);
    setPadding(channelPadding, channelPadding, channelPadding, channelPadding);
    addChannelView();
}

/**
 * 设置频道View
 */
private void addChannelView() {
    if (channelContents != null) {
        groupChannelColumns = new int[channelContents.size()];
        int j = 0;
        int startRow = 0;
        for (String aKeySet : channelContents.keySet()) {//遍历key值,设置标题名称
            String[] channelContent = channelContents.get(aKeySet);
            if (channelContent == null) {
                channelContent = new String[]{};
            }
            groupChannelColumns[j] = channelContent.length % channelColumn == 0 ? channelContent.length / channelColumn : channelContent.length / channelColumn + 1;
            if (j == 0) {
                startRow = 0;
            } else {
                startRow += groupChannelColumns[j - 1] + 1;
            }
            Spec rowSpec = GridLayout.spec(startRow);
            //标题要占channelColumn列
            Spec columnSpec = GridLayout.spec(0, channelColumn);
            LayoutParams layoutParams = new LayoutParams(rowSpec, columnSpec);
            View view = LayoutInflater.from(mContext).inflate(R.layout.cgl_my_channel, null);
            if (j == 0) {
                tipEdit = view.findViewById(R.id.tv_tip_edit);
                tipEdit.setVisibility(VISIBLE);
                tipEdit.setOnClickListener(this);
                tipFinish = view.findViewById(R.id.tv_tip_finish);
                tipFinish.setVisibility(INVISIBLE);
                tipFinish.setOnClickListener(this);
            }
            ChannelAttr channelTitleAttr = new ChannelAttr();
            channelTitleAttr.type = ChannelAttr.TITLE;
            channelTitleAttr.coordinate = new PointF();
            //为标题View添加一个ChannelAttr属性
            view.setTag(channelTitleAttr);
            TextView tvTitle = view.findViewById(R.id.tv_title);
            tvTitle.setText(aKeySet);
            addView(view, layoutParams);
            channelTitleGroups.add(view);
            ArrayList<View> channelGroup = new ArrayList<>();
            int remainder = channelContent.length % channelColumn;
            for (int i = 0; i < channelContent.length; i++) {//遍历value中的频道
                TextView textView = new TextView(mContext);
                ChannelAttr channelAttr = new ChannelAttr();
                channelAttr.type = ChannelAttr.CHANNEL;
                channelAttr.groupIndex = j;
                channelAttr.coordinate = new PointF();
                if (j != 0) {
                    channelAttr.belong = j;
                } else {
                    if (channelBelongs.indexOfKey(i) >= 0) {
                        int belongId = channelBelongs.get(i);
                        if (belongId > 0 && belongId < channelContents.size()) {
                            channelAttr.belong = belongId;
                        } else {
                            Log.w(getClass().getSimpleName(), "归属ID不存在,默认设置为1");
                        }
                    }
                }
                //为频道添加ChannelAttr属性
                textView.setTag(channelAttr);
                textView.setText(channelContent[i]);
                textView.setGravity(Gravity.CENTER);
                textView.setBackgroundResource(channelNormalBackground);
                if (j == 0 && i <= channelFixedToPosition) {
                    textView.setTextColor(channelFixedColor);
                }
                textView.setOnClickListener(this);
                textView.setOnTouchListener(this);
                textView.setOnLongClickListener(this);
                //设置每个频道的间距
                LayoutParams params = new LayoutParams();
                int leftMargin = verticalSpacing, topMargin = horizontalSpacing, rightMargin = verticalSpacing, bottomMargin = horizontalSpacing;
                if (i % channelColumn == 0) {
                    leftMargin = 0;
                }
                if ((i + 1) % channelColumn == 0) {
                    rightMargin = 0;
                }
                if (i < channelColumn) {
                    topMargin = 0;
                }
                if (remainder == 0) {
                    if (i >= channelContent.length - channelColumn) {
                        bottomMargin = 0;
                    }
                } else {
                    if (i >= channelContent.length - remainder) {
                        bottomMargin = 0;
                    }
                }
                params.setMargins(leftMargin, topMargin, rightMargin, bottomMargin);
                addView(textView, params);
                channelGroup.add(textView);
            }
            channelGroups.add(channelGroup);
            j++;
        }
    }
}

  通过setColumnCount(channelColumn)我们设置GridView的列数为channelColumn列。

  在addChannelView()中,我们主要做了如下几个方面:
  1. 从存储频道的集合Map<String, String[]> channelContents中获取数据。channelContents的长度代表有多少组频道,channelContents中的key为每组频道的标题,比如有“我的频道”、“推荐频道”、“国内频道”、“国外频道”等,channelContents中的value为频道组中的具体频道。默认channelContents中的第0项为“我的频道”,是可以拖拽排序的,其他组都为待添加的频道,不能拖拽,如果channelContents的大小为1,也就是只有“我的频道”,那么会默认再添加一组频道数量为0的频道组,为的是可以删除已选择的频道,这段逻辑没有在上面的代码中,上面代码中的channelContents是已经处理好的变量,具体处理的细节可以看工程中的代码;

  2. 遍历channelContents的key值,创建频道的标题View,将key的值设为频道标题;并让这个View所占的列数为channelColumn,将标题添加到channelTitleGroups集合中;

  3. 在遍历key的同时,遍历value中的频道,将每个频道作为TextView添加到GridView中,并且所占的列数为1列,将频道添加到channelGroups集合中;

  4. 为每个子View设置一个属性ChannelAttr,属性中包含了类型、坐标等:

/**
 * 频道属性
 */
private class ChannelAttr {
    static final int TITLE = 0x01;
    static final int CHANNEL = 0x02;

    /**
     * view类型
     */
    private int type;

    /**
     * view坐标
     */
    private PointF coordinate;

    /**
     * view所在的channelGroups位置
     */
    private int groupIndex;

    /**
     * 频道归属,用于删除频道时该频道的归属位置(推荐、国内、国外),默认都为1
     */
    private int belong = 1;
}

  groupIndex:说明当前频道所在哪个频道组,在添加频道或删除频道时会发生变化,频道标题没有该属性;

  belong:是不会变化的,在初始化数据时已经确定,它表明了该频道原来是属于什么地方的,当从“我的频道”中删除时我们可以根据它知道该频道应该到哪去,频道标题没有该属性;

  coordinate:表示当前频道的坐标,会随着增、删、移动频道时发生变化;

  type:表示当前View的类别,只有两种,频道标题或者频道。

  • 测量和布局
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (isAgainMeasure) {
        int width = MeasureSpec.getSize(widthMeasureSpec);//ChannelLayout的宽
        //不是通过动画改变ChannelLayout的高度
        if (!isAnimateChangeHeight) {
            int height = 0;
            int allChannelTitleHeight = 0;
            for (int i = 0; i < getChildCount(); i++) {
                View childAt = getChildAt(i);
                if (((ChannelAttr) childAt.getTag()).type == ChannelAttr.TITLE) {
                    //计算标题View的宽高
                    childAt.measure(MeasureSpec.makeMeasureSpec(width - channelPadding * 2, MeasureSpec.EXACTLY), heightMeasureSpec);
                    allChannelTitleHeight += childAt.getMeasuredHeight();
                } else if (((ChannelAttr) childAt.getTag()).type == ChannelAttr.CHANNEL) {
                    //计算每个频道的宽高
                    channelWidth = (width - verticalSpacing * (channelColumn * 2 - 2) - channelPadding * 2) / channelColumn;
                    childAt.measure(MeasureSpec.makeMeasureSpec(channelWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(channelHeight, MeasureSpec.EXACTLY));
                }
            }
            for (int groupChannelColumn : groupChannelColumns) {
                if (groupChannelColumn > 0) {
                    height += channelHeight * groupChannelColumn + (groupChannelColumn * 2 - 2) * horizontalSpacing;
                }
            }
            allChannelGroupsHeight = height;
            height += channelPadding * 2 + allChannelTitleHeight;//ChannelLayout的高
            setMeasuredDimension(width, height);
        } else {//通过动画改变ChannelLayout的高度
            setMeasuredDimension(width, animateHeight);
        }
    }
}

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    if (isAgainLayout) {
        super.onLayout(changed, left, top, right, bottom);
        for (int i = 0; i < getChildCount(); i++) {
            View childAt = getChildAt(i);
            ChannelAttr tag = (ChannelAttr) childAt.getTag();
            tag.coordinate.x = childAt.getX();
            tag.coordinate.y = childAt.getY();
        }
        isAgainLayout = false;
    }
}

  onMeasure()方法中测量出选择器的宽高,宽width已经计算出,高由子View来决定。这里首先通过measure(int widthMeasureSpec, int heightMeasureSpec)方法测量所有子View大小。子View只有两种类型,标题子View和频道子View,其中标题View是从xml布局中获取的,它的宽高是已经确定的值,不需要我们自己计算(代码中它的宽还是需要我们计算的,因为我们为ChannelView自定义的属性中有padding,所以还需要减去padding的值)。频道子View的高度由ChannelView的自定义属性确定不需要计算,宽度我们可以通过ChannelView的宽除以列数再减去padding和频道之间的间距就可以得到。最后,我们根据子View的行数和每行的高度确定ChannelView的高,然后调用setMeasuredDimension()方法就可以了。

  在onMeasure()方法中,有两个setMeasuredDimension()方法,其中上面的是我们用来第一次计算ChannelLayout用的,下面的是动态的改变高度时调用的,也就是频道的行数有变化时,能让高度通过动画形式平滑的改变高度,而不是突然变高或者变矮。

  onLayout()方法中很简单,不需要我们自己确定子View的位置,只需要存储它在布局好之后的位置坐标就可以。

  • 效果需求

  上面的两步完成之后我们已经得到了想要的布局,也确定了每个子View的坐标位置,现在我们已经不需要GridLayout的帮助了,忘记它,它已经完成了它的使命。接下来我们要做的就是拖动改变频道顺序、增删频道。

  我们先来考虑一下我们想要的效果,当长按“我的频道”时我们希望能编辑它,拖动频道可以改变它的顺序,并且它的样式也能改变,提示用户能删除这个频道,然后后面的频道能往前移动,删除的频道可以回归到它所属于的频道组。当点击“完成”时,我们希望样式能恢复。对于其它的频道组,我们希望点击它的时候能添加到“我的频道”中去,后面的频道能往前排移动,这就是我们想要的效果。

  整个代码中不需要实现特别复杂的触摸事件,所以我们只需要继承OnTouchListener、OnLongClickListener、OnClickListener就可以了

@Override
public boolean onTouch(View v, MotionEvent event) {
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
        downX = event.getRawX();
        downY = event.getRawY();
    }
    if (event.getAction() == MotionEvent.ACTION_MOVE && isChannelLongClick) {
        //手移动时拖动频道
        channelDrag(v, event);
    }
    if (event.getAction() == MotionEvent.ACTION_UP && isChannelLongClick) {
        //手抬起时频道状态
        channelDragUp(v);
    }
    return false;
}

@Override
public void onClick(View v) {
    if (v == tipFinish) {//点击完成按钮时
        changeTip(false);
        List<String> myChannels = new ArrayList<>();
        for (View view : channelGroups.get(0)) {
            myChannels.add(((TextView) view).getText().toString());
        }
        if (onChannelListener != null) {
            onChannelListener.channelFinish(myChannels);
        }
    } else {
        ChannelAttr tag = (ChannelAttr) v.getTag();
        ArrayList<View> channels = channelGroups.get(tag.groupIndex);
        if (tag.groupIndex == 0) {//如果点击的是我的频道组中的频道
            if (channelClickType == DELETE && channels.indexOf(v) > channelFixedToPosition) {
                forwardSort(v, channels);
                //减少我的频道
                deleteMyChannel(v);
            } else if (channelClickType == NORMAL) {
                //普通状态时进行点击事件回调
                if (onChannelListener != null) {
                    onChannelListener.channelItemClick(channels.indexOf(v), ((TextView) v).getText().toString());
                }
            }
        } else {//点击的其他频道组中的频道
            forwardSort(v, channels);
            //增加我的频道
            addMyChannel(v);
        }
    }
}

@Override
public boolean onLongClick(View v) {
    v.bringToFront();
    ChannelAttr tag = (ChannelAttr) v.getTag();
    if (tag.groupIndex == 0) {//判断是否点击的我的频道组
        ArrayList<View> views = channelGroups.get(0);
        int indexOf = views.indexOf(v);
        if (indexOf > channelFixedToPosition) {
            for (int i = channelFixedToPosition + 1; i < views.size(); i++) {
                if (i == indexOf) {
                    views.get(i).setBackgroundResource(channelFocusedBackground);
                } else {
                    views.get(i).setBackgroundResource(channelSelectedBackground);
                }
            }
            changeTip(true);
        }
    }
    //要返回true,否则会出发onclick事件
    return true;
}

  在各个点击事件中,我们需要判断每次点击的View属性,根据v.getTag()方法获取到ChannelAttr,判断它此时所在的频道组(位于channelGroups中的位置),判断它原来归属于哪个频道组,以及它的坐标,然后做出相应的操作,比如拖拽、增删等,下面我们来具体看一下这部分的代码。

  • 效果实现
/**
 * 后面的频道向前排序
 *
 * @param v
 * @param channels
 */
private void forwardSort(View v, ArrayList<View> channels) {
    int size = channels.size();
    int indexOfValue = channels.indexOf(v);
    if (indexOfValue != size - 1) {
        for (int i = size - 1; i > indexOfValue; i--) {
            View lastView = channels.get(i - 1);
            ChannelAttr lastViewTag = (ChannelAttr) lastView.getTag();
            View currentView = channels.get(i);
            ChannelAttr currentViewTag = (ChannelAttr) currentView.getTag();
            currentViewTag.coordinate = lastViewTag.coordinate;
            currentView.animate().x(currentViewTag.coordinate.x).y(currentViewTag.coordinate.y).setDuration(DURATION_TIME);
        }
    }
}

/**
 * 增加我的频道
 *
 * @param v
 */
private void addMyChannel(final View v) {
    //让点击的view置于最前方,避免遮挡
    v.bringToFront();
    ChannelAttr tag = (ChannelAttr) v.getTag();
    ArrayList<View> channels = channelGroups.get(tag.groupIndex);
    ArrayList<View> myChannels = channelGroups.get(0);
    View finalMyChannel;
    if (myChannels.size() == 0) {
        finalMyChannel = channelTitleGroups.get(0);
    } else {
        finalMyChannel = myChannels.get(myChannels.size() - 1);
    }
    ChannelAttr finalMyChannelTag = (ChannelAttr) finalMyChannel.getTag();
    myChannels.add(myChannels.size(), v);
    channels.remove(v);
    animateChangeGridViewHeight();
    final ViewPropertyAnimator animate = v.animate();
    if (myChannels.size() % channelColumn == 1 || channelColumn == 1) {
        if (myChannels.size() == 1) {
            tag.coordinate = new PointF(finalMyChannelTag.coordinate.x, finalMyChannelTag.coordinate.y + finalMyChannel.getMeasuredHeight());
            //我的频道多一行,下面的view往下移
            viewMove(1, channelHeight);
        } else {
            ChannelAttr firstMyChannelTag = (ChannelAttr) myChannels.get(0).getTag();
            tag.coordinate = new PointF(firstMyChannelTag.coordinate.x, finalMyChannelTag.coordinate.y + channelHeight + horizontalSpacing * 2);
            //我的频道多一行,下面的view往下移
            viewMove(1, channelHeight + horizontalSpacing * 2);
        }
        animate.x(tag.coordinate.x).y(tag.coordinate.y).setDuration(DURATION_TIME);
    } else {
        tag.coordinate = new PointF(finalMyChannelTag.coordinate.x + channelWidth + verticalSpacing * 2, finalMyChannelTag.coordinate.y);
        animate.x(tag.coordinate.x).y(tag.coordinate.y).setDuration(DURATION_TIME);
    }
    animate.setListener(new Animator.AnimatorListener() {
        @Override
        public void onAnimationStart(Animator animation) {

        }

        @Override
        public void onAnimationEnd(Animator animation) {
            if (channelClickType == DELETE) {
                v.setBackgroundResource(channelSelectedBackground);
                animate.setListener(null);
            }
        }

        @Override
        public void onAnimationCancel(Animator animation) {

        }

        @Override
        public void onAnimationRepeat(Animator animation) {

        }
    });
    //该频道少一行,下面的view往上移
    if (channels.size() % channelColumn == 0) {
        if (channels.size() == 0) {
            viewMove(tag.groupIndex + 1, -channelHeight);
        } else {
            viewMove(tag.groupIndex + 1, -channelHeight - horizontalSpacing * 2);
        }
    }
    tag.groupIndex = 0;
}

/**
 * 删除我的频道
 *
 * @param v
 */
private void deleteMyChannel(View v) {
    //让点击的view置于最前方,避免遮挡
    v.bringToFront();
    if (channelClickType == DELETE) {
        v.setBackgroundResource(channelNormalBackground);
    }
    ChannelAttr tag = (ChannelAttr) v.getTag();
    ArrayList<View> beLongChannels = channelGroups.get(tag.belong);
    if (beLongChannels.size() == 0) {
        tag.coordinate = new PointF(((ChannelAttr) channelTitleGroups.get(tag.belong).getTag()).coordinate.x, ((ChannelAttr) channelTitleGroups.get(tag.belong).getTag()).coordinate.y + channelTitleGroups.get(tag.belong).getMeasuredHeight());
    } else {
        ChannelAttr arriveTag = (ChannelAttr) beLongChannels.get(0).getTag();
        tag.coordinate = arriveTag.coordinate;
    }
    v.animate().x(tag.coordinate.x).y(tag.coordinate.y).setDuration(DURATION_TIME);
    beLongChannels.add(0, v);
    channelGroups.get(0).remove(v);
    animateChangeGridViewHeight();
    PointF newPointF;
    ChannelAttr finalChannelViewTag = (ChannelAttr) beLongChannels.get(beLongChannels.size() - 1).getTag();
    //这个地方要注意顺序
    if (channelGroups.get(0).size() % channelColumn == 0) {
        //我的频道中少了一行,底下的所有view全都上移
        if (channelGroups.get(0).size() == 0) {
            viewMove(1, -channelHeight);
        } else {
            viewMove(1, -channelHeight - horizontalSpacing * 2);
        }
    }
    if (beLongChannels.size() % channelColumn == 1) {
        //回收来频道中多了一行,底下的所有view全都下移
        if (beLongChannels.size() == 1) {
            viewMove(tag.belong + 1, channelHeight);
        } else {
            viewMove(tag.belong + 1, channelHeight + horizontalSpacing * 2);
        }
        newPointF = new PointF(tag.coordinate.x, finalChannelViewTag.coordinate.y + channelHeight + horizontalSpacing * 2);
    } else {
        newPointF = new PointF(finalChannelViewTag.coordinate.x + channelWidth + verticalSpacing * 2, finalChannelViewTag.coordinate.y);
    }
    for (int i = 1; i < beLongChannels.size(); i++) {
        View currentView = beLongChannels.get(i);
        ChannelAttr currentViewTag = (ChannelAttr) currentView.getTag();
        if (i < beLongChannels.size() - 1) {
            View nextView = beLongChannels.get(i + 1);
            ChannelAttr nextViewTag = (ChannelAttr) nextView.getTag();
            currentViewTag.coordinate = nextViewTag.coordinate;
        } else {
            currentViewTag.coordinate = newPointF;
        }
        currentView.animate().x(currentViewTag.coordinate.x).y(currentViewTag.coordinate.y).setDuration(DURATION_TIME);
    }
    tag.groupIndex = tag.belong;
}

/**
 * 行数变化后的gridview高度并用动画改变
 */
private void animateChangeGridViewHeight() {
    int newAllChannelGroupsHeight = 0;
    for (int i = 0; i < channelGroups.size(); i++) {
        ArrayList<View> channels = channelGroups.get(i);
        groupChannelColumns[i] = channels.size() % channelColumn == 0 ? channels.size() / channelColumn : channels.size() / channelColumn + 1;
    }
    for (int groupChannelColumn : groupChannelColumns) {
        if (groupChannelColumn > 0) {
            newAllChannelGroupsHeight += channelHeight * groupChannelColumn + (groupChannelColumn * 2 - 2) * horizontalSpacing;
        }
    }
    int changeHeight = newAllChannelGroupsHeight - allChannelGroupsHeight;
    if (changeHeight != 0) {
        allChannelGroupsHeight = newAllChannelGroupsHeight;
        ValueAnimator valueAnimator = ValueAnimator.ofInt(getMeasuredHeight(), getMeasuredHeight() + changeHeight);
        valueAnimator.setDuration(DURATION_TIME);
        valueAnimator.start();
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                animateHeight = (int) animation.getAnimatedValue();
                isAnimateChangeHeight = true;
                requestLayout();
            }
        });
        valueAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {

            }

            @Override
            public void onAnimationEnd(Animator animation) {
                isAnimateChangeHeight = false;
            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }

            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        });
    }
}

/**
 * 受到行数所影响的view进行上移或下移操作
 */
private void viewMove(int position, int offSetY) {
    for (int i = position; i < channelTitleGroups.size(); i++) {
        View view = channelTitleGroups.get(i);
        ChannelAttr tag = (ChannelAttr) view.getTag();
        tag.coordinate = new PointF(tag.coordinate.x, tag.coordinate.y + offSetY);
        view.animate().x(tag.coordinate.x).y(tag.coordinate.y).setDuration(DURATION_TIME);
    }
    for (int i = position; i < channelGroups.size(); i++) {
        ArrayList<View> otherChannels = channelGroups.get(i);
        for (int j = 0; j < otherChannels.size(); j++) {
            View view = otherChannels.get(j);
            ChannelAttr tag = (ChannelAttr) view.getTag();
            tag.coordinate = new PointF(tag.coordinate.x, tag.coordinate.y + offSetY);
            view.animate().x(tag.coordinate.x).y(tag.coordinate.y).setDuration(DURATION_TIME);
        }
    }
}

float downX, downY;
float moveX, moveY;

/**
 * 频道拖动
 */
private void channelDrag(View v, MotionEvent event) {
    moveX = event.getRawX();
    moveY = event.getRawY();
    v.setX(v.getX() + (moveX - downX));
    v.setY(v.getY() + (moveY - downY));
    downX = moveX;
    downY = moveY;
    ArrayList<View> myChannels = channelGroups.get(0);
    ChannelAttr vTag = (ChannelAttr) v.getTag();
    int vIndex = myChannels.indexOf(v);
    for (int i = 0; i < myChannels.size(); i++) {
        if (i > channelFixedToPosition && i != vIndex) {
            View iChannel = myChannels.get(i);
            ChannelAttr iChannelTag = (ChannelAttr) iChannel.getTag();
            int x1 = (int) iChannelTag.coordinate.x;
            int y1 = (int) iChannelTag.coordinate.y;
            int sqrt = (int) Math.sqrt((v.getX() - x1) * (v.getX() - x1) + (v.getY() - y1) * (v.getY() - y1));
            if (sqrt <= RANGE && !animatorSet.isRunning()) {
                animatorSet = new AnimatorSet();
                PointF tempPoint = iChannelTag.coordinate;
                ObjectAnimator[] objectAnimators = new ObjectAnimator[Math.abs(i - vIndex) * 2];
                if (i < vIndex) {
                    for (int j = i; j < vIndex; j++) {
                        TextView view = (TextView) myChannels.get(j);
                        ChannelAttr viewTag = (ChannelAttr) view.getTag();
                        ChannelAttr nextGridViewAttr = ((ChannelAttr) myChannels.get(j + 1).getTag());
                        viewTag.coordinate = nextGridViewAttr.coordinate;
                        objectAnimators[2 * (j - i)] = ObjectAnimator.ofFloat(view, "X", viewTag.coordinate.x);
                        objectAnimators[2 * (j - i) + 1] = ObjectAnimator.ofFloat(view, "Y", viewTag.coordinate.y);
                    }
                } else if (i > vIndex) {
                    for (int j = i; j > vIndex; j--) {
                        TextView view = (TextView) myChannels.get(j);
                        ChannelAttr viewTag = (ChannelAttr) view.getTag();
                        ChannelAttr preGridViewAttr = ((ChannelAttr) myChannels.get(j - 1).getTag());
                        viewTag.coordinate = preGridViewAttr.coordinate;
                        objectAnimators[2 * (j - vIndex - 1)] = ObjectAnimator.ofFloat(view, "X", viewTag.coordinate.x);
                        objectAnimators[2 * (j - vIndex - 1) + 1] = ObjectAnimator.ofFloat(view, "Y", viewTag.coordinate.y);
                    }
                }
                animatorSet.playTogether(objectAnimators);
                animatorSet.setDuration(DURATION_TIME);
                isAgainMeasure = false;
                animatorSet.start();
                vTag.coordinate = tempPoint;
                myChannels.remove(v);
                myChannels.add(i, v);
                break;
            }
        }
    }
}

/**
 * 频道拖动抬起
 *
 * @param v
 */
private void channelDragUp(View v) {
    isAgainMeasure = true;
    isChannelLongClick = false;
    ChannelAttr vTag = (ChannelAttr) v.getTag();
    v.animate().x(vTag.coordinate.x).y(vTag.coordinate.y).setDuration(DURATION_TIME);
    v.setBackgroundResource(channelSelectedBackground);
}

  上面的代码主要有这几个方法:
  forwardSort(View v, ArrayList<View> channels):让被点击频道后面的所有频道往前移动,不管是添加频道还是删除频道,只要该频道的后面还有其它频道,那么它们一定要往前排移动。具体做法就是先获取该频道所在的频道组,然后遍历被点击频道后面的所有频道,改变它们的坐标,然后通过属性动画v.animate().x().y()方法改变他们的位置;

  addMyChannel(final View v):点击其他频道增加我的频道时触发的方法,在之前会先调用forwardSort()方法,该方法主要是让点击的频道做位移动画,移动到需要到达的位置,该位置坐标之前是不确定的,所以需要通过计算得到;

  deleteMyChannel(View v):该方法的作用同上个方法类似,点击我的频道时删除该频道的操作,要让被删除的频道移动到它所属(也就是ChannelAttr的belong值)的频道组的第一个位置。现在要注意的是,这个方法和上面的方法会发生一个问题,行数可能会改变,行数改变整个View的高度也会发生改变,所以我们需要下面这个方法来计算到底改变了多少,如何去改变它的高度;

  animateChangeGridViewHeight():这个就是通过动画改变整个View高度的方法,原理很简单,通过channelGroups集合得到每次增删后的行数(所以该方法在addMyChannel()和deleteMyChannel()方法中都需要调用),和上一次增删操作的行数比较得到行数差就可以了,然后通过ValueAnimator动画改变高度值,调用requestLayout()方法重新测量高度即可,在onMeasure()方法中我们已经说过了为什么会有两个不同的setMeasuredDimension()方法;

  viewMove(int position, int offSetY):行数改变除了导致高度发生变化外,它底部的频道组也会发生变化。比如如果该频道组行数增加,那么它下面的所有频道组包括频道标题也都需要往下移,如果该频道组行数减少,那它下面的需要往上移。该方法接收一个频道组所在位置参数和一个高度变化量的参数,通过这两个参数遍历频道组和频道标题组,让它们所有的View位置发生改变;

  channelDrag(View v, MotionEvent event):频道拖动方法,当该频道在拖动时,我们判断该频道的位置和离它最近的一个频道的距离,如果该距离小于我们定义的最小距离,那就让该频道插入到这个位置,它之前或者之后的频道往后或者往前排列。要注意这个时候对于该频道组的顺序也要相应调整,通过List中的add()和remove()方法实现,还要注意他们的坐标也发生了改变;

  channelDragUp(View v):这个方法是手抬起时触发的方法,让该频道通过动画回到它应该呆着的位置。

5.总结

  以上就是频道选择器的核心代码,其他像接口的暴露、自定义属性的实现,在工程中都写的很详细。总之,这篇文章是介绍我们实现这个自定义View的思路,大家看的时候不必完全盯着代码细看,因为一些实现细节可能并不是你想写的,一千个人眼中有一千个哈姆雷特,每个人的思路都是不同的,遇到问题能想到一个具体的思路比实现上面那些代码细节要高明的多。如果想看代码细节,那就看一些优秀的开源项目,那些开源项目中出色的设计模式、优雅的接口、完善的内存管理才是我们应该学习的。

项目链接:https://github.com/chengzhicao/ChannelView

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

推荐阅读更多精彩内容