您的位置:首页 > 移动开发 > Android开发

Android自定义LinearLayout实现左右侧滑菜单,完美兼容ListView、ScrollView、ViewPager等滑动控件

2016-06-14 17:04 1031 查看
国际惯例,先来效果图



在阅读本文章之前,请确定熟悉【Scroller】相关的知识,如果不熟悉,请小伙伴儿先百度后再来吧。

假如你已经知道【Scroller】了,那么就接着往下看吧。

首先,我们把侧拉菜单的构造给解析出来。多次观看上面的效果图,我们可以得出以下的结论。

整体可以看做是一个ViewGroup,这个ViewGroup包含了最多三个子View(分别是左菜单的红色View、中间正文内容的白色View、右菜单的蓝色View);
三个子View(我称为UI界面,因为代码中的Java类就取名这个)的移动是在ViewGroup的onTouchEvent方法中控制;
每个UI界面都拥有独特的东西,比如子控件布局,因此我们希望用R.layout.*的方式引入;
每个UI界面又都拥有相同的属性,比如都有宽度属性,滑动临界值属性,那么就可以用一个超类来封装所有相似的东西;
最最重要的地方,动态计算出scrollX的值,然后用Scroller来滑动。

理清楚了结构后,我们来开始第一步的设计,也就是封装超类,首先给出代码:
/**
* Created by ccwxf on 2016/6/14.
*/
public abstract class UI {

protected Context context;
//当前UI界面的布局文件
protected View contentView;
//当前UI界面在父控件的起点X坐标
protected int startX;
//当前UI界面在父控件的终点X坐标
protected int stopX;
//当前UI界面的宽度
protected int width;

protected UI(Context context, View contentView){
this.context = context;
this.contentView = contentView;
}

protected abstract void calculate(float leftScale, float rightScale);

protected void show(Scroller mScroller){
if(mScroller != null){
mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), startX - mScroller.getFinalX(), 0);
}
}
}


这个UI超类就用于模拟每一个界面,其中主要封装了内容View的设置、跳转界面的逻辑代码,以及暴露出去需要子类实现的calculate方法,这个calculate方法主要是要计算startX、stopX、width以及各子类独有的属性。

接下来展示左菜单的实现类LeftMenuUI:
/**
* Created by ccwxf on 2016/6/14.
*/
public class LeftMenuUI extends UI {
// 是指要打开该UI界面所需要滚动的X坐标临界值
public int openX;
// 是指要关闭该UI界面所需要的滚动的X坐标临界值
public int closeX;

public LeftMenuUI(Context context, View contentView) {
super(context, contentView);
}

@Override
protected void calculate(float leftScale, float rightScale) {
startX = 0;
stopX = (int) (Util.getScreenWidth(context) * leftScale);
this.width = stopX - startX;
this.openX = (int) (startX + (1 - SideLayout.DEFAULT_SIDE) * this.width);
this.closeX = (int) (startX + SideLayout.DEFAULT_SIDE * this.width);
}

}


代码那是相当的简洁,在calculate方法中除了计算startX和stopX和width之外,还计算了openX和closeX的值。那么问题来了,此处的openX和closeX的什么东西呢?先看下图所示。



首先黑色框代表的是整个的布局,被分为了三个部分,分别是左菜单、正文内容、右菜单。红色框代表的是手机的屏幕,默认手机屏幕的宽高和正文内容的宽高都是一样的。因此图上所示是重合的。
那么问题来了,途中所示的绿色横线代表的openX和closeX分别是什么意思呢?我们假想一下,我们现在正处于正文的内容,此时手指向右滑屏,将滑出左菜单的部分,此时红框代表的屏幕就会向左移动(如果听不懂就真的需要先了解Scroller的使用哟),如果红色框移动到openX这个绿线的左边,我们就认为超出了滑动的临界值,判断为显示左菜单的操作,现在应该明白了openX的意思了吧,就是超过这个值就显示左菜单。
那么问题又来了,closeX怎么解释呢?我们再次假象一下,我们现在正处于左菜单,此时我们向左滑动屏幕,如果红色框从0开始向右移动,如果超出了closeX这个临界值,就代表我们要滑出左菜单进入正文内容,这就是closeX的意思。

