工作中需要实现如下的一个效果,有三个界面,两边的界面都漏出一部分来,点击两边或者在中间滑动就可以让旁边的界面同中间的进行交换.要怎么来实现这个效果呢?
1. 思路
考虑到这三个界面互相独立而且相对有各自的业务,混在一起的话很乱,而且以后如果要替换某个界面会很麻烦(千万不要低估产品同学们改来改去的决心). 所以我们准备使用3个Fragment来分别实现3个界面的内容,在各个Fragment内部完成界面的渲染和数据的请求等. 那我们就需要三个Layout排列成图中的样子,然后将Fragment添加进去就可以了.
2. 实现
有了思路就开始干吧.一开始的想法是在一个RelativeLayout里面放上3个Layout, 并分别进行定位, 结果发现两边的Layout并不会伸到屏幕的外面去,而是都积压到一起, 完全变形了. 看来只能自定义View并手动将里面的Layout给添加进去了.
2.1 布局定位
我们自定义一个ViewSwitcherView继承自RelativeLayout(其它的也可以), 起名为SwitcherView吧. 在使用的时候让其包含3个子view:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25<com.mushuichuan.threefragmetsswitcher.SwitcherView
android:id="@+id/switcherview"
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"
>
<FrameLayout
android:id="@+id/child_middle"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@android:color/holo_blue_dark"/>
<FrameLayout
android:id="@+id/child_left"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@android:color/holo_green_dark"/>
<FrameLayout
android:id="@+id/child_right"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@android:color/holo_red_dark"/>
</com.mushuichuan.threefragmetsswitcher.SwitcherView>
这样我们在SwitcherView内部就有了3个子View, 将它们find出来:1
2
3
4
5void initChildView() {
mChildMiddle = (FrameLayout) findViewById(R.id.child_middle);
mChildLeft = (FrameLayout) findViewById(R.id.child_left);
mChildRight = (FrameLayout) findViewById(R.id.child_right);
}
在ViewGroup中有一个onLayout方法, 当需要给子View进行定位和指定大小的时候就会调用, 那我们就可以在这个方法里面对这三个子View进行定位了. 调用的时候会将SwitcherView四个角的值传进来, 我们可以用这四个值计算子View的位置. 计算出每一个子View的位置后, 调用其layout方法就可以对子View进行定位了. 最后我们还需要将三个子View的LayoutParams给保存下来, 方便我们下一步调换子View的位置时使用.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mChildMiddle == null) {
initChildView();
}
int middleWidth = (int) (r * middleProportion);
middleLeft = (r - middleWidth) / 2;
middleRight = r - (r - middleWidth) / 2;
mChildMiddle.layout(middleLeft, t + middleMarginTopAndDown, middleRight, b - middleMarginTopAndDown);
int leftRight = middleLeft - middleMarginLeftAndRight;
int leftLeft = -(middleWidth - leftRight);
mChildLeft.layout(leftLeft, t + sideMarginTopAndDown, leftRight, b - sideMarginTopAndDown);
int rightLeft = middleRight + middleMarginLeftAndRight;
int rightRight = rightLeft + middleWidth;
mChildRight.layout(rightLeft, t + sideMarginTopAndDown, rightRight, b - sideMarginTopAndDown);
mMiddleParam = (LayoutParams) mChildMiddle.getLayoutParams();
mLeftParam = (LayoutParams) mChildLeft.getLayoutParams();
mRightParam = (LayoutParams) mChildRight.getLayoutParams();
}
2.2 互换位置
由于我们已将三个子View的LayoutParams给保存到了变量中, 所以当我们需要更换两个子View的位置时, 我们只需要将他们的LayoutParams的换一下就可以达到目的. 在这里我们还添加了动画, 让交互更好一些.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55public void switchLeftAndMiddle() {
Animation leftInAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_left_in);
mChildLeft.startAnimation(leftInAnimation);
Animation leftOutAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_left_out);
mChildMiddle.startAnimation(leftOutAnimation);
leftOutAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
mChildMiddle.setLayoutParams(mLeftParam);
mChildLeft.setLayoutParams(mMiddleParam);
FrameLayout temp = mChildMiddle;
mChildMiddle = mChildLeft;
mChildLeft = temp;
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
}
public void switchRightAndMiddle() {
Animation leftInAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_right_in);
mChildRight.startAnimation(leftInAnimation);
Animation rightOutAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.slide_right_out);
mChildMiddle.startAnimation(rightOutAnimation);
rightOutAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
mChildMiddle.setLayoutParams(mRightParam);
mChildRight.setLayoutParams(mMiddleParam);
FrameLayout temp = mChildMiddle;
mChildMiddle = mChildRight;
mChildRight = temp;
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
}
2.3 添加手势
最后就是添加手势实现点击两边或者中间滑动实现子View之间的互换. 我们重写了onTouchEvent方法, touch down 的时候记录下其x坐标, touch up的时候再获取其x坐标, 两者取其差的绝对值, 超过某个范围就认为其滑动了, 否则认为其为一个点击事件.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34@Override
public boolean onTouchEvent(MotionEvent event) {
Log.d(TAG, "onTouchEvent:" + event.toString());
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
startX = event.getX();
Log.d(TAG, "startx:" + startX);
return true;
}
case MotionEvent.ACTION_UP: {
endX = event.getX();
Log.d(TAG, "endX:" + endX);
if (abs(endX - startX) < CLICK_THRESHOLD) {
if (startX < middleLeft) {
switchLeftAndMiddle();
return true;
} else if (startX > middleRight) {
switchRightAndMiddle();
return true;
}
} else {
if (endX > startX) {
switchRightAndMiddle();
return true;
} else if (endX < startX) {
switchLeftAndMiddle();
return true;
}
}
break;
}
}
return false;
}
似乎实现要求的功能了, 但是使用时发现如果两边的Fragment里面有实现对点击事件的监听, 我们这里就监听不到点击事件了, 所以需要对两边的点击事件进行拦截. 我们重写了onIntercepTouchEvent方法来实现这个拦截. 当有touch down的事件时, 我们判断一下其点击的区域, 如果处于两边的边缘区域, 则返回true, 代表这次的点击事件被我们的SwitcherView给拦截了, 不会再向下分发.1
2
3
4
5
6
7
8@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
if (ev.getX() < middleLeft || ev.getX() > middleRight)
return true;
}
return false;
}
3. 完善
Ok, 功能都实现了, 但是还有一些细节没做好, 如中间这个Layout的宽度占屏幕宽度的比例, 两边的Layout同中间的间隔大小, 及上线的间隔等. 如果改这些每次都要改源码那可麻烦死了, 而且这个View可能被用在多个不同的地方. 所以我们来对SwitcherView添加几个属性吧, 在使用的时候根据实际情况进行配置.
首先在values目录下创建一个文件attrs.xml, 将我们要添加的属性添加到这个文件中, 在这里我们定义了4个属性, 并分别指定属性的类型:1
2
3
4
5
6
7
8
9<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SwticherView">
<attr name="middleProportion" format="float"></attr>
<attr name="sideMarginTopAndDown" format="dimension"></attr>
<attr name="middleMarginLeftAndRight" format="dimension"></attr>
<attr name="middleMarginTopAndDown" format="dimension"></attr>
</declare-styleable>
</resources>
然后在SwticherView中读取这些参数, 读取到的参数就可以用在代码里来进行各种配置了:1
2
3
4
5
6
7
8
9public SwitcherView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.SwticherView);
middleProportion = mTypedArray.getFloat(R.styleable.SwticherView_middleProportion, 0.75f);
sideMarginTopAndDown = mTypedArray.getDimensionPixelSize(R.styleable.SwticherView_sideMarginTopAndDown, 0);
middleMarginTopAndDown = mTypedArray.getDimensionPixelSize(R.styleable.SwticherView_middleMarginTopAndDown, 0);
middleMarginLeftAndRight = mTypedArray.getDimensionPixelSize(R.styleable.SwticherView_middleMarginLeftAndRight, 0);
initChildView();
}
使用的时候来指定参数的值:1
2
3
4
5
6
7
8
9
10
11<com.mushuichuan.threefragmetsswitcher.SwitcherView
android:id="@+id/switcherview"
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:middleMarginLeftAndRight="10dp"
app:middleMarginTopAndDown="5dp"
app:middleProportion="0.75"
app:sideMarginTopAndDown="20dp"
>
4.改进
使用中会发现,如果touch事件被Fragment给消费掉了, 我们的SwitcherView的onTouchEvent方法将接收不到touch事件了. 所以我们不能重写onTouchEvent方法而是重写dispatchTouchEvent方法. 根据Android的事件分发机制, 所有需要传递到Fragment里面的事件都需要经过我们SwitcherView的dispatchTouchEvent的分发, 所以我们可以在这里进行滑动手势的监听. 需要特别注意的是对于ACTION_DOWN的事件一定要返回true, 这样后续的事件才会继续分发到这里.
如果SwitcherView是嵌套到listview里面的, 当滑动的时候经常会触发上下滑动,造成误操作. 当Listview上下滑动的时候,我们会接收到ACTION_CANCEL事件, 所以我们也需要处理一下ACTION_CANCEL事件, 这样即使触发了上下滑动,我们的左右滑动还是可以使用的.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36@Override
public boolean dispatchTouchEvent(MotionEvent event) {
Log.i(TAG, "dispatchTouchEvent:" + event.toString());
boolean handled = super.dispatchTouchEvent(event);
Log.i(TAG, "handled:" + handled);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
startX = event.getX();
Log.i(TAG, "startx:" + startX);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP: {
endX = event.getX();
Log.i(TAG, "endX:" + endX);
if (abs(endX - startX) < CLICK_THRESHOLD) {
//点击事件
if (startX < middleLeft) {
switchLeftAndMiddle();
} else if (startX > middleRight) {
switchRightAndMiddle();
}
} else {
//滑动事件
if (endX > startX) {
switchRightAndMiddle();
} else if (endX < startX) {
switchLeftAndMiddle();
}
}
break;
}
}
return true;
}
5.结语
到这里我们就实现了预期的效果了, 来看看实现效果吧:
本文中的完整代码请移步Github
6. 再改进
上述的方法还是有局限性,如不能随着手指滑动而滑动,然后又发现了新的方法,这次是在ViewPager的基础上做的。ViewPager已经将滑动实现好了,所以就需要处理一下如何让两边的Fragment也漏出一点来。使用的方法是设置ViewPager及其父ViewGroup的clipChildren属性为false,该属性默认是设为true的,让我们看一下文档中对改属性的解释:
Defines whether a child is limited to draw inside of its bounds or not. This is useful with animations that scale the size of the children to more than 100% for instance. In such a case, this property should be set to false to allow the children to draw outside of their bounds. The default value of this property is true.
就是说是不是要子View局限在它的范围内.如果我们将ViewPager及其父view的这项属性都设为false,那ViewPager里面两边的Fragment也能漏出来了。
1 | <RelativeLayout |
但是仅仅是漏出来了,如果你点击或者滑动漏出来的地方是不会触发Viewpager的滑动的,这是因为ViewPager还是原来那么大。如果要处理点击两边也要ViewPager滑动的话,就要监听其父View的onTouch事件再进行操作。
最后就是实现两边的Fragment缩小的问题了。ViewPager可以设置PageTransformer,我们可以自定义一个PageTransformer来实现两边缩小的问题。
1 | public class ZoomPageTransformer implements ViewPager.PageTransformer { |
效果图如下,是不是比上面的效果好多了呢?