Android魔术系列:一步步实现滑动折叠列表

简介: 这个效果是多年前做电商的时候的一个需求,当时是模仿一个叫喵街的app(也不知道现在还在不在了)

前言


这个效果是多年前做电商的时候的一个需求,当时是模仿一个叫喵街的app(也不知道现在还在不在了),实现后效果如下:


网络异常,图片无法展示
|


效果分析


首先我们看静止状态,如图:

网络异常,图片无法展示
|


这时处于顶端展示的item相对于其他item是展开的状态,有几点表现:一是整体高度要高一些;二是无遮罩高亮状态;三是文字内容大一些。这样就达到了一个凸显的效果。

然后我们观察滑动中的状态,如图:


网络异常,图片无法展示
|


当我们向上滑动的时候,可以看到第一个item开始折叠,而第二个item逐渐展开,同时遮罩效果减弱,文字内容逐渐变大。这样就产生了滑动折叠的效果。

而且,为了能让最后的item也可以凸显出来,我们需要在列表的结尾插入一个footer以保证最后的item可以置顶显示,如图:


网络异常,图片无法展示
|


Item布局


效果分析完了,下面我们来看看如何实现。

首先是Item的布局,这里只关注重要的部分,代码如下:


<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
             android:layout_width="match_parent"
             android:layout_height="wrap_content">
    <RelativeLayout
        android:id="@+id/item_content"
        android:layout_width="match_parent"
        android:layout_height="@dimen/scroll_fold_item_height">
        <ImageView
            android:id="@+id/item_img"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="fitXY"/>
        <ImageView
            android:id="@+id/item_img_shade"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:src="#000000"/>
        <LinearLayout
            android:id="@+id/scale_item_content"
            android:layout_width="200dp"
            android:layout_height="wrap_content"
            android:layout_centerHorizontal="true"
            android:orientation="vertical">
            ...
        </LinearLayout>
        ...
    </RelativeLayout>
</FrameLayout>
复制代码


最外层用FrameLayout,这样当FrameLayout高度变小时,item_content可以超出FrameLayout的范围,产生折叠的效果。

item_content的高度是固定不变的,真正改变的是外层的FrameLayout。

scale_item_content中是那些大小可变的文字内容

布局比较简单,后面会讲到如何使用这些layout达到效果。

另外还有一个footer的布局,因为很简单就不贴出代码了。


Adapter


列表是通过RecyclerView来实现的,所以我们先实现Adapter。代码也比较简单,我们挑重点说。

首先是Adapter的两个基本方法的实现:


@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
   if(viewType == 0) {
      View item = LayoutInflater.from(mContext).inflate(R.layout.scroll_fold_list_item, null);
      return new ItemViewHolder(item);
   }
   else{
      View bottom = LayoutInflater.from(mContext).inflate(R.layout.scroll_fold_list_footer, null);
      return new BottomViewHolder(bottom);
   }
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
   holder.initData(position);
}
这里使用viewType来区分普通的item和footer(通过getItemViewType方法)。BottomViewHolder和ItemViewHolder继承同一个类,代码如下:
abstract class ViewHolder extends RecyclerView.ViewHolder{
   View item;
   public ViewHolder(View itemView) {
      super(itemView);
      item = itemView;
   }
   abstract void initData(int position);
}
class BottomViewHolder extends ViewHolder{
   public BottomViewHolder(View itemView) {
      super(itemView);
   }
   @Override
   void initData(int position) {
      ViewGroup.LayoutParams bottomParams = itemView.getLayoutParams();
      if(bottomParams == null){
         bottomParams = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
      }
      bottomParams.height = recyclerView.getHeight() - itemHeight + 10;
      itemView.setLayoutParams(bottomParams);
   }
}
class ItemViewHolder extends ViewHolder{
   View content;
   ImageView image;
   TextView name;
   public ItemViewHolder(View itemView) {
      super(itemView);
      item = itemView;
      content = itemView.findViewById(R.id.item_content);
      image = (ImageView)itemView.findViewById(R.id.item_img);
      name = (TextView) itemView.findViewById(R.id.item_name);
   }
   void initData(int position){
      image.setImageResource(IMGS[position]);
      name.setText(NAMES[position]);
      ViewGroup.LayoutParams params = item.getLayoutParams();
      if(params == null){
         params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
      }
      params.height = itemSmallHeight;
      content.findViewById(R.id.item_img_shade).setAlpha(ITEM_SHADE_DARK_ALPHA);
      item.setLayoutParams(params);
   }
}
复制代码


我们先看BottomViewHolder,动态的设置footer的高度为列表高度减去itemHeight,再加上10像素。这个itemHeight是展开后item的高度,即置顶的item的高度。这里之所以再加上10像素,是因为如果设置高度正好是余下的高度,当快速滑动到底部的时候有

几率会出现问题,所以这里让高度略大于实际展示的高度。