好了,理解了左菜单这个类,那么正文内容和右菜单也同样好理解了。接下来给出正文类:ContentUI
public class ContentUI extends UI {

public ContentUI(Context context, View contentView) {
super(context, contentView);
}

@Override
protected void calculate(float leftScale, float rightScale) {
int width = Util.getScreenWidth(context);
int leftWidth = (int) (width * leftScale);
startX = leftWidth;
stopX = leftWidth + width;
this.width = stopX - startX;
}

}


正文菜单更简单,没有openX和closeX的计算,为什么呢?因为左菜单和右菜单的划入划出判断我都放在对应的UI类里面。接下来是RightMenuUI
/**
* Created by ccwxf on 2016/6/14.
*/
public class RightMenuUI extends UI {
// 是指要打开该UI界面所需要滚动的X坐标临界值
public int openX;
// 是指要关闭该UI界面所需要的滚动的X坐标临界值
public int closeX;

public RightMenuUI(Context context, View contentView) {
super(context, contentView);
}

@Override
protected void calculate(float leftScale, float rightScale) {
int width = Util.getScreenWidth(context);
startX = (int) (width * (1 + leftScale));
stopX = (int) (width * (1 + leftScale + rightScale));
this.width = stopX - startX;
this.openX = (int) (startX - width + SideLayout.DEFAULT_SIDE * this.width);
this.closeX = (int) (startX - width + (1 - SideLayout.DEFAULT_SIDE) * this.width);
}

/**
* 必须重载父类方法,因为滑动的起点是从0开始
*/
protected void show(Scroller mScroller, int measureWidth){
if(mScroller != null){
mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), measureWidth - Util.getScreenWidth(context) - mScroller.getFinalX(), 0);
}
}
}


这个类也同样有openX和closeX的计算,但是大家要特别注意的一点是:右菜单的openX和closeX是在正文菜单的坐标内,要问为什么的话,大家需要了解Scroller的原理并向我一样画一个草图来理解。

最后就是SideLayout这个自定义控件了,其实就只是在onTouchEvent中做了滑动的逻辑判断操作。首先给出源代码:
/**
* Created by ccwxf on 2016/6/14.
*/
public class SideLayout extends LinearLayout {
//默认的菜单宽度与屏幕宽度的比值
public static final float DEFAULT_SCALE = 0.66f;
//默认的滑动切换阀值相对于菜单宽度的比值
public static final float DEFAULT_SIDE = 0.25f;
private Scroller mScroller;
//三个UI界面
private LeftMenuUI leftMenuUI;
private ContentUI contentUI;
private RightMenuUI rightMenuUI;
//左菜单和右菜单相对于屏幕的比值
private float leftScale = 0;
private float rightScale = 0;
//控件的测量宽度
private float measureWidth = 0;
//手指Touch时的X坐标和移动时的X坐标
private float mTouchX;
private float mMoveX;

public SideLayout(Context context) {
super(context);
init();
}

public SideLayout(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}

public SideLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}

private void init() {
mScroller = new Scroller(getContext());
setOrientation(LinearLayout.HORIZONTAL);
}

/**
*  设置左菜单的布局
* @param view 左菜单布局
* @return 返回当类
*/
public SideLayout setLeftMenuView(View view){
return setLeftMenuView(view, DEFAULT_SCALE);
}

public SideLayout setLeftMenuView(View view, float leftScale){
leftMenuUI = new LeftMenuUI(getContext(), view);
this.leftScale = leftScale;
return this;
}

/**
*  设置右菜单的布局
* @param view 右菜单布局
* @return 当类
*/
public SideLayout setRightMenuView(View view){
return setRightMenuView(view, DEFAULT_SCALE);
}

public SideLayout setRightMenuView(View view, float rightScale){
rightMenuUI = new RightMenuUI(getContext(), view);
this.rightScale = rightScale;
return this;
}

/**
*  设置正文布局
* @param view 正文布局
* @return 返回当类
*/
public SideLayout setContentView(View view){
contentUI = new ContentUI(getContext(), view);
return this;
}

