Android开发实战手把手教你实现一个折叠式布局

本文出自门心叼龙的博客,属于原创类容,转载请注明出处。

在上一篇文章我们实现了一个头部固定的ExpandedListView,今天需要在它的基础上实现:在它头部加一个背景图片,默认状态下他处于展开状态,往上滑的时候背景图片逐渐的折叠起来,往下滑的时候背景图片慢慢的展开效果图如下:
在这里插入图片描述

通过CoordinateLayout实现的折叠式布局

有人可能会说这不就是折叠式布局吗?是的,这就是Android 5.0给我们提供的材料设计库中的CoordinateLayout就是解决这个问题的,使用CoordinateLayout来协调ScrollView,NestedScrollView,ListView,RecycleView和顶部的背景图片、ToolBar之间的滚动关系、在很多的手机应用中,时不时会看到关于折叠布局的效果,现在我们先看看CoordinateLayout是怎么实现的然后在讲我们自定义实现一个折叠式布局,直接上代码:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout 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">

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="200dp">

        <android.support.design.widget.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:titleEnabled="false">

            <ImageView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scaleType="centerCrop"
                android:src="@mipmap/homepage_pic_banner"
                app:layout_collapseMode="parallax" />

            <android.support.v7.widget.Toolbar
                android:id="@+id/view_toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light">

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"
                    android:text="新闻详情" />
            </android.support.v7.widget.Toolbar>
        </android.support.design.widget.CollapsingToolbarLayout>
    </android.support.design.widget.AppBarLayout>

    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <TextView
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:text="hello world" />
            ...
            ...
        </LinearLayout>
    </android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>

在这里插入图片描述
以上就是实现一个折叠式布局的典型模板布局代码,一个简简单单的布局就实现了这样的效果,但是必须要注意在AndroidMnifest.xml必须要给Activity指定它的theme为NoActionBar的样式代码如下:

 <activity
            android:name=".test.CoordinatorLayoutTestActivity"
            android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>

否则会出现ActionBar和ToolBar共存的情况,的显示效果如下:
在这里插入图片描述
另外还需要把自己自定义的ToolBar告诉给系统,即第9行的setSupportActionBar(toolbar),否则我们的ToolBar会作为一个普通的View而存在

public class CoordinatorLayoutTestActivity extends AppCompatActivity {

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_coordinator_layout_test);
        Toolbar toolbar = findViewById(R.id.view_toolbar);
        setSupportActionBar(toolbar);
        getSupportActionBar().setDisplayShowTitleEnabled(false);
        toolbar.setNavigationIcon(R.mipmap.callback_white_icon);
        toolbar.setNavigationOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                onBackPressed();
            }
        });
    }
}

如果只指定了 setSupportActionBar(toolbar),没有AndroidMnifest.xml在指定Activity的theme为NoActionBar,那就运行就直接崩溃了,会报错如下:

 Caused by: java.lang.IllegalStateException: This Activity already has an action bar supplied by the window decor. Do not request Window.FEATURE_SUPPORT_ACTION_BAR and set windowActionBar to false in your theme to use a Toolbar instead.
        at android.support.v7.app.AppCompatDelegateImpl.setSupportActionBar(AppCompatDelegateImpl.java:345)
        at android.support.v7.app.AppCompatActivity.setSupportActionBar(AppCompatActivity.java:130)

意思是说Activity已经有一个ActionBar了,请在你的样式中使用ToolBar替代

在上面的布局文件代码中,根布局CoordinatorLayout 就是用来协调AppBarLayout和NestedScrollView之间滚动的,40行的NestedScrollView是我们要滚动的内容,在11行的CollapsingToolbarLayout标签的内部就是要折叠的内容

  • 其中43行的 app:layout_behavior不配置的效果:
    在这里插入图片描述
    NestedScrollView的内容在ToolBar之上滚动

  • 其中13行app:layout_scrollFlags="scroll|exitUntilCollapsed"如果不配置效果图如下:
    在这里插入图片描述
    如果没有配置则CollapsingToolbarLayout包裹内容内容就会固定在顶部,不会滚动

  • 28行 app:layout_collapseMode="pin"不配置,效果图:
    在这里插入图片描述
    ToolBar会跟随NestedScrollView的滚动而滚动,而不会固定在布局顶部位置

  • 14行app:titleEnabled="false"不配置,效果图:
    在这里插入图片描述
    即使33行的TextView配置了android:layout_gravity=“center”,title也不会居中显示

