自定义View同时显示3个Fragment并自由切换

工作中需要实现如下的一个效果,有三个界面,两边的界面都漏出一部分来,点击两边或者在中间滑动就可以让旁边的界面同中间的进行交换.要怎么来实现这个效果呢?

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
5
void 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
55
public 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
9
public 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<RelativeLayout
android:id="@+id/body"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
tools:context="com.mushuichuan.threefragmetsswitcher.MainActivity2">

<android.support.v4.view.ViewPager
android:id="@+id/view_pager"
android:layout_width="300dp"
android:layout_height="match_parent"
android:layout_centerInParent="true"
android:clipChildren="false"/>
</RelativeLayout>

但是仅仅是漏出来了,如果你点击或者滑动漏出来的地方是不会触发Viewpager的滑动的,这是因为ViewPager还是原来那么大。如果要处理点击两边也要ViewPager滑动的话,就要监听其父View的onTouch事件再进行操作。

最后就是实现两边的Fragment缩小的问题了。ViewPager可以设置PageTransformer,我们可以自定义一个PageTransformer来实现两边缩小的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ZoomPageTransformer implements ViewPager.PageTransformer {
private static final float MIN_SCALE = 0.85f;


@SuppressLint("NewApi")
public void transformPage(View view, float position) {
Log.d("test", view.getId() + ":" + position);
if (position <= 1) {
float scaleFactor = Math.max(MIN_SCALE, 1 - Math.abs(position));
view.setScaleX(scaleFactor);
view.setScaleY(scaleFactor);
}
}
}

效果图如下,是不是比上面的效果好多了呢?

image