/* * 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.widget; import android.content.Context; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.RectF; import android.os.Build; 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; /** * 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 { /** * 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 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 = 100; 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; } private PinnedHeaderAdapter mAdapter; private int mSize; private PinnedHeader[] mHeaders; private RectF mBounds = new RectF(); private Rect mClipRect = new Rect(); private OnScrollListener mOnScrollListener; private OnItemSelectedListener mOnItemSelectedListener; private int mScrollState; private int mAnimationDuration = DEFAULT_ANIMATION_DURATION; private boolean mAnimating; private long mAnimationTargetTime; private int mHeaderPaddingLeft; private int mHeaderWidth; public PinnedHeaderListView(Context context) { //this(context, null, com.android.internal.R.attr.listViewStyle); this(context, null, android.R.attr.listViewStyle); } public PinnedHeaderListView(Context context, AttributeSet attrs) { //this(context, attrs, com.android.internal.R.attr.listViewStyle); 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); mHeaderPaddingLeft = getPaddingLeft(); mHeaderWidth = r - l - mHeaderPaddingLeft - getPaddingRight(); } public void setPinnedHeaderAnimationDuration(int duration) { mAnimationDuration = duration; } @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 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(); } 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. */ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { int height = getHeight(); int windowTop = 0; int windowBottom = height; int prevHeaderBottom = 0; 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); } } 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()) { int widthSpec = MeasureSpec.makeMeasureSpec(mHeaderWidth, MeasureSpec.EXACTLY); int heightSpec; ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); if (layoutParams != null && layoutParams.height > 0) { heightSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, MeasureSpec.EXACTLY); } else { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } view.measure(widthSpec, heightSpec); int height = view.getMeasuredHeight(); mHeaders[viewIndex].height = height; view.layout(0, 0, mHeaderWidth, 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) { if (mScrollState == SCROLL_STATE_IDLE) { final int y = (int)ev.getY(); for (int i = mSize; --i >= 0;) { PinnedHeader header = mHeaders[i]; if (header.visible && header.y <= y && header.y + header.height > y) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { return smoothScrollToPartition(i); } else { return true; } } } } return super.onInterceptTouchEvent(ev); } private boolean smoothScrollToPartition(int partition) { 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; } } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { smoothScrollToPosition(position + getHeaderViewsCount()); } else { smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset); } 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(); mClipRect.set(0, top, getWidth(), bottom); canvas.clipRect(mClipRect); } super.dispatchDraw(canvas); if (hasVisibleHeaders) { canvas.restore(); // 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(); canvas.translate(mHeaderPaddingLeft, header.y); if (header.state == FADING) { mBounds.set(0, 0, mHeaderWidth, view.getHeight()); canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG); } view.draw(canvas); canvas.restoreToCount(saveCount); } } }