转载请注明出处:http://blog.csdn.net/singwhatiwanna/article/details/25546871(来自singwhatiwanna的csdn博客)
前言
Android中,大家都用过ListView,ExpandableListView等,也许你还用过PinnedHeaderListView,但是如果我说PinnedHeaderExpandableListView,你听过吗?还有可下拉的PinnedHeaderExpandableListView呢?没听过也不要紧,本文就是介绍这个东西的,为了让大家有更直观的了解,先上效果图。通过效果图可以看出,首先它是一个ExpandableListView,但是它的头部可以固定,其次,在它的上面还有一个头部可以来回伸缩,恩,这就是本文要介绍的自定义view。为了提高复用性,这个效果我分成来了2个view来实现,第一个是PinnedHeaderExpandableListView来实现头部固定的ExpandableListView,第二个view是StickyLayout,这个view具有一个可以上下滑动的头部,最后将这2个view组合在一起,就达到了如下的效果。
PinnedHeaderExpandableListView的实现
关于ExpandableListView的使用方法请自己了解下,网上很多。关于这个view,它的实现方式是这样的:
首先继承自ExpandableListView,然后再它滚动的时候我们要监听顶部的item是属于哪个group的,当知道是哪个group以后,我们就在view的顶部绘制这个group,这样就完成了头部固定这个效果。当然过程远没有我描述的这个简单,期间有一些问题需要正确处理,下面分别说明:
1.如何知道顶部的item是哪个group,这个简单,略过;
2. 如何在顶部绘制group,这个我们可以重写dispatchDraw这个方法,在这个方法里drawChild即可,dispatchDraw是被draw方法用来绘制子元素的,和onDraw不同,onDraw是用来绘制自己的,我们要知道,view绘图的过程是先背景再自己最后在绘制子元素;
3. 滑动过程中header的更新,当滑动的时候,要去判断最上面的group是否发生改变,如果改变了就需要重新绘制group,这个很简单。注意到有一个效果,就是当两个group接近的时候,下面的group会把上面的header推上去,这个效果就难处理一些,推动的效果可以用layout来实现,通过layout将上面的group的位置给改变就可以了;
4.header的点击,要知道固定的头部是绘制上去的,并且它也不是ExpandableListView的子元素,可以理解为我们凭空绘制的一个view,如果处理它的点击,这个貌似很难,但是可以这么解决,当点击事件发生的时候,判断其区域是否落在header内部,如果落在了内部将可以处理点击事件了,处理后要讲事件消耗掉;
同时,我还提供了一个接口,OnHeaderUpdateListener,通过实现这个接口,PinnedHeaderExpandableListView就知道如何绘制和更新header了。下面看代码:
- /**
- The MIT License (MIT)
- Copyright (c) 2014 singwhatiwanna
- https://github.com/singwhatiwanna
- http://blog.csdn.net/singwhatiwanna
- Permission is hereby granted, free of charge, to any person obtaining a copy
- of this software and associated documentation files (the "Software"), to deal
- in the Software without restriction, including without limitation the rights
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- copies of the Software, and to permit persons to whom the Software is
- furnished to do so, subject to the following conditions:
- The above copyright notice and this permission notice shall be included in all
- copies or substantial portions of the Software.
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- SOFTWARE.
- */
- package com.ryg.expandable.ui;
- import android.content.Context;
- import android.graphics.Canvas;
- import android.util.AttributeSet;
- import android.util.Log;
- import android.view.MotionEvent;
- import android.view.View;
- import android.widget.AbsListView;
- import android.widget.ExpandableListView;
- import android.widget.AbsListView.OnScrollListener;
- public class PinnedHeaderExpandableListView extends ExpandableListView implements OnScrollListener {
- private static final String TAG = "PinnedHeaderExpandableListView";
- public interface OnHeaderUpdateListener {
- /**
- * 采用单例模式返回同一个view对象即可
- * 注意:view必须要有LayoutParams
- */
- public View getPinnedHeader();
- public void updatePinnedHeader(int firstVisibleGroupPos);
- }
- private View mHeaderView;
- private int mHeaderWidth;
- private int mHeaderHeight;
- private OnScrollListener mScrollListener;
- private OnHeaderUpdateListener mHeaderUpdateListener;
- private boolean mActionDownHappened = false;
- public PinnedHeaderExpandableListView(Context context) {
- super(context);
- initView();
- }
- public PinnedHeaderExpandableListView(Context context, AttributeSet attrs) {
- super(context, attrs);
- initView();
- }
- public PinnedHeaderExpandableListView(Context context, AttributeSet attrs, int defStyle) {
- super(context, attrs, defStyle);
- initView();
- }
- private void initView() {
- setFadingEdgeLength(0);
- setOnScrollListener(this);
- }
- @Override
- public void setOnScrollListener(OnScrollListener l) {
- if (l != this) {
- mScrollListener = l;
- }
- super.setOnScrollListener(this);
- }
- public void setOnHeaderUpdateListener(OnHeaderUpdateListener listener) {
- mHeaderUpdateListener = listener;
- if (listener == null) {
- return;
- }
- mHeaderView = listener.getPinnedHeader();
- int firstVisiblePos = getFirstVisiblePosition();
- int firstVisibleGroupPos = getPackedPositionGroup(getExpandableListPosition(firstVisiblePos));
- listener.updatePinnedHeader(firstVisibleGroupPos);
- requestLayout();
- postInvalidate();
- }
- @Override
- protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
- super.onMeasure(widthMeasureSpec, heightMeasureSpec);
- if (mHeaderView == null) {
- return;
- }
- measureChild(mHeaderView, widthMeasureSpec, heightMeasureSpec);
- mHeaderWidth = mHeaderView.getMeasuredWidth();
- mHeaderHeight = mHeaderView.getMeasuredHeight();
- }
- @Override
- protected void onLayout(boolean changed, int l, int t, int r, int b) {
- super.onLayout(changed, l, t, r, b);
- if (mHeaderView == null) {
- return;
- }
- mHeaderView.layout(0, 0, mHeaderWidth, mHeaderHeight);
- }
- @Override
- protected void dispatchDraw(Canvas canvas) {
- super.dispatchDraw(canvas);
- if (mHeaderView != null) {
- drawChild(canvas, mHeaderView, getDrawingTime());
- }
- }
- @Override
- public boolean dispatchTouchEvent(MotionEvent ev) {
- int x = (int) ev.getX();
- int y = (int) ev.getY();
- Log.d(TAG, "dispatchTouchEvent");
- int pos = pointToPosition(x, y);
- if (y >= mHeaderView.getTop() && y <= mHeaderView.getBottom()) {
- if (ev.getAction() == MotionEvent.ACTION_DOWN) {
- mActionDownHappened = true;
- } else if (ev.getAction() == MotionEvent.ACTION_UP) {
- int groupPosition = getPackedPositionGroup(getExpandableListPosition(pos));
- if (groupPosition != INVALID_POSITION && mActionDownHappened) {
- if (isGroupExpanded(groupPosition)) {
- collapseGroup(groupPosition);
- } else {
- expandGroup(groupPosition);
- }
- mActionDownHappened = false;
- }
- }
- return true;
- }
- return super.dispatchTouchEvent(ev);
- }
- protected void refreshHeader() {
- if (mHeaderView == null) {
- return;
- }
- int firstVisiblePos = getFirstVisiblePosition();
- int pos = firstVisiblePos + 1;
- int firstVisibleGroupPos = getPackedPositionGroup(getExpandableListPosition(firstVisiblePos));
- int group = getPackedPositionGroup(getExpandableListPosition(pos));
- if (group == firstVisibleGroupPos + 1) {
- View view = getChildAt(1);
- if (view.getTop() <= mHeaderHeight) {
- int delta = mHeaderHeight - view.getTop();
- mHeaderView.layout(0, -delta, mHeaderWidth, mHeaderHeight - delta);
- }
- } else {
- mHeaderView.layout(0, 0, mHeaderWidth, mHeaderHeight);
- }
- if (mHeaderUpdateListener != null) {
- mHeaderUpdateListener.updatePinnedHeader(firstVisibleGroupPos);
- }
- }
- @Override
- public void onScrollStateChanged(AbsListView view, int scrollState) {
- if (mHeaderView != null && scrollState == SCROLL_STATE_IDLE) {
- int firstVisiblePos = getFirstVisiblePosition();
- if (firstVisiblePos == 0) {
- mHeaderView.layout(0, 0, mHeaderWidth, mHeaderHeight);
- }
- }
- if (mScrollListener != null) {
- mScrollListener.onScrollStateChanged(view, scrollState);
- }
- }
- @Override
- public void onScroll(AbsListView view, int firstVisibleItem,
- int visibleItemCount, int totalItemCount) {
- if (totalItemCount > 0) {
- refreshHeader();
- }
- if (mScrollListener != null) {
- mScrollListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
- }
- }
- }
下拉效果的实现
现在介绍第二个view,即StickyLayout,字面意思是黏性的layout,这个view内部分为2部分,header和content,并且header可以来回收缩。至于如何让header上下收缩,有几个看似可行的方案,我们分析下:
1.通过scrollTo/scrollBy来实现view的滚动,由于这两个api是对view内容的滚动,不管怎么滚动,内容都不会覆盖到别的view上去,除非你用了FrameLayout、RelativeLayout且经过精心布局,否则很难实现将内容滚动到别的view上面,即便如此,如果将header展开和收缩也是一个很大的问题,除非你动态地去调整header的布局,通过分析,这个方法不可行;
2. 通过动画来实现view的平移,从效果上来说,这个可行的,使用平移和缩放动画并结合手势的监听,可以实现这个效果,但是动画有一个问题,就是点击事件的处理,我们知道view动画,即使view区域发生了改变,但是事件点击区域仍然不变,而属性动画在3.0以下系统上根本不支持,就算采用兼容包,但是属性动画在3.0以下系统的点击事件区域仍然不会随着动画而改变,这更加证实了一个结论:动画是对view的显示发生作用,而不是view这个对象,也即是说动画并不影响view的区域(4个顶点)。说了这么多,好像还挺晦涩的,直白来说,采用动画来实现的问题是:在3.0以下系统,虽然view已经看起来跑到新位置了,但是你在新位置点击是不会触发点击事件的,而老位置还是可以触发点击事件,这就意味着,content移动后,content无法点击了,基于此,动画不可行;
3.第三种方案,也就是本文所采用的方案:通过手势监听结合header高度的改变来实现整个动画效果,具体点就是,当手指滑动的时候,动态去调整header的高度并重绘,这个时候由于header的高度发生了改变,所以content中的内容就会挤上去,就实现了本文中的效果了;
有了这个StickyLayout,想实现类似的效果,这要把可以收缩的内容放到header里,其他内容放到content里即可。下面看代码:
- /**
- The MIT License (MIT)
- Copyright (c) 2014 singwhatiwanna
- https://github.com/singwhatiwanna
- http://blog.csdn.net/singwhatiwanna
- Permission is hereby granted, free of charge, to any person obtaining a copy
- of this software and associated documentation files (the "Software"), to deal
- in the Software without restriction, including without limitation the rights
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- copies of the Software, and to permit persons to whom the Software is
- furnished to do so, subject to the following conditions:
- The above copyright notice and this permission notice shall be included in all
- copies or substantial portions of the Software.
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- SOFTWARE.
- */
- package com.ryg.expandable.ui;
- import java.util.NoSuchElementException;
- import android.annotation.TargetApi;
- import android.content.Context;
- import android.os.Build;
- import android.util.AttributeSet;
- import android.util.Log;
- import android.view.MotionEvent;
- import android.view.View;
- import android.view.ViewConfiguration;
- import android.widget.LinearLayout;
- public class StickyLayout extends LinearLayout {
- private static final String TAG = "StickyLayout";
- public interface OnGiveUpTouchEventListener {
- public boolean giveUpTouchEvent(MotionEvent event);
- }
- private View mHeader;
- private View mContent;
- private OnGiveUpTouchEventListener mGiveUpTouchEventListener;
- // header的高度 单位:px
- private int mOriginalHeaderHeight;
- private int mHeaderHeight;
- private int mStatus = STATUS_EXPANDED;
- public static final int STATUS_EXPANDED = 1;
- public static final int STATUS_COLLAPSED = 2;
- private int mTouchSlop;
- // 分别记录上次滑动的坐标
- private int mLastX = 0;
- private int mLastY = 0;
- // 分别记录上次滑动的坐标(onInterceptTouchEvent)
- private int mLastXIntercept = 0;
- private int mLastYIntercept = 0;
- // 用来控制滑动角度,仅当角度a满足如下条件才进行滑动:tan a = deltaX / deltaY > 2
- private static final int TAN = 2;
- public StickyLayout(Context context) {
- super(context);
- }
- public StickyLayout(Context context, AttributeSet attrs) {
- super(context, attrs);
- }
- @TargetApi(Build.VERSION_CODES.HONEYCOMB)
- public StickyLayout(Context context, AttributeSet attrs, int defStyle) {
- super(context, attrs, defStyle);
- }
- @Override
- public void onWindowFocusChanged(boolean hasWindowFocus) {
- super.onWindowFocusChanged(hasWindowFocus);
- if (hasWindowFocus && (mHeader == null || mContent == null)) {
- initData();
- }
- }
- private void initData() {
- int headerId= getResources().getIdentifier("header", "id", getContext().getPackageName());
- int contentId = getResources().getIdentifier("content", "id", getContext().getPackageName());
- if (headerId != 0 && contentId != 0) {
- mHeader = findViewById(headerId);
- mContent = findViewById(contentId);
- mOriginalHeaderHeight = mHeader.getMeasuredHeight();
- mHeaderHeight = mOriginalHeaderHeight;
- mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
- Log.d(TAG, "mTouchSlop = " + mTouchSlop);
- } else {
- throw new NoSuchElementException("Did your view with \"header\" or \"content\" exist?");
- }
- }
- public void setOnGiveUpTouchEventListener(OnGiveUpTouchEventListener l) {
- mGiveUpTouchEventListener = l;
- }
- @Override
- public boolean onInterceptTouchEvent(MotionEvent event) {
- int intercepted = 0;
- int x = (int) event.getX();
- int y = (int) event.getY();
- switch (event.getAction()) {
- case MotionEvent.ACTION_DOWN: {
- mLastXIntercept = x;
- mLastYIntercept = y;
- mLastX = x;
- mLastY = y;
- intercepted = 0;
- break;
- }
- case MotionEvent.ACTION_MOVE: {
- int deltaX = x - mLastXIntercept;
- int deltaY = y - mLastYIntercept;
- if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
- intercepted = 1;
- } else if (mGiveUpTouchEventListener != null) {
- if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
- intercepted = 1;
- }
- }
- break;
- }
- case MotionEvent.ACTION_UP: {
- intercepted = 0;
- mLastXIntercept = mLastYIntercept = 0;
- break;
- }
- default:
- break;
- }
- Log.d(TAG, "intercepted=" + intercepted);
- return intercepted != 0;
- }
- @Override
- public boolean onTouchEvent(MotionEvent event) {
- int x = (int) event.getX();
- int y = (int) event.getY();
- Log.d(TAG, "x=" + x + " y=" + y + " mlastY=" + mLastY);
- switch (event.getAction()) {
- case MotionEvent.ACTION_DOWN: {
- break;
- }
- case MotionEvent.ACTION_MOVE: {
- int deltaX = x - mLastX;
- int deltaY = y - mLastY;
- Log.d(TAG, "mHeaderHeight=" + mHeaderHeight + " deltaY=" + deltaY + " mlastY=" + mLastY);
- mHeaderHeight += deltaY;
- setHeaderHeight(mHeaderHeight);
- break;
- }
- case MotionEvent.ACTION_UP: {
- // 这里做了下判断,当松开手的时候,会自动向两边滑动,具体向哪边滑,要看当前所处的位置
- int destHeight = 0;
- if (mHeaderHeight <= mOriginalHeaderHeight * 0.5) {
- destHeight = 0;
- mStatus = STATUS_COLLAPSED;
- } else {
- destHeight = mOriginalHeaderHeight;
- mStatus = STATUS_EXPANDED;
- }
- // 慢慢滑向终点
- this.smoothSetHeaderHeight(mHeaderHeight, destHeight, 500);
- break;
- }
- default:
- break;
- }
- mLastX = x;
- mLastY = y;
- return true;
- }
- public void smoothSetHeaderHeight(final int from, final int to, long duration) {
- final int frameCount = (int) (duration / 1000f * 30) + 1;
- final float partation = (to - from) / (float) frameCount;
- new Thread("Thread#smoothSetHeaderHeight") {
- @Override
- public void run() {
- for (int i = 0; i < frameCount; i++) {
- final int height;
- if (i == frameCount - 1) {
- height = to;
- } else {
- height = (int) (from + partation * i);
- }
- post(new Runnable() {
- public void run() {
- setHeaderHeight(height);
- }
- });
- try {
- sleep(10);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- };
- }.start();
- }
- private void setHeaderHeight(int height) {
- Log.d(TAG, "setHeaderHeight height=" + height);
- if (height < 0) {
- height = 0;
- } else if (height > mOriginalHeaderHeight) {
- height = mOriginalHeaderHeight;
- }
- if (mHeaderHeight != height || true) {
- mHeaderHeight = height;
- mHeader.getLayoutParams().height = mHeaderHeight;
- mHeader.requestLayout();
- }
- }
- }
关于这个view还需要说明的是滑动冲突,如果content里是个listview,由于两者都能竖向滑动,这就会有冲突,如何解决滑动冲突一直是一个难点,我的解决思路是这样的:首先StickyLayout默认不拦截事件,如果子元素不处理事件,它就会上下滑动,如果子元素处理了事件,它就不会滑动,所以在最外层我们需要知道子元素何时处理事件、何时不处理事件,为了解决这个问题,提供了一个接口OnGiveUpTouchEventListener,当子元素不处理事件的时候,StickyLayout就可以处理滑动事件,具体请参看代码中的onInterceptTouchEvent和onTouchEvent。下面看一下activity对这2个接口的实现。
Activity的实现
由于Activity中大部分代码都是围绕ExpandableListAdapter,是比较普通的代码,这里要介绍的是activity对上述2个view中接口的实现,分别为PinnedHeaderExpandableListView中如何绘制和更新固定的头部以及StickyLayout中content何时放弃事件处理。
- @Override
- public View getPinnedHeader() {
- if (mHeaderView == null) {
- mHeaderView = (ViewGroup) getLayoutInflater().inflate(R.layout.group, null);
- mHeaderView.setLayoutParams(new LayoutParams(
- LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
- }
- return mHeaderView;
- }
- @Override
- public void updatePinnedHeader(int firstVisibleGroupPos) {
- Group firstVisibleGroup = (Group) adapter.getGroup(firstVisibleGroupPos);
- TextView textView = (TextView) getPinnedHeader().findViewById(R.id.group);
- textView.setText(firstVisibleGroup.getTitle());
- }
- @Override
- public boolean giveUpTouchEvent(MotionEvent event) {
- if (expandableListView.getFirstVisiblePosition() == 0) {
- View view = expandableListView.getChildAt(0);
- if (view != null && view.getTop() >= 0) {
- return true;
- }
- }
- return false;
- }
总结
demo效果上还是不错的,在4.x和2.x上都经过测试,完美运行,市面上不少android应用有类似的效果,欢迎大家fork代码,欢迎大家交流。
代码地址
https://github.com/singwhatiwanna/PinnedHeaderExpandableListView
需要注意的是:该项目采用MIT共享协议发布,意味着如果你要使用或修改它,必须在源代码中保留头部的版权声明,这个要求够不够低啊,哈哈!