/* Copyright © 2013-2014, Silent Circle, LLC. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Any redistribution, use, or modification is done solely for personal benefit and not for any commercial purpose or for monetary gain * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name Silent Circle nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL SILENT CIRCLE, LLC BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /* * This implementation is edited version of original Android sources. */ /* * 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.silentcircle.contacts.widget; import android.annotation.TargetApi; 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 = 20; 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, 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); 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); } @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()) { 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); } @TargetApi(Build.VERSION_CODES.HONEYCOMB) 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) 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); } } }