Android魔术(第五弹)—— 一步步实现滑动折叠列表
16lz
2021-01-23
Android魔法系列: http://blog.csdn.net/chzphoenix/article/details/77962259
项目的github地址: FastWidget4Android 很多炫酷的自定义效果,欢迎fork和star!
这时处于顶端展示的item相对于其他item是展开的状态,有几点表现:一是整体高度要高一些;二是无遮罩高亮状态;三是文字内容大一些。这样就达到了一个凸显的效果。
然后我们观察滑动中的状态,如图:
当我们向上滑动的时候,可以看到第一个item开始折叠,而第二个item逐渐展开,同时遮罩效果减弱,文字内容逐渐变大。这样就产生了滑动折叠的效果。
而且,为了能让最后的item也可以凸显出来,我们需要在列表的结尾插入一个footer以保证最后的item可以置顶显示,如图:
xmlns: android = "http://schemas.android.com/apk/res/android"
android :layout_width= "match_parent"
android :layout_height= "wrap_content" >
android :id= "@+id/item_content"
android :layout_width= "match_parent"
android :layout_height= "@dimen/scroll_fold_item_height" >
android :id= "@+id/item_img"
android :layout_width= "match_parent"
android :layout_height= "match_parent"
android :scaleType= "fitXY" />
android :id= "@+id/item_img_shade"
android :layout_width= "match_parent"
android :layout_height= "match_parent"
android :src= "#000000" />
android :id= "@+id/scale_item_content"
android :layout_width= "200dp"
android :layout_height= "wrap_content"
android :layout_centerHorizontal= "true"
android :orientation= "vertical" >
...
...
最外层用FrameLayout,这样当 FrameLayout高度变小时,item_content可以超出 FrameLayout的范围,产生折叠的效果。 item_content的高度是固定不变的,真正改变的是外层的 FrameLayout。 scale_item_content中是那些大小可变的文字内容 布局比较简单,后面会讲到如何使用这些layout达到效果。
另外还有一个footer的布局,因为很简单就不贴出代码了。
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的高度,而且将遮罩设置为最暗。注意这里全部初始化为收缩状态,没有单独设置一个置顶展开的状态,这个我们后面会解释为什么。
@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变为展开状态,所以初始的展示状态是正确的。
@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函数,主要目的是校正一些误差。
Android魔法系列: http://blog.csdn.net/chzphoenix/article/details/77962259
项目的github地址: FastWidget4Android 很多炫酷的自定义效果,欢迎fork和star!
1、效果展示
这个效果是一年多前完成的,是模仿了当时喵街app的首页的效果,现在整理出来可能有些过时了,不过一些知识点和思路还是很有帮助的。实现后效果如下:2、效果分析
首先我们看静止状态,如图:这时处于顶端展示的item相对于其他item是展开的状态,有几点表现:一是整体高度要高一些;二是无遮罩高亮状态;三是文字内容大一些。这样就达到了一个凸显的效果。
然后我们观察滑动中的状态,如图:
当我们向上滑动的时候,可以看到第一个item开始折叠,而第二个item逐渐展开,同时遮罩效果减弱,文字内容逐渐变大。这样就产生了滑动折叠的效果。
而且,为了能让最后的item也可以凸显出来,我们需要在列表的结尾插入一个footer以保证最后的item可以置顶显示,如图:
3、Item布局
效果分析完了,下面我们来看看如何实现。 首先是Item的布局,这里只关注重要的部分,代码如下:android :layout_width= "match_parent"
android :layout_height= "wrap_content" >
android :layout_width= "match_parent"
android :layout_height= "@dimen/scroll_fold_item_height" >
android :layout_width= "match_parent"
android :layout_height= "match_parent"
android :scaleType= "fitXY" />
android :layout_width= "match_parent"
android :layout_height= "match_parent"
android :src= "#000000" />
android :layout_width= "200dp"
android :layout_height= "wrap_content"
android :layout_centerHorizontal= "true"
android :orientation= "vertical" >
...
...
另外还有一个footer的布局,因为很简单就不贴出代码了。
3、实现Adapter
列表是通过RecyclerView来实现的,所以我们先实现Adapter。代码也比较简单,我们挑重点说。 首先是Adapter的两个基本方法的实现: @Overridepublic 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的高度,而且将遮罩设置为最暗。注意这里全部初始化为收缩状态,没有单独设置一个置顶展开的状态,这个我们后面会解释为什么。
4、监听滑动
上面我们完成了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变为展开状态,所以初始的展示状态是正确的。
5、回弹效果
以上是滑动的时候的处理,然而这样还不够。当滑动停止的时候,有可能第一个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函数,主要目的是校正一些误差。
6、总结一下
整个效果中其实没有太多难点,主要是考察了对RecyclerView滑动的理解。目前这个版本在快滑时还有一个小问题。 除了RecyclerView这个版本,实际上这个效果还有一个ScrollView的版本。其实在ListView和RecyclerView上实现这个效果都多少有些问题。所以我早期自己实现了能够复用和回收的 ScrollView,利用这个自定义的 ScrollView实现了这个效果,并且为其自定义了scroller使其回弹有了动画效果。 ScrollView版本目前未发现任何问题,但是由于很多功能要自己实现,整体代码比较复杂,就选用了 RecyclerView这个版本来给大家讲解。大家有兴趣可以去github上的项目中,切到tag v1.0就可以看到了。项目的github地址:
FastWidget4Android 很多炫酷的自定义效果,欢迎fork和star!Android魔法系列: http://blog.csdn.net/chzphoenix/article/details/77962259
联系我
更多相关文章
- android变色状态栏
- Android 动画分析之翻转效果
- Android 监听U盘OTG挂载状态
- Android 广播监听应用APK卸载、覆盖、安装的状态
- 如何在Android中实现全屏,去掉标题栏效果
- Android实现自定义滑动式抽屉效果菜单
- Android设置沉浸式状态栏时改变状态栏的颜色(只对MIUI V6可用)