美文网首页Android开发精选
Android逆天控件:CircleListView(圆弧形列表

Android逆天控件:CircleListView(圆弧形列表

作者: NAME_CJF | 来源:发表于2020-01-16 15:15 被阅读0次

背景

近日设计师小姐姐创作能力爆棚,设计了一个狂拽酷炫的效果:星球环绕列表。如下图:

image

在拿着板砖去沟通,且看到对方40米长的大刀后,愉快地确认出页面的如下特性:

  1. 需要实现一个可围绕圆弧(图中星球)轨道旋转的列表。

  2. 圆形列表在只有一个item时,显示在轨道最右侧。

  3. 圆形列表下拉极限是第一个item在轨道最右侧。

  4. 圆形列表上拉极限是最后一个item在轨道最右侧。

  5. 圆形列表中的任一item均可设置到轨道最右侧。

  6. item数量不会很多。

  7. 轨道为圆形右侧的少半段弧,轨道圆心及半径会给出。

  8. 一个半圆可以显示9个item。

在网络上遍寻轮子无果的情况下,最终决定自定义了这个CircleListView。


分析

首先,遇到这样的要求,内心会产生胆怯,此时需要为自己打气:

我们遇到什么困难也不要怕,微笑着面对它!消除恐惧的最好办法就是面对恐惧!坚持,才是胜利。加油!奥利给!

image

------------------------------------------跑偏了,言归正传------------------------------------------

最初,计划重写RecyclerView或ListView来实现上述要求。后来发现,系统自带的列表控件,难以计算每个item的坐标(系统列表控件必按照横向或竖向排列item,而圆弧列表不分横竖向,因为圆弧中两点之间可以指向任何方向),如下图就无法分辨两个item之间是横向关系还是竖向关系:

image

故重写系统列表不可行。捷径不可行,那就重写ViewGroup,自己计算并显示其中子View的位置,来实现需要的效果。

那么,每个item的位置怎么计算呢?我们来整理一下我们已知的位置相关的信息:圆弧的圆心位置及半径可以确认,为了兼容性适配,圆心及半径我们按照屏幕百分比进行转化:

  • 圆心y轴坐标:控件高度*0.45

  • 圆心x轴坐标:-控件宽度*0.25

  • 圆的半径:控件宽度*0.9

image

根据以上信息,我们可以确认出圆弧上任意一点的坐标:

image

假设我们要求图中r与圆弧的焦点(A)坐标:

先求a与b的长度:

a = cosα * r

b = sinα * r

再通过圆心与屏幕的位置来求A的xy轴坐标:

x = b + 圆心x轴坐标

y = 圆心y轴坐标 - b

故只需确定α,即可得x与y

(这应该是初中数学,快忘干净啦···)

我们还可以知道,半圆弧上有9个item,也就是每两个item到圆心的夹角度数为180°/8=22.5°。所以我们只需要确认第一个item的α(设为α1),那么之后的item的α角(αn)就是α11 + n*22.5。而手势滑动,则只需改变α1的值即可。至此,思路已理清。开始编码。


实现

首先,我们仿照ListView的用法,新建一个CircleListView和Adapter类。其中CircleListView继承ViewGroup,Adapter为抽象类。

其中Adapter主要实现数据的绑定。即CircleListView中子View的创建。其中重要的方法为getView方法,用于创建子View实例。还有getCount方法,用于控制子View显示的数目(根据该数据,遍历调用getView方法,其中参数position从0一直增大至getCount的返回值大小)。

public abstract class Adapter {

    public CircleListView circleListView;
    public Adapter(CircleListView circleListView) {
        this.circleListView = circleListView;
    }

    public int getCount() {
        return 0;
    }

    public abstract View getView(int position);
    public void notifyDataChanged() {
        circleListView.refreshList();
    }

    public void setPosition(int position) {
        if (position > getCount() - 1) {
            position = getCount() - 1;
        } else if (position < 0) {
            position = 0;
        }
        circleListView.setPosition(position);
    }
}

接下来,我们在CircleListView中定义必要的参数:

    private static final double intervalAngel = 22.5;//子view之间的间隔角

    int circleR;//圆的半径
    int ccx;//圆心的x轴坐标
    int ccy;//圆心的y轴坐标
    double angel = 0;//偏移角度
    private float oldTouchY;//上一次触摸的y轴位置
    private boolean isScrolling = false;//是否在滑动状态

位置相关参数的初始化,都在onLayout中计算(onLayout在onMeasure之后,此时可以获得控件自身及子View的宽高),onLayout中也进行子View的定位:

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        circleR = (getRight() - getLeft()) / 10 * 9;
        ccy = (int) (getHeight() * 0.45);
        ccx = -getWidth() / 4;
        for (int i = 0; i < adapter.getCount(); i++) {
            View childView = getChildAt(i);
                double childViewAngel = i * intervalAngel + angel + 90;
            if (childViewAngel > 270) {
                childViewAngel = 270;
            }
            if (childViewAngel < -90) {
                childViewAngel = -90;
            }
            int x = ccx + (int) (Math.sin(Math.toRadians(childViewAngel)) * circleR);
            int y = ccy - (int) (Math.cos(Math.toRadians(childViewAngel)) * circleR);
            int vl = x - childView.getMeasuredWidth() / 2;
            int vt = y - childView.getMeasuredHeight() / 2;
            int vr = x + childView.getMeasuredWidth() / 2;
            int vb = y + childView.getMeasuredHeight() / 2;
            childView.layout(vl, vt, vr + 100, vb + 100);
        }
    }

在onMeasure中需要测量子View的宽高,否则动态添加的子View可能没有宽高:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

此时控件中子View已经可以显示在正确的位置上了。接下来进行手势滑动。

手势滑动思路:根据手势移动时在y轴偏移量(offsetY),来设置α1(angel )的值,并进行刷新,来达成滑动效果。角度与手指y轴位移的公式为:α1= α1 + offsetY/20。实现滑动效果的同时,也需要实现非滑动状态下触摸事件正确的分发。手势模块代码如下:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                oldTouchY = ev.getY();
                super.dispatchTouchEvent(ev);
                return true;
            case MotionEvent.ACTION_MOVE:
                if (!isScrolling && Math.abs(oldTouchY - ev.getY()) > 50) {
                    isScrolling = true;
                    float offSetY = 0;
                    oldTouchY = ev.getY();
                    angel += offSetY / 20;
                    requestLayout();
                    return true;
                } else if (isScrolling) {
                    float offSetY = ev.getY() - oldTouchY;
                    oldTouchY = ev.getY();
                    if ((angel + offSetY / 20) < ((adapter.getCount() - 1) * -intervalAngel)) {
                        angel = (adapter.getCount() - 1) * -intervalAngel;
                    } else if ((angel + offSetY / 20) > 0) {
                        angel = 0;
                    } else {
                        angel += offSetY / 20;
                    }
                    requestLayout();
                    return true;
                }
                return super.dispatchTouchEvent(ev);
            case MotionEvent.ACTION_UP:
                boolean notDispatch = isScrolling;
                isScrolling = false;
                if (notDispatch) {
                    return false;
                } else {
                    performClick();
                    return super.dispatchTouchEvent(ev);
                }
            default:
                isScrolling = false;
                return super.dispatchTouchEvent(ev);
        }
    }