然后来看ItemViewHolder,也是动态的设置高度为ItemSmallHeight,这个高度是收缩后item的高度,而且将遮罩设置为最暗。注意这里全部初始化为收缩状态,没有单独设置一个置顶展开的状态,这个我们后面会解释为什么。


监听滑动


上面我们完成了adapter类,添加给RecyclerView即可。不过想要实现效果,就需要监听RecyclerView的滑动,并做相应的处理,代码如下:


list.addOnScrollListener(new RecyclerView.OnScrollListener() {
   @Override
   public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
      changeItemState();
   }
   @Override
   public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
      ...
   }
});
可以看到在滑动过程(onScrolled)中调用changeItemState()这个函数,代码如下:
private void changeItemState(){
   int firstVisibleIndex = linearLayoutManager.findFirstVisibleItemPosition();
   ViewGroup first = (ViewGroup) linearLayoutManager.findViewByPosition(firstVisibleIndex);
   int firstVisibleOffset = -first.getTop();
   int changeheight = (int) (firstVisibleOffset * (ScrollFoldAdapter.ITEM_CONTENT_TEXT_SCALE - 1));
   // 减少当前展示的第一个item的高度。
   if (first == null) {
      return;
   }
   changeItemHeight(first, itemHeight - changeheight);
   changeItemState(first, ScrollFoldAdapter.ITEM_CONTENT_TEXT_SCALE, ScrollFoldAdapter.ITEM_SHADE_LIGHT_ALPHA);
   // 增大当前展示的第二个item的高度,改变内容大小,改变透明度
   if (firstVisibleIndex + 1 < adapter.getItemCount() - 1) {
      ViewGroup second = (ViewGroup) linearLayoutManager.findViewByPosition(firstVisibleIndex + 1);
      changeItemHeight(second, itemSmallHeight + changeheight);
      float scale = (float) firstVisibleOffset / itemSmallHeight
            * (ScrollFoldAdapter.ITEM_CONTENT_TEXT_SCALE - 1) + 1.0f;
      float alpha = (ScrollFoldAdapter.ITEM_SHADE_DARK_ALPHA - ScrollFoldAdapter.ITEM_SHADE_LIGHT_ALPHA)
            * (1 - (float) firstVisibleOffset / itemSmallHeight)
            + ScrollFoldAdapter.ITEM_SHADE_LIGHT_ALPHA;
      changeItemState(second, scale, alpha);
   }
   /**
    * 由于快速滑动,导致计算及状态有误 所以下面就是消除这种误差,校准状态。具体如下
    * 将第一个item上面(存在的)的和第二个Item下面的都变为收缩的高度,内容缩放到最小,透明度为0。65
    */
   for (int i = 0; i <= linearLayoutManager.findLastVisibleItemPosition(); i++) {
      if (i < adapter.getItemCount() - 1 && i != firstVisibleIndex && i != firstVisibleIndex + 1) {
         ViewGroup item = (ViewGroup) linearLayoutManager.findViewByPosition(i);
         if(item == null){
                   continue;
               }
               changeItemHeight(item, itemSmallHeight);
         float scale = 1;
         float alpha = ScrollFoldAdapter.ITEM_SHADE_DARK_ALPHA;
         changeItemState(item, scale, alpha);
      }
   }
}
复制代码


整体思路如下:

获取当前置顶展示的item,计算该item相对于列表顶端的偏移。这个偏移是关键参数,通过这个偏移计算出第一个item收缩的高度和第二个item展开的高度,并且计算第二个item遮罩的透明度和文字内容的大小。

这里调用了另外两个函数changeItemHeight(view, int)changeItemState(view, float, float)。其中changeItemHeight(view, int)用来改变item的高度实现展开或折叠;而changeItemState(view, float, float)用来改变遮罩透明度和文字内容大小。两个函数代码如下:


/**
 * 改变一个item的高度。
 *
 * @param item
 * @param height
 */
private void changeItemHeight(View item, int height) {
   ViewGroup.LayoutParams itemParams = item.getLayoutParams();
   itemParams.height = height;
   item.setLayoutParams(itemParams);
}
/**
 * 改变一个item的状态,包括透明度,大小等
 * @param item
 * @param scale
 * @param alpha
 */
private void changeItemState(ViewGroup item, float scale, float alpha) {
   if (item.getChildCount() > 0) {
      View changeView = item.findViewById(R.id.scale_item_content);
      changeView.setScaleX(scale);
      changeView.setScaleY(scale);
      View shade = item.findViewById(R.id.item_img_shade);
      shade.setAlpha(alpha);
   }
}
复制代码


改变高度很简单,没必要解释了。改变遮罩透明度就是改变其alpha,而文字内容大小的改变则是利用setScaleX和setScaleY两个函数,实际上是将scale_item_content这个layout整个进行缩放,其内容就会随着变大/小。

回到changeItemState()函数,改变了第一个和第二个item后,可以看到又将其他的item置为收缩状态。这是因为快速滑动会造成某些item处于中间的状态,做这一步操作就是校正快速滑动导致的一些问题。

