FanLayout圆弧滑动效果
陈小缘 人气:0前言
在上篇文章(Android实现圆弧滑动效果之ArcSlidingHelper篇)中,我们把圆弧滑动手势处理好了,那么这篇文章我们就来自定义一个ViewGroup,名字叫就风扇布局吧,接地气。 在开始之前,我们先来看2张效果图 (表情包来自百度贴吧):
哈哈,其实还有以下特性的,就先不发那么多图了:
简单分析
圆弧手势滑动我们现在可以跳过了(因为在上一篇文章中做好了),先从最基本的开始,想一下该怎么layout? 其实也很简单:从上面几张效果图中我们可以看出来,那一串串小表情是围着大表情旋转的,即小表情的旋转点(mPivotX,mPivotY) = 大表情的中心点(宽高 ÷ 2)
,至于旋转,肯定是用setRotation方法啦,不过在setRotation之前,还要先set一下PivotX和PivotY。
创建FanLayout
首先是onLayout方法:
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childCount = getChildCount(); //每个item要旋转的角度 float angle = 360F / childCount; //旋转基点,现在也就是这个ViewGroup的中心点 mPivotX = getWidth() / 2; mPivotY = getHeight() / 2; for (int i = 0; i < childCount; i++) { View view = getChildAt(i); int layoutHeight = view.getMeasuredHeight() / 2; int layoutWidth = view.getMeasuredWidth(); //在圆心的右边,并且垂直居中 view.layout(mPivotX, mPivotY - layoutHeight, mPivotX + layoutWidth, mPivotY + layoutHeight); //更新旋转的中心点 view.setPivotX(0); view.setPivotY(layoutHeight); //设置旋转的角度 view.setRotation(i * angle); } }
onMeasure我们先不考虑那么细,measureChildren后直接setMeasuredDimension:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { measureChildren(widthMeasureSpec, heightMeasureSpec); setMeasuredDimension(widthMeasureSpec, heightMeasureSpec); }
好了,就这么简单,我们来看看效果怎么样: 我们的布局 (item就是一排ImageView):
<com.test.FanLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <include layout="@layout/item" /> <include layout="@layout/item" /> <include layout="@layout/item" /> <include layout="@layout/item" /> <include layout="@layout/item" /> <include layout="@layout/item" /> </com.test.FanLayout>
效果:
支持圆弧手势
哈哈,现在最基本的效果是出来了,但是还未支持手势,这时候我们上篇做的ArcSlidingHelper要登场了,我们把ArcSlidingHelper添加进来:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (mArcSlidingHelper == null) { mArcSlidingHelper = ArcSlidingHelper.create(this, this); //开始惯性滚动 mArcSlidingHelper.enableInertialSliding(true); } else { //刷新旋转基点 mArcSlidingHelper.updatePivotX(w / 2); mArcSlidingHelper.updatePivotY(h / 2); } }
我们把ArcSlidingHelper放到onSizeChanged里面初始化,为什么呢,因为这个方法回调时,getWidth和getHeight已经能获取到正确的值了
@Override public boolean onTouchEvent(MotionEvent event) { //把触摸事件交给Helper去处理 mArcSlidingHelper.handleMovement(event); return true; } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); //释放资源 if (mArcSlidingHelper != null) { mArcSlidingHelper.release(); mArcSlidingHelper = null; } } @Override public void onSliding(float angle) { for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); //更新角度 child.setRotation(child.getRotation() + angle); } }
onSliding方法就是ArcSlidingHelper的OnSlidingListener接口,当ArcSlidingHelper计算出角度之后,就会回调onSliding方法,我们在这里面直接更新了子view的角度,并且在onDetachedFromWindow释放了ArcSlidingHelper,哈哈,节约内存,从每一个细节做起。 好了,添加支持圆弧手势滑动就这么简单,我们来看看效果如何:
可以看到已经成功处理圆弧滑动手势了,但是还有一个情况就是,当子view设置了自己的OnClickListener,这个时候如果我们手指刚好是按在这个子view上,当手指移动时会发现,旋转不了,因为这个事件正在被这个子view消费。所以还要在onInterceptTouchEvent方法里处理一下:如果手指滑动的距离超过了指定的最小距离,则拦截这个事件,交给我们的ArcSlidingHelper来处理。 我们来看看代码怎么写:
private float mStartX, mStartY;//上次的坐标 private int mTouchSlop;//触发滑动的最小距离 private boolean isBeingDragged;//手指是否滑动中 @Override public boolean onInterceptTouchEvent(MotionEvent event) { //如果已经开始了滑动,那就直接拦截这个事件 if ((event.getAction() == MotionEvent.ACTION_MOVE && isBeingDragged) || super.onInterceptTouchEvent(event)) { return true; } //set了enable为false就不要了 if (!isEnabled()) { return false; } float x = event.getX(), y = event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //当手指按下时,停止惯性滚动 mArcSlidingHelper.abortAnimation(); //更新记录坐标 mStartX = x; mStartY = y; break; case MotionEvent.ACTION_MOVE: //本次较上一次的滑动距离 float offsetX = x - mStartX; float offsetY = y - mStartY; //判断是否触发拖动事件 if (Math.abs(offsetX) > mTouchSlop || Math.abs(offsetY) > mTouchSlop) { //标记已开始滑动 (拦截本次) isBeingDragged = true; } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_OUTSIDE: //手指松开,刷新状态 isBeingDragged = false; break; } return isBeingDragged; }
当然了,onTouchEvent方法也要加上手指松开后,标记isBeingDragged为false:
@Override public boolean onTouchEvent(MotionEvent event) { //把触摸事件交给Helper去处理 mArcSlidingHelper.handleMovement(event); switch (event.getAction()) { case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_OUTSIDE: //手指抬起,清除正在滑动的标记 isBeingDragged = false; break; } return true; }
mTouchSlop的初始化放在构造方法中:
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
哈哈,那个拦截的方法是参考自ScrollView的 (我们平时从SDK源码中也能学到不少东西) 好的,看看效果怎么样:
额。。有没有发现,每次拦截之后,开始滑动时都是跳一下,是什么原因呢? 就是因为我们的ArcSlidingHelper内部也是用startX和startY来记录上一次手指坐标的,在FanLayout拦截事件之前,有一段距离已经被消费了,所以ArcSlidingHelper里面的startX和startY并不是最新的距离 (计算出来的滑动距离会偏长),就会出现上面这种:跳了一下 的情况。 那么,我们应该怎么解决呢,加上触发滑动的最小距离吗?哈哈,当然不是了,这个方法太麻烦,还要根据手指的滑动趋势来决定是加还是减。 其实ArcSlidingHelper早就已经准备了一个方法来应对这种情况:
/** * 更新当前手指触摸的坐标,在ViewGroup的onInterceptTouchEvent中使用 */ public void updateMovement(MotionEvent event) { checkIsRecycled(); if (event.getAction() == MotionEvent.ACTION_DOWN || event.getAction() == MotionEvent.ACTION_MOVE) { if (isSelfSliding) { mStartX = event.getRawX(); mStartY = event.getRawY(); } else { mStartX = event.getX(); mStartY = event.getY(); } } }
我们在onInterceptTouchEvent方法中更新一下坐标就可以了:
@Override public boolean onInterceptTouchEvent(MotionEvent event) { ... case MotionEvent.ACTION_DOWN: ... //手指按下时更新一次 mArcSlidingHelper.updateMovement(event); break; case MotionEvent.ACTION_MOVE: if (Math.abs(offsetX) > mTouchSlop || Math.abs(offsetY) > mTouchSlop) { //开始拦截之前也更新一次 mArcSlidingHelper.updateMovement(event); ... } break; return isBeingDragged; }
updateMovement方法里面直接更新了x和y的值,这样的话,就不会出现跳一下的情况了。
添加轴承(中间的大表情)
我们的轴承有两种类型:Color和View,Color类型就是指定一种颜色,不能接受点击事件,是直接用Paint画出来的。View类型可以自己定义轴承的内容,来看看下面两张效果图:
哈哈,可以看到,我们除了添加两种不同的轴承类型之外,还加上了动态切换轴承的位置(在顶部或底部)和圆形半径还有Item偏移量,View类型下还可以设置是否跟随子View旋转。
一下子多了这么多属性,但是不要怕,我们来逐个击破。 现在是时候在attr中自定义这些属性了:
<resources> <declare-styleable name="FanLayout"> <!--轴承类型--> <attr name="bearing_type" format="enum"> <enum name="color" value="0" /> <enum name="view" value="1" /> </attr> <!--轴承半径--> <attr name="bearing_radius" format="dimension" /> <!--轴承颜色 (当type=color时才有效)--> <attr name="bearing_color" format="color" /> <!--自定义的轴承布局 (当type=view时才有效)--> <attr name="bearing_layout" format="reference" /> <!--轴承是否可以转动--> <attr name="bearing_can_roll" format="boolean" /> <!--轴承是否在底部--> <attr name="bearing_on_bottom" format="boolean" /> <!--item偏移量--> <attr name="item_offset" format="dimension" /> </declare-styleable> </resources>
好了,定义完布局属性之后,再回到FanLayout中,也要定义对应的属性:
public static final int TYPE_COLOR = 0;//Color类型 public static final int TYPE_VIEW = 1;//View类型 private int mRadius;//轴承半径 private int mItemOffset;//item偏移量 private boolean isBearingCanRoll;//轴承是否可以滚动 private boolean isBearingOnBottom;//轴承是否在底部 private int mCurrentBearingType;//当前轴承类型 private int mBearingColor;//轴承颜色 private int mBearingLayoutId;//轴承布局id private View mBearingView;//轴承view private Paint mPaint;
最后,在构造方法中获取这些属性:
public FanLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { ... initAttrs(context, attrs, defStyleAttr); ... } private void initAttrs(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FanLayout, defStyleAttr, 0); //轴承是否可以旋转,默认不可以 isBearingCanRoll = a.getBoolean(R.styleable.FanLayout_bearing_can_roll, false); //轴承是否在底部,默认不可以 isBearingOnBottom = a.getBoolean(R.styleable.FanLayout_bearing_on_bottom, false); //当前轴承类型,默认Color类型 mCurrentBearingType = a.getInteger(R.styleable.FanLayout_bearing_type, TYPE_COLOR); //轴承颜色,需设置类型为Color才有效,默认黑色 mBearingColor = a.getColor(R.styleable.FanLayout_bearing_color, Color.BLACK); //判断是否View类型 if (isViewType()) { //获取轴承的布局id mBearingLayoutId = a.getResourceId(R.styleable.FanLayout_bearing_layout, 0); //如果轴承是View类型,必须要指定一个布局,否则报错 if (mBearingLayoutId == 0) { throw new IllegalStateException("bearing layout not set!"); } else { //加载这个布局,并添加在FanLayout中 mBearingView = LayoutInflater.from(context).inflate(mBearingLayoutId, this, false); addView(mBearingView); } } else { //如果是Color类型,就获取轴承的半径,默认:0 mRadius = a.getDimensionPixelSize(R.styleable.FanLayout_bearing_radius, 0); //初始化画笔 mPaint = new Paint(); mPaint.setAntiAlias(true); mPaint.setColor(mBearingColor); //使其回调onDraw方法 setWillNotDraw(false); } //获取item偏移量 mItemOffset = a.getDimensionPixelSize(R.styleable.FanLayout_item_offset, 0); //记得回收资源 a.recycle(); } /** * 判断当前轴承类型是否为View类型 */ private boolean isViewType() { return mCurrentBearingType == TYPE_VIEW; }
在获取到这些属性之后,我们需要改一下onMeasure方法了,刚刚贪方便,测量了子view后直接setMeasuredDimension了,这样做一般是不可取的,因为还要考虑宽高为wrap_content的情况,好,我们来看看修改之后的onMeasure方法:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //先测量子View们 measureChildren(widthMeasureSpec, heightMeasureSpec); int specSize = MeasureSpec.getSize(widthMeasureSpec); int specMode = MeasureSpec.getMode(widthMeasureSpec); int size; //如果指定了宽度,那就用这个指定的尺寸 if (specMode == MeasureSpec.EXACTLY) { size = specSize; } else { //获取最大的子View宽度 int childMaxWidth = 0; for (int i = 0; i < getChildCount(); i++) { childMaxWidth = Math.max(childMaxWidth, getChildAt(i).getMeasuredWidth()); } //如果没有指定宽度的话,那么FanLayout的宽就用 轴承的直径 + Item的偏移量 + 最大的子View宽度 size = 2 * mRadius + mItemOffset + childMaxWidth; } int height = MeasureSpec.getSize(heightMeasureSpec); //这个时候,如果指定了高度,那就用这个指定的尺寸,如果没有的话,我们就把高度设置跟宽度一样 setMeasuredDimension(size, MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY ? height : size); //如果是轴承是View类型的话,那么就更新圆的半径为 轴承View的宽和高中,更大的一方 的一半 if (isViewType()) { mRadius = Math.max(mBearingView.getMeasuredWidth(), mBearingView.getMeasuredHeight()) / 2; } }
改完onMeasure方法后,onLayout方法也要改了,因为我们加入了轴承,如果是View类型,那就应该不能把它当作Item来layout,还加入了Item偏移量和轴承半径这两个属性,所以Item的位置也要调整一下:
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { // 旋转基点,现在也就是这个ViewGroup的中心点 mPivotX = getWidth() / 2; mPivotY = getHeight() / 2; // 是否View类型的轴承在底部 boolean isHasBottomBearing = isViewType() && isBearingOnBottom; // 如果轴承为View类型,startIndex = 1,否则0, // 因为layoutItem的时候会根据子View的个数来计算出每个Item应该旋转的初始角度,而轴承是在中间的,不用参与本次旋转, // 所以等下会用childCount - startIndex int startIndex = layoutBearing(); layoutItems(isHasBottomBearing, startIndex); } private int layoutBearing() { int startIndex = 0; //判断轴承是否View类型 if (isViewType()) { int width = mBearingView.getMeasuredWidth() / 2; int height = mBearingView.getMeasuredHeight() / 2; //轴承放在旋转中心点上 mBearingView.layout(mPivotX - width, mPivotY - height, mPivotX + width, mPivotY + height); startIndex = 1; } return startIndex; } private void layoutItems(boolean isHasBottomBearing, int startIndex) { int childCount = getChildCount(); //每个item要旋转的角度 (如果轴承是View类型,要减一个) float angle = 360F / (childCount - startIndex); for (int i = 0; i < childCount; i++) { View view = getChildAt(i); //如果是轴承View的话,我们不处理,直接略过 if (view == mBearingView) { continue; } int height = view.getMeasuredHeight() / 2; int width = view.getMeasuredWidth(); //Item的left就是旋转点的x轴 + 轴承的半径 + Item的偏移量 int baseLeft = mPivotX + mRadius + mItemOffset; //在圆心的右边,并且垂直居中 view.layout(baseLeft, mPivotY - height, baseLeft + width, mPivotY + height); //更新旋转的中心点 view.setPivotX(-mRadius - mItemOffset); view.setPivotY(height); //如果View类型的轴承在底部的话,还要减去1,因为我们要忽略这个轴承 int index = isHasBottomBearing ? i - 1 : i; float rotation = index * angle; //设置旋转的角度 view.setRotation(rotation); } }
可以看到,在layoutItem方法中,根据当前轴承的半径和Item偏移量来计算出正确的Item位置。
好吧,现在已经迫不及待的想看看效果了,等等,还是先在布局里面设置下刚刚加进去的一些属性吧:
<com.wuyr.testview.FanLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" app:bearing_layout="@layout/bearing" app:bearing_type="view" app:item_offset="-20dp"> <include layout="@layout/item" /> <include layout="@layout/item" /> <include layout="@layout/item" /> <include layout="@layout/item" /> <include layout="@layout/item" /> <include layout="@layout/item" /> </com.wuyr.testview.FanLayout>
我们把bearing_type设置为View类型,并且指定了轴承的布局:
<ImageView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="100dp" android:layout_height="100dp" android:src="@drawable/ic_4" />
轴承的布局就只是一个ImageView,还有,我们还把item_offset设置为-20dp,好了,现在来看看效果吧:
emmm,还差一个轴承在顶部的效果没实现呢,还有一个轴承不跟随旋转的,这个非常简单,我们在旋转的回调方法里面加一个条件就可以了,因为现在是遍历了全部的子View来设置旋转角度的:
@Override public void onSliding(float angle) { for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); //如果当前遍历到的子View是轴承View并且当前的轴承类型是View类型并且设置了轴承不能旋转的话,就略过 if (child == mBearingView && isViewType() && !isBearingCanRoll) { continue; } //更新角度 child.setRotation(child.getRotation() + angle); } }
好了,现在我们来想想轴承在顶部的效果应该要怎么做呢,可能有同学就会想了:真是的,把它放到最后添加不就行了,还用想吗? 额,其实还有2点要考虑的:因为我们现在做的是支持动态增删Item和动态的设置轴承在顶部还是在底部,这样一来,如果轴承添加进去之后,又继续添加了Item,这时候新添加的Item就会盖住轴承的了,所以我们决定重写addView方法:
@Override public void addView(View child, int index, LayoutParams params) { //如果当前轴承是View类型并且设置了在顶部,那就应该在移除后继续添加回去 boolean needAdd = false; //判断getChildCount() > 0是因为这个if的最终目的是移除轴承View,如果当前没有子View的话,自然不需要移除了 //判断child != mBearingView是因为:如果本次添加的就是轴承View自己,证明现在还没有被添加进去,自然也不需要继续执行下去了 if (isViewType() && !isBearingOnBottom && getChildCount() > 0 && child != mBearingView) { //如果现在已经添加了就先暂时移除 if (mBearingView != null) { super.removeView(mBearingView); //标记一下需要添加 needAdd = true; } } //调用父类的addView方法正常添加 super.addView(child, index, params); //如果被标记过需要添加,证明轴承View已被移除,现在把它添加回去 if (needAdd) { addView(mBearingView); } }
那现在来测试下刚刚加进去的那两个效果如何:
哈哈,可以看到,经过我们重写addView方法之后,如果是在顶部的话,就算新添加进去的Item,也不会遮住轴承View的,这是我们想看到的效果。
呼~~ 现在我们来看看Color类型的应该怎么做:其实很简单,这个圆形直接在onDraw里面去drawCircle就行了,不过这个onDraw方法是draw在子view的下面的,那么我们如果要它在上面的话怎么办呢,嘻嘻,其实View还有一个onDrawForeground方法,如果要画在子View上面的话,可以在这个方法里面draw,看下代码怎么写:
@Override protected void onDraw(Canvas canvas) { //必须不是View类型,并且是在底部才draw if (!isViewType() && isBearingOnBottom) { canvas.drawCircle(mPivotX, mPivotY, mRadius, mPaint); } } @Override public void onDrawForeground(Canvas canvas) { //必须不是View类型,并且是在顶部才draw if (!isViewType() && !isBearingOnBottom) { canvas.drawCircle(mPivotX, mPivotY, mRadius, mPaint); } }
emmm,就是这么简单,在动态改变了轴承的位置之后(invalidate()),它也会根据isBearingOnBottom来决定圆形draw在顶部或底部。
对齐方式
我们的对齐方式有:左(默认)、右、上、下、左上、右上、左下、右下 8种,可能有同学看到一共有8种这么多就怕了,其实不用怕,这个很简单的,代码很少。 在开始之前,我们来回忆一下,刚刚的onLayout方法中,Item是怎么layout的:
//Item的left就是旋转点的x轴 + 轴承的半径 + Item的偏移量 int baseLeft = mPivotX + mRadius + mItemOffset; //在圆心的右边,并且垂直居中 view.layout(baseLeft, mPivotY - height, baseLeft + width, mPivotY + height);
可以看到,Item的位置都是取决于mPivotX和mPivotY的,这样的话,我们只需改变一下mPivotX
和mPivotY
的值,然后requestLayout
就行了。那么,怎么根据不同的对齐方式计算出正确的mPivotX和mPivotY呢?
在开始之前,我们先来看看这张图:
这样思路就清晰很多了,我们根本就不用怎么去计算,都是直接取:0,宽度、高度、一半宽度、一半高度就行了,好了,首先我们要声明一下有哪些对齐方式:
attr中:
<!--对齐方式--> <attr name="bearing_gravity" format="enum"> <enum name="left" value="0" /> <enum name="right" value="1" /> <enum name="top" value="2" /> <enum name="bottom" value="3" /> <enum name="left_top" value="4" /> <enum name="left_bottom" value="5" /> <enum name="right_top" value="6" /> <enum name="right_bottom" value="7" /> </attr>
FanLayout中:
public static final int LEFT = 0; public static final int RIGHT = 1; public static final int TOP = 2; public static final int BOTTOM = 3; public static final int LEFT_TOP = 4; public static final int LEFT_BOTTOM = 5; public static final int RIGHT_TOP = 6; public static final int RIGHT_BOTTOM = 7; private int mCurrentGravity;//当前对齐方式
构造方法中也要加上一句:
//对齐方式,默认:左 mCurrentGravity = a.getInteger(R.styleable.FanLayout_bearing_gravity, LEFT);
再看看计算方法怎么写:
/** * 更新旋转基点 */ private void updateCircleCenterPoint() { int cx = 0, cy = 0; int totalWidth = getMeasuredWidth(); int totalHeight = getMeasuredHeight(); switch (mCurrentGravity) { case RIGHT: cx = totalWidth; cy = totalHeight / 2; break; case LEFT: cy = totalHeight / 2; break; case BOTTOM: cy = totalHeight; cx = totalWidth / 2; break; case TOP: cx = totalWidth / 2; break; case RIGHT_BOTTOM: cx = totalWidth; cy = totalHeight; break; case LEFT_BOTTOM: cy = totalHeight; break; case RIGHT_TOP: cx = totalWidth; break; default: break; } mPivotX = cx; mPivotY = cy; //当然了,别忘记更新ArcSlidingHelper的旋转基点 if (mArcSlidingHelper != null) { mArcSlidingHelper.updatePivotX(cx); mArcSlidingHelper.updatePivotY(cy); } }
好了,那么我们应该在哪里调用这个方法最好呢?哈哈,当然是onMeasure了:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { ... updateCircleCenterPoint(); }
现在可以把onLayout方法里面的
mPivotX = getWidth() / 2; mPivotY = getHeight() / 2;
还有onSizeChanged里面的:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { ... else { //刷新旋转基点 mArcSlidingHelper.updatePivotX(w / 2); mArcSlidingHelper.updatePivotY(h / 2); } ... }
这4句删掉了。 再提供一个setGravity方法:
/** * 设置对齐方式 */ public void setGravity(@Gravity int gravity) { if (mCurrentGravity != gravity) { mCurrentGravity = gravity; requestLayout(); } }
@Gravity就是使用了@IntDef的自定义注解:
@IntDef({LEFT, RIGHT, TOP, BOTTOM, LEFT_TOP, LEFT_BOTTOM, RIGHT_TOP, RIGHT_BOTTOM}) @Retention(RetentionPolicy.SOURCE) private @interface Gravity { }
好,来看看效果:
哈哈,可以了。 咦?等等!
当设置为右对齐的时候,item居然反了。。额其实不是反了,只是它正的一面我们看不到而已,那么我们要怎么样使它变正呢?很简单,layoutItems方法中,加个条件判断是不是右边的对其方式,如果是,layout 子View时从左边开始就行:
private void layoutItems(boolean isHasBottomBearing, int startIndex) { ... for (int i = 0; i < childCount; i++) { ... //判断对齐方式是不是右、右上、右下 if (mCurrentGravity == RIGHT || mCurrentGravity == RIGHT_TOP || mCurrentGravity == RIGHT_BOTTOM) { //如果是,就把子View layout在圆心的左边,并且垂直居中 int baseLeft = mPivotX - mRadius - mItemOffset; view.layout(baseLeft - width, mPivotY - height, baseLeft, mPivotY + height); //更新旋转的中心点 view.setPivotX(width + mRadius + mItemOffset); } else { //如果不是就在圆心的右边,并且垂直居中 int baseLeft = mPivotX + mRadius + mItemOffset; view.layout(baseLeft, mPivotY - height, baseLeft + width, mPivotY + height); //更新旋转的中心点 view.setPivotX(-mRadius - mItemOffset); } ... } }
哈哈,这样就可以了。
Item保持垂直
哈哈,有没有发现开启这个效果之后,小表情们就充满活力了?其实实现这个效果非常简单: 首先attr中定义个属性:
<!--设置item是否保持垂直--> <attr name="item_direction_fixed" format="boolean"/>
FanLayout中:
private boolean isItemDirectionFixed;
private void initAttrs(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { ... //item是否保持垂直 isItemDirectionFixed = a.getBoolean(R.styleable.FanLayout_item_direction_fixed, false); ... }
接下来就是在旋转回调里面做手脚了,但是有一点需要注意的就是,这个所谓的Item保持垂直,并不是FanLayout的直接子View,而是FanLayout的子View的子View,为什么呢?因为现在FanLayout的Item布局都是一个LinearLayout里面水平放ImageView的,所以想要达到上图中的效果,必须拿FanLayout的子View的子View来旋转,而旋转角度,是跟子View的旋转角度相反,而非直接设置为0,我们来看代码:
@Override public void onSliding(float angle) { for (int i = 0; i < getChildCount(); i++) { ... //如果开启了保持垂直效果 if (isItemDirectionFixed) { if (child != mBearingView && child instanceof ViewGroup) { ViewGroup viewGroup = (ViewGroup) child; for (int j = 0; j < viewGroup.getChildCount(); j++) { View childView = viewGroup.getChildAt(j); //这个旋转角度正好跟item的旋转角度相反 childView.setRotation(-viewGroup.getRotation()); } } } } }
哈哈,这样就可以了,就是这么简单。
轴承偏移
可以看到,当对齐方式不同的时候,设置偏移量,这个偏移的方向也会不同(它总是会朝着FanLayout的中心偏移) 其实这个也是非常简单的,因为我们刚刚已经做好了对齐方式,那么,现在只要在对齐方式的基础上,按规则加上或减去这个轴承的偏移量就行了。
我们添加mBearingOffset属性之后,把updateCircleCenterPoint方法改成这样:
/** * 更新旋转的中心点位置 */ private void updateCircleCenterPoint() { int cx = 0, cy = 0; int totalWidth = getMeasuredWidth(); int totalHeight = getMeasuredHeight(); switch (mCurrentGravity) { case RIGHT: cx = totalWidth; cy = totalHeight / 2; //在右边: 偏移量越大,越往左边靠 cx -= mBearingOffset; break; case LEFT: cy = totalHeight / 2; //在右边: 偏移量越大,越往右边靠 cx += mBearingOffset; break; case BOTTOM: cy = totalHeight; cx = totalWidth / 2; //在底部: 偏移量越大,越往上面靠 cy -= mBearingOffset; break; case TOP: cx = totalWidth / 2; //在顶部: 偏移量越大,越往下面靠 cy += mBearingOffset; break; case RIGHT_BOTTOM: cx = totalWidth; cy = totalHeight; //右下: 同时向上和向左靠 cx -= mBearingOffset; cy -= mBearingOffset; break; case LEFT_BOTTOM: cy = totalHeight; //左下: 同时向右和向上靠 cx += mBearingOffset; cy -= mBearingOffset; break; case RIGHT_TOP: cx = totalWidth; //右上: 同时向左和向下靠 cx -= mBearingOffset; cy += mBearingOffset; break; case LEFT_TOP: //左上的话,同时向右和向下靠,这里可以直接赋值了,因为此时的cx和cy都是0 cx = cy = mBearingOffset; break; default: break; } mPivotX = cx; mPivotY = cy; //当然了,别忘记更新ArcSlidingHelper的旋转基点 if (mArcSlidingHelper != null) { mArcSlidingHelper.updatePivotX(cx); mArcSlidingHelper.updatePivotY(cy); } }
哈哈,这样当偏移量设置得越大的时候,就越向中心靠拢了(当然了,太大也会超出范围的)。
自动选中
好了,到了自动选中就稍微有点复杂了,但是我们也不要怕他,先看看下面这张图:
可以看到,当自动选中一打开,就自动选择了距离中线最近的那一个item,当惯性滚动结束后,也会自动选择距离最近的item。那现在我们已经有初步的思路了:找到离目标角度最近的item,然后平滑旋转它,直到item的角度 = 目标角度为止
但是怎么找到这个距离最近的item呢?因为目标角度是跟随着对齐方式的变化而变化的,所以肯定不能把代码写死了。 emmm,其实我们可以先根据当前的对齐方式来获取到目标角度:
/** * 获取目标角度 (始终在屏幕内能看见的) */ private int getTargetAngle() { int targetAngle; switch (mCurrentGravity) { case TOP: //在顶部时,选中的item就应该垂直向下了,所以应该是90度 targetAngle = 90; break; case BOTTOM: //在底部时,跟顶部的相反,所以是-90, //因为在一个圆中我们看到的-90度跟270度是一样的,所以这里直接用正的角度 targetAngle = 270; break; case LEFT_TOP: case RIGHT_BOTTOM: //左上,右下就是45度了 //这里为什么左上的角度跟右下是一样的呢? //正常情况,这个角度应该是: 90+45=135才对 //但是因为右,右上,右下这三种对齐方式,在onLayout时,都是layout在旋转基点的左边的 //这时候在正常情况的角度来看,它已经是90度了,所以这里直接设置为45度了,下同 targetAngle = 45; break; case LEFT_BOTTOM: case RIGHT_TOP: //左下,右上跟左上相反:360-45=315度 targetAngle = 315; break; case LEFT: case RIGHT: //居左和居右,都是0了 default: targetAngle = 0; break; } return targetAngle; }
拿到目标角度之后,下一步就是根据这个目标角度,来找出离它最近的那个item了,大概的思路就是:遍历子View,逐个判断,取距离目标角度更近的那一个item的索引。然后我们就可以根据这个索引找到对应的子View来计算出所需要的旋转角度了,最后判断是需要顺时针还是逆时针旋转,再播放旋转的动画就完成了。
我们来看看查找离目标角度最近的Item代码:
/** * 找出最近的Item * * @param targetAngle 目标角度 * @return 最近Item的index */ private int findClosestViewPos(float targetAngle) { int childCount = getChildCount(); //如果设置了轴承为View类型并且是放在底部的话,查找的时候就要跳过它 int startIndex = isHasBottomBearing() ? 1 : 0; //获取第一个Item的当前旋转角度 float temp = getChildAt(startIndex).getRotation(); if (targetAngle == 0 && temp > 180) { //如果对齐方式是 左或右 那当这个Item的旋转角度>180时,即超过了半圆 //这时候拿到的角度就不是更小的那一边了,所以这里要用360减去它,得到更小那一边的角度 temp = 360 - temp; } //当前认为是离目标角度最近的角度 float hitRotation = Math.abs(targetAngle - temp); //认为是离目标角度最近的Item索引 int hitPos = startIndex; //遍历子View,逐个判断 for (int i = startIndex; i < childCount; i++) { View childView = getChildAt(i); //如果是轴承View的话,就可以略过了 if (childView == mBearingView) { continue; } //获取当前Item的旋转角度 temp = childView.getRotation(); //取更小的一边 if (targetAngle == 0 && temp > 180) { temp = 360 - temp; } //计算当前Item距离 float rotation = Math.abs(targetAngle - temp); //跟现在认为最近的距离做比较,取更近的那一方 if (rotation < hitRotation) { hitPos = i; hitRotation = rotation; } } return hitPos; }
好,我们现在定义一个调整位置的方法:
/** * 滚动结束后,调整位置的动画 */ private void playFixingAnimation() { int childCount = getChildCount(); //如果手指正在拖动中或者没有Item的话,就不需要播放动画了 if (isBeingDragged || childCount == 0 || (childCount == 1 && isViewType())) { return; } //先获取目标角度 int targetAngle = getTargetAngle(); //找到最近的Item索引 int index = findClosestViewPos(targetAngle); //获取这个Item的旋转角度 float rotation = getChildAt(index).getRotation(); //判断一下要旋转的角度是否大于半圆,如果是的话,证明现在还不是最小的角度,需要取它另一边的角度 if (Math.abs(rotation - targetAngle) > 180) { targetAngle = 360 - targetAngle; } //计算出需要旋转的角度 float angle = Math.abs(rotation - fixRotation(targetAngle)); //用当前Item的角度与目标角度做比较,如果当前角度比目标角度大的话,那么就是需要逆时针旋转了,反之 startValueAnimator(rotation > fixRotation(targetAngle) ? -angle : angle, index); }
fixRotation方法就是来用调整角度,使其始终处于0和360之间的 (这个在上一篇也有介绍到):
/** * 调整一下角度,使其保持在0~360之间 */ private float fixRotation(float rotation) { //周角 float angle = 360F; if (rotation < 0) { //将负的角度变成正的, 比如:-1 --> 359,在视觉上是一样的,这样我们内部处理起来会比较轻松 rotation += angle; } //避免大于360度,即:362 --> 2 if (rotation > angle) { rotation %= angle; } return rotation; }
最后调用了startValueAnimator方法,来看看:
/** * 开始播放动画 * * @param end end值 * @param index 当前选中的index */ private void startValueAnimator(float end, final int index) { //记录当前选中的Item索引 mCurrentSelectedIndex = index; //如果上一次的动画未播放完,就先取消它 if (mAnimator != null && mAnimator.isRunning()) { mAnimator.cancel(); } mAnimator = ValueAnimator.ofFloat(0, end).setDuration(mFixingAnimationDuration); mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { private float mLastScrollOffset; @Override public void onAnimationUpdate(ValueAnimator animation) { float currentValue = (float) animation.getAnimatedValue(); if (mLastScrollOffset != 0) { //开始旋转 onSliding(currentValue - mLastScrollOffset); } mLastScrollOffset = currentValue; } }); mAnimator.start(); }
很简单,就播放了一个ValueAnimator,mFixingAnimationDuration这个就是自定义的动画时长,贴心的我们还把它做成可以动态设置选中动画时长。 动画更新的时候,通过调用onSliding方法来旋转Item们,我们还可以提供一个OnItemSelectedListener,在动画播放结束后,利用它来通知外部有新的Item选中。
emmm,现在是万事俱备,只欠东风了,我们需要在手指停止滑动或惯性滚动结束后,来调用playFixingAnimation方法,这个接口在ArcSlidingHelper里也有提供了,哈哈,我们现在只需这样:
mArcSlidingHelper.setOnSlideFinishListener(new ArcSlidingHelper.OnSlideFinishListener() { @Override public void onSlideFinished() { playFixingAnimation(); } });
转为lambda后只有一行代码:
mArcSlidingHelper.setOnSlideFinishListener(this::playFixingAnimation);
添加是否自动选中的自定义属性的话,可以在调用playFixingAnimation方法之前判断一下是否已开启自动选中效果就行了。
好啦,快来看看效果吧:
哈哈,可以了,是不是很开心 (*^__^*)
布局模式
对了,那位同学提出了个问题就是:当Item只有四五个的时候,可不可以把他们都显示出来呢,因为现在是把360平均分了。 这个当然是可以的,我们干脆就分成两种布局模式吧:平均分布模式和指定角度模式。指定角度模式,那就肯定要指定一个角度了,所以如果是设置了这个模式的话,我们还要添加一个mItemAngleOffset属性来记录每个Item之间的偏移角度。 先来定义一下属性:
<attr name="item_layout_mode" format="enum"> <enum name="average" value="0" /> <enum name="fixed" value="1" /> </attr> <attr name="item_angle_offset" format="float" />
我们添加了2个新属性:item_layout_mode(布局方式)和item_angle_offset(Item偏移角度),布局方式有两种:average(平均)和fixed(指定角度),默认为前者。当设置为fixed的时候,还要再指定一个偏移的角度,因为FanLayout不知道每一个Item的偏移角度是多少。 在FanLayout中,也要添加对应的属性:
public static final int MODE_AVERAGE = 0;//平均分布 public static final int MODE_FIXED = 1;//指定角度 private int mItemLayoutMode;//item布局模式 private float mItemAngleOffset;//item角度偏移量
然后再在构造方法中获取到属性:
//获取布局方式并判断是不是fixed模式 if ((mItemLayoutMode = a.getInteger(R.styleable.FanLayout_item_layout_mode, MODE_AVERAGE)) == MODE_FIXED) { //如果设置了fixed模式,则获取Item偏移角度,如果角度不在1~360之间,则抛出异常 mItemAngleOffset = a.getFloat(R.styleable.FanLayout_item_angle_offset, 0); if (mItemAngleOffset <= 0 || mItemAngleOffset > 360) { throw new IllegalStateException("item_angle_offset must be between 1~360!"); } }
接下来就很简单了,只需要在layoutItems方法中改一行代码:
private void layoutItems(boolean isHasBottomBearing, int startIndex) { ... //AVERAGE模式:每个item要旋转的角度 (如果轴承是View类型,要减一个) //FIXED模式:直接使用设置的偏移量 float angle = mItemLayoutMode == MODE_AVERAGE ? 360F / (childCount - startIndex) : mItemAngleOffset; ... }
哈哈,判断一下是不是fixed模式,如果是fixed模式,直接使用指定的偏移角度就行了。 我们再来定义两个set方法来动态设置布局方式和Item偏移量:
/** * item的布局方式: 默认: MODE_AVERAGE(平均) * 如设置为fixed需指定偏移角度: setItemAngleOffset(float angle) */ public void setItemLayoutMode(@LayoutMode int layoutMode) { if (mItemLayoutMode != layoutMode) { mItemLayoutMode = layoutMode; requestLayout(); } } /** * 指定Item的偏移角度 LayoutMode=MODE_FIXED时有效 */ public void setItemAngleOffset(float angle) { if (mItemAngleOffset != angle) { mItemAngleOffset = angle; if (mItemLayoutMode == MODE_FIXED) { requestLayout(); } } }
setItemLayoutMode方法中的@LayoutMode也是使用了@IntDef的自定义注解。
好,我们来看看效果:
哈哈,可以了。 但是可能有同学会觉得:为什么添加新Item和偏移时只能是顺时针呢?而且现在的Item总是在轴承的右下方,如果我要Item显示在正右方怎么办?现在是这样:
这样看上去就不是很舒服了,因为上方有一部分是没有Item的。 好吧,既然这样,那就再加上一个可以动态设置Item的添加方向的效果吧。
Item添加方向
除了顺时针和逆时针,我们还准备再添加一个交叉添加模式,哈哈,就是一个顺时针一个逆时针了,这样做的话,就可以实现刚刚说的:让Item整体保持在正右方。 先添加属性,首先是attr:
<!--item的添加方向: 默认: 顺时针添加--> <attr name="item_add_direction" format="enum"> <!--顺时针--> <enum name="clockwise" value="0" /> <!--逆时针--> <enum name="counterclockwise" value="1" /> <!--交叉添加--> <enum name="interlaced" value="2" /> </attr>
FanLayout:
public static final int ADD_DIRECTION_CLOCKWISE = 0;//顺时针方向添加 public static final int ADD_DIRECTION_COUNTERCLOCKWISE = 1;//逆时针添加 public static final int ADD_DIRECTION_INTERLACED = 2;//交叉添加 private int mItemAddDirection;//item添加模式
我们在构造方法中拿到item_add_direction这个属性之后,就要想想接下来应该怎么做了:
- 其实如果是顺时针的话,我们什么都不用做,保持原来的就行;
- 如果是逆时针呢?那就刚好跟顺时针相反,顺时针是50度的话,那么逆时针就是-50度了,所以我们等下直接用360减去顺时针中的角度就行了;
- 交叉模式的话,我们是打算奇数顺时针添加,偶数逆时针添加,这样就能实现交叉的效果了;
好,来看看代码怎么写(也是只需要在layoutItems方法里面修改一下就行了):
private void layoutItems(boolean isHasBottomBearing, int startIndex) { ... for (int i = 0; i < childCount; i++) { ... //排除轴承View之后的索引,也就是要忽略轴承View了 int index; //Item最终要旋转的角度 float rotation; if (mItemAddDirection == ADD_DIRECTION_COUNTERCLOCKWISE) { //逆时针添加 //如果View类型的轴承在底部的话,还要减去1,因为我们要忽略这个轴承 index = isHasBottomBearing ? i - 1 : i; //这个角度跟顺时针的相反,所以直接用360减去当前角度 rotation = 360F - index * angle; } else if (mItemAddDirection == ADD_DIRECTION_INTERLACED) { //交叉添加 //这里计算的index为什么跟顺时针的和逆时针的不同呢?总是比它们大1 //是因为第一个Item不用动,改变添加方向的是从第二个Item开始的,所以这里要比其他两个方向的index值要大1 index = isHasBottomBearing ? i : i + 1; //当前index前面相同方向的item个数 int hitCount = 0; //当前index是否偶数 boolean isDual = index % 2 == 0; //从0开始数起,一直数到当前index for (int j = 0; j < index; j++) { //判断当前index是否偶数 if (isDual) { //进一步判断当前遍历到的是否偶数,如果是偶数的话才+1 //为什么还要这样判断呢? //是因为: 上面判断isDual,仅仅是为了确定当前index的item究竟是逆时针添加还是顺时针添加, //添加的方向是确定了,但要偏移的角度还不知道,而这一次的判断呢, //就是为了计算出当前index的前面还有多少个跟它相同方向的item,下同 if (j % 2 == 0) { hitCount++; } } else { //如果是奇数的话,也进一步判断当前遍历到的是否奇数才+1 if (j % 2 != 0) { hitCount++; } } } //我们设置,如果当前index是奇数的话,就顺时针添加,否则逆时针添加,这样的话,就能实现交叉添加了 rotation = isDual ? 360F - hitCount * angle : hitCount * angle; } else { //顺时针添加 //如果View类型的轴承在底部的话,还要减去1,因为我们要忽略这个轴承 index = isHasBottomBearing ? i - 1 : i; rotation = index * angle; } //设置旋转的角度 view.setRotation(fixRotation(rotation + getTargetAngle())); } }
好了,在添加seter方法之后,看看效果如何:
/** * 设置Item的添加方向 默认: 顺时针添加 */ public void setItemAddDirection(@DirectionMode int direction) { if (mItemAddDirection != direction) { mItemAddDirection = direction; requestLayout(); } }
哈哈哈,三种模式我们都实现了,是不是很开心。
添加指定选中
可能还有同学不满足,ListView有setSelection,RecyclerView有scrollToPosition,为什么FanLayout就不能有一个选中指定Item的方法呢? 好吧,那我们也加一个这个的效果吧:
/** * 指定选中 * * @param index item索引 * @param isSmooth 是否播放平滑动画 */ public void setSelection(int index, boolean isSmooth) { //必须要开启自动选中,并且将要选中的index不能大于当前子view数量,再排除轴承view if (isAutoSelect && index < getChildCount() && getChildCount() > (isViewType() ? 1 : 0)) { //判断轴承是否View类型 if (isViewType()) { //如果轴承在底部的话,那就是要+1了,因为要排除轴承View if (isBearingOnBottom) { //+1的前提是不溢出 if (index + 1 < getChildCount()) { index++; } } else { //如果轴承在顶部的话,并且传进来的index刚好是轴承的index,则-1(排除轴承View) if (index == getChildCount() - 1) { index--; //如果减了1之后<0的话,也没必要继续了 if (index < 0) { return; } } } } //转动到指定的index scrollToPosition(index, isSmooth); } } /** * 转动到指定的index * * @param isSmooth 是否平滑滚动 */ private void scrollToPosition(int index, boolean isSmooth) { //刷新当前选中的index mCurrentSelectedIndex = index; View view = getChildAt(index); //目标角度 float targetAngle = getTargetAngle(); //当前index对应的view的角度 float rotation = view.getRotation(); //取更小的那一边 if (Math.abs(rotation - targetAngle) > 180) { targetAngle = 360 - targetAngle; } //先拿到正的角度 float angle = Math.abs(rotation - fixRotation(targetAngle)); //如果是平滑滚动,就交给ValueAnimator去处理 if (isSmooth) { startValueAnimator(rotation > fixRotation(targetAngle) ? -angle : angle, index); } else { //如果不是就直接滚动到目标角度 onSliding(rotation > fixRotation(targetAngle) ? -angle : angle); //回调有新的Item选中 notifyListener(); } } private void notifyListener() { if (mOnItemSelectedListener != null) { //根据记录的当前选中index来获取到对应的view View view = getChildAt(mCurrentSelectedIndex); //检查下这个view是不是轴承view,如果是的话,还要排除它,并且找到正确的item if (isViewType() && view == mBearingView) { //如果轴承在底部的话,那就是要+1了 if (isBearingOnBottom) { //+1的前提是不溢出 if (mCurrentSelectedIndex + 1 < getChildCount()) { mCurrentSelectedIndex++; } else { //如果溢出了,也没必要继续了 return; } } else { //逻辑同上 if (mCurrentSelectedIndex - 1 >= 0) { mCurrentSelectedIndex--; } else { return; } } } //回调接口 mOnItemSelectedListener.onSelected(getChildAt(mCurrentSelectedIndex)); } } /** * Item被选中的回调 */ public interface OnItemSelectedListener { void onSelected(View item); }
我们一气之下 一口气加了三个方法,经过之前的一些分析,相信上面那些代码大家都可以轻松看懂了。
来看看效果如何:
哈哈,可以看到,开启自动选中之后,通过点击上面那一排数字,也能正确地旋转到对应的Item了。
好啦,我们这篇文章算是结束了,有错误的地方请指出,谢谢大家! github地址:https://github.com/wuyr/FanLayout 欢迎star
加载全部内容