package com.emilsjolander.components.stickylistheaders; import android.content.Context; import android.content.res.TypedArray; import android.database.DataSetObserver; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.widget.AbsListView; import android.widget.AbsListView.OnScrollListener; import android.widget.ListAdapter; import android.widget.ListView; /** * @author Emil Sj��lander * * * Copyright 2012 Emil Sj��lander * * Licensed under the Apache License, Version 2.0 (the "License"); you * may not use this file except in compliance with the License. You may * obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or * implied. See the License for the specific language governing * permissions and limitations under the License. */ public class StickyListHeadersListView extends ListView implements OnScrollListener { public interface OnHeaderClickListener { public void onHeaderClick(StickyListHeadersListView l, View header, int itemPosition, long headerId, boolean currentlySticky); } private static final int[] ATTRS = { android.R.attr.dividerHeight, android.R.attr.listDivider }; private static final int ATTRS_dividerHeight = 0; private static final int ATTRS_listDivider = 1; private OnScrollListener scrollListener; private boolean areHeadersSticky = true; private int headerBottomPosition; private View header; private int dividerHeight; private Drawable divider; private boolean clippingToPadding; private boolean clipToPaddingHasBeenSet; private final Rect clippingRect = new Rect(); private Long currentHeaderId = null; private StickyListHeadersAdapterWrapper adapter; private float headerDownY = -1; private boolean headerBeingPressed = false; private OnHeaderClickListener onHeaderClickListener; private int headerPosition; private ViewConfiguration viewConfig; private StickyListHeadersAdapterWrapper.OnHeaderClickListener addapterHeaderClickListener = new StickyListHeadersAdapterWrapper.OnHeaderClickListener() { @Override public void onHeaderClick(View header, int itemPosition, long headerId) { if (onHeaderClickListener != null) { onHeaderClickListener.onHeaderClick( StickyListHeadersListView.this, header, itemPosition, headerId, false); } } }; private DataSetObserver dataSetChangedObserver = new DataSetObserver() { @Override public void onChanged() { reset(); } @Override public void onInvalidated() { reset(); } }; public StickyListHeadersListView(Context context) { this(context, null); } public StickyListHeadersListView(Context context, AttributeSet attrs) { this(context, attrs, android.R.attr.listViewStyle); } public StickyListHeadersListView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); TypedArray a = context.obtainStyledAttributes(attrs, ATTRS); divider = a.getDrawable(ATTRS_listDivider); dividerHeight = a.getDimensionPixelSize(ATTRS_dividerHeight, -1); a.recycle(); super.setOnScrollListener(this); // null out divider, dividers are handled by adapter so they look good // with headers super.setDivider(null); super.setDividerHeight(0); setVerticalFadingEdgeEnabled(false); viewConfig = ViewConfiguration.get(context); } private void reset() { headerBottomPosition = 0; header = null; currentHeaderId = null; } @Override public boolean performItemClick(View view, int position, long id) { view = ((WrapperView) view).item; return super.performItemClick(view, position, id); } /** * can only be set to false if headers are sticky, not compatible with * fading edges */ @Override public void setVerticalFadingEdgeEnabled(boolean verticalFadingEdgeEnabled) { if (areHeadersSticky) { super.setVerticalFadingEdgeEnabled(false); } else { super.setVerticalFadingEdgeEnabled(verticalFadingEdgeEnabled); } } @Override public void setDivider(Drawable divider) { this.divider = divider; if (adapter != null) { adapter.setDivider(divider); } // TODO what to do here? notifyDataSetChanged()? } @Override public void setDividerHeight(int height) { dividerHeight = height; if (adapter != null) { adapter.setDividerHeight(height); } // TODO what to do here? notifyDataSetChanged()? } @Override public void setOnScrollListener(OnScrollListener l) { scrollListener = l; } public void setAreHeadersSticky(boolean areHeadersSticky) { if (this.areHeadersSticky != areHeadersSticky) { if (areHeadersSticky) { super.setVerticalFadingEdgeEnabled(false); } requestLayout(); this.areHeadersSticky = areHeadersSticky; } } public boolean getAreHeadersSticky() { return areHeadersSticky; } @Override public void setAdapter(ListAdapter adapter) { if (!clipToPaddingHasBeenSet) { clippingToPadding = true; } if (!(adapter instanceof StickyListHeadersAdapter)) { throw new IllegalArgumentException( "Adapter must implement StickyListHeadersAdapter"); } this.adapter = new StickyListHeadersAdapterWrapper(getContext(), (StickyListHeadersAdapter) adapter); this.adapter.setDivider(divider); this.adapter.setDividerHeight(dividerHeight); this.adapter.registerDataSetObserver(dataSetChangedObserver); this.adapter.setOnHeaderClickListener(addapterHeaderClickListener); reset(); super.setAdapter(this.adapter); } @Override public StickyListHeadersAdapter getAdapter() { return adapter == null ? null : adapter.delegate; } @Override protected void dispatchDraw(Canvas canvas) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.FROYO) { scrollChanged(getFirstVisiblePosition()); } super.dispatchDraw(canvas); if (header == null || !areHeadersSticky) { return; } int headerHeight = getHeaderHeight(); int top = headerBottomPosition - headerHeight; clippingRect.left = getPaddingLeft(); clippingRect.right = getWidth() - getPaddingRight(); clippingRect.bottom = top + headerHeight; if (clippingToPadding) { clippingRect.top = getPaddingTop(); } else { clippingRect.top = 0; } canvas.save(); canvas.clipRect(clippingRect); canvas.translate(getPaddingLeft(), top); header.draw(canvas); canvas.restore(); } private void measureHeader(){ int widthMeasureSpec = MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY); int heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); header.measure(widthMeasureSpec, heightMeasureSpec); header.layout(getLeft() + getPaddingLeft(), 0, getRight() - getPaddingRight(), header.getMeasuredHeight()); } private int getHeaderHeight() { if (header != null) { return header.getMeasuredHeight(); } return 0; } @Override public void setClipToPadding(boolean clipToPadding) { super.setClipToPadding(clipToPadding); clippingToPadding = clipToPadding; clipToPaddingHasBeenSet = true; } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { if (scrollListener != null) { scrollListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO) { scrollChanged(firstVisibleItem); } } private void scrollChanged(int firstVisibleItem) { if (adapter == null || adapter.getCount() == 0 || !areHeadersSticky) return; firstVisibleItem = getFixedFirstVisibleItem(firstVisibleItem); long newHeaderId = adapter.delegate.getHeaderId(firstVisibleItem); if (currentHeaderId == null || currentHeaderId != newHeaderId) { headerPosition = firstVisibleItem; header = adapter.delegate.getHeaderView(headerPosition, header, this); measureHeader(); } currentHeaderId = newHeaderId; final int childCount = getChildCount(); if (childCount != 0) { WrapperView viewToWatch = (WrapperView) super.getChildAt(0); int firstChildDistance; if (clippingToPadding) { firstChildDistance = Math .abs((viewToWatch.getTop() - getPaddingTop())); } else { firstChildDistance = Math.abs(viewToWatch.getTop()); } int headerHeight = getHeaderHeight(); for (int i = 1; i < childCount; i++) { WrapperView child = (WrapperView) super.getChildAt(i); int secondChildDistance; if (clippingToPadding) { secondChildDistance = Math .abs((child.getTop() - getPaddingTop()) - headerHeight); } else { secondChildDistance = Math.abs(child.getTop() - headerHeight); } if (!viewToWatch.hasHeader() || (child.hasHeader() && secondChildDistance < firstChildDistance)) { viewToWatch = child; } } if (viewToWatch.hasHeader()) { if (firstVisibleItem == 0 && super.getChildAt(0).getTop() > 0 && !clippingToPadding) { headerBottomPosition = 0; } else { if (clippingToPadding) { headerBottomPosition = Math.min(viewToWatch.getTop(), headerHeight + getPaddingTop()); headerBottomPosition = headerBottomPosition < getPaddingTop() ? headerHeight + getPaddingTop() : headerBottomPosition; } else { headerBottomPosition = Math.min(viewToWatch.getTop(), headerHeight); headerBottomPosition = headerBottomPosition < 0 ? headerHeight : headerBottomPosition; } } } else { headerBottomPosition = headerHeight; if (clippingToPadding) { headerBottomPosition += getPaddingTop(); } } } int top = clippingToPadding ? getPaddingTop() : 0; for (int i = 0; i < childCount; i++) { WrapperView child = (WrapperView) super.getChildAt(i); if (child.hasHeader()) { View childHeader = child.header; if (child.getTop() < top) { childHeader.setVisibility(View.INVISIBLE); } else { childHeader.setVisibility(View.VISIBLE); } } } } private int getFixedFirstVisibleItem(int firstVisibleItem) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { return firstVisibleItem; } for (int i = 0; i < getChildCount(); i++) { if (getChildAt(i).getBottom() >= 0) { firstVisibleItem += i; break; } } // work around to fix bug with firstVisibleItem being to high because // listview does not take clipToPadding=false into account if (!clippingToPadding && getPaddingTop() > 0) { if (super.getChildAt(0).getTop() > 0) { if (firstVisibleItem > 0) { firstVisibleItem -= 1; } } } return firstVisibleItem; } @Override public void onScrollStateChanged(AbsListView view, int scrollState) { if (scrollListener != null) { scrollListener.onScrollStateChanged(view, scrollState); } } @Override public void setSelectionFromTop(int position, int y) { if (areHeadersSticky) { y += getHeaderHeight(); } super.setSelectionFromTop(position, y); } public void setOnHeaderClickListener( OnHeaderClickListener onHeaderClickListener) { this.onHeaderClickListener = onHeaderClickListener; } @Override public boolean onTouchEvent(MotionEvent ev) { int action = ev.getAction(); if (action == MotionEvent.ACTION_DOWN && ev.getY() <= headerBottomPosition) { headerDownY = ev.getY(); headerBeingPressed = true; header.setPressed(true); header.invalidate(); invalidate(0, 0, getWidth(), headerBottomPosition); return true; } if (headerBeingPressed) { if (Math.abs(ev.getY() - headerDownY) < viewConfig .getScaledTouchSlop()) { if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { headerDownY = -1; headerBeingPressed = false; header.setPressed(false); header.invalidate(); invalidate(0, 0, getWidth(), headerBottomPosition); if (onHeaderClickListener != null) { onHeaderClickListener.onHeaderClick(this, header, headerPosition, currentHeaderId, true); } } return true; } else { headerDownY = -1; headerBeingPressed = false; header.setPressed(false); header.invalidate(); invalidate(0, 0, getWidth(), headerBottomPosition); } } return super.onTouchEvent(ev); } }