上面我们提到过,所有的item都初始化成收缩状态了。其实当RecyclerView添加到屏幕上时,是一定会产生滑动的。所以我们进入页面的时候,我们什么都没有操作,滑动监听的函数却被调用了。这样通过changeItemState()函数就可以将置顶的item变为展开状态,所以初始的展示状态是正确的。


回弹效果


以上是滑动的时候的处理,然而这样还不够。当滑动停止的时候,有可能第一个item正处于显示一半的状态,这样第二个item也没有完全展开,显示效果不好。

所以我们还需要实现一个回弹效果,当滑动停止的时候,让列表自动调整到某一个item正好置顶的状态。

这部分的处理在滑动监听的onScrollStateChanged中,代码如下:


list.addOnScrollListener(new RecyclerView.OnScrollListener() {
   @Override
   public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
      changeItemState();
   }
   @Override
   public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
      if (newState == RecyclerView.SCROLL_STATE_IDLE) {
         int firstVisibleIndex = linearLayoutManager.findFirstVisibleItemPosition();
         View first = linearLayoutManager.findViewByPosition(firstVisibleIndex);
         int firstVisibleOffset = -first.getTop();
         if (firstVisibleOffset == 0) {
            return;
         }
         if (firstVisibleOffset < itemSmallHeight / 2) {
            list.scrollBy(0, -firstVisibleOffset);
         } else {
            list.scrollBy(0, itemSmallHeight - firstVisibleOffset);
         }
         changeItemState();
      }
   }
});
复制代码


上面是完整的滑动监听的代码。在onScrollStateChanged中,判断状态是否是滑动结束(SCROLL_STATE_IDLE)。如果滑动结束,判断顶部显示的item的偏移,根据偏移的大小选择回弹方向。如果偏移很小(第一个item大部分内容显示出来了),则下滚至第一个item置顶的状态;否则上滚至第二个item置顶的状态。

这样保证了静止状态下一定有一个item完全置顶高亮显示。

最后又调用了changeItemState函数,主要目的是校正一些误差。


总结一下


整个效果中其实没有太多难点,主要是考察了对RecyclerView滑动的理解。目前这个版本在快滑时还有一个小问题。

除了RecyclerView这个版本,实际上这个效果还有一个ScrollView的版本。其实在ListView和RecyclerView上实现这个效果都多少有些问题。所以我早期自己实现了能够复用和回收的ScrollView,利用这个自定义的ScrollView实现了这个效果,并且为其自定义了scroller使其回弹有了动画效果。ScrollView版本目前未发现任何问题,但是由于很多功能要自己实现,整体代码比较复杂,就选用了RecyclerView这个版本来给大家讲解。大家有兴趣可以去github上的项目中,切到tag v1.0就可以看到了ScrollView版本的代码了。


目录
相关文章
|
5月前
|
存储 数据库 Android开发
安卓Jetpack Compose+Kotlin,支持从本地添加音频文件到播放列表,支持删除,使用ExoPlayer播放音乐
为了在UI界面添加用于添加和删除本地音乐文件的按钮,以及相关的播放功能,你需要实现以下几个步骤: 1. **集成用户选择本地音乐**:允许用户从设备中选择音乐文件。 2. **创建UI按钮**:在界面中创建添加和删除按钮。 3. **数据库功能**:使用Room数据库来存储音频文件信息。 4. **更新ViewModel**:处理添加、删除和播放音频文件的逻辑。 5. **UI实现**:在UI层支持添加、删除音乐以及播放功能。
|
3月前
|
Android开发
Android使用ViewPager做无限轮播,人为滑动时停止
Android使用ViewPager做无限轮播,人为滑动时停止
74 2
|
3月前
|
Android开发 开发者 UED
Android项目架构设计问题之加载数据到列表如何解决
Android项目架构设计问题之加载数据到列表如何解决
32 0
|
4月前
|
Android开发
Android仿高德首页三段式滑动
Android仿高德首页三段式滑动
131 0
|
5月前
|
存储 API Android开发
29. 【Android教程】折叠列表 ExpandableListView
29. 【Android教程】折叠列表 ExpandableListView
476 2
|
5月前
|
前端开发 API Android开发
25. 【Android教程】列表控件 ListView
25. 【Android教程】列表控件 ListView
156 2
|
5月前
|
安全 Java API
Android获取Wi-Fi网络列表
【6月更文挑战第21天】
|
5月前
|
编解码 Android开发
Android 解决TextView多行滑动与NestedScrollView嵌套滑动冲突的问题
Android 解决TextView多行滑动与NestedScrollView嵌套滑动冲突的问题
86 0
|
6月前
|
Android开发
Android获取蓝牙设备列表的方法
Android获取蓝牙设备列表的方法
522 5
|
6月前
|
Android开发
Android 获取 USB设备列表
Android 获取 USB设备列表 【5月更文挑战第6天】
184 4