diff options
author | emancebo <emancebo@cyngn.com> | 2014-08-26 13:27:17 -0700 |
---|---|---|
committer | emancebo <emancebo@cyngn.com> | 2014-09-29 14:48:15 -0700 |
commit | 0888f8e20d475e03ea79f5d416f9089b479d447c (patch) | |
tree | 905c9534852d7a12418e4ee56029013ed45c2ddd | |
parent | 8be09ac4aebd03fce7071324e87b92a28978c0a6 (diff) | |
download | android_external_cyanogen_UICommon-0888f8e20d475e03ea79f5d416f9089b479d447c.tar.gz android_external_cyanogen_UICommon-0888f8e20d475e03ea79f5d416f9089b479d447c.tar.bz2 android_external_cyanogen_UICommon-0888f8e20d475e03ea79f5d416f9089b479d447c.zip |
Add horizontal list scrubber to UICommon
* Add option to use either ListView or RecyclerView as the source
* Add optional fade in/out behavior on list idle
Change-Id: Idf2751c6201a23f4a25b3a1c55af13f60dd69793
-rw-r--r-- | res/drawable-nodpi/letter_indicator.9.png | bin | 0 -> 3614 bytes | |||
-rw-r--r-- | res/drawable/scrubber_back.xml | 5 | ||||
-rw-r--r-- | res/drawable/seek_back.xml | 7 | ||||
-rw-r--r-- | res/layout/scrub_layout.xml | 87 | ||||
-rw-r--r-- | src/com/cyngn/uicommon/view/ListScrubber.java | 233 | ||||
-rw-r--r-- | src/com/cyngn/uicommon/view/ListScrubberFadeHelper.java | 152 | ||||
-rw-r--r-- | src/com/cyngn/uicommon/view/ListScrubberSeekBar.java | 47 |
7 files changed, 531 insertions, 0 deletions
diff --git a/res/drawable-nodpi/letter_indicator.9.png b/res/drawable-nodpi/letter_indicator.9.png Binary files differnew file mode 100644 index 0000000..af3578e --- /dev/null +++ b/res/drawable-nodpi/letter_indicator.9.png diff --git a/res/drawable/scrubber_back.xml b/res/drawable/scrubber_back.xml new file mode 100644 index 0000000..8d2be51 --- /dev/null +++ b/res/drawable/scrubber_back.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="#99333333"/> + <corners android:radius="5dip"/> +</shape> diff --git a/res/drawable/seek_back.xml b/res/drawable/seek_back.xml new file mode 100644 index 0000000..358c022 --- /dev/null +++ b/res/drawable/seek_back.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> + +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="line"> + <stroke android:width="4dp" android:color="#FFF"/> + <size android:height="2dp" /> +</shape> diff --git a/res/layout/scrub_layout.xml b/res/layout/scrub_layout.xml new file mode 100644 index 0000000..2f725da --- /dev/null +++ b/res/layout/scrub_layout.xml @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="utf-8"?> + +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/scrubberWidget" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_marginRight="20dp" + android:layout_marginLeft="20dp" + android:layout_height="wrap_content"> + + <LinearLayout + android:clickable="true" + android:layout_marginTop="-5dp" + android:layout_below="@+id/scrubberIndicator" + android:orientation="horizontal" + android:background="@drawable/scrubber_back" + android:layout_width="match_parent" + android:layout_height="50dp"> + + <Space + android:layout_weight="0.1" + android:layout_width="0dp" + android:layout_height="match_parent" /> + + <TextView + android:id="@+id/firstSection" + android:gravity="center" + android:textColor="@android:color/white" + android:paddingRight="10dp" + android:textStyle="bold" + style="?android:attr/textAppearanceLarge" + android:layout_width="wrap_content" + android:layout_height="match_parent" /> + + <LinearLayout + android:layout_weight="0.9" + android:layout_width="0dp" + android:layout_gravity="center" + android:orientation="vertical" + android:layout_height="match_parent"> + + <com.cyngn.uicommon.view.ListScrubberSeekBar + android:id="@+id/scrubber" + android:paddingLeft="0dp" + android:paddingRight="0dp" + android:layout_marginRight="-10dp" + android:layout_marginLeft="-10dp" + android:thumb="@android:color/transparent" + android:thumbOffset="-10dp" + android:progressDrawable="@drawable/seek_back" + android:layout_width="match_parent" + android:layout_gravity="center" + android:layout_height="match_parent" /> + + </LinearLayout> + + + <TextView + android:gravity="center" + android:id="@+id/lastSection" + android:paddingLeft="10dp" + android:textColor="@android:color/white" + android:textStyle="bold" + style="?android:attr/textAppearanceLarge" + android:layout_width="wrap_content" + android:layout_height="match_parent" /> + + <Space + android:layout_weight="0.1" + android:layout_width="0dp" + android:layout_height="match_parent" /> + + </LinearLayout> + + <TextView + android:id="@+id/scrubberIndicator" + android:background="@drawable/letter_indicator" + android:layout_width="80dp" + android:textSize="30sp" + android:gravity="center" + android:textColor="@android:color/white" + android:clickable="false" + android:layout_marginBottom="-20dp" + android:visibility="invisible" + android:layout_height="100dp" /> + +</RelativeLayout> diff --git a/src/com/cyngn/uicommon/view/ListScrubber.java b/src/com/cyngn/uicommon/view/ListScrubber.java new file mode 100644 index 0000000..f4e9596 --- /dev/null +++ b/src/com/cyngn/uicommon/view/ListScrubber.java @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2014 The CyanogenMod 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.cyngn.uicommon.view; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.AbsListView; +import android.widget.HeaderViewListAdapter; +import android.widget.LinearLayout; +import android.widget.ListAdapter; +import android.widget.ListView; +import android.widget.RelativeLayout; +import android.widget.SectionIndexer; +import android.widget.SeekBar; +import android.widget.TextView; +import com.cyngn.uicommon.R; + +/** + * Widget to fast scroll through a list view with a horizontal scrubber. + * Usage: + * scrubber = new ListScrubber(context); + * scrubber.setFadeInOnMotion(true); // optional + * scrubber.setSource(listView); // call when adapter is ready + */ +public class ListScrubber extends LinearLayout implements OnClickListener { + + private SectionIndexer mSectionIndexer; + private RelativeLayout mScrubberWidget; + private ListView mListView; + private RecyclerView mRecyclerView; + private TextView mFirstIndicator, mLastIndicator; + private TextView mScrubberIndicator; + private SeekBar mSeekBar; + private String[] mSections; + private int mHeaderCount = 0; + private boolean mFadeInOnMotion; + private ListScrubberFadeHelper mFadeHelper; + + public ListScrubber(Context context) { + super(context); + LayoutInflater.from(context).inflate(R.layout.scrub_layout, this); + mScrubberWidget = (RelativeLayout)findViewById(R.id.scrubberWidget); + mFirstIndicator = ((TextView) findViewById(R.id.firstSection)); + mFirstIndicator.setOnClickListener(this); + mLastIndicator = ((TextView) findViewById(R.id.lastSection)); + mLastIndicator.setOnClickListener(this); + mScrubberIndicator = (TextView) findViewById(R.id.scrubberIndicator); + mSeekBar = (SeekBar) findViewById(R.id.scrubber); + init(); + } + + public void updateSections() { + mSections = (String[]) mSectionIndexer.getSections(); + if (mSections.length > 0) { + mSeekBar.setMax(mSections.length - 1); + mFirstIndicator.setText(mSections[0]); + mLastIndicator.setText(mSections[mSections.length - 1]); + } + } + + public void setSource(ListView listView) { + resetSource(); + mListView = listView; + + ListAdapter adapter = listView.getAdapter(); + if (adapter instanceof HeaderViewListAdapter) { + mHeaderCount = ((HeaderViewListAdapter) adapter).getHeadersCount(); + adapter = ((HeaderViewListAdapter) adapter).getWrappedAdapter(); + } + + if (adapter instanceof SectionIndexer) { + mSectionIndexer = (SectionIndexer)adapter; + } else { + throw new IllegalArgumentException("ListView adapter must implement SectionIndexer"); + } + } + + public void setSource(RecyclerView recyclerView) { + resetSource(); + mRecyclerView = recyclerView; + mSectionIndexer = (SectionIndexer)mRecyclerView.getAdapter(); + + } + + private void resetSource() { + mRecyclerView = null; + mListView = null; + } + + private boolean isReady() { + return (mListView != null || mRecyclerView != null) && + mSectionIndexer != null && + mSections != null; + } + + private void init() { + mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, final int progress, boolean fromUser) { + if (!isReady()) { + return; + } + resetScrubber(); + mScrubberIndicator.setTranslationX((progress * seekBar.getWidth()) / mSections.length); + String section = String.valueOf(mSections[progress]); + scrollToPositionWithOffset(mSectionIndexer.getPositionForSection(progress) + + mHeaderCount, 0); + mScrubberIndicator.setText(section); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + if (!isReady()) { + return; + } + resetScrubber(); + mScrubberIndicator.setAlpha(1f); + mScrubberIndicator.setVisibility(View.VISIBLE); + if (mFadeInOnMotion) { + mFadeHelper.onEvent(ListScrubberFadeHelper.Event.SCRUBBER_TOUCH); + } + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + if (!isReady()) { + return; + } + resetScrubber(); + mScrubberIndicator.animate().alpha(0f).translationYBy(20f) + .setDuration(200).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mScrubberIndicator.setVisibility(View.INVISIBLE); + } + }); + if (mFadeInOnMotion) { + mFadeHelper.onEvent(ListScrubberFadeHelper.Event.SCRUBBER_RELEASE); + } + } + + private void resetScrubber() { + mScrubberIndicator.animate().cancel(); + mScrubberIndicator.setTranslationY(0f); + } + }); + } + + @Override + public void onClick(View v) { + if (v == mFirstIndicator) { + int positionForFirstSection = mSectionIndexer.getPositionForSection(0); + scrollToPositionWithOffset(positionForFirstSection, 0); + } else if (v == mLastIndicator) { + int positionForLastSection = mSectionIndexer.getPositionForSection(mSections.length - 1); + scrollToPositionWithOffset(positionForLastSection, 0); + } + } + + /** + * If set to true, the scrubber appears once the list is scrolled and disappears when + * the list is idle for some predetermined amount of time. + * + * Defaults to false + * + * @param fadeInOnMotion true to enable fading behavior + */ + public void setFadeInOnMotion(boolean fadeInOnMotion) { + if (fadeInOnMotion) { + mFadeHelper = new ListScrubberFadeHelper(mScrubberWidget); + mFadeHelper.updateState(ListScrubberFadeHelper.State.HIDDEN); + } + else { + mFadeHelper = null; + } + mFadeInOnMotion = fadeInOnMotion; + } + + public boolean isFadeInOnMotion() { + return mFadeInOnMotion; + } + + private void scrollToPositionWithOffset(int position, int y) { + if (mListView != null) { + mListView.setSelectionFromTop(position, y); + } + else if (mRecyclerView != null) { + LinearLayoutManager layoutManager = + (LinearLayoutManager)mRecyclerView.getLayoutManager(); + layoutManager.scrollToPositionWithOffset(position, y); + } + } + + public void handleScrollStateChanged(int scrollState) { + if (!mFadeInOnMotion) + return; + + switch (scrollState) { + case AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL: + mFadeHelper.onEvent(ListScrubberFadeHelper.Event.LIST_SCROLL); + break; + case AbsListView.OnScrollListener.SCROLL_STATE_FLING: + mFadeHelper.onEvent(ListScrubberFadeHelper.Event.LIST_FLING); + break; + case AbsListView.OnScrollListener.SCROLL_STATE_IDLE: + mFadeHelper.onEvent(ListScrubberFadeHelper.Event.LIST_IDLE); + break; + } + } + + public void setHeaderCount(int count) { + mHeaderCount = count; + } +}
\ No newline at end of file diff --git a/src/com/cyngn/uicommon/view/ListScrubberFadeHelper.java b/src/com/cyngn/uicommon/view/ListScrubberFadeHelper.java new file mode 100644 index 0000000..7b20c5c --- /dev/null +++ b/src/com/cyngn/uicommon/view/ListScrubberFadeHelper.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2014 The CyanogenMod 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.cyngn.uicommon.view; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.os.Handler; +import android.view.View; +import android.view.ViewGroup; + +/** + * State machine to manage fading the list scrubber in and out when the user interacts + * with the list view or the scrubber itself. + */ +public class ListScrubberFadeHelper { + + private static final int FADE_OUT_DELAY = 2000; + + public static enum State { + VISIBLE, + HIDDEN, + FADING_IN, + FADING_OUT, + FADE_OUT_SCHEDULED, + }; + + public static enum Event { + LIST_FLING, + LIST_SCROLL, + LIST_IDLE, + LIST_IDLE_TIMEOUT, + SCRUBBER_TOUCH, + SCRUBBER_RELEASE, + } + + private ViewGroup mScrubber; + private Handler mHandler; + private Runnable mFadeOutRunnable; + private State mState; + + class FadeOutRunnable implements Runnable { + @Override + public void run() { + onEvent(Event.LIST_IDLE_TIMEOUT); + } + } + + public ListScrubberFadeHelper(ViewGroup scrubber) { + mScrubber = scrubber; + mHandler = new Handler(); + } + + public void onEvent(Event event) { + switch (event) { + case LIST_FLING: + case LIST_SCROLL: + if (mState == State.FADING_OUT || mState == State.FADE_OUT_SCHEDULED) { + cancelFade(); + updateState(State.VISIBLE); + } else if (mState == State.HIDDEN) { + updateState(State.FADING_IN); + } + break; + + case LIST_IDLE: + updateState(State.FADE_OUT_SCHEDULED); + break; + + case LIST_IDLE_TIMEOUT: + updateState(State.FADING_OUT); + break; + + case SCRUBBER_TOUCH: + if (mState == State.FADING_OUT || mState == State.FADE_OUT_SCHEDULED) { + cancelFade(); + } + break; + + case SCRUBBER_RELEASE: + updateState(State.FADE_OUT_SCHEDULED); + break; + } + } + + public void updateState(State state) { + mState = state; + switch (state) { + case VISIBLE: + mScrubber.setAlpha(1f); + mScrubber.setVisibility(View.VISIBLE); + break; + case HIDDEN: + mScrubber.setAlpha(0f); + mScrubber.setVisibility(View.INVISIBLE); + break; + case FADING_IN: + mScrubber.animate().alpha(1f).setDuration(200) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // strange that this is required... + updateState(State.VISIBLE); + } + @Override + public void onAnimationCancel(Animator animation) { + updateState(State.VISIBLE); + } + }); + break; + case FADING_OUT: + mScrubber.animate().alpha(0f).setDuration(200) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // strange that this is required... + updateState(State.HIDDEN); + } + @Override + public void onAnimationCancel(Animator animation) { + updateState(State.HIDDEN); + } + }); + break; + case FADE_OUT_SCHEDULED: + mFadeOutRunnable = new FadeOutRunnable(); + mHandler.postDelayed(mFadeOutRunnable, FADE_OUT_DELAY); + break; + } + } + + private void cancelFade() { + if (mFadeOutRunnable != null) { + mHandler.removeCallbacks(mFadeOutRunnable); + mFadeOutRunnable = null; + } + mScrubber.animate().cancel(); + } +} diff --git a/src/com/cyngn/uicommon/view/ListScrubberSeekBar.java b/src/com/cyngn/uicommon/view/ListScrubberSeekBar.java new file mode 100644 index 0000000..83ce176 --- /dev/null +++ b/src/com/cyngn/uicommon/view/ListScrubberSeekBar.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014 The CyanogenMod 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.cyngn.uicommon.view; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.SeekBar; + +/** + * Override SeekBar for use in list scrubber + */ +public class ListScrubberSeekBar extends SeekBar { + + public ListScrubberSeekBar(Context context) { + super(context); + } + + public ListScrubberSeekBar(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ListScrubberSeekBar(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public boolean isInScrollingContainer() { + // For the list scrubber seek bar we can simplify things by assuming this + // is false. This would only backfire if we attempt to put a list scrubber + // inside a list item. + return false; + } +} |