/**
* 提交配置,必须调用
*/
public void commit() {
removeAllViews();
if(leftMenuUI != null){
leftMenuUI.calculate(leftScale, rightScale);
measureWidth += leftMenuUI.width;
addView(leftMenuUI.contentView, new LayoutParams(leftMenuUI.width, LayoutParams.MATCH_PARENT));
}
if(contentUI != null){
contentUI.calculate(leftScale, rightScale);
measureWidth += contentUI.width;
addView(contentUI.contentView, new LayoutParams(contentUI.width, LayoutParams.MATCH_PARENT));
}
if(rightMenuUI != null){
rightMenuUI.calculate(leftScale, rightScale);
measureWidth += rightMenuUI.width;
addView(rightMenuUI.contentView, new LayoutParams(rightMenuUI.width, LayoutParams.MATCH_PARENT));
}

}

@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
mTouchX = event.getX();
mMoveX = event.getX();
return true;
case MotionEvent.ACTION_MOVE:
int dx = (int) (event.getX() - mMoveX);
if(dx > 0){
//右滑
if(mScroller.getFinalX() > 0){
mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), -dx, 0);
}else{
mScroller.setFinalX(0);
}
}else{
//左滑
if(mScroller.getFinalX() + Util.getScreenWidth(getContext()) - dx < measureWidth){
mScroller.startScroll(mScroller.getFinalX(), mScroller.getFinalY(), -dx, 0);
}else{
mScroller.setFinalX((int) (measureWidth - Util.getScreenWidth(getContext())));
}
}
mMoveX = event.getX();
invalidate();
break;
case MotionEvent.ACTION_UP:
toTargetUI((int) (event.getX() - mTouchX));
break;
}
return super.onTouchEvent(event);
}

/**
*  滑动切换到目标的UI界面
* @param dx 手指抬起时相比手指落下,滑动的距离
*/
private void toTargetUI(int dx){
int scrollX = mScroller.getFinalX();
if(dx > 0){
//右滑
if(leftMenuUI != null){
if(scrollX >= leftMenuUI.openX && scrollX < leftMenuUI.stopX){
contentUI.show(mScroller);
}else if(scrollX >= leftMenuUI.startX && scrollX < leftMenuUI.openX){
leftMenuUI.show(mScroller);
}
}
if(rightMenuUI != null){
if(scrollX >= rightMenuUI.closeX){
rightMenuUI.show(mScroller, (int) measureWidth);
}else if(scrollX >= contentUI.startX && scrollX < rightMenuUI.closeX){
contentUI.show(mScroller);
}
}
}else{
//左滑
if(leftMenuUI != null){
if(scrollX > leftMenuUI.startX && scrollX <= leftMenuUI.closeX){
leftMenuUI.show(mScroller);
}else if(scrollX > leftMenuUI.closeX && scrollX < leftMenuUI.stopX){
contentUI.show(mScroller);
}
}
if(rightMenuUI != null){
if(scrollX > contentUI.startX && scrollX <= rightMenuUI.openX){
contentUI.show(mScroller);
}else if(scrollX > rightMenuUI.openX){
rightMenuUI.show(mScroller, (int) measureWidth);
}
}
}
}

@Override
public void computeScroll(){
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
super.computeScroll();
}
}


在ACTION_MOVE操作中,根据移动的偏移量来滑动控件,这里需要特别注意左边的0临界值和右边的measureWidth测量宽度的临界值,不然会滑出屏幕之外哟。最重要的方法还是toTargetUI这个方法,我单独把这个方法剔出来讲解。
/**
*  滑动切换到目标的UI界面
* @param dx 手指抬起时相比手指落下,滑动的距离
*/
private void toTargetUI(int dx){
int scrollX = mScroller.getFinalX();
if(dx > 0){
//右滑
if(leftMenuUI != null){
if(scrollX >= leftMenuUI.openX && scrollX < leftMenuUI.stopX){
contentUI.show(mScroller);
}else if(scrollX >= leftMenuUI.startX && scrollX < leftMenuUI.openX){
leftMenuUI.show(mScroller);
}
}
if(rightMenuUI != null){
if(scrollX >= rightMenuUI.closeX){
rightMenuUI.show(mScroller, (int) measureWidth);
}else if(scrollX >= contentUI.startX && scrollX < rightMenuUI.closeX){
contentUI.show(mScroller);
}
}
}else{
//左滑
if(leftMenuUI != null){
if(scrollX > leftMenuUI.startX && scrollX <= leftMenuUI.closeX){
leftMenuUI.show(mScroller);
}else if(scrollX > leftMenuUI.closeX && scrollX < leftMenuUI.stopX){
contentUI.show(mScroller);
}
}
if(rightMenuUI != null){
if(scrollX > contentUI.startX && scrollX <= rightMenuUI.openX){
contentUI.show(mScroller);
}else if(scrollX > rightMenuUI.openX){
rightMenuUI.show(mScroller, (int) measureWidth);
}
}
}
}