我们感觉折叠式布局就是给我们的View设置相关的属性配置,不需要进行任何编码就能完成我们的折叠效果,我们不得的赞叹android 5.0给我们提供这一强大的功能

我们来总结一下:
CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout结合起来才能产生这么神奇的效果,不要幻想使用其中的一个控件就能完成这样的效果

ToolBar的设置

系统默认使用的就是系统自带的ActionBar,如果我们要使用自定义的ToolBar,就必须明确的告诉Activity不需要使用系统自带的ActionBar即要给activity设置NoActionBar的样式,另外必须调用setSupportActionBar(toolbar)将自己定义的ToolBar设置给Activity。

CoordinatorLayout下可滑动控件的设置

CoordinatorLayout作为整个布局的父布局容器。给你的可以滑动的控件例如RecyclerView设置如下属性:app:layout_behavior=@string/appbar_scrolling_view_behavior
CoordinatorLayout还提供了layout_anchor 和 layout_anchorGravity属性一起配合使用,可以用于设置FloatingActionButton的位置,此处我是放在appBar的右下角。
app:layout_anchor="@id/appbar"
app:layout_anchorGravity=“bottom|right|end”

CollapsingToolbarLayout的layout_scrollFlags属性

AppBarLayout里面定义的子view只要设置了app:layout_scrollFlags属性,就可以在RecyclerView滚动事件发生的时候被触发某种行为
例如我给CollapsingToolbarLayout控件设置了 app:layout_scrollFlags="scroll|exitUntilCollapsed"此刻如果没有这个属性,CollapsingToolbarLayout是不会折叠的那么问题来了,layout_scrollFlags中的属性值除了可以触发折叠的行为,还有其它的属性值吗?并且各个属性的意义是什么?scroll至少有一个scroll,即可滚动。

属性 作用
scroll 必须要给其至少有设置一个scroll,即可滚动
enterAlways 向下滚动即可见。例如下拉时,立即显示Toolbar
exitUntilCollapsed 这个flag是定义何时收缩。当你定义了一个minHeight,这个view将在滚动到达这个最小高度的时候消失
enterAlwaysCollapsed 这个flag是定义何时展开。当你定义了一个最小高度minHeight, 同时enterAlways也定义了,那么view将在到达这个最小高度的时候开始展示
snap 当一个滚动事件结束,它将根据显示百分比的大小自动滚动到收缩或展开。

如果不设置该属性,则该布局不能滑动

CollapsingToolbarLayout的其他属性

另外还可以给CollapsingToolbarLayout设置以下属性:

属性 作用
contentScrim 设置当完全折叠(收缩)后的背景颜色。
expandedTitleMarginEnd 没有扩张的时候标题显示的位置
expandedTitleMarginStart 扩张的时候标题向左填充的距离。
statusBarScrim 设置折叠时状态栏的颜色

CollapsingToolbarLayout下的view的layout_collapseMode属性

CollapsingToolbarLayout里面定义的view只要设置了app:layout_collapseMode属性,就可以控制子视图的折叠模式。
折叠模式分为两种:

属性 作用
pin 固定模式。在收缩的时候最后固定在顶端(例如向上滚动的时候就固定toolBar)
parallax 视差模式,在折叠的时候会有个视差折叠的效果。(例如向下滚动的时候就展开ImageView)

CoordinatorLayout 的fitsSystemWindows属性

fitsSystemWindows属性可以让view根据系统窗口来调整自己的布局,简单点说就是我们在设置应用布局时是否考虑系统窗口布局,这里系统窗口包括系统状态栏、导航栏、输入法等,包括一些手机系统带有的底部虚拟按键。android:fitsSystemWindows=”true” (触发View的padding属性来给系统窗口留出空间) 这个属性可以给任何view设置,只要设置了这个属性此view的其他所有padding属性失效,同时该属性的生效条件是只有在设置了透明状态栏(StatusBar)或者导航栏(NavigationBar)此属性才会生效

如何监听CollapsingToolbarLayout的展开与折叠

使用官方提供的 AppBarLayout.OnOffsetChangedListener就能实现了,不过要封装一下才好用,自定义一个继承了 AppBarLayout.OnOffsetChangedListener的类这里命名为AppBarStateChangeListener

public abstract class AppBarStateChangeListener implements AppBarLayout.OnOffsetChangedListener {

    public enum State {
        EXPANDED,
        COLLAPSED,
        IDLE
    }
    private State mCurrentState = State.IDLE;