至此,CircleListView已经顺利实现。接下来进行最后一步:Adapter数据的绑定:

    protected void refreshList() {
        removeAllViews();
        for (int i = 0; i < adapter.getCount(); i++) {
            if (i == 0 && angel < -intervalAngel * (adapter.getCount() - 1)){
                angel = -intervalAngel * (adapter.getCount() - 1);
            }
            addView(adapter.getView(i));
            if(adapter.getCount() == 1){
                setPosition(0);
            }
        }
        invalidate();
    }

上述方法用在CircleListView的setAdapter及Adapter的notifyDataChanged中:

public class CircleListView extends ViewGroup {
···
    public void setAdapter(Adapter adapter) {
        this.adapter = adapter;
        refreshList();
    }
···
}
public abstract class Adapter {
···
    public void notifyDataChanged() {
        circleListView.refreshList();
    }
···
}

至此,编码完成。一起看一下效果吧:

image

总结

之前写过一个自定义控件MSeekBar,它是自定义了View,主要操作是在Canvas上进行图形绘制。而本文中的自定义控件自定义了ViewGroup,主要用于子View布局计算。

源码地址:https://github.com/cjfu/CircleListView
该控件已放入远程仓库,想研究的小伙伴们可以直接远程依赖然后阅读源码,核心类:CircleListView。

相关文章

网友评论

    本文标题:Android逆天控件:CircleListView(圆弧形列表

    本文链接:https://www.haomeiwen.com/subject/nobfzctx.html