blob: a801624b4df62e0e99f23642e44642abbe6638e7 [file] [log] [blame]
/*
* Copyright (C) 2010 The Android Open Source Project
*
* 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.
*/
package com.android.contacts.common.list;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.AbsListView.OnScrollListener;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.ListAdapter;
import com.android.dialer.util.ViewUtil;
/**
* A ListView that maintains a header pinned at the top of the list. The pinned header can be pushed
* up and dissolved as needed.
*/
public class PinnedHeaderListView extends AutoScrollListView
implements OnScrollListener, OnItemSelectedListener {
private static final int MAX_ALPHA = 255;
private static final int TOP = 0;
private static final int BOTTOM = 1;
private static final int FADING = 2;
private static final int DEFAULT_ANIMATION_DURATION = 20;
private static final int DEFAULT_SMOOTH_SCROLL_DURATION = 100;
private PinnedHeaderAdapter mAdapter;
private int mSize;
private PinnedHeader[] mHeaders;
private RectF mBounds = new RectF();
private OnScrollListener mOnScrollListener;
private OnItemSelectedListener mOnItemSelectedListener;
private int mScrollState;
private boolean mScrollToSectionOnHeaderTouch = false;
private boolean mHeaderTouched = false;
private int mAnimationDuration = DEFAULT_ANIMATION_DURATION;
private boolean mAnimating;
private long mAnimationTargetTime;
private int mHeaderPaddingStart;
private int mHeaderWidth;
public PinnedHeaderListView(Context context) {
this(context, null, android.R.attr.listViewStyle);
}
public PinnedHeaderListView(Context context, AttributeSet attrs) {
this(context, attrs, android.R.attr.listViewStyle);
}
public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
super.setOnScrollListener(this);
super.setOnItemSelectedListener(this);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
mHeaderPaddingStart = getPaddingStart();
mHeaderWidth = r - l - mHeaderPaddingStart - getPaddingEnd();
}
@Override
public void setAdapter(ListAdapter adapter) {
mAdapter = (PinnedHeaderAdapter) adapter;
super.setAdapter(adapter);
}
@Override
public void setOnScrollListener(OnScrollListener onScrollListener) {
mOnScrollListener = onScrollListener;
super.setOnScrollListener(this);
}
@Override
public void setOnItemSelectedListener(OnItemSelectedListener listener) {
mOnItemSelectedListener = listener;
super.setOnItemSelectedListener(this);
}
public void setScrollToSectionOnHeaderTouch(boolean value) {
mScrollToSectionOnHeaderTouch = value;
}
@Override
public void onScroll(
AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if (mAdapter != null) {
int count = mAdapter.getPinnedHeaderCount();
if (count != mSize) {
mSize = count;
if (mHeaders == null) {
mHeaders = new PinnedHeader[mSize];
} else if (mHeaders.length < mSize) {
PinnedHeader[] headers = mHeaders;
mHeaders = new PinnedHeader[mSize];
System.arraycopy(headers, 0, mHeaders, 0, headers.length);
}
}
for (int i = 0; i < mSize; i++) {
if (mHeaders[i] == null) {
mHeaders[i] = new PinnedHeader();
}
mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this);
}
mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration;
mAdapter.configurePinnedHeaders(this);
invalidateIfAnimating();
}
if (mOnScrollListener != null) {
mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount);
}
}
@Override
protected float getTopFadingEdgeStrength() {
// Disable vertical fading at the top when the pinned header is present
return mSize > 0 ? 0 : super.getTopFadingEdgeStrength();
}
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
mScrollState = scrollState;
if (mOnScrollListener != null) {
mOnScrollListener.onScrollStateChanged(this, scrollState);
}
}
/**
* Ensures that the selected item is positioned below the top-pinned headers and above the
* bottom-pinned ones.
*/
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
int height = getHeight();
int windowTop = 0;
int windowBottom = height;
for (int i = 0; i < mSize; i++) {
PinnedHeader header = mHeaders[i];
if (header.visible) {
if (header.state == TOP) {
windowTop = header.y + header.height;
} else if (header.state == BOTTOM) {
windowBottom = header.y;
break;
}
}
}
View selectedView = getSelectedView();
if (selectedView != null) {
if (selectedView.getTop() < windowTop) {
setSelectionFromTop(position, windowTop);
} else if (selectedView.getBottom() > windowBottom) {
setSelectionFromTop(position, windowBottom - selectedView.getHeight());
}
}
if (mOnItemSelectedListener != null) {
mOnItemSelectedListener.onItemSelected(parent, view, position, id);
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
if (mOnItemSelectedListener != null) {
mOnItemSelectedListener.onNothingSelected(parent);
}
}
public int getPinnedHeaderHeight(int viewIndex) {
ensurePinnedHeaderLayout(viewIndex);
return mHeaders[viewIndex].view.getHeight();
}
/**
* Set header to be pinned at the top.
*
* @param viewIndex index of the header view
* @param y is position of the header in pixels.
* @param animate true if the transition to the new coordinate should be animated
*/
public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) {
ensurePinnedHeaderLayout(viewIndex);
PinnedHeader header = mHeaders[viewIndex];
header.visible = true;
header.y = y;
header.state = TOP;
// TODO perhaps we should animate at the top as well
header.animating = false;
}
/**
* Set header to be pinned at the bottom.
*
* @param viewIndex index of the header view
* @param y is position of the header in pixels.
* @param animate true if the transition to the new coordinate should be animated
*/
public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) {
ensurePinnedHeaderLayout(viewIndex);
PinnedHeader header = mHeaders[viewIndex];
header.state = BOTTOM;
if (header.animating) {
header.targetTime = mAnimationTargetTime;
header.sourceY = header.y;
header.targetY = y;
} else if (animate && (header.y != y || !header.visible)) {
if (header.visible) {
header.sourceY = header.y;
} else {
header.visible = true;
header.sourceY = y + header.height;
}
header.animating = true;
header.targetVisible = true;
header.targetTime = mAnimationTargetTime;
header.targetY = y;
} else {
header.visible = true;
header.y = y;
}
}
/**
* Set header to be pinned at the top of the first visible item.
*
* @param viewIndex index of the header view
* @param position is position of the header in pixels.
*/
public void setFadingHeader(int viewIndex, int position, boolean fade) {
ensurePinnedHeaderLayout(viewIndex);
View child = getChildAt(position - getFirstVisiblePosition());
if (child == null) {
return;
}
PinnedHeader header = mHeaders[viewIndex];
header.visible = true;
header.state = FADING;
header.alpha = MAX_ALPHA;
header.animating = false;
int top = getTotalTopPinnedHeaderHeight();
header.y = top;
if (fade) {
int bottom = child.getBottom() - top;
int headerHeight = header.height;
if (bottom < headerHeight) {
int portion = bottom - headerHeight;
header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight;
header.y = top + portion;
}
}
}
/**
* Makes header invisible.
*
* @param viewIndex index of the header view
* @param animate true if the transition to the new coordinate should be animated
*/
public void setHeaderInvisible(int viewIndex, boolean animate) {
PinnedHeader header = mHeaders[viewIndex];
if (header.visible && (animate || header.animating) && header.state == BOTTOM) {
header.sourceY = header.y;
if (!header.animating) {
header.visible = true;
header.targetY = getBottom() + header.height;
}
header.animating = true;
header.targetTime = mAnimationTargetTime;
header.targetVisible = false;
} else {
header.visible = false;
}
}
private void ensurePinnedHeaderLayout(int viewIndex) {
View view = mHeaders[viewIndex].view;
if (view.isLayoutRequested()) {
ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
int widthSpec;
int heightSpec;
if (layoutParams != null && layoutParams.width > 0) {
widthSpec = View.MeasureSpec.makeMeasureSpec(layoutParams.width, View.MeasureSpec.EXACTLY);
} else {
widthSpec = View.MeasureSpec.makeMeasureSpec(mHeaderWidth, View.MeasureSpec.EXACTLY);
}
if (layoutParams != null && layoutParams.height > 0) {
heightSpec =
View.MeasureSpec.makeMeasureSpec(layoutParams.height, View.MeasureSpec.EXACTLY);
} else {
heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
}
view.measure(widthSpec, heightSpec);
int height = view.getMeasuredHeight();
mHeaders[viewIndex].height = height;
view.layout(0, 0, view.getMeasuredWidth(), height);
}
}
/** Returns the sum of heights of headers pinned to the top. */
public int getTotalTopPinnedHeaderHeight() {
for (int i = mSize; --i >= 0; ) {
PinnedHeader header = mHeaders[i];
if (header.visible && header.state == TOP) {
return header.y + header.height;
}
}
return 0;
}
/** Returns the list item position at the specified y coordinate. */
public int getPositionAt(int y) {
do {
int position = pointToPosition(getPaddingLeft() + 1, y);
if (position != -1) {
return position;
}
// If position == -1, we must have hit a separator. Let's examine
// a nearby pixel
y--;
} while (y > 0);
return 0;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
mHeaderTouched = false;
if (super.onInterceptTouchEvent(ev)) {
return true;
}
if (mScrollState == SCROLL_STATE_IDLE) {
final int y = (int) ev.getY();
final int x = (int) ev.getX();
for (int i = mSize; --i >= 0; ) {
PinnedHeader header = mHeaders[i];
// For RTL layouts, this also takes into account that the scrollbar is on the left
// side.
final int padding = getPaddingLeft();
if (header.visible
&& header.y <= y
&& header.y + header.height > y
&& x >= padding
&& padding + header.view.getWidth() >= x) {
mHeaderTouched = true;
if (mScrollToSectionOnHeaderTouch && ev.getAction() == MotionEvent.ACTION_DOWN) {
return smoothScrollToPartition(i);
} else {
return true;
}
}
}
}
return false;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (mHeaderTouched) {
if (ev.getAction() == MotionEvent.ACTION_UP) {
mHeaderTouched = false;
}
return true;
}
return super.onTouchEvent(ev);
}
private boolean smoothScrollToPartition(int partition) {
if (mAdapter == null) {
return false;
}
final int position = mAdapter.getScrollPositionForHeader(partition);
if (position == -1) {
return false;
}
int offset = 0;
for (int i = 0; i < partition; i++) {
PinnedHeader header = mHeaders[i];
if (header.visible) {
offset += header.height;
}
}
smoothScrollToPositionFromTop(
position + getHeaderViewsCount(), offset, DEFAULT_SMOOTH_SCROLL_DURATION);
return true;
}
private void invalidateIfAnimating() {
mAnimating = false;
for (int i = 0; i < mSize; i++) {
if (mHeaders[i].animating) {
mAnimating = true;
invalidate();
return;
}
}
}
@Override
protected void dispatchDraw(Canvas canvas) {
long currentTime = mAnimating ? System.currentTimeMillis() : 0;
int top = 0;
int bottom = getBottom();
boolean hasVisibleHeaders = false;
for (int i = 0; i < mSize; i++) {
PinnedHeader header = mHeaders[i];
if (header.visible) {
hasVisibleHeaders = true;
if (header.state == BOTTOM && header.y < bottom) {
bottom = header.y;
} else if (header.state == TOP || header.state == FADING) {
int newTop = header.y + header.height;
if (newTop > top) {
top = newTop;
}
}
}
}
if (hasVisibleHeaders) {
canvas.save();
}
super.dispatchDraw(canvas);
if (hasVisibleHeaders) {
canvas.restore();
// If the first item is visible and if it has a positive top that is greater than the
// first header's assigned y-value, use that for the first header's y value. This way,
// the header inherits any padding applied to the list view.
if (mSize > 0 && getFirstVisiblePosition() == 0) {
View firstChild = getChildAt(0);
PinnedHeader firstHeader = mHeaders[0];
if (firstHeader != null) {
int firstHeaderTop = firstChild != null ? firstChild.getTop() : 0;
firstHeader.y = Math.max(firstHeader.y, firstHeaderTop);
}
}
// First draw top headers, then the bottom ones to handle the Z axis correctly
for (int i = mSize; --i >= 0; ) {
PinnedHeader header = mHeaders[i];
if (header.visible && (header.state == TOP || header.state == FADING)) {
drawHeader(canvas, header, currentTime);
}
}
for (int i = 0; i < mSize; i++) {
PinnedHeader header = mHeaders[i];
if (header.visible && header.state == BOTTOM) {
drawHeader(canvas, header, currentTime);
}
}
}
invalidateIfAnimating();
}
private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) {
if (header.animating) {
int timeLeft = (int) (header.targetTime - currentTime);
if (timeLeft <= 0) {
header.y = header.targetY;
header.visible = header.targetVisible;
header.animating = false;
} else {
header.y =
header.targetY + (header.sourceY - header.targetY) * timeLeft / mAnimationDuration;
}
}
if (header.visible) {
View view = header.view;
int saveCount = canvas.save();
int translateX =
ViewUtil.isViewLayoutRtl(this)
? getWidth() - mHeaderPaddingStart - view.getWidth()
: mHeaderPaddingStart;
canvas.translate(translateX, header.y);
if (header.state == FADING) {
mBounds.set(0, 0, view.getWidth(), view.getHeight());
canvas.saveLayerAlpha(mBounds, header.alpha);
}
view.draw(canvas);
canvas.restoreToCount(saveCount);
}
}
/** Adapter interface. The list adapter must implement this interface. */
public interface PinnedHeaderAdapter {
/** Returns the overall number of pinned headers, visible or not. */
int getPinnedHeaderCount();
/** Creates or updates the pinned header view. */
View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent);
/**
* Configures the pinned headers to match the visible list items. The adapter should call {@link
* PinnedHeaderListView#setHeaderPinnedAtTop}, {@link
* PinnedHeaderListView#setHeaderPinnedAtBottom}, {@link PinnedHeaderListView#setFadingHeader}
* or {@link PinnedHeaderListView#setHeaderInvisible}, for each header that needs to change its
* position or visibility.
*/
void configurePinnedHeaders(PinnedHeaderListView listView);
/**
* Returns the list position to scroll to if the pinned header is touched. Return -1 if the list
* does not need to be scrolled.
*/
int getScrollPositionForHeader(int viewIndex);
}
private static final class PinnedHeader {
View view;
boolean visible;
int y;
int height;
int alpha;
int state;
boolean animating;
boolean targetVisible;
int sourceY;
int targetY;
long targetTime;
}
}