    @Override
    public final void onOffsetChanged(AppBarLayout appBarLayout, int i) {
        if (i == 0) {
            if (mCurrentState != State.EXPANDED) {
                onStateChanged(appBarLayout, State.EXPANDED);
            }
            mCurrentState = State.EXPANDED;
        } else if (Math.abs(i) >= appBarLayout.getTotalScrollRange()) {
            if (mCurrentState != State.COLLAPSED) {
                onStateChanged(appBarLayout, State.COLLAPSED);
            }
            mCurrentState = State.COLLAPSED;
        } else {
            if (mCurrentState != State.IDLE) {
                onStateChanged(appBarLayout, State.IDLE);
            }
            mCurrentState = State.IDLE;
        }
    }
    public abstract void onStateChanged(AppBarLayout appBarLayout, State state);
}

然后我们这样使用它:

 mAppBarLayout.addOnOffsetChangedListener(new AppBarStateChangeListener() {
            @Override
            public void onStateChanged(AppBarLayout appBarLayout, State state) {
                Log.d("STATE", state.name());
                if( state == State.EXPANDED ) {
                     //展开状态                    
                }else if(state == State.COLLAPSED){
                    //折叠状态                     
                }else {                
                    //中间状态                
                }
            }
        });

这样就可以在不同的状态下根据自己的业务需求去实现相关的逻辑了

StickyLayout自定折叠式布局的实现

好了,上面就是关于通过CoordinateLayout实现的折叠式布局所有的知识点,如果说前面只是开胃菜,现在我们就开始上主菜了,我们能不能自己实现这样一个折叠式的布局,利用上一篇我们所讲的头部固定的ExpandedListView,把它作为具有滑动功能的主View,在它的顶部添加具有背景图片Header,随着ExpandedListView的滑动header实现扩展和收缩的效果,效果如下:
在这里插入图片描述

功能分析

其实这个效果图在文章的一开始就展示过了,整个布局分为上下两部分:上分部分为可折叠的Header,下半部分就是我们头部固定的ExpandedListView,他们公共父view就是今天我们要实现的折叠式布局StickyLayout,ExpandedListView是自身所具备滑动功能的,而我们在整个屏幕上,往上滑动的时候如果header处于展开状态则Header慢慢的要折叠起来,往下滑动的时候如果ExpandedListView顶部数据都显示出来的情况下再往下拉的时候Header就慢慢的展开,其他的状态就是我们的ExpandedListView在上下滑动,也就是说我们的Header在折叠和展开的状态下的这些事件被StickyLayout拦截了,其他的事件就交给ExpandedListView进行处理从而实现了他的上下滑动,这就属于典型的滑动冲突问题,简言之就是我们在上下滑动的过程中的有些事件需要被StickyLayout拦截消掉来实现Header的折叠和展开效果,其他的事件就交给ExpandedListView来实现它的滑动效果
现在我们要思考的是哪些情况下被拦截:

  • 左右滑动的不需要处理,只处理上下滑动的事件
  • 在展开的状态下,上滑事件需要拦截
  • ExpandedListView的第0个元素处于可见状态,此时的下滑事件需要拦截

在事件拦截方法中处理滑动冲突

public class StickyLayout extends LinearLayout {
    ...
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                mLastInterceptX = x;
                mLastInterceptY = y;
                intercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                int dx = x - mLastInterceptX;
                int dy = y - mLastInterceptY;
                if(y <= mCurrHeaderHeight){
                    intercept = false;
                }else if(Math.abs(dx) > Math.abs(dy)){
                    intercept = false;
                }else if(mState == mStateExpand && dy <= - mScaledTouchSlop){
                    //上滑
                    intercept = true;
                }else if(mGiveUpTouchEventListener.giveUpTouchEvent() && dy > mScaledTouchSlop){
                    //下滑
                    intercept = true;
                }else{
                    intercept = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                mLastInterceptX = 0;
                mLastInterceptY = 0;
                intercept = false;
                break;
        }
        return intercept;
    }
    ...
}

上面就是关于事件拦截的核心代码,首先我们看17行:y <= mCurrHeaderHeight 如果触摸事件是在Header之上也就不拦截了,再看19行Math.abs(dx) > Math.abs(dy),如果是横向滑动也不是我们所需要的事件也不拦截,否则上就是上下滑动的事件了,在这个状态下状态Header处于展开状态且是上滑那就需要拦截处理,也就是21行:mState == mStateExpand && dy <= - mScaledTouchSlop所处理的逻辑,在看24行:mGiveUpTouchEventListener.giveUpTouchEvent() && dy > mScaledTouchSlop,giveUpTouchEvent方法表示如果ExpandedListView的第一个可见元素是0且dy > mScaledTouchSlop(表示是上滑)此时的事件也是需要拦截的

