diff options
Diffstat (limited to 'src/android/support/wearable/view/WearableListView.java')
-rw-r--r-- | src/android/support/wearable/view/WearableListView.java | 1387 |
1 files changed, 1387 insertions, 0 deletions
diff --git a/src/android/support/wearable/view/WearableListView.java b/src/android/support/wearable/view/WearableListView.java new file mode 100644 index 00000000..01baa98b --- /dev/null +++ b/src/android/support/wearable/view/WearableListView.java @@ -0,0 +1,1387 @@ +/* + * Copyright (C) 2015 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 android.support.wearable.view; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.PointF; +import android.os.Build; +import android.os.Handler; +import android.support.v7.widget.LinearSmoothScroller; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Property; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.Scroller; + +import java.util.ArrayList; +import java.util.List; + +/** + * An alternative version of ListView that is optimized for ease of use on small screen wearable + * devices. It displays a vertically scrollable list of items, and automatically snaps to the + * nearest item when the user stops scrolling. + * + * <p> + * For a quick start, you will need to implement a subclass of {@link .Adapter}, + * which will create and bind your views to the {@link .ViewHolder} objects. If you want to add + * more visual treatment to your views when they become the central items of the + * WearableListView, have them implement the {@link .OnCenterProximityListener} interface. + * </p> + */ +@TargetApi(Build.VERSION_CODES.KITKAT_WATCH) +public class WearableListView extends RecyclerView { + @SuppressWarnings("unused") + private static final String TAG = "WearableListView"; + + private static final long FLIP_ANIMATION_DURATION_MS = 150; + private static final long CENTERING_ANIMATION_DURATION_MS = 150; + + private static final float TOP_TAP_REGION_PERCENTAGE = .33f; + private static final float BOTTOM_TAP_REGION_PERCENTAGE = .33f; + + // Each item will occupy one third of the height. + private static final int THIRD = 3; + + private final int mMinFlingVelocity; + private final int mMaxFlingVelocity; + + private boolean mMaximizeSingleItem; + private boolean mCanClick = true; + // WristGesture navigation signals are delivered as KeyEvents. Allow developer to disable them + // for this specific View. It might be cleaner to simply have users re-implement onKeyDown(). + // TOOD: Finalize the disabling mechanism here. + private boolean mGestureNavigationEnabled = true; + private int mTapPositionX; + private int mTapPositionY; + private ClickListener mClickListener; + + private Animator mScrollAnimator; + // This is a little hacky due to the fact that animator provides incremental values instead of + // deltas and scrolling code requires deltas. We animate WearableListView directly and use this + // field to calculate deltas. Obviously this means that only one scrolling algorithm can run at + // a time, but I don't think it would be wise to have more than one running. + private int mLastScrollChange; + + private SetScrollVerticallyProperty mSetScrollVerticallyProperty = + new SetScrollVerticallyProperty(); + + private final List<OnScrollListener> mOnScrollListeners = new ArrayList<OnScrollListener>(); + + private final List<OnCentralPositionChangedListener> mOnCentralPositionChangedListeners = + new ArrayList<OnCentralPositionChangedListener>(); + + private OnOverScrollListener mOverScrollListener; + + private boolean mGreedyTouchMode; + + private float mStartX; + + private float mStartY; + + private float mStartFirstTop; + + private final int mTouchSlop; + + private boolean mPossibleVerticalSwipe; + + private int mInitialOffset = 0; + + private Scroller mScroller; + + // Top and bottom boundaries for tap checking. Need to recompute by calling computeTapRegions + // before referencing. + private final float[] mTapRegions = new float[2]; + + private boolean mGestureDirectionLocked; + private int mPreviousCentral = 0; + + // Temp variable for storing locations on screen. + private final int[] mLocation = new int[2]; + + // TODO: Consider clearing this when underlying data set changes. If the data set changes, you + // can't safely assume that this pressed view is in the same place as it was before and it will + // receive setPressed(false) unnecessarily. In theory it should be fine, but in practice we + // have places like this: mIconView.setCircleColor(pressed ? mPressedColor : mSelectedColor); + // This might set selected color on non selected item. Our logic should be: if you change + // underlying data set, all best are off and you need to preserve the state; we will clear + // this field. However, I am not willing to introduce this so late in C development. + private View mPressedView = null; + + private final Runnable mPressedRunnable = new Runnable() { + @Override + public void run() { + if (getChildCount() > 0) { + mPressedView = getChildAt(findCenterViewIndex()); + mPressedView.setPressed(true); + } else { + Log.w(TAG, "mPressedRunnable: the children were removed, skipping."); + } + } + }; + + private final Runnable mReleasedRunnable = new Runnable() { + @Override + public void run() { + releasePressedItem(); + } + }; + + private Runnable mNotifyChildrenPostLayoutRunnable = new Runnable() { + @Override + public void run() { + notifyChildrenAboutProximity(false); + } + }; + + private final AdapterDataObserver mObserver = new AdapterDataObserver() { + @Override + public void onChanged() { + WearableListView.this.addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + WearableListView.this.removeOnLayoutChangeListener(this); + if (WearableListView.this.getChildCount() > 0) { + WearableListView.this.animateToCenter(); + } + } + }); + } + }; + + public WearableListView(Context context) { + this(context, null); + } + + public WearableListView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public WearableListView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setHasFixedSize(true); + setOverScrollMode(View.OVER_SCROLL_NEVER); + setLayoutManager(new LayoutManager()); + + final RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(RecyclerView recyclerView, int newState) { + if (newState == RecyclerView.SCROLL_STATE_IDLE && getChildCount() > 0) { + handleTouchUp(null, newState); + } + for (OnScrollListener listener : mOnScrollListeners) { + listener.onScrollStateChanged(newState); + } + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + onScroll(dy); + } + }; + setOnScrollListener(onScrollListener); + + final ViewConfiguration vc = ViewConfiguration.get(context); + mTouchSlop = vc.getScaledTouchSlop(); + + mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); + mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity(); + } + + @Override + public void setAdapter(RecyclerView.Adapter adapter) { + RecyclerView.Adapter currentAdapter = getAdapter(); + if (currentAdapter != null) { + currentAdapter.unregisterAdapterDataObserver(mObserver); + } + + super.setAdapter(adapter); + + if (adapter != null) { + adapter.registerAdapterDataObserver(mObserver); + } + } + + /** + * @return the position of the center child's baseline; -1 if no center child exists or if + * the center child does not return a valid baseline. + */ + @Override + public int getBaseline() { + // No children implies there is no center child for which a baseline can be computed. + if (getChildCount() == 0) { + return super.getBaseline(); + } + + // Compute the baseline of the center child. + final int centerChildIndex = findCenterViewIndex(); + final int centerChildBaseline = getChildAt(centerChildIndex).getBaseline(); + + // If the center child has no baseline, neither does this list view. + if (centerChildBaseline == -1) { + return super.getBaseline(); + } + + return getCentralViewTop() + centerChildBaseline; + } + + /** + * @return true if the list is scrolled all the way to the top. + */ + public boolean isAtTop() { + if (getChildCount() == 0) { + return true; + } + + int centerChildIndex = findCenterViewIndex(); + View centerView = getChildAt(centerChildIndex); + return getChildAdapterPosition(centerView) == 0 && + getScrollState() == RecyclerView.SCROLL_STATE_IDLE; + } + + /** + * Clears the state of the layout manager that positions list items. + */ + public void resetLayoutManager() { + setLayoutManager(new LayoutManager()); + } + + /** + * Controls whether WearableListView should intercept all touch events and also prevent the + * parent from receiving them. + * @param greedy If true it will intercept all touch events. + */ + public void setGreedyTouchMode(boolean greedy) { + mGreedyTouchMode = greedy; + } + + /** + * By default the first element of the list is initially positioned in the center of the screen. + * This method allows the developer to specify a different offset, e.g. to hide the + * WearableListView before the user is allowed to use it. + * + * @param top How far the elements should be pushed down. + */ + public void setInitialOffset(int top) { + mInitialOffset = top; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return false; + } + + if (mGreedyTouchMode && getChildCount() > 0) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + mStartX = event.getX(); + mStartY = event.getY(); + mStartFirstTop = getChildCount() > 0 ? getChildAt(0).getTop() : 0; + mPossibleVerticalSwipe = true; + mGestureDirectionLocked = false; + } else if (action == MotionEvent.ACTION_MOVE && mPossibleVerticalSwipe) { + handlePossibleVerticalSwipe(event); + } + getParent().requestDisallowInterceptTouchEvent(mPossibleVerticalSwipe); + } + return super.onInterceptTouchEvent(event); + } + + private boolean handlePossibleVerticalSwipe(MotionEvent event) { + if (mGestureDirectionLocked) { + return mPossibleVerticalSwipe; + } + float deltaX = Math.abs(mStartX - event.getX()); + float deltaY = Math.abs(mStartY - event.getY()); + float distance = (deltaX * deltaX) + (deltaY * deltaY); + // Verify that the distance moved in the combined x/y direction is at + // least touch slop before determining the gesture direction. + if (distance > (mTouchSlop * mTouchSlop)) { + if (deltaX > deltaY) { + mPossibleVerticalSwipe = false; + } + mGestureDirectionLocked = true; + } + return mPossibleVerticalSwipe; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return false; + } + + // super.onTouchEvent can change the state of the scroll, keep a copy so that handleTouchUp + // can exit early if scrollState != IDLE when the touch event started. + int scrollState = getScrollState(); + boolean result = super.onTouchEvent(event); + if (getChildCount() > 0) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + handleTouchDown(event); + } else if (action == MotionEvent.ACTION_UP) { + handleTouchUp(event, scrollState); + getParent().requestDisallowInterceptTouchEvent(false); + } else if (action == MotionEvent.ACTION_MOVE) { + if (Math.abs(mTapPositionX - (int) event.getX()) >= mTouchSlop || + Math.abs(mTapPositionY - (int) event.getY()) >= mTouchSlop) { + releasePressedItem(); + mCanClick = false; + } + result |= handlePossibleVerticalSwipe(event); + getParent().requestDisallowInterceptTouchEvent(mPossibleVerticalSwipe); + } else if (action == MotionEvent.ACTION_CANCEL) { + getParent().requestDisallowInterceptTouchEvent(false); + mCanClick = true; + } + } + return result; + } + + private void releasePressedItem() { + if (mPressedView != null) { + mPressedView.setPressed(false); + mPressedView = null; + } + Handler handler = getHandler(); + if (handler != null) { + handler.removeCallbacks(mPressedRunnable); + } + } + + private void onScroll(int dy) { + for (OnScrollListener listener : mOnScrollListeners) { + listener.onScroll(dy); + } + notifyChildrenAboutProximity(true); + } + + /** + * Adds a listener that will be called when the content of the list view is scrolled. + */ + public void addOnScrollListener(OnScrollListener listener) { + mOnScrollListeners.add(listener); + } + + /** + * Removes listener for scroll events. + */ + public void removeOnScrollListener(OnScrollListener listener) { + mOnScrollListeners.remove(listener); + } + + /** + * Adds a listener that will be called when the central item of the list changes. + */ + public void addOnCentralPositionChangedListener(OnCentralPositionChangedListener listener) { + mOnCentralPositionChangedListeners.add(listener); + } + + /** + * Removes a listener that would be called when the central item of the list changes. + */ + public void removeOnCentralPositionChangedListener(OnCentralPositionChangedListener listener) { + mOnCentralPositionChangedListeners.remove(listener); + } + + /** + * Determines if navigation of list with wrist gestures is enabled. + */ + public boolean isGestureNavigationEnabled() { + return mGestureNavigationEnabled; + } + + /** + * Sets whether navigation of list with wrist gestures is enabled. + */ + public void setEnableGestureNavigation(boolean enabled) { + mGestureNavigationEnabled = enabled; + } + + @Override /* KeyEvent.Callback */ + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Respond to keycodes (at least originally generated and injected by wrist gestures). + if (mGestureNavigationEnabled) { + switch (keyCode) { + case KeyEvent.KEYCODE_NAVIGATE_PREVIOUS: + fling(0, -mMinFlingVelocity); + return true; + case KeyEvent.KEYCODE_NAVIGATE_NEXT: + fling(0, mMinFlingVelocity); + return true; + case KeyEvent.KEYCODE_NAVIGATE_IN: + return tapCenterView(); + case KeyEvent.KEYCODE_NAVIGATE_OUT: + // Returing false leaves the action to the container of this WearableListView + // (e.g. finishing the activity containing this WearableListView). + return false; + } + } + return super.onKeyDown(keyCode, event); + } + + /** + * Simulate tapping the child view at the center of this list. + */ + private boolean tapCenterView() { + if (!isEnabled() || getVisibility() != View.VISIBLE) { + return false; + } + int index = findCenterViewIndex(); + View view = getChildAt(index); + ViewHolder holder = getChildViewHolder(view); + if (mClickListener != null) { + mClickListener.onClick(holder); + return true; + } + return false; + } + + private boolean checkForTap(MotionEvent event) { + // No taps are accepted if this view is disabled. + if (!isEnabled()) { + return false; + } + + float rawY = event.getRawY(); + int index = findCenterViewIndex(); + View view = getChildAt(index); + ViewHolder holder = getChildViewHolder(view); + computeTapRegions(mTapRegions); + if (rawY > mTapRegions[0] && rawY < mTapRegions[1]) { + if (mClickListener != null) { + mClickListener.onClick(holder); + } + return true; + } + if (index > 0 && rawY <= mTapRegions[0]) { + animateToMiddle(index - 1, index); + return true; + } + if (index < getChildCount() - 1 && rawY >= mTapRegions[1]) { + animateToMiddle(index + 1, index); + return true; + } + if (index == 0 && rawY <= mTapRegions[0] && mClickListener != null) { + // Special case: if the top third of the screen is empty and the touch event happens + // there, we don't want to immediately disallow the parent from using it. We tell + // parent to disallow intercept only after we locked a gesture. Before that he + // might do something with the action. + mClickListener.onTopEmptyRegionClick(); + return true; + } + return false; + } + + private void animateToMiddle(int newCenterIndex, int oldCenterIndex) { + if (newCenterIndex == oldCenterIndex) { + throw new IllegalArgumentException( + "newCenterIndex must be different from oldCenterIndex"); + } + List<Animator> animators = new ArrayList<Animator>(); + View child = getChildAt(newCenterIndex); + int scrollToMiddle = getCentralViewTop() - child.getTop(); + startScrollAnimation(animators, scrollToMiddle, FLIP_ANIMATION_DURATION_MS); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration) { + startScrollAnimation(animators, scroll, duration, 0); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration, + long delay) { + startScrollAnimation(animators, scroll, duration, delay, null); + } + + private void startScrollAnimation( + int scroll, long duration, long delay, Animator.AnimatorListener listener) { + startScrollAnimation(null, scroll, duration, delay, listener); + } + + private void startScrollAnimation(List<Animator> animators, int scroll, long duration, + long delay, Animator.AnimatorListener listener) { + if (mScrollAnimator != null) { + mScrollAnimator.cancel(); + } + + mLastScrollChange = 0; + ObjectAnimator scrollAnimator = ObjectAnimator.ofInt(this, mSetScrollVerticallyProperty, + 0, -scroll); + + if (animators != null) { + animators.add(scrollAnimator); + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(animators); + mScrollAnimator = animatorSet; + } else { + mScrollAnimator = scrollAnimator; + } + mScrollAnimator.setDuration(duration); + if (listener != null) { + mScrollAnimator.addListener(listener); + } + if (delay > 0) { + mScrollAnimator.setStartDelay(delay); + } + mScrollAnimator.start(); + } + + @Override + public boolean fling(int velocityX, int velocityY) { + if (getChildCount() == 0) { + return false; + } + // If we are flinging towards empty space (before first element or after last), we reuse + // original flinging mechanism. + final int index = findCenterViewIndex(); + final View child = getChildAt(index); + int currentPosition = getChildPosition(child); + if ((currentPosition == 0 && velocityY < 0) || + (currentPosition == getAdapter().getItemCount() - 1 && velocityY > 0)) { + return super.fling(velocityX, velocityY); + } + + if (Math.abs(velocityY) < mMinFlingVelocity) { + return false; + } + velocityY = Math.max(Math.min(velocityY, mMaxFlingVelocity), -mMaxFlingVelocity); + + if (mScroller == null) { + mScroller = new Scroller(getContext(), null, true); + } + mScroller.fling(0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, + Integer.MIN_VALUE, Integer.MAX_VALUE); + int finalY = mScroller.getFinalY(); + int delta = finalY / (getPaddingTop() + getAdjustedHeight() / 2); + if (delta == 0) { + // If the fling would not be enough to change position, we increase it to satisfy user's + // intent of switching current position. + delta = velocityY > 0 ? 1 : -1; + } + int finalPosition = Math.max( + 0, Math.min(getAdapter().getItemCount() - 1, currentPosition + delta)); + smoothScrollToPosition(finalPosition); + return true; + } + + public void smoothScrollToPosition(int position, RecyclerView.SmoothScroller smoothScroller) { + LayoutManager layoutManager = (LayoutManager) getLayoutManager(); + layoutManager.setCustomSmoothScroller(smoothScroller); + smoothScrollToPosition(position); + layoutManager.clearCustomSmoothScroller(); + } + + @Override + public ViewHolder getChildViewHolder(View child) { + return (ViewHolder) super.getChildViewHolder(child); + } + + /** + * Adds a listener that will be called when the user taps on the WearableListView or its items. + */ + public void setClickListener(ClickListener clickListener) { + mClickListener = clickListener; + } + + /** + * Adds a listener that will be called when the user drags the top element below its allowed + * bottom position. + * + * @hide + */ + public void setOverScrollListener(OnOverScrollListener listener) { + mOverScrollListener = listener; + } + + private int findCenterViewIndex() { + // TODO(gruszczy): This could be easily optimized, so that we stop looking when we the + // distance starts growing again, instead of finding the closest. It would safe half of + // the loop. + int count = getChildCount(); + int index = -1; + int closest = Integer.MAX_VALUE; + int centerY = getCenterYPos(this); + for (int i = 0; i < count; ++i) { + final View child = getChildAt(i); + int childCenterY = getTop() + getCenterYPos(child); + final int distance = Math.abs(centerY - childCenterY); + if (distance < closest) { + closest = distance; + index = i; + } + } + if (index == -1) { + throw new IllegalStateException("Can't find central view."); + } + return index; + } + + private static int getCenterYPos(View v) { + return v.getTop() + v.getPaddingTop() + getAdjustedHeight(v) / 2; + } + + private void handleTouchUp(MotionEvent event, int scrollState) { + if (mCanClick && event != null && checkForTap(event)) { + Handler handler = getHandler(); + if (handler != null) { + handler.postDelayed(mReleasedRunnable, ViewConfiguration.getTapTimeout()); + } + return; + } + + if (scrollState != RecyclerView.SCROLL_STATE_IDLE) { + // We are flinging, so let's not start animations just yet. Instead we will start them + // when the fling finishes. + return; + } + + if (isOverScrolling()) { + mOverScrollListener.onOverScroll(); + } else { + animateToCenter(); + } + } + + private boolean isOverScrolling() { + return getChildCount() > 0 + // If first view top was below the central top, it means it was never centered. + // Don't allow overscroll, otherwise a simple touch (instead of a drag) will be + // enough to trigger overscroll. + && mStartFirstTop <= getCentralViewTop() + && getChildAt(0).getTop() >= getTopViewMaxTop() + && mOverScrollListener != null; + } + + private int getTopViewMaxTop() { + return getHeight() / 2; + } + + private int getItemHeight() { + // Round up so that the screen is fully occupied by 3 items. + return getAdjustedHeight() / THIRD + 1; + } + + /** + * Returns top of the central {@code View} in the list when such view is fully centered. + * + * This is a more or a less a static value that you can use to align other views with the + * central one. + */ + public int getCentralViewTop() { + return getPaddingTop() + getItemHeight(); + } + + /** + * Automatically starts an animation that snaps the list to center on the element closest to the + * middle. + */ + public void animateToCenter() { + final int index = findCenterViewIndex(); + final View child = getChildAt(index); + final int scrollToMiddle = getCentralViewTop() - child.getTop(); + startScrollAnimation(scrollToMiddle, CENTERING_ANIMATION_DURATION_MS, 0, + new SimpleAnimatorListener() { + @Override + public void onAnimationEnd(Animator animator) { + if (!wasCanceled()) { + mCanClick = true; + } + } + }); + } + + /** + * Animate the list so that the first view is back to its initial position. + * @param endAction Action to execute when the animation is done. + * @hide + */ + public void animateToInitialPosition(final Runnable endAction) { + final View child = getChildAt(0); + final int scrollToMiddle = getCentralViewTop() + mInitialOffset - child.getTop(); + startScrollAnimation(scrollToMiddle, CENTERING_ANIMATION_DURATION_MS, 0, + new SimpleAnimatorListener() { + @Override + public void onAnimationEnd(Animator animator) { + if (endAction != null) { + endAction.run(); + } + } + }); + } + + private void handleTouchDown(MotionEvent event) { + if (mCanClick) { + mTapPositionX = (int) event.getX(); + mTapPositionY = (int) event.getY(); + float rawY = event.getRawY(); + computeTapRegions(mTapRegions); + if (rawY > mTapRegions[0] && rawY < mTapRegions[1]) { + View view = getChildAt(findCenterViewIndex()); + if (view instanceof OnCenterProximityListener) { + Handler handler = getHandler(); + if (handler != null) { + handler.removeCallbacks(mReleasedRunnable); + handler.postDelayed(mPressedRunnable, ViewConfiguration.getTapTimeout()); + } + } + } + } + } + + private void setScrollVertically(int scroll) { + scrollBy(0, scroll - mLastScrollChange); + mLastScrollChange = scroll; + } + + private int getAdjustedHeight() { + return getAdjustedHeight(this); + } + + private static int getAdjustedHeight(View v) { + return v.getHeight() - v.getPaddingBottom() - v.getPaddingTop(); + } + + private void computeTapRegions(float[] tapRegions) { + mLocation[0] = mLocation[1] = 0; + getLocationOnScreen(mLocation); + int mScreenTop = mLocation[1]; + int height = getHeight(); + tapRegions[0] = mScreenTop + height * TOP_TAP_REGION_PERCENTAGE; + tapRegions[1] = mScreenTop + height * (1 - BOTTOM_TAP_REGION_PERCENTAGE); + } + + /** + * Determines if, when there is only one item in the WearableListView, that the single item + * is laid out so that it's height fills the entire WearableListView. + */ + public boolean getMaximizeSingleItem() { + return mMaximizeSingleItem; + } + + /** + * When set to true, if there is only one item in the WearableListView, it will fill the entire + * WearableListView. When set to false, the default behavior will be used and the single item + * will fill only a third of the screen. + */ + public void setMaximizeSingleItem(boolean maximizeSingleItem) { + mMaximizeSingleItem = maximizeSingleItem; + } + + private void notifyChildrenAboutProximity(boolean animate) { + LayoutManager layoutManager = (LayoutManager) getLayoutManager(); + int count = layoutManager.getChildCount(); + + if (count == 0) { + return; + } + + int index = layoutManager.findCenterViewIndex(); + for (int i = 0; i < count; ++i) { + final View view = layoutManager.getChildAt(i); + ViewHolder holder = getChildViewHolder(view); + holder.onCenterProximity(i == index, animate); + } + final int position = getChildViewHolder(getChildAt(index)).getPosition(); + if (position != mPreviousCentral) { + for (OnScrollListener listener : mOnScrollListeners) { + listener.onCentralPositionChanged(position); + } + for (OnCentralPositionChangedListener listener : + mOnCentralPositionChangedListeners) { + listener.onCentralPositionChanged(position); + } + mPreviousCentral = position; + } + } + + // TODO: Move this to a separate class, so it can't directly interact with the WearableListView. + private class LayoutManager extends RecyclerView.LayoutManager { + private int mFirstPosition; + + private boolean mPushFirstHigher; + + private int mAbsoluteScroll; + + private boolean mUseOldViewTop = true; + + private boolean mWasZoomedIn = false; + + private RecyclerView.SmoothScroller mSmoothScroller; + + private RecyclerView.SmoothScroller mDefaultSmoothScroller; + + // We need to have another copy of the same method, because this one uses + // LayoutManager.getChildCount/getChildAt instead of View.getChildCount/getChildAt and + // they return different values. + private int findCenterViewIndex() { + // TODO(gruszczy): This could be easily optimized, so that we stop looking when we the + // distance starts growing again, instead of finding the closest. It would safe half of + // the loop. + int count = getChildCount(); + int index = -1; + int closest = Integer.MAX_VALUE; + int centerY = getCenterYPos(WearableListView.this); + for (int i = 0; i < count; ++i) { + final View child = getLayoutManager().getChildAt(i); + int childCenterY = getTop() + getCenterYPos(child); + final int distance = Math.abs(centerY - childCenterY); + if (distance < closest) { + closest = distance; + index = i; + } + } + if (index == -1) { + throw new IllegalStateException("Can't find central view."); + } + return index; + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, State state) { + final int parentBottom = getHeight() - getPaddingBottom(); + // By default we assume this is the first run and the first element will be centered + // with optional initial offset. + int oldTop = getCentralViewTop() + mInitialOffset; + // Here we handle any other situation where we relayout or we want to achieve a + // specific layout of children. + if (mUseOldViewTop && getChildCount() > 0) { + // We are performing a relayout after we already had some children, because e.g. the + // contents of an adapter has changed. First we want to check, if the central item + // from before the layout is still here, because we want to preserve it. + int index = findCenterViewIndex(); + int position = getPosition(getChildAt(index)); + if (position == NO_POSITION) { + // Central item was removed. Let's find the first surviving item and use it + // as an anchor. + for (int i = 0, N = getChildCount(); index + i < N || index - i >= 0; ++i) { + View child = getChildAt(index + i); + if (child != null) { + position = getPosition(child); + if (position != NO_POSITION) { + index = index + i; + break; + } + } + child = getChildAt(index - i); + if (child != null) { + position = getPosition(child); + if (position != NO_POSITION) { + index = index - i; + break; + } + } + } + } + if (position == NO_POSITION) { + // None of the children survives the relayout, let's just use the top of the + // first one. + oldTop = getChildAt(0).getTop(); + int count = state.getItemCount(); + // Lets first make sure that the first position is not above the last element, + // which can happen if elements were removed. + while (mFirstPosition >= count && mFirstPosition > 0) { + mFirstPosition--; + } + } else { + // Some of the children survived the relayout. We will keep it in its place, + // but go through previous children and maybe add them. + if (!mWasZoomedIn) { + // If we were previously zoomed-in on a single item, ignore this and just + // use the default value set above. Reasoning: if we are still zoomed-in, + // oldTop will be ignored when laying out the single child element. If we + // are no longer zoomed in, then we want to position items using the top + // of the single item as if the single item was not zoomed in, which is + // equal to the default value. + oldTop = getChildAt(index).getTop(); + } + while (oldTop > getPaddingTop() && position > 0) { + position--; + oldTop -= getItemHeight(); + } + if (position == 0 && oldTop > getCentralViewTop()) { + // We need to handle special case where the first, central item was removed + // and now the first element is hanging below, instead of being nicely + // centered. + oldTop = getCentralViewTop(); + } + mFirstPosition = position; + } + } else if (mPushFirstHigher) { + // We are trying to position elements ourselves, so we force position of the first + // one. + oldTop = getCentralViewTop() - getItemHeight(); + } + + performLayoutChildren(recycler, state, parentBottom, oldTop); + + // Since the content might have changed, we need to adjust the absolute scroll in case + // some elements have disappeared or were added. + if (getChildCount() == 0) { + setAbsoluteScroll(0); + } else { + View child = getChildAt(findCenterViewIndex()); + setAbsoluteScroll(child.getTop() - getCentralViewTop() + getPosition(child) * + getItemHeight()); + } + + mUseOldViewTop = true; + mPushFirstHigher = false; + } + + private void performLayoutChildren(Recycler recycler, State state, int parentBottom, + int top) { + detachAndScrapAttachedViews(recycler); + + if (mMaximizeSingleItem && state.getItemCount() == 1) { + performLayoutOneChild(recycler, parentBottom); + mWasZoomedIn = true; + } else { + performLayoutMultipleChildren(recycler, state, parentBottom, top); + mWasZoomedIn = false; + } + + if (getChildCount() > 0) { + post(mNotifyChildrenPostLayoutRunnable); + } + } + + private void performLayoutOneChild(Recycler recycler, int parentBottom) { + final int right = getWidth() - getPaddingRight(); + View v = recycler.getViewForPosition(getFirstPosition()); + addView(v, 0); + measureZoomView(v); + v.layout(getPaddingLeft(), getPaddingTop(), right, parentBottom); + } + + private void performLayoutMultipleChildren(Recycler recycler, State state, int parentBottom, + int top) { + int bottom; + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + final int count = state.getItemCount(); + // If we are laying out children with center element being different than the first, we + // need to start with previous child which appears half visible at the top. + for (int i = 0; getFirstPosition() + i < count; i++, top = bottom) { + if (top >= parentBottom) { + break; + } + View v = recycler.getViewForPosition(getFirstPosition() + i); + addView(v, i); + measureThirdView(v); + bottom = top + getItemHeight(); + v.layout(left, top, right, bottom); + } + } + + private void setAbsoluteScroll(int absoluteScroll) { + mAbsoluteScroll = absoluteScroll; + for (OnScrollListener listener : mOnScrollListeners) { + listener.onAbsoluteScrollChange(mAbsoluteScroll); + } + } + + private void measureView(View v, int height) { + final LayoutParams lp = (LayoutParams) v.getLayoutParams(); + final int widthSpec = getChildMeasureSpec(getWidth(), + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin, lp.width, + canScrollHorizontally()); + final int heightSpec = getChildMeasureSpec(getHeight(), + getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin, + height, canScrollVertically()); + v.measure(widthSpec, heightSpec); + } + + private void measureThirdView(View v) { + measureView(v, (int) (1 + (float) getHeight() / THIRD)); + } + + private void measureZoomView(View v) { + measureView(v, getHeight()); + } + + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + + @Override + public boolean canScrollVertically() { + // Disable vertical scrolling when zoomed. + return getItemCount() != 1 || !mWasZoomedIn; + } + + @Override + public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, State state) { + // TODO(gruszczy): This code is shit, needs to be rewritten. + if (getChildCount() == 0) { + return 0; + } + int scrolled = 0; + final int left = getPaddingLeft(); + final int right = getWidth() - getPaddingRight(); + if (dy < 0) { + while (scrolled > dy) { + final View topView = getChildAt(0); + if (getFirstPosition() > 0) { + final int hangingTop = Math.max(-topView.getTop(), 0); + final int scrollBy = Math.min(scrolled - dy, hangingTop); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + if (getFirstPosition() > 0 && scrolled > dy) { + mFirstPosition--; + View v = recycler.getViewForPosition(getFirstPosition()); + addView(v, 0); + measureThirdView(v); + final int bottom = topView.getTop(); + final int top = bottom - getItemHeight(); + v.layout(left, top, right, bottom); + } else { + break; + } + } else { + mPushFirstHigher = false; + int maxScroll = mOverScrollListener!= null ? + getHeight() : getTopViewMaxTop(); + final int scrollBy = Math.min(-dy + scrolled, maxScroll - topView.getTop()); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + break; + } + } + } else if (dy > 0) { + final int parentHeight = getHeight(); + while (scrolled < dy) { + final View bottomView = getChildAt(getChildCount() - 1); + if (state.getItemCount() > mFirstPosition + getChildCount()) { + final int hangingBottom = + Math.max(bottomView.getBottom() - parentHeight, 0); + final int scrollBy = -Math.min(dy - scrolled, hangingBottom); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + if (scrolled < dy) { + View v = recycler.getViewForPosition(mFirstPosition + getChildCount()); + final int top = getChildAt(getChildCount() - 1).getBottom(); + addView(v); + measureThirdView(v); + final int bottom = top + getItemHeight(); + v.layout(left, top, right, bottom); + } else { + break; + } + } else { + final int scrollBy = + Math.max(-dy + scrolled, getHeight() / 2 - bottomView.getBottom()); + scrolled -= scrollBy; + offsetChildrenVertical(scrollBy); + break; + } + } + } + recycleViewsOutOfBounds(recycler); + setAbsoluteScroll(mAbsoluteScroll + scrolled); + return scrolled; + } + + @Override + public void scrollToPosition(int position) { + mUseOldViewTop = false; + if (position > 0) { + mFirstPosition = position - 1; + mPushFirstHigher = true; + } else { + mFirstPosition = position; + mPushFirstHigher = false; + } + requestLayout(); + } + + public void setCustomSmoothScroller(RecyclerView.SmoothScroller smoothScroller) { + mSmoothScroller = smoothScroller; + } + + public void clearCustomSmoothScroller() { + mSmoothScroller = null; + } + + public RecyclerView.SmoothScroller getDefaultSmoothScroller(RecyclerView recyclerView) { + if (mDefaultSmoothScroller == null) { + mDefaultSmoothScroller = new SmoothScroller( + recyclerView.getContext(), this); + } + return mDefaultSmoothScroller; + } + @Override + public void smoothScrollToPosition(RecyclerView recyclerView, State state, + int position) { + RecyclerView.SmoothScroller scroller = mSmoothScroller; + if (scroller == null) { + scroller = getDefaultSmoothScroller(recyclerView); + } + scroller.setTargetPosition(position); + startSmoothScroll(scroller); + } + + private void recycleViewsOutOfBounds(RecyclerView.Recycler recycler) { + final int childCount = getChildCount(); + final int parentWidth = getWidth(); + // Here we want to use real height, so we don't remove views that are only visible in + // padded section. + final int parentHeight = getHeight(); + boolean foundFirst = false; + int first = 0; + int last = 0; + for (int i = 0; i < childCount; i++) { + final View v = getChildAt(i); + if (v.hasFocus() || (v.getRight() >= 0 && v.getLeft() <= parentWidth && + v.getBottom() >= 0 && v.getTop() <= parentHeight)) { + if (!foundFirst) { + first = i; + foundFirst = true; + } + last = i; + } + } + for (int i = childCount - 1; i > last; i--) { + removeAndRecycleViewAt(i, recycler); + } + for (int i = first - 1; i >= 0; i--) { + removeAndRecycleViewAt(i, recycler); + } + if (getChildCount() == 0) { + mFirstPosition = 0; + } else if (first > 0) { + mPushFirstHigher = true; + mFirstPosition += first; + } + } + + public int getFirstPosition() { + return mFirstPosition; + } + + @Override + public void onAdapterChanged(RecyclerView.Adapter oldAdapter, + RecyclerView.Adapter newAdapter) { + removeAllViews(); + } + } + + /** + * Interface for receiving callbacks when WearableListView children become or cease to be the + * central item. + */ + public interface OnCenterProximityListener { + /** + * Called when this view becomes central item of the WearableListView. + * + * @param animate Whether you should animate your transition of the View to become the + * central item. If false, this is the initial setting and you should + * transition immediately. + */ + void onCenterPosition(boolean animate); + + /** + * Called when this view stops being the central item of the WearableListView. + * @param animate Whether you should animate your transition of the View to being + * non central item. If false, this is the initial setting and you should + * transition immediately. + */ + void onNonCenterPosition(boolean animate); + } + + /** + * Interface for listening for click events on WearableListView. + */ + public interface ClickListener { + /** + * Called when the central child of the WearableListView is tapped. + * @param view View that was clicked. + */ + public void onClick(ViewHolder view); + + /** + * Called when the user taps the top third of the WearableListView and no item is present + * there. This can happen when you are in initial state and the first, top-most item of the + * WearableListView is centered. + */ + public void onTopEmptyRegionClick(); + } + + /** + * @hide + */ + public interface OnOverScrollListener { + public void onOverScroll(); + } + + /** + * Interface for listening to WearableListView content scrolling. + */ + public interface OnScrollListener { + /** + * Called when the content is scrolled, reporting the relative scroll value. + * @param scroll Amount the content was scrolled. This is a delta from the previous + * position to the new position. + */ + public void onScroll(int scroll); + + /** + * Called when the content is scrolled, reporting the absolute scroll value. + * + * @deprecated BE ADVISED DO NOT USE THIS This might provide wrong values when contents + * of a RecyclerView change. + * + * @param scroll Absolute scroll position of the content inside the WearableListView. + */ + @Deprecated + public void onAbsoluteScrollChange(int scroll); + + /** + * Called when WearableListView's scroll state changes. + * + * @param scrollState The updated scroll state. One of {@link #SCROLL_STATE_IDLE}, + * {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}. + */ + public void onScrollStateChanged(int scrollState); + + /** + * Called when the central item of the WearableListView changes. + * + * @param centralPosition Position of the item in the Adapter. + */ + public void onCentralPositionChanged(int centralPosition); + } + + /** + * A listener interface that can be added to the WearableListView to get notified when the + * central item is changed. + */ + public interface OnCentralPositionChangedListener { + /** + * Called when the central item of the WearableListView changes. + * + * @param centralPosition Position of the item in the Adapter. + */ + void onCentralPositionChanged(int centralPosition); + } + + /** + * Base class for adapters providing data for the WearableListView. For details refer to + * RecyclerView.Adapter. + */ + public static abstract class Adapter extends RecyclerView.Adapter<ViewHolder> { + } + + private static class SmoothScroller extends LinearSmoothScroller { + + private static final float MILLISECONDS_PER_INCH = 100f; + + private final LayoutManager mLayoutManager; + + public SmoothScroller(Context context, WearableListView.LayoutManager manager) { + super(context); + mLayoutManager = manager; + } + + @Override + protected void onStart() { + super.onStart(); + } + + // TODO: (mindyp): when flinging, return the dydt that triggered the fling. + @Override + protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { + return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; + } + + @Override + public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int + snapPreference) { + // Snap to center. + return (boxStart + boxEnd) / 2 - (viewStart + viewEnd) / 2; + } + + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + if (targetPosition < mLayoutManager.getFirstPosition()) { + return new PointF(0, -1); + } else { + return new PointF(0, 1); + } + } + } + + /** + * Wrapper around items displayed in the list view. {@link .Adapter} must return objects that + * are instances of this class. Consider making the wrapped View implement + * {@link .OnCenterProximityListener} if you want to receive a callback when it becomes or + * ceases to be the central item in the WearableListView. + */ + public static class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(View itemView) { + super(itemView); + } + + /** + * Called when the wrapped view is becoming or ceasing to be the central item of the + * WearableListView. + * + * Retained as protected for backwards compatibility. + * + * @hide + */ + protected void onCenterProximity(boolean isCentralItem, boolean animate) { + if (!(itemView instanceof OnCenterProximityListener)) { + return; + } + OnCenterProximityListener item = (OnCenterProximityListener) itemView; + if (isCentralItem) { + item.onCenterPosition(animate); + } else { + item.onNonCenterPosition(animate); + } + } + } + + private class SetScrollVerticallyProperty extends Property<WearableListView, Integer> { + public SetScrollVerticallyProperty() { + super(Integer.class, "scrollVertically"); + } + + @Override + public Integer get(WearableListView wearableListView) { + return wearableListView.mLastScrollChange; + } + + @Override + public void set(WearableListView wearableListView, Integer value) { + wearableListView.setScrollVertically(value); + } + } +} |