Android UI设计系列之自定义ListView仿QQ空间阻尼下拉刷新和渐变菜单栏效果(8)
作者:llew2011 时间:2023-05-23 14:57:04
好久没有写有关UI的博客了,刚刚翻了一下之前的博客,最近一篇有关UI的博客:Android UI设计系列之自定义Dialog实现各种风格的对话框效果(7) ,实现各种风格效果的对话框,在那篇博客写完后由于公司封闭开发封网以及其它原因致使博客中断至今,中断这么久很是惭愧,后续我会尽量把该写的都补充出来。近来项目有个需求,要做个和QQ空间类似的菜单栏透明度渐变和下拉刷新带有阻尼回弹的效果。于是花点时间动手试了试,基本上达到了QQ空间的效果,截图如下:
通过观察QQ空间的运行效果,发现当往上滚动时菜单栏会随着滚动距离的增大其透明度组件增大直到完全不透明,反之逐渐透明。当滚动到顶部后继续下拉会出现拉升效果当松手之后出现阻尼回弹效果。于是就通过重写ListView模仿了QQ空间的运行效果。
实现QQ空间运行效果前需要考虑两个问题:
1)、如何实现菜单栏透明度渐变
通过观察QQ空间的运行效果可知其菜单栏的透明度是根据滚动距离而动态变化的,要想实现透明度的变化就需要知道的ListView的滚动距离,所以有关透明度的问题也就转化成了滚动距离的问题。
2)、如何实现阻尼拉升和回弹效果
要想利用ListView实现阻尼效果就要求ListView首先滚动到了顶部,当ListView滚动到了顶部之后若继续手动下滑就要求其第一个Child变化来模拟下拉效果,当手指松开后该Child要回弹到初始状态。
我们先看第一个问题:要想实现透明度渐变就要先获取到ListView的滚动距离,通过滚动距离来计算相应的透明度。由于ListView的复用机制就决定了不能通过第一个可见Item的getTop()方法来得到滚动值,所以我们可以通过HeaderView来获取滚动距离,因为Header在ListView中是不参与复用的。
下面先了解一下ListView添加HeaderView后的滚动流程:
上图大致画了ListView含有HeaderView时的三个滚动状态,状态一可称为初始状态或者是恰好滚动到最顶部状态,此时HeaderView的getTop()值为0;状态二为ListView的滚动中状态,此时HeaderView没有完全滚动出ListView边界,getTop()的返回值为负数且其绝对值范围在0和HeaderView的高度之间;状态三表示的是HeaderView完全滚动出了ListView边界,若调用getTop()得到的返回值为负数且绝对值等于HeaderView的高度(此后可理解成HeaderView一直固定在ListView的顶部)。
明白了ListView的滚动原理,我们先尝试实现渐变菜单栏的功能。首先定义自己的ListView,取名为FlexibleListView,单词flexible是灵活的、多样的的意思,因为我们的ListView不仅要实现菜单栏的透明度渐变还要实现阻尼效果,所以取名为FlexibleListView比较恰当。FlexibleListView继承ListView后需要实现其构造方法,代码如下:
public class FlexibleListView extends ListView {
public FlexibleListView(Context context) {
super(context);
}
public FlexibleListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public FlexibleListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@TargetApi(21)
public FlexibleListView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
}
FlexibleListView仅仅是继承了ListView,这本质上和ListView没有区别。既然我们是通过给ListView添加HeaderView的方式来判断滚动距离,那就要获取到HeaderView对象。怎么获取到HeaderView对象呢?这里有个技巧,由于给ListView添加HeaderView最终是调用ListView的addHeaderView(View v, Object data, boolean isSelected)方法,所以我们可以重写该方法,取到添加进来的第一个HeaderView,那怎么判断是第一个添加进来的HeaderView呢?因为HeaderView的添加是有序的即先添加的先绘制。所以可以定义一个代表第一个HeaderView的属性mHeaderView,当调用到addHeaderView()方法时通过判断mHeaderView的值是否为空,如果为空就赋值否则不赋值,代码如下:
public class FlexibleListView extends ListView {
private View mHeaderView;
private int mMaxScrollHeight;
public FlexibleListView(Context context) {
super(context);
}
public FlexibleListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public FlexibleListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@TargetApi(21)
public FlexibleListView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public void addHeaderView(View v, Object data, boolean isSelectable) {
super.addHeaderView(v, data, isSelectable);
if(null == mHeaderView) {
mHeaderView = v;
mMaxScrollHeight = mHeaderView.getLayoutParams().height;
}
}
}
FlexibleListView中定义了mHeaderView和mMaxScrollHeight属性,在addHeaderView()方法中对mHeaderView做非空判断来获取到第一个HeaderView并赋值给mHeadereView,mMaxScrollHeight表示HeaderView的最大滚动距离,当HeaderView的滚动距离超过此值我们就要设置菜单栏不透明否则就更改透明度。在这里我直接使用了HeaderView的高度来表示其允许滚动的最大距离。
现在可以获取到ListView的第一个HeaderView,接下来就是判断ListView的滚动了,这时候有的童靴可能会想到采用给ListView添加ScrollListener的方式,这种方式是可行的,但我们这次不采用添加Listener的方式,如果你对ListView的源码比较熟悉的话就清楚触发OnItemScrollListener的回调时机是在AbsListView的invokeOnItemScrollListener()方法中,该方法源码如下:
/**
* Notify our scroll listener (if there is one) of a change in scroll state
*/
void invokeOnItemScrollListener() {
if (mFastScroll != null) {
mFastScroll.onScroll(mFirstPosition, getChildCount(), mItemCount);
}
if (mOnScrollListener != null) {
mOnScrollListener.onScroll(this, mFirstPosition, getChildCount(), mItemCount);
}
onScrollChanged(0, 0, 0, 0); // dummy values, View's implementation does not use these.
}
invokeOnItemScrollListener()方法就是触发滚动回调的,无论我们给不给ListView设置OnItemScrollListener那该方法都会调用,细心的同学可能发现在该方法最后调用了View的onScrollChanged()方法,这时候你恍然大悟,我们可以重写该方法呀,当ListView发生滚动了也就调用了onScrollChange()方法,多省事呀。呵呵,恭喜你,答对了,我们今天就是采用重写onScrollChanged()方法并在该方法中通过判断ListView的HeaderView的滚动距离来设置菜单栏的透明度的。
现在我们清楚了ListView的滚动时机,也有了HeaderView和最大滚动距离,接下来就是分析实现渐变的条件了:要实现渐变我们就要清楚是谁要渐变,在我们的APP中可能是ActionBar,也可能是ToolBar,还有可能是我们自定义的一个ViewGroup来模拟的ActionBar,所以FlexibleListView得有个代表ActionBar的mActionBar属性并对外提供一个方法bindActionBar(),该方法就表示把需要实现渐变的ActionBar传递进来,代码如下:
public class FlexibleListView extends ListView {
private View mActionBar;
private View mHeaderView;
private int mMaxScrollHeight;
private Drawable mActionBarBackground;
public FlexibleListView(Context context) {
super(context);
}
public FlexibleListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public FlexibleListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@TargetApi(21)
public FlexibleListView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if(null != mActionBarBackground) {
mActionBarBackground.setAlpha(evaluateAlpha(Math.abs(mHeaderView.getTop())));
}
}
@Override
public void addHeaderView(View v, Object data, boolean isSelectable) {
super.addHeaderView(v, data, isSelectable);
if(null == mHeaderView) {
mHeaderView = v;
mMaxScrollHeight = mHeaderView.getLayoutParams().height;
}
}
private int evaluateAlpha(int t) {
if (t >= mMaxScrollHeight) {
return 255;
}
return (int) (255 * t /(float) mMaxScrollHeight);
}
public void bindActionBar(View actionBar) {
if(null != actionBar) {
mActionBar = actionBar;
mActionBarBackground = actionBar.getBackground();
if(null == mActionBarBackground) {
mActionBarBackground = new ColorDrawable(Color.TRANSPARENT);
}
mActionBarBackground.setAlpha(0);
if(Build.VERSION.SDK_INT >= 16) {
mActionBar.setBackground(mActionBarBackground);
} else {
mActionBar.setBackgroundDrawable(mActionBarBackground);
}
}
}
public void bindActionBar(ActionBar actionBar) {
if(null != actionBar) {
// TODO impl with ActionBar
// actionBar.setBackgroundDrawable();
}
}
}
FlexibleListView新增了mActionBar和mActionBarBackground属性,mActionBar代表需要渐变的菜单栏,mActionBarBackground为菜单栏的背景。其次对外提供了重载方法bindActionBar(),参数为ActionBar的方法是空实现,里边添加了TODO提示符并给了setBackgroundDrawable()提示(注意ActionBar实现渐变需要设置WindowFeature),希望童靴们自己可以实现出来。
FlexibleListView中重写了onScrollChanged()方法,在该方法中通过获取mHeaderView的getTop()值然后调用evaluateAlpha()方法计算出alpha值,evaluateAlpha()的计算很简单,当滚动值超过了最大滚动距离mMaxScrollHeight就返回255(255表示不透明,0表示透明),否则计算出当前滚动值所对应的alpha值,最后通过调用mActionBarBackground的setAlpha()来达到mActionBar的透明度变化。
现在实现菜单栏的透明度的逻辑准备就绪了,我们先测试一下看看,定义菜单栏布局,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/action_bar_height"
android:background="#aabbcc"
android:clickable="true"
android:orientation="vertical"
android:paddingLeft="10dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:drawableLeft="@mipmap/back"
android:text="动态"
android:textColor="#b8e7fe"
android:textSize="17sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="好友动态"
android:textColor="#b8e7fe"
android:textSize="17sp" />
</FrameLayout>
菜单栏包含一个返回按钮和一个标题,并且给菜单栏设置了固定高度和背景色,然后布局我们的activity_main.xml文件,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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">
<com.llew.wb.git.qqzone.FlexibleListView
android:id="@+id/flexible_list_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="none"></com.llew.wb.git.qqzone.FlexibleListView>
<include
android:id="@+id/custom_action_bar"
layout="@layout/action_bar_layout"/>
</FrameLayout>
activity_main.xml的布局文件很简单,采用FrameLayout根布局让菜单栏悬浮在FlexibleListView上边,然后编写我们的MainActivity代码,如下所示:
public class MainActivity extends AppCompatActivity {
private FlexibleListView mListView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initGlobalParams();
}
private void initGlobalParams() {
mListView = (FlexibleListView) findViewById(R.id.flexible_list_view);
View mFlexibleHeaderView = new View(getApplicationContext());
mFlexibleHeaderView.setBackgroundColor(Color.parseColor("#bbaacc"));
int height = getResources().getDimensionPixelSize(R.dimen.header_height);
LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, height);
mFlexibleHeaderView.setLayoutParams(params);
final View actionBar = findViewById(R.id.custom_action_bar);
mListView.bindActionBar(actionBar);
mListView.addHeaderView(mFlexibleHeaderView);
mListView.setAdapter(new Adapter());
}
static class Adapter extends BaseAdapter {
@Override
public int getCount() {
return 80;
}
@Override
public Object getItem(int position) {
return null;
}
@Override
public long getItemId(int position) {
return 0;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
TextView textView = new TextView(parent.getContext());
textView.setPadding(50, 50, 50, 50);
textView.setText(position + 10 + "");
return textView;
}
}
}
在MainActivity中给FlexibleListView添加了一个固定高度背景色为"#bbaacc"的Header,并把悬浮菜单栏actionBar赋值给了FlexibleListView的mActionBar,最后设置Adapter,为了测试代码写的很简单,我们运行一下程序,看看效果:
看到运行效果好开心呀,(*^__^*) ……透明度渐变功能达到了我们的预期,接下来开始实现阻尼效果,阻尼效果就是当ListView滚动到了顶部此时若继续下滑,ListView能够继续往下滚动一段距离当手指离开屏幕后ListView要恢复原位置。为了实现这个功能有的童靴可能会想到重写有关事件传递的onXXXEvent()等方法,之后在MotionEvent为DOWN,MOVE,UP或者CANCEL条件下分别做逻辑判断来实现阻尼效果,此方式可行,但是和今天我们的实现相比起来复杂了许多....
这里所实现阻尼效果所采用的方法是利用View的overScrollBy()方法,有的童靴可能会问overScrollBy()方法是2.3版本之后才增加的,2.3版本之前的兼容性怎么办?我实现这个功能之前也考虑过这个问题,一方面我们公司的APP只支持3.0以上版本,另一方面2.3及以前的版本市场占有率几乎微乎其微了,所以可以考虑不再兼容2.3以前的老版本。
有的同学或许对overScrollBy()方法比较陌生,先大致说一下该方法,其源码如下:
/**
* Scroll the view with standard behavior for scrolling beyond the normal
* content boundaries. Views that call this method should override
* {@link #onOverScrolled(int, int, boolean, boolean)} to respond to the
* results of an over-scroll operation.
*
* Views can use this method to handle any touch or fling-based scrolling.
*
* @param deltaX Change in X in pixels
* @param deltaY Change in Y in pixels
* @param scrollX Current X scroll value in pixels before applying deltaX
* @param scrollY Current Y scroll value in pixels before applying deltaY
* @param scrollRangeX Maximum content scroll range along the X axis
* @param scrollRangeY Maximum content scroll range along the Y axis
* @param maxOverScrollX Number of pixels to overscroll by in either direction
* along the X axis.
* @param maxOverScrollY Number of pixels to overscroll by in either direction
* along the Y axis.
* @param isTouchEvent true if this scroll operation is the result of a touch event.
* @return true if scrolling was clamped to an over-scroll boundary along either
* axis, false otherwise.
*/
@SuppressWarnings({"UnusedParameters"})
protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY,
int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) {
// ......
}
阅读源码看注释很重要,我们先看一 * 释,大致意思如下:
当View组件滚动到边界时还会继续进行之前的滚动操作(注意:没有滚动到边界时是不会触发该方法的),如果View组件调用了该方法那么View组件就应该重写onOverScrolled()方法来响应over-scroll操作。View控件可以调用该方法处理任何的触摸滚动或者是快速滑动等。感觉翻译的好别扭,说的直白点就是当ListView,ScrollView等滚动到头了若继续下滑就会调用该方法。
overScrollBy()方法有9个参数,每个参数注释都说的很详细,我们只看需要用到的俩参数deltaY和isTouchEvent;deltaY表示的是在Y轴上滚动的相对值,比如ListView滚动到了顶部此时如果继续下拉,deltaY值为负数,当其滚动到了最底部当我们继续上拉,deltaY值为正数,所以我们可以根据deltaY判断ListView是上拉操作还是下拉操作,isTouchEvent为true表示手指在触摸屏幕否则离开屏幕。
了解overScrollBy()方法后开始实现阻尼效果,核心就是重写overScrollBy()方法,在该方法中动态改变HeaderView的高度,若手指松开我们就复原HeaderView。我们知道QQ空间顶部是一张图片,当下拉的时候该图片有弹性拉升效果,当手指松开后图片又伸缩回去了,所以我们就直接用ImageView模拟此效果。模拟图片阻尼可以让ImageView的宽高为MATCH_PARENT(HeaderView的高度改变之后ImageView的高度也可以随之更改),这个时候还要设置ImageView的scaleType为CENTER_CROP(不清楚ImageView的scaleType属性可参照我之前写的一篇博文:Android 源码系列之<一>从源码的角度深入理解ImageView的ScaleType属性)。
现在开始在FlexibleListView中重写overScrollBy()方法,代码如下:
@Override
protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) {
if(null != mHeaderView) {
if(isTouchEvent && deltaY < 0) {
mHeaderView.getLayoutParams().height += Math.abs(deltaY / 3.0);
mHeaderView.requestLayout();
}
}
return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent);
}
overScrollBy()方法中我们根据deltaY值动态的更改了mHeaderView的高度并重新布局达到更改ImageView高度的目的,注意:计算高度的时候用了deltaY除以3,此时的3表示增长因子,目的是让HeaderView缓慢的增长,这里可以对外提供一个方法来设置此值。
现在仅实现了HeaderView的拉升功能,但是还没有实现缩放功能,因为overScrollBay()中实现的是手指触摸的下拉,当手指离开屏幕后要进行HeaderView的复原操作,所以我们可以在考虑在onTouchEvent()方法中判断MotionEvent的类型,当为UP或者CANCEL时就复原HeaderView,复原HeaderView不能一下子复原而是要用动画的方式,这样看上去才比较自然,所以onTouchEvent()代码如下:
@Override
public boolean onTouchEvent(MotionEvent ev) {
if(null != mHeaderView) {
int action = ev.getAction();
if(MotionEvent.ACTION_UP == action || MotionEvent.ACTION_CANCEL == action) {
resetHeaderViewHeight();
}
}
return super.onTouchEvent(ev);
}
private void resetHeaderViewHeight() {
ValueAnimator valueAnimator = ValueAnimator.ofInt(1);
valueAnimator.setDuration(700);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
final float f = animation.getAnimatedFraction();
mHeaderView.getLayoutParams().height -= f * (mHeaderView.getLayoutParams().height - mMaxScrollHeight);
mHeaderView.requestLayout();
}
});
valueAnimator.setInterpolator(new OvershootInterpolator());
valueAnimator.start();
}
HeaderView的复原动画我们采用了ValueAnimator,当动画执行过程中我们动态的更改HeaderView的值来达到渐变效果。接下来布局HeaderView来模拟QQ空间,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/header_height">
<ImageView
android:id="@+id/iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:src="@mipmap/ttt" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_gravity="bottom"
android:background="#33333333"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="相册"
android:gravity="center"
android:textColor="@android:color/white" />
<View
android:layout_width="1dp"
android:layout_height="20dp"
android:background="#ffffff" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="说说"
android:gravity="center"
android:textColor="@android:color/white" />
<View
android:layout_width="1dp"
android:layout_height="20dp"
android:background="#ffffff" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="个性化"
android:gravity="center"
android:textColor="@android:color/white" />
<View
android:layout_width="1dp"
android:layout_height="20dp"
android:background="#ffffff" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="\@ 与我相关"
android:gravity="center"
android:textColor="@android:color/white" />
</LinearLayout>
</FrameLayout>
HeaderView的布局中让ImageView的宽高都设置成了match_parent并且把scaleType设置为centerCrop。修改MainActivity的initGlobalParams()方法,代码如下:
void initGlobalParams() {
mListView = (FlexibleListView) findViewById(R.id.flexible_list_view);
View mFlexibleHeaderView = LayoutInflater.from(this).inflate(R.layout.flexible_header_layout, mListView, false);
AbsListView.LayoutParams params = (AbsListView.LayoutParams)mFlexibleHeaderView.getLayoutParams();
if(null == params) {
params = new AbsListView.LayoutParams(AbsListView.LayoutParams.MATCH_PARENT, AbsListView.LayoutParams.WRAP_CONTENT);
}
params.height = getResources().getDimensionPixelSize(R.dimen.header_height);
mFlexibleHeaderView.setLayoutParams(params);
final View actionBar = findViewById(R.id.custom_action_bar);
mListView.bindActionBar(actionBar);
mListView.addHeaderView(mFlexibleHeaderView);
mListView.setAdapter(new Adapter());
}
OK,一切都准备就绪,赶紧运行一下程序,看看效果吧(*^__^*) ……
恩,看上去效果还不错......