View滑动距离常量TouchSlop

在21行细心的同学可能会看到这么一句dy <= - mScaledTouchSlop,dy指的是滑动的距离,mScaledTouchSlop到底是什么?其实他是Android系统给我们提供的View滑动最小距离常量TouchSlop,也就是说两个Move事件之间的滑动距离如果小于这个常量就系统不认为他是滑动,因为滑动距离太短,反之就认为它是滑动,这个常量值和设备有关,不同的设置上这个值可能是不相同的,我们可以通过如下方式即可获取这个常量:

int mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();

折叠展开的事件消费

上面的17到29行就是处理事件拦截的核心处理逻辑,事件拦截完毕,事件就交给TouchEvent方法进行消费了,下面看看Header到底具体是怎么折叠的?其实很简单就是不用重置Header的height就OK了,我们看看代码:

public class StickyLayout extends LinearLayout {
    ...
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                int dx = x - mLastX;
                int dy = y - mLastY;
                mCurrHeaderHeight += dy;
                setHeaderHeight(mCurrHeaderHeight);
                break;
            case MotionEvent.ACTION_UP:
                int dest = 0;
                if(mCurrHeaderHeight <= mOriginHeaderHeight * 0.5){
                    dest = 0;
                    mState = mStateCollapsed;
                }else{
                    dest = mOriginHeaderHeight;
                    mState = mStateExpand;
                }
                smoothSetHeaderHeight(mCurrHeaderHeight,dest,500);
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.onTouchEvent(event);
    }
    ...
}

其中12行到13行就是手指拖动状态下的核心逻辑 ,12行计算两次Move事件所移动的距离,13行根据手指滑动的距离来计算Header当前的高度,计算完毕就可以调用setHeaderHeight设置Header的高了

设置Header的高来实现折叠效果

private void setHeaderHeight(int height) {
        if(height <= 0){
            height = 0;
        }else if(height >= mOriginHeaderHeight){
            height = mOriginHeaderHeight;
        }
        if(height == 0 ){
            mState = mStateCollapsed;
        }else{
            mState = mStateExpand;
        }
        headerView.getLayoutParams().height = height;
        headerView.requestLayout();
    }

其中第2行到第6行对Header高度的越界处理,第7行到11行是设置Header的状态,第12行到13行给Header的高赋值并刷新Header来变它的位置与大小

手指抬起的自动回弹折叠展开效果

如果当前Header的高小于原始高度的一半手指抬起的时候Header进行收缩,反之就进行展开操作,核心代码在上面的onTouchEvent(MotionEvent event)方法的的17行到25行:

  int dest = 0;
  if(mCurrHeaderHeight <= mOriginHeaderHeight * 0.5){
        dest = 0;
        mState = mStateCollapsed;
  }else{
        dest = mOriginHeaderHeight;
        mState = mStateExpand;
   }
  smoothSetHeaderHeight(mCurrHeaderHeight,dest,500);

最后在调用smoothSetHeaderHeight实现弹性展开,折叠

    private void smoothSetHeaderHeight(int from,int to,int duration) {
       ValueAnimator valueAnimator = ValueAnimator.ofInt(from, to).setDuration(duration);
       valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
           @Override
           public void onAnimationUpdate(ValueAnimator animation) {
               setHeaderHeight((Integer) animation.getAnimatedValue());
           }
       });
       valueAnimator.start();
   }

总结

截止目前整个折叠式自定义View就全部讲完了,事件拦截这块的判断逻辑是整个代码的核心,找到了判断折叠、展开的的算法那么其他的问题也就不是什么大问题了,解决滑动冲突问题也是我们在自定义View开发过程中的常见问题,也是难点问题,只要多练习,多思考就能孰能生巧最后我将整个测试代码传到了github上,欢迎学习下载https://github.com/mxdldev/android-custom-view/,其中StickyLayout.java就是我们本例中的自定义View的全部代码实现,下载完整项目后直接运行安装完毕,点击StickyLayout按钮就进入了我们的测试页面,效果图如下:
在这里插入图片描述

问题反馈

在使用学习中有任何问题,请留言,或加入Android、Java开发技术交流群

发布了102 篇原创文章 · 获赞 916 · 访问量 42万+

猜你喜欢

转载自blog.csdn.net/geduo_83/article/details/102792544