/* * 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. * *

* 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. *

*/ @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 mOnScrollListeners = new ArrayList(); private final List mOnCentralPositionChangedListeners = new ArrayList(); 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 animators = new ArrayList(); View child = getChildAt(newCenterIndex); int scrollToMiddle = getCentralViewTop() - child.getTop(); startScrollAnimation(animators, scrollToMiddle, FLIP_ANIMATION_DURATION_MS); } private void startScrollAnimation(List animators, int scroll, long duration) { startScrollAnimation(animators, scroll, duration, 0); } private void startScrollAnimation(List 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 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 { } 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 { 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); } } }