背景
近日设计师小姐姐创作能力爆棚,设计了一个狂拽酷炫的效果:星球环绕列表。如下图:
image在拿着板砖去沟通,且看到对方40米长的大刀后,愉快地确认出页面的如下特性:
-
需要实现一个可围绕圆弧(图中星球)轨道旋转的列表。
-
圆形列表在只有一个item时,显示在轨道最右侧。
-
圆形列表下拉极限是第一个item在轨道最右侧。
-
圆形列表上拉极限是最后一个item在轨道最右侧。
-
圆形列表中的任一item均可设置到轨道最右侧。
-
item数量不会很多。
-
轨道为圆形右侧的少半段弧,轨道圆心及半径会给出。
-
一个半圆可以显示9个item。
在网络上遍寻轮子无果的情况下,最终决定自定义了这个CircleListView。
分析
首先,遇到这样的要求,内心会产生胆怯,此时需要为自己打气:
我们遇到什么困难也不要怕,微笑着面对它!消除恐惧的最好办法就是面对恐惧!坚持,才是胜利。加油!奥利给!
image------------------------------------------跑偏了,言归正传------------------------------------------
最初,计划重写RecyclerView或ListView来实现上述要求。后来发现,系统自带的列表控件,难以计算每个item的坐标(系统列表控件必按照横向或竖向排列item,而圆弧列表不分横竖向,因为圆弧中两点之间可以指向任何方向),如下图就无法分辨两个item之间是横向关系还是竖向关系:
image故重写系统列表不可行。捷径不可行,那就重写ViewGroup,自己计算并显示其中子View的位置,来实现需要的效果。
那么,每个item的位置怎么计算呢?我们来整理一下我们已知的位置相关的信息:圆弧的圆心位置及半径可以确认,为了兼容性适配,圆心及半径我们按照屏幕百分比进行转化:
-
圆心y轴坐标:控件高度*0.45
-
圆心x轴坐标:-控件宽度*0.25
-
圆的半径:控件宽度*0.9
根据以上信息,我们可以确认出圆弧上任意一点的坐标:
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。
网友评论