首先,根据ACTION_UP传递进来dx参数进入滑动方向的判断,这个非常重要,不同的滑动方向对于处在不同scrollX的控件来说,操作目的是不一样。我们以右滑为例,如果leftMenuUI为空,就代表用户只想要左滑功能,rightMenuUI为空,就代表用户只想要右滑功能。leftMenuUI和rightMenuUI都不为空,表示用户同时想要左滑和右滑的功能。
然后对于不同的坐标区别进行判断,并显示出对应的UI界面。这里文字一时半会儿说不明白。大家把我上面的图拿来对比代码进行分析,一会儿就看明白啦。

还有一个工具方法,是为了得到屏幕宽度的:
/**
* Created by ccwxf on 2016/6/14.
*/
public class Util {

public static int getScreenWidth(Context context){
return context.getResources().getDisplayMetrics().widthPixels;
}
}


OK,自定义控件的源代码就上面这6个文件,接下来讲讲怎么使用。
首先,我们准备三个布局文件,分别代表左菜单布局、正文内容布局和右菜单布局。

left.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/holo_red_light"
android:orientation="vertical">

<ListView
android:id="@+id/listView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:listSelector="@android:color/transparent"
android:cacheColorHint="@android:color/transparent"
android:dividerHeight="0dp"
android:divider="@null"
/>
</LinearLayout>


content.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:orientation="vertical">

<android.support.v4.view.ViewPager
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="300dp"/>

</LinearLayout>


right.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/holo_blue_light"
android:orientation="vertical">

<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
>

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
>
<TextView
android:layout_width="300dp"
android:layout_height="300dp"
android:background="@android:color/holo_green_light"
/>
<TextView
android:layout_width="300dp"
android:layout_height="300dp"
android:background="@android:color/darker_gray"
/>
<TextView
android:layout_width="300dp"
android:layout_height="300dp"
android:background="@android:color/holo_orange_light"
/>
</LinearLayout>

</HorizontalScrollView>
</LinearLayout>


然后我们需要一个activity_main布局用户呈现Activity(这不废话么。。。)
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

<cc.wxf.side.SideLayout
android:id="@+id/sideLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</RelativeLayout>


最后一步了,在代码中调用:如果你只想要左菜单,那么就只调用setLeftMenuView,如果只想要右菜单,那么就只调用setRightMenuView,如果左菜单和右菜单都想要,那么就一起调用。(setContentView必须调用哈,别问我为什么


public class MainActivity extends Activity {

private View leftView;
private View contentView;
private View rightView;

@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

initUI();

SideLayout sideLayout = (SideLayout) findViewById(R.id.sideLayout);
sideLayout.setLeftMenuView(leftView).setContentView(contentView).setRightMenuView(rightView).commit();
}

private void initUI(){
leftView = View.inflate(this, R.layout.left, null);
contentView = View.inflate(this, R.layout.content, null);
rightView = View.inflate(this, R.layout.right, null);
//初始化左边菜单
ListView listView = (ListView) leftView.findViewById(R.id.listView);
listView.setAdapter(new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, new String[]{
"123","456","789","101","112","123","456","789","101","112","123","456","789","101","112","123","456","789","101","112"
}));
//初始化正文内容
ViewPager viewPager = (ViewPager) contentView.findViewById(R.id.viewPager);
viewPager.setAdapter(new TestDemoAdapter());
}

public class TestDemoAdapter extends PagerAdapter{

private ImageView[] imageViews = new ImageView[5];

public TestDemoAdapter() {
for(int i = 0; i < imageViews.length; i++){
imageViews[i] = new ImageView(MainActivity.this);
imageViews[i].setImageResource(R.mipmap.ic_launcher);
}
}

@Override
public int getCount() {
return 5;
}

@Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
}

@Override
public Object instantiateItem(ViewGroup container, int position) {
container.addView(imageViews[position]);
return imageViews[position];
}

@Override
public void destroyItem(ViewGroup container, int position, Object object) {
container.removeView(imageViews[position]);
}
}

}


好了,讲解完了,接下来是激动人心的时候了,Demo地址~~~
点我去下载Demo哟
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: