1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 
17 package androidx.wear.ble.view;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorSet;
21 import android.animation.ObjectAnimator;
22 import android.annotation.TargetApi;
23 import android.content.Context;
24 import android.graphics.PointF;
25 import android.os.Build;
26 import android.os.Handler;
27 import android.util.AttributeSet;
28 import android.util.DisplayMetrics;
29 import android.util.Log;
30 import android.util.Property;
31 import android.view.KeyEvent;
32 import android.view.MotionEvent;
33 import android.view.View;
34 import android.view.ViewConfiguration;
35 import android.view.ViewGroup;
36 import android.widget.Scroller;
37 
38 import androidx.recyclerview.widget.LinearSmoothScroller;
39 import androidx.recyclerview.widget.RecyclerView;
40 
41 import java.util.ArrayList;
42 import java.util.List;
43 
44 /**
45  * An alternative version of ListView that is optimized for ease of use on small screen wearable
46  * devices. It displays a vertically scrollable list of items, and automatically snaps to the
47  * nearest item when the user stops scrolling.
48  *
49  * <p>
50  * For a quick start, you will need to implement a subclass of {@link .Adapter},
51  * which will create and bind your views to the {@link .ViewHolder} objects. If you want to add
52  * more visual treatment to your views when they become the central items of the
53  * WearableListView, have them implement the {@link .OnCenterProximityListener} interface.
54  * </p>
55  */
56 @TargetApi(Build.VERSION_CODES.KITKAT_WATCH)
57 public class WearableListView extends RecyclerView {
58     @SuppressWarnings("unused")
59     private static final String TAG = "WearableListView";
60 
61     private static final long FLIP_ANIMATION_DURATION_MS = 150;
62     private static final long CENTERING_ANIMATION_DURATION_MS = 150;
63 
64     private static final float TOP_TAP_REGION_PERCENTAGE = .33f;
65     private static final float BOTTOM_TAP_REGION_PERCENTAGE = .33f;
66 
67     // Each item will occupy one third of the height.
68     private static final int THIRD = 3;
69 
70     private final int mMinFlingVelocity;
71     private final int mMaxFlingVelocity;
72 
73     private boolean mMaximizeSingleItem;
74     private boolean mCanClick = true;
75     // WristGesture navigation signals are delivered as KeyEvents. Allow developer to disable them
76     // for this specific View. It might be cleaner to simply have users re-implement onKeyDown().
77     // TOOD: Finalize the disabling mechanism here.
78     private boolean mGestureNavigationEnabled = true;
79     private int mTapPositionX;
80     private int mTapPositionY;
81     private ClickListener mClickListener;
82 
83     private Animator mScrollAnimator;
84     // This is a little hacky due to the fact that animator provides incremental values instead of
85     // deltas and scrolling code requires deltas. We animate WearableListView directly and use this
86     // field to calculate deltas. Obviously this means that only one scrolling algorithm can run at
87     // a time, but I don't think it would be wise to have more than one running.
88     private int mLastScrollChange;
89 
90     private SetScrollVerticallyProperty mSetScrollVerticallyProperty =
91             new SetScrollVerticallyProperty();
92 
93     private final List<OnScrollListener> mOnScrollListeners = new ArrayList<OnScrollListener>();
94 
95     private final List<OnCentralPositionChangedListener> mOnCentralPositionChangedListeners =
96             new ArrayList<OnCentralPositionChangedListener>();
97 
98     private OnOverScrollListener mOverScrollListener;
99 
100     private boolean mGreedyTouchMode;
101 
102     private float mStartX;
103 
104     private float mStartY;
105 
106     private float mStartFirstTop;
107 
108     private final int mTouchSlop;
109 
110     private boolean mPossibleVerticalSwipe;
111 
112     private int mInitialOffset = 0;
113 
114     private Scroller mScroller;
115 
116     // Top and bottom boundaries for tap checking.  Need to recompute by calling computeTapRegions
117     // before referencing.
118     private final float[] mTapRegions = new float[2];
119 
120     private boolean mGestureDirectionLocked;
121     private int mPreviousCentral = 0;
122 
123     // Temp variable for storing locations on screen.
124     private final int[] mLocation = new int[2];
125 
126     // TODO: Consider clearing this when underlying data set changes. If the data set changes, you
127     // can't safely assume that this pressed view is in the same place as it was before and it will
128     // receive setPressed(false) unnecessarily. In theory it should be fine, but in practice we
129     // have places like this: mIconView.setCircleColor(pressed ? mPressedColor : mSelectedColor);
130     // This might set selected color on non selected item. Our logic should be: if you change
131     // underlying data set, all best are off and you need to preserve the state; we will clear
132     // this field. However, I am not willing to introduce this so late in C development.
133     private View mPressedView = null;
134 
135     private final Runnable mPressedRunnable = new Runnable() {
136         @Override
137         public void run() {
138             if (getChildCount() > 0) {
139                 mPressedView = getChildAt(findCenterViewIndex());
140                 mPressedView.setPressed(true);
141             } else {
142                 Log.w(TAG, "mPressedRunnable: the children were removed, skipping.");
143             }
144         }
145     };
146 
147     private final Runnable mReleasedRunnable = new Runnable() {
148         @Override
149         public void run() {
150             releasePressedItem();
151         }
152     };
153 
154     private Runnable mNotifyChildrenPostLayoutRunnable = new Runnable() {
155         @Override
156         public void run() {
157             notifyChildrenAboutProximity(false);
158         }
159     };
160 
161     private final AdapterDataObserver mObserver = new AdapterDataObserver() {
162         @Override
163         public void onChanged() {
164             WearableListView.this.addOnLayoutChangeListener(new OnLayoutChangeListener() {
165                 @Override
166                 public void onLayoutChange(View v, int left, int top, int right, int bottom,
167                         int oldLeft, int oldTop, int oldRight, int oldBottom) {
168                     WearableListView.this.removeOnLayoutChangeListener(this);
169                     if (WearableListView.this.getChildCount() > 0) {
170                         WearableListView.this.animateToCenter();
171                     }
172                 }
173             });
174         }
175     };
176 
WearableListView(Context context)177     public WearableListView(Context context) {
178         this(context, null);
179     }
180 
WearableListView(Context context, AttributeSet attrs)181     public WearableListView(Context context, AttributeSet attrs) {
182         this(context, attrs, 0);
183     }
184 
WearableListView(Context context, AttributeSet attrs, int defStyleAttr)185     public WearableListView(Context context, AttributeSet attrs, int defStyleAttr) {
186         super(context, attrs, defStyleAttr);
187         setHasFixedSize(true);
188         setOverScrollMode(View.OVER_SCROLL_NEVER);
189         setLayoutManager(new LayoutManager());
190 
191         final RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() {
192             @Override
193             public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
194                 if (newState == RecyclerView.SCROLL_STATE_IDLE && getChildCount() > 0) {
195                     handleTouchUp(null, newState);
196                 }
197                 for (OnScrollListener listener : mOnScrollListeners) {
198                     listener.onScrollStateChanged(newState);
199                 }
200             }
201 
202             @Override
203             public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
204                 onScroll(dy);
205             }
206         };
207         setOnScrollListener(onScrollListener);
208 
209         final ViewConfiguration vc = ViewConfiguration.get(context);
210         mTouchSlop = vc.getScaledTouchSlop();
211 
212         mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
213         mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
214     }
215 
216     @Override
setAdapter(RecyclerView.Adapter adapter)217     public void setAdapter(RecyclerView.Adapter adapter) {
218         RecyclerView.Adapter currentAdapter = getAdapter();
219         if (currentAdapter != null) {
220             currentAdapter.unregisterAdapterDataObserver(mObserver);
221         }
222 
223         super.setAdapter(adapter);
224 
225         if (adapter != null) {
226             adapter.registerAdapterDataObserver(mObserver);
227         }
228     }
229 
230     /**
231      * @return the position of the center child's baseline; -1 if no center child exists or if
232      *      the center child does not return a valid baseline.
233      */
234     @Override
getBaseline()235     public int getBaseline() {
236         // No children implies there is no center child for which a baseline can be computed.
237         if (getChildCount() == 0) {
238             return super.getBaseline();
239         }
240 
241         // Compute the baseline of the center child.
242         final int centerChildIndex = findCenterViewIndex();
243         final int centerChildBaseline = getChildAt(centerChildIndex).getBaseline();
244 
245         // If the center child has no baseline, neither does this list view.
246         if (centerChildBaseline == -1) {
247             return super.getBaseline();
248         }
249 
250         return getCentralViewTop() + centerChildBaseline;
251     }
252 
253     /**
254      * @return true if the list is scrolled all the way to the top.
255      */
isAtTop()256     public boolean isAtTop() {
257         if (getChildCount() == 0) {
258             return true;
259         }
260 
261         int centerChildIndex = findCenterViewIndex();
262         View centerView = getChildAt(centerChildIndex);
263         return getChildAdapterPosition(centerView) == 0 &&
264                 getScrollState() == RecyclerView.SCROLL_STATE_IDLE;
265     }
266 
267     /**
268      * Clears the state of the layout manager that positions list items.
269      */
resetLayoutManager()270     public void resetLayoutManager() {
271         setLayoutManager(new LayoutManager());
272     }
273 
274     /**
275      * Controls whether WearableListView should intercept all touch events and also prevent the
276      * parent from receiving them.
277      * @param greedy If true it will intercept all touch events.
278      */
setGreedyTouchMode(boolean greedy)279     public void setGreedyTouchMode(boolean greedy) {
280         mGreedyTouchMode = greedy;
281     }
282 
283     /**
284      * By default the first element of the list is initially positioned in the center of the screen.
285      * This method allows the developer to specify a different offset, e.g. to hide the
286      * WearableListView before the user is allowed to use it.
287      *
288      * @param top How far the elements should be pushed down.
289      */
setInitialOffset(int top)290     public void setInitialOffset(int top) {
291         mInitialOffset = top;
292     }
293 
294     @Override
onInterceptTouchEvent(MotionEvent event)295     public boolean onInterceptTouchEvent(MotionEvent event) {
296         if (!isEnabled()) {
297             return false;
298         }
299 
300         if (mGreedyTouchMode && getChildCount() > 0) {
301             int action = event.getActionMasked();
302             if (action == MotionEvent.ACTION_DOWN) {
303                 mStartX = event.getX();
304                 mStartY = event.getY();
305                 mStartFirstTop = getChildCount() > 0 ? getChildAt(0).getTop() : 0;
306                 mPossibleVerticalSwipe = true;
307                 mGestureDirectionLocked = false;
308             } else if (action == MotionEvent.ACTION_MOVE && mPossibleVerticalSwipe) {
309                 handlePossibleVerticalSwipe(event);
310             }
311             getParent().requestDisallowInterceptTouchEvent(mPossibleVerticalSwipe);
312         }
313         return super.onInterceptTouchEvent(event);
314     }
315 
handlePossibleVerticalSwipe(MotionEvent event)316     private boolean handlePossibleVerticalSwipe(MotionEvent event) {
317         if (mGestureDirectionLocked) {
318             return mPossibleVerticalSwipe;
319         }
320         float deltaX = Math.abs(mStartX - event.getX());
321         float deltaY = Math.abs(mStartY - event.getY());
322         float distance = (deltaX * deltaX) + (deltaY * deltaY);
323         // Verify that the distance moved in the combined x/y direction is at
324         // least touch slop before determining the gesture direction.
325         if (distance > (mTouchSlop * mTouchSlop)) {
326             if (deltaX > deltaY) {
327                 mPossibleVerticalSwipe = false;
328             }
329             mGestureDirectionLocked = true;
330         }
331         return mPossibleVerticalSwipe;
332     }
333 
334     @Override
onTouchEvent(MotionEvent event)335     public boolean onTouchEvent(MotionEvent event) {
336         if (!isEnabled()) {
337             return false;
338         }
339 
340         // super.onTouchEvent can change the state of the scroll, keep a copy so that handleTouchUp
341         // can exit early if scrollState != IDLE when the touch event started.
342         int scrollState = getScrollState();
343         boolean result = super.onTouchEvent(event);
344         if (getChildCount() > 0) {
345             int action = event.getActionMasked();
346             if (action == MotionEvent.ACTION_DOWN) {
347                 handleTouchDown(event);
348             } else if (action == MotionEvent.ACTION_UP) {
349                 handleTouchUp(event, scrollState);
350                 getParent().requestDisallowInterceptTouchEvent(false);
351             } else if (action == MotionEvent.ACTION_MOVE) {
352                 if (Math.abs(mTapPositionX - (int) event.getX()) >= mTouchSlop ||
353                         Math.abs(mTapPositionY - (int) event.getY()) >= mTouchSlop) {
354                     releasePressedItem();
355                     mCanClick = false;
356                 }
357                 result |= handlePossibleVerticalSwipe(event);
358                 getParent().requestDisallowInterceptTouchEvent(mPossibleVerticalSwipe);
359             } else if (action == MotionEvent.ACTION_CANCEL) {
360                 getParent().requestDisallowInterceptTouchEvent(false);
361                 mCanClick = true;
362             }
363         }
364         return result;
365     }
366 
releasePressedItem()367     private void releasePressedItem() {
368         if (mPressedView != null) {
369             mPressedView.setPressed(false);
370             mPressedView = null;
371         }
372         Handler handler = getHandler();
373         if (handler != null) {
374             handler.removeCallbacks(mPressedRunnable);
375         }
376     }
377 
onScroll(int dy)378     private void onScroll(int dy) {
379         for (OnScrollListener listener : mOnScrollListeners) {
380             listener.onScroll(dy);
381         }
382         notifyChildrenAboutProximity(true);
383     }
384 
385     /**
386      * Adds a listener that will be called when the content of the list view is scrolled.
387      */
addOnScrollListener(OnScrollListener listener)388     public void addOnScrollListener(OnScrollListener listener) {
389         mOnScrollListeners.add(listener);
390     }
391 
392     /**
393      * Removes listener for scroll events.
394      */
removeOnScrollListener(OnScrollListener listener)395     public void removeOnScrollListener(OnScrollListener listener) {
396         mOnScrollListeners.remove(listener);
397     }
398 
399     /**
400      * Adds a listener that will be called when the central item of the list changes.
401      */
addOnCentralPositionChangedListener(OnCentralPositionChangedListener listener)402     public void addOnCentralPositionChangedListener(OnCentralPositionChangedListener listener) {
403         mOnCentralPositionChangedListeners.add(listener);
404     }
405 
406     /**
407      * Removes a listener that would be called when the central item of the list changes.
408      */
removeOnCentralPositionChangedListener(OnCentralPositionChangedListener listener)409     public void removeOnCentralPositionChangedListener(OnCentralPositionChangedListener listener) {
410         mOnCentralPositionChangedListeners.remove(listener);
411     }
412 
413     /**
414      * Determines if navigation of list with wrist gestures is enabled.
415      */
isGestureNavigationEnabled()416     public boolean isGestureNavigationEnabled() {
417         return mGestureNavigationEnabled;
418     }
419 
420     /**
421      * Sets whether navigation of list with wrist gestures is enabled.
422      */
setEnableGestureNavigation(boolean enabled)423     public void setEnableGestureNavigation(boolean enabled) {
424         mGestureNavigationEnabled = enabled;
425     }
426 
427     @Override /* KeyEvent.Callback */
onKeyDown(int keyCode, KeyEvent event)428     public boolean onKeyDown(int keyCode, KeyEvent event) {
429         // Respond to keycodes (at least originally generated and injected by wrist gestures).
430         if (mGestureNavigationEnabled) {
431             switch (keyCode) {
432                 case KeyEvent.KEYCODE_NAVIGATE_PREVIOUS:
433                     fling(0, -mMinFlingVelocity);
434                     return true;
435                 case KeyEvent.KEYCODE_NAVIGATE_NEXT:
436                     fling(0, mMinFlingVelocity);
437                     return true;
438                 case KeyEvent.KEYCODE_NAVIGATE_IN:
439                     return tapCenterView();
440                 case KeyEvent.KEYCODE_NAVIGATE_OUT:
441                     // Returing false leaves the action to the container of this WearableListView
442                     // (e.g. finishing the activity containing this WearableListView).
443                     return false;
444             }
445         }
446         return super.onKeyDown(keyCode, event);
447     }
448 
449     /**
450      * Simulate tapping the child view at the center of this list.
451      */
tapCenterView()452     private boolean tapCenterView() {
453         if (!isEnabled() || getVisibility() != View.VISIBLE) {
454             return false;
455         }
456         int index = findCenterViewIndex();
457         View view = getChildAt(index);
458         ViewHolder holder = getChildViewHolder(view);
459         if (mClickListener != null) {
460             mClickListener.onClick(holder);
461             return true;
462         }
463         return false;
464     }
465 
checkForTap(MotionEvent event)466     private boolean checkForTap(MotionEvent event) {
467         // No taps are accepted if this view is disabled.
468         if (!isEnabled()) {
469             return false;
470         }
471 
472         float rawY = event.getRawY();
473         int index = findCenterViewIndex();
474         View view = getChildAt(index);
475         ViewHolder holder = getChildViewHolder(view);
476         computeTapRegions(mTapRegions);
477         if (rawY > mTapRegions[0] && rawY < mTapRegions[1]) {
478             if (mClickListener != null) {
479                 mClickListener.onClick(holder);
480             }
481             return true;
482         }
483         if (index > 0 && rawY <= mTapRegions[0]) {
484             animateToMiddle(index - 1, index);
485             return true;
486         }
487         if (index < getChildCount() - 1 && rawY >= mTapRegions[1]) {
488             animateToMiddle(index + 1, index);
489             return true;
490         }
491         if (index == 0 && rawY <= mTapRegions[0] && mClickListener != null) {
492             // Special case: if the top third of the screen is empty and the touch event happens
493             // there, we don't want to immediately disallow the parent from using it. We tell
494             // parent to disallow intercept only after we locked a gesture. Before that he
495             // might do something with the action.
496             mClickListener.onTopEmptyRegionClick();
497             return true;
498         }
499         return false;
500     }
501 
animateToMiddle(int newCenterIndex, int oldCenterIndex)502     private void animateToMiddle(int newCenterIndex, int oldCenterIndex) {
503         if (newCenterIndex == oldCenterIndex) {
504             throw new IllegalArgumentException(
505                     "newCenterIndex must be different from oldCenterIndex");
506         }
507         List<Animator> animators = new ArrayList<Animator>();
508         View child = getChildAt(newCenterIndex);
509         int scrollToMiddle = getCentralViewTop() - child.getTop();
510         startScrollAnimation(animators, scrollToMiddle, FLIP_ANIMATION_DURATION_MS);
511     }
512 
startScrollAnimation(List<Animator> animators, int scroll, long duration)513     private void startScrollAnimation(List<Animator> animators, int scroll, long duration) {
514         startScrollAnimation(animators, scroll, duration, 0);
515     }
516 
startScrollAnimation(List<Animator> animators, int scroll, long duration, long delay)517     private void startScrollAnimation(List<Animator> animators, int scroll, long duration,
518             long  delay) {
519         startScrollAnimation(animators, scroll, duration, delay, null);
520     }
521 
startScrollAnimation( int scroll, long duration, long delay, Animator.AnimatorListener listener)522     private void startScrollAnimation(
523             int scroll, long duration, long  delay, Animator.AnimatorListener listener) {
524         startScrollAnimation(null, scroll, duration, delay, listener);
525     }
526 
startScrollAnimation(List<Animator> animators, int scroll, long duration, long delay, Animator.AnimatorListener listener)527     private void startScrollAnimation(List<Animator> animators, int scroll, long duration,
528             long  delay, Animator.AnimatorListener listener) {
529         if (mScrollAnimator != null) {
530             mScrollAnimator.cancel();
531         }
532 
533         mLastScrollChange = 0;
534         ObjectAnimator scrollAnimator = ObjectAnimator.ofInt(this, mSetScrollVerticallyProperty,
535                 0, -scroll);
536 
537         if (animators != null) {
538             animators.add(scrollAnimator);
539             AnimatorSet animatorSet = new AnimatorSet();
540             animatorSet.playTogether(animators);
541             mScrollAnimator = animatorSet;
542         } else {
543             mScrollAnimator = scrollAnimator;
544         }
545         mScrollAnimator.setDuration(duration);
546         if (listener != null) {
547             mScrollAnimator.addListener(listener);
548         }
549         if (delay > 0) {
550             mScrollAnimator.setStartDelay(delay);
551         }
552         mScrollAnimator.start();
553     }
554 
555     @Override
fling(int velocityX, int velocityY)556     public boolean fling(int velocityX, int velocityY) {
557         if (getChildCount() == 0) {
558             return false;
559         }
560         // If we are flinging towards empty space (before first element or after last), we reuse
561         // original flinging mechanism.
562         final int index = findCenterViewIndex();
563         final View child = getChildAt(index);
564         int currentPosition = getChildPosition(child);
565         if ((currentPosition == 0 && velocityY < 0) ||
566                 (currentPosition == getAdapter().getItemCount() - 1 && velocityY > 0)) {
567             return super.fling(velocityX, velocityY);
568         }
569 
570         if (Math.abs(velocityY) < mMinFlingVelocity) {
571             return false;
572         }
573         velocityY = Math.max(Math.min(velocityY, mMaxFlingVelocity), -mMaxFlingVelocity);
574 
575         if (mScroller == null) {
576             mScroller = new Scroller(getContext(), null, true);
577         }
578         mScroller.fling(0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE,
579                 Integer.MIN_VALUE, Integer.MAX_VALUE);
580         int finalY = mScroller.getFinalY();
581         int delta = finalY / (getPaddingTop() + getAdjustedHeight() / 2);
582         if (delta == 0) {
583             // If the fling would not be enough to change position, we increase it to satisfy user's
584             // intent of switching current position.
585             delta = velocityY > 0 ? 1 : -1;
586         }
587         int finalPosition = Math.max(
588                 0, Math.min(getAdapter().getItemCount() - 1, currentPosition + delta));
589         smoothScrollToPosition(finalPosition);
590         return true;
591     }
592 
smoothScrollToPosition(int position, RecyclerView.SmoothScroller smoothScroller)593     public void smoothScrollToPosition(int position, RecyclerView.SmoothScroller smoothScroller) {
594         LayoutManager layoutManager = (LayoutManager) getLayoutManager();
595         layoutManager.setCustomSmoothScroller(smoothScroller);
596         smoothScrollToPosition(position);
597         layoutManager.clearCustomSmoothScroller();
598     }
599 
600     @Override
getChildViewHolder(View child)601     public ViewHolder getChildViewHolder(View child) {
602         return (ViewHolder) super.getChildViewHolder(child);
603     }
604 
605     /**
606      * Adds a listener that will be called when the user taps on the WearableListView or its items.
607      */
setClickListener(ClickListener clickListener)608     public void setClickListener(ClickListener clickListener) {
609         mClickListener = clickListener;
610     }
611 
612     /**
613      * Adds a listener that will be called when the user drags the top element below its allowed
614      * bottom position.
615      *
616      * @hide
617      */
setOverScrollListener(OnOverScrollListener listener)618     public void setOverScrollListener(OnOverScrollListener listener) {
619         mOverScrollListener = listener;
620     }
621 
findCenterViewIndex()622     private int findCenterViewIndex() {
623         // TODO(gruszczy): This could be easily optimized, so that we stop looking when we the
624         // distance starts growing again, instead of finding the closest. It would safe half of
625         // the loop.
626         int count = getChildCount();
627         int index = -1;
628         int closest = Integer.MAX_VALUE;
629         int centerY = getCenterYPos(this);
630         for (int i = 0; i < count; ++i) {
631             final View child = getChildAt(i);
632             int childCenterY = getTop() + getCenterYPos(child);
633             final int distance = Math.abs(centerY - childCenterY);
634             if (distance < closest) {
635                 closest = distance;
636                 index = i;
637             }
638         }
639         if (index == -1) {
640             throw new IllegalStateException("Can't find central view.");
641         }
642         return index;
643     }
644 
getCenterYPos(View v)645     private static int getCenterYPos(View v) {
646         return v.getTop() + v.getPaddingTop() + getAdjustedHeight(v) / 2;
647     }
648 
handleTouchUp(MotionEvent event, int scrollState)649     private void handleTouchUp(MotionEvent event, int scrollState) {
650         if (mCanClick && event != null && checkForTap(event)) {
651             Handler handler = getHandler();
652             if (handler != null) {
653                 handler.postDelayed(mReleasedRunnable, ViewConfiguration.getTapTimeout());
654             }
655             return;
656         }
657 
658         if (scrollState != RecyclerView.SCROLL_STATE_IDLE) {
659             // We are flinging, so let's not start animations just yet. Instead we will start them
660             // when the fling finishes.
661             return;
662         }
663 
664         if (isOverScrolling()) {
665             mOverScrollListener.onOverScroll();
666         } else {
667             animateToCenter();
668         }
669     }
670 
isOverScrolling()671     private boolean isOverScrolling() {
672         return getChildCount() > 0
673                 // If first view top was below the central top, it means it was never centered.
674                 // Don't allow overscroll, otherwise a simple touch (instead of a drag) will be
675                 // enough to trigger overscroll.
676                 && mStartFirstTop <= getCentralViewTop()
677                 && getChildAt(0).getTop() >= getTopViewMaxTop()
678                 && mOverScrollListener != null;
679     }
680 
getTopViewMaxTop()681     private int getTopViewMaxTop() {
682         return getHeight() / 2;
683     }
684 
getItemHeight()685     private int getItemHeight() {
686         // Round up so that the screen is fully occupied by 3 items.
687         return getAdjustedHeight() / THIRD + 1;
688     }
689 
690     /**
691      * Returns top of the central {@code View} in the list when such view is fully centered.
692      *
693      * This is a more or a less a static value that you can use to align other views with the
694      * central one.
695      */
getCentralViewTop()696     public int getCentralViewTop() {
697         return getPaddingTop() + getItemHeight();
698     }
699 
700     /**
701      * Automatically starts an animation that snaps the list to center on the element closest to the
702      * middle.
703      */
animateToCenter()704     public void animateToCenter() {
705         final int index = findCenterViewIndex();
706         final View child = getChildAt(index);
707         final int scrollToMiddle = getCentralViewTop() - child.getTop();
708         startScrollAnimation(scrollToMiddle, CENTERING_ANIMATION_DURATION_MS, 0,
709                 new SimpleAnimatorListener() {
710                     @Override
711                     public void onAnimationEnd(Animator animator) {
712                         if (!wasCanceled()) {
713                             mCanClick = true;
714                         }
715                     }
716                 });
717     }
718 
719     /**
720      * Animate the list so that the first view is back to its initial position.
721      * @param endAction Action to execute when the animation is done.
722      * @hide
723      */
animateToInitialPosition(final Runnable endAction)724     public void animateToInitialPosition(final Runnable endAction) {
725         final View child = getChildAt(0);
726         final int scrollToMiddle = getCentralViewTop() + mInitialOffset - child.getTop();
727         startScrollAnimation(scrollToMiddle, CENTERING_ANIMATION_DURATION_MS, 0,
728                 new SimpleAnimatorListener() {
729                     @Override
730                     public void onAnimationEnd(Animator animator) {
731                         if (endAction != null) {
732                             endAction.run();
733                         }
734                     }
735                 });
736     }
737 
handleTouchDown(MotionEvent event)738     private void handleTouchDown(MotionEvent event) {
739         if (mCanClick) {
740             mTapPositionX = (int) event.getX();
741             mTapPositionY = (int) event.getY();
742             float rawY = event.getRawY();
743             computeTapRegions(mTapRegions);
744             if (rawY > mTapRegions[0] && rawY < mTapRegions[1]) {
745                 View view = getChildAt(findCenterViewIndex());
746                 if (view instanceof OnCenterProximityListener) {
747                     Handler handler = getHandler();
748                     if (handler != null) {
749                         handler.removeCallbacks(mReleasedRunnable);
750                         handler.postDelayed(mPressedRunnable, ViewConfiguration.getTapTimeout());
751                     }
752                 }
753             }
754         }
755     }
756 
setScrollVertically(int scroll)757     private void setScrollVertically(int scroll) {
758         scrollBy(0, scroll - mLastScrollChange);
759         mLastScrollChange = scroll;
760     }
761 
getAdjustedHeight()762     private int getAdjustedHeight() {
763         return getAdjustedHeight(this);
764     }
765 
getAdjustedHeight(View v)766     private static int getAdjustedHeight(View v) {
767         return v.getHeight() - v.getPaddingBottom() - v.getPaddingTop();
768     }
769 
computeTapRegions(float[] tapRegions)770     private void computeTapRegions(float[] tapRegions) {
771         mLocation[0] = mLocation[1] = 0;
772         getLocationOnScreen(mLocation);
773         int mScreenTop = mLocation[1];
774         int height = getHeight();
775         tapRegions[0] = mScreenTop + height * TOP_TAP_REGION_PERCENTAGE;
776         tapRegions[1] = mScreenTop + height * (1 - BOTTOM_TAP_REGION_PERCENTAGE);
777     }
778 
779     /**
780      * Determines if, when there is only one item in the WearableListView, that the single item
781      * is laid out so that it's height fills the entire WearableListView.
782      */
getMaximizeSingleItem()783     public boolean getMaximizeSingleItem() {
784         return mMaximizeSingleItem;
785     }
786 
787     /**
788      * When set to true, if there is only one item in the WearableListView, it will fill the entire
789      * WearableListView. When set to false, the default behavior will be used and the single item
790      * will fill only a third of the screen.
791      */
setMaximizeSingleItem(boolean maximizeSingleItem)792     public void setMaximizeSingleItem(boolean maximizeSingleItem) {
793         mMaximizeSingleItem = maximizeSingleItem;
794     }
795 
notifyChildrenAboutProximity(boolean animate)796     private void notifyChildrenAboutProximity(boolean animate) {
797         LayoutManager layoutManager = (LayoutManager) getLayoutManager();
798         int count = layoutManager.getChildCount();
799 
800         if (count == 0) {
801             return;
802         }
803 
804         int index = layoutManager.findCenterViewIndex();
805         for (int i = 0; i < count; ++i) {
806             final View view = layoutManager.getChildAt(i);
807             ViewHolder holder = getChildViewHolder(view);
808             holder.onCenterProximity(i == index, animate);
809         }
810         final int position = getChildViewHolder(getChildAt(index)).getPosition();
811         if (position != mPreviousCentral) {
812             for (OnScrollListener listener : mOnScrollListeners) {
813                 listener.onCentralPositionChanged(position);
814             }
815             for (OnCentralPositionChangedListener listener :
816                     mOnCentralPositionChangedListeners) {
817                 listener.onCentralPositionChanged(position);
818             }
819             mPreviousCentral = position;
820         }
821     }
822 
823     // TODO: Move this to a separate class, so it can't directly interact with the WearableListView.
824     private class LayoutManager extends RecyclerView.LayoutManager {
825         private int mFirstPosition;
826 
827         private boolean mPushFirstHigher;
828 
829         private int mAbsoluteScroll;
830 
831         private boolean mUseOldViewTop = true;
832 
833         private boolean mWasZoomedIn = false;
834 
835         private RecyclerView.SmoothScroller mSmoothScroller;
836 
837         private RecyclerView.SmoothScroller mDefaultSmoothScroller;
838 
839         // We need to have another copy of the same method, because this one uses
840         // LayoutManager.getChildCount/getChildAt instead of View.getChildCount/getChildAt and
841         // they return different values.
findCenterViewIndex()842         private int findCenterViewIndex() {
843             // TODO(gruszczy): This could be easily optimized, so that we stop looking when we the
844             // distance starts growing again, instead of finding the closest. It would safe half of
845             // the loop.
846             int count = getChildCount();
847             int index = -1;
848             int closest = Integer.MAX_VALUE;
849             int centerY = getCenterYPos(WearableListView.this);
850             for (int i = 0; i < count; ++i) {
851                 final View child = getLayoutManager().getChildAt(i);
852                 int childCenterY = getTop() + getCenterYPos(child);
853                 final int distance = Math.abs(centerY - childCenterY);
854                 if (distance < closest) {
855                     closest = distance;
856                     index = i;
857                 }
858             }
859             if (index == -1) {
860                 throw new IllegalStateException("Can't find central view.");
861             }
862             return index;
863         }
864 
865         @Override
onLayoutChildren(RecyclerView.Recycler recycler, State state)866         public void onLayoutChildren(RecyclerView.Recycler recycler, State state) {
867             final int parentBottom = getHeight() - getPaddingBottom();
868             // By default we assume this is the first run and the first element will be centered
869             // with optional initial offset.
870             int oldTop = getCentralViewTop() + mInitialOffset;
871             // Here we handle any other situation where we relayout or we want to achieve a
872             // specific layout of children.
873             if (mUseOldViewTop && getChildCount() > 0) {
874                 // We are performing a relayout after we already had some children, because e.g. the
875                 // contents of an adapter has changed. First we want to check, if the central item
876                 // from before the layout is still here, because we want to preserve it.
877                 int index = findCenterViewIndex();
878                 int position = getPosition(getChildAt(index));
879                 if (position == NO_POSITION) {
880                     // Central item was removed. Let's find the first surviving item and use it
881                     // as an anchor.
882                     for (int i = 0, N = getChildCount(); index + i < N || index - i >= 0; ++i) {
883                         View child = getChildAt(index + i);
884                         if (child != null) {
885                             position = getPosition(child);
886                             if (position != NO_POSITION) {
887                                 index = index + i;
888                                 break;
889                             }
890                         }
891                         child = getChildAt(index - i);
892                         if (child != null) {
893                             position = getPosition(child);
894                             if (position != NO_POSITION) {
895                                 index = index - i;
896                                 break;
897                             }
898                         }
899                     }
900                 }
901                 if (position == NO_POSITION) {
902                     // None of the children survives the relayout, let's just use the top of the
903                     // first one.
904                     oldTop = getChildAt(0).getTop();
905                     int count = state.getItemCount();
906                     // Lets first make sure that the first position is not above the last element,
907                     // which can happen if elements were removed.
908                     while (mFirstPosition >= count && mFirstPosition > 0) {
909                         mFirstPosition--;
910                     }
911                 } else {
912                     // Some of the children survived the relayout. We will keep it in its place,
913                     // but go through previous children and maybe add them.
914                     if (!mWasZoomedIn) {
915                         // If we were previously zoomed-in on a single item, ignore this and just
916                         // use the default value set above. Reasoning: if we are still zoomed-in,
917                         // oldTop will be ignored when laying out the single child element. If we
918                         // are no longer zoomed in, then we want to position items using the top
919                         // of the single item as if the single item was not zoomed in, which is
920                         // equal to the default value.
921                         oldTop = getChildAt(index).getTop();
922                     }
923                     while (oldTop > getPaddingTop() && position > 0) {
924                         position--;
925                         oldTop -= getItemHeight();
926                     }
927                     if (position == 0 && oldTop > getCentralViewTop()) {
928                         // We need to handle special case where the first, central item was removed
929                         // and now the first element is hanging below, instead of being nicely
930                         // centered.
931                         oldTop = getCentralViewTop();
932                     }
933                     mFirstPosition = position;
934                 }
935             } else if (mPushFirstHigher) {
936                 // We are trying to position elements ourselves, so we force position of the first
937                 // one.
938                 oldTop = getCentralViewTop() - getItemHeight();
939             }
940 
941             performLayoutChildren(recycler, state, parentBottom, oldTop);
942 
943             // Since the content might have changed, we need to adjust the absolute scroll in case
944             // some elements have disappeared or were added.
945             if (getChildCount() == 0) {
946                 setAbsoluteScroll(0);
947             } else {
948                 View child = getChildAt(findCenterViewIndex());
949                 setAbsoluteScroll(child.getTop() - getCentralViewTop() + getPosition(child) *
950                         getItemHeight());
951             }
952 
953             mUseOldViewTop = true;
954             mPushFirstHigher = false;
955         }
956 
performLayoutChildren(Recycler recycler, State state, int parentBottom, int top)957         private void performLayoutChildren(Recycler recycler, State state, int parentBottom,
958                                            int top) {
959             detachAndScrapAttachedViews(recycler);
960 
961             if (mMaximizeSingleItem && state.getItemCount() == 1) {
962                 performLayoutOneChild(recycler, parentBottom);
963                 mWasZoomedIn = true;
964             } else {
965                 performLayoutMultipleChildren(recycler, state, parentBottom, top);
966                 mWasZoomedIn = false;
967             }
968 
969             if (getChildCount() > 0) {
970                 post(mNotifyChildrenPostLayoutRunnable);
971             }
972         }
973 
performLayoutOneChild(Recycler recycler, int parentBottom)974         private void performLayoutOneChild(Recycler recycler, int parentBottom) {
975             final int right = getWidth() - getPaddingRight();
976             View v = recycler.getViewForPosition(getFirstPosition());
977             addView(v, 0);
978             measureZoomView(v);
979             v.layout(getPaddingLeft(), getPaddingTop(), right, parentBottom);
980         }
981 
performLayoutMultipleChildren(Recycler recycler, State state, int parentBottom, int top)982         private void performLayoutMultipleChildren(Recycler recycler, State state, int parentBottom,
983                                                    int top) {
984             int bottom;
985             final int left = getPaddingLeft();
986             final int right = getWidth() - getPaddingRight();
987             final int count = state.getItemCount();
988             // If we are laying out children with center element being different than the first, we
989             // need to start with previous child which appears half visible at the top.
990             for (int i = 0; getFirstPosition() + i < count; i++, top = bottom) {
991                 if (top >= parentBottom) {
992                     break;
993                 }
994                 View v = recycler.getViewForPosition(getFirstPosition() + i);
995                 addView(v, i);
996                 measureThirdView(v);
997                 bottom = top + getItemHeight();
998                 v.layout(left, top, right, bottom);
999             }
1000         }
1001 
setAbsoluteScroll(int absoluteScroll)1002         private void setAbsoluteScroll(int absoluteScroll) {
1003             mAbsoluteScroll = absoluteScroll;
1004             for (OnScrollListener listener : mOnScrollListeners) {
1005                 listener.onAbsoluteScrollChange(mAbsoluteScroll);
1006             }
1007         }
1008 
measureView(View v, int height)1009         private void measureView(View v, int height) {
1010             final LayoutParams lp = (LayoutParams) v.getLayoutParams();
1011             final int widthSpec = getChildMeasureSpec(getWidth(),
1012                 getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin, lp.width,
1013                 canScrollHorizontally());
1014             final int heightSpec = getChildMeasureSpec(getHeight(),
1015                 getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin,
1016                 height, canScrollVertically());
1017             v.measure(widthSpec, heightSpec);
1018         }
1019 
measureThirdView(View v)1020         private void measureThirdView(View v) {
1021             measureView(v, (int) (1 + (float) getHeight() / THIRD));
1022         }
1023 
measureZoomView(View v)1024         private void measureZoomView(View v) {
1025             measureView(v, getHeight());
1026         }
1027 
1028         @Override
generateDefaultLayoutParams()1029         public RecyclerView.LayoutParams generateDefaultLayoutParams() {
1030             return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
1031                     ViewGroup.LayoutParams.WRAP_CONTENT);
1032         }
1033 
1034         @Override
canScrollVertically()1035         public boolean canScrollVertically() {
1036             // Disable vertical scrolling when zoomed.
1037             return getItemCount() != 1 || !mWasZoomedIn;
1038         }
1039 
1040         @Override
scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, State state)1041         public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, State state) {
1042             // TODO(gruszczy): This code is shit, needs to be rewritten.
1043             if (getChildCount() == 0) {
1044                 return 0;
1045             }
1046             int scrolled = 0;
1047             final int left = getPaddingLeft();
1048             final int right = getWidth() - getPaddingRight();
1049             if (dy < 0) {
1050                 while (scrolled > dy) {
1051                     final View topView = getChildAt(0);
1052                     if (getFirstPosition() > 0) {
1053                         final int hangingTop = Math.max(-topView.getTop(), 0);
1054                         final int scrollBy = Math.min(scrolled - dy, hangingTop);
1055                         scrolled -= scrollBy;
1056                         offsetChildrenVertical(scrollBy);
1057                         if (getFirstPosition() > 0 && scrolled > dy) {
1058                             mFirstPosition--;
1059                             View v = recycler.getViewForPosition(getFirstPosition());
1060                             addView(v, 0);
1061                             measureThirdView(v);
1062                             final int bottom = topView.getTop();
1063                             final int top = bottom - getItemHeight();
1064                             v.layout(left, top, right, bottom);
1065                         } else {
1066                             break;
1067                         }
1068                     } else {
1069                         mPushFirstHigher = false;
1070                         int maxScroll = mOverScrollListener!= null ?
1071                                 getHeight() : getTopViewMaxTop();
1072                         final int scrollBy = Math.min(-dy + scrolled, maxScroll - topView.getTop());
1073                         scrolled -= scrollBy;
1074                         offsetChildrenVertical(scrollBy);
1075                         break;
1076                     }
1077                 }
1078             } else if (dy > 0) {
1079                 final int parentHeight = getHeight();
1080                 while (scrolled < dy) {
1081                     final View bottomView = getChildAt(getChildCount() - 1);
1082                     if (state.getItemCount() > mFirstPosition + getChildCount()) {
1083                         final int hangingBottom =
1084                                 Math.max(bottomView.getBottom() - parentHeight, 0);
1085                         final int scrollBy = -Math.min(dy - scrolled, hangingBottom);
1086                         scrolled -= scrollBy;
1087                         offsetChildrenVertical(scrollBy);
1088                         if (scrolled < dy) {
1089                             View v = recycler.getViewForPosition(mFirstPosition + getChildCount());
1090                             final int top = getChildAt(getChildCount() - 1).getBottom();
1091                             addView(v);
1092                             measureThirdView(v);
1093                             final int bottom = top + getItemHeight();
1094                             v.layout(left, top, right, bottom);
1095                         } else {
1096                             break;
1097                         }
1098                     } else {
1099                         final int scrollBy =
1100                                 Math.max(-dy + scrolled, getHeight() / 2 - bottomView.getBottom());
1101                         scrolled -= scrollBy;
1102                         offsetChildrenVertical(scrollBy);
1103                         break;
1104                     }
1105                 }
1106             }
1107             recycleViewsOutOfBounds(recycler);
1108             setAbsoluteScroll(mAbsoluteScroll + scrolled);
1109             return scrolled;
1110         }
1111 
1112         @Override
scrollToPosition(int position)1113         public void scrollToPosition(int position) {
1114             mUseOldViewTop = false;
1115             if (position > 0) {
1116                 mFirstPosition = position - 1;
1117                 mPushFirstHigher = true;
1118             } else {
1119                 mFirstPosition = position;
1120                 mPushFirstHigher = false;
1121             }
1122             requestLayout();
1123         }
1124 
setCustomSmoothScroller(RecyclerView.SmoothScroller smoothScroller)1125         public void setCustomSmoothScroller(RecyclerView.SmoothScroller smoothScroller) {
1126             mSmoothScroller = smoothScroller;
1127         }
1128 
clearCustomSmoothScroller()1129         public void clearCustomSmoothScroller() {
1130             mSmoothScroller = null;
1131         }
1132 
getDefaultSmoothScroller(RecyclerView recyclerView)1133         public RecyclerView.SmoothScroller getDefaultSmoothScroller(RecyclerView recyclerView) {
1134             if (mDefaultSmoothScroller == null) {
1135                 mDefaultSmoothScroller = new SmoothScroller(
1136                         recyclerView.getContext(), this);
1137             }
1138             return mDefaultSmoothScroller;
1139         }
1140         @Override
smoothScrollToPosition(RecyclerView recyclerView, State state, int position)1141         public void smoothScrollToPosition(RecyclerView recyclerView, State state,
1142                 int position) {
1143             RecyclerView.SmoothScroller scroller = mSmoothScroller;
1144             if (scroller == null) {
1145                 scroller = getDefaultSmoothScroller(recyclerView);
1146             }
1147             scroller.setTargetPosition(position);
1148             startSmoothScroll(scroller);
1149         }
1150 
recycleViewsOutOfBounds(RecyclerView.Recycler recycler)1151         private void recycleViewsOutOfBounds(RecyclerView.Recycler recycler) {
1152             final int childCount = getChildCount();
1153             final int parentWidth = getWidth();
1154             // Here we want to use real height, so we don't remove views that are only visible in
1155             // padded section.
1156             final int parentHeight = getHeight();
1157             boolean foundFirst = false;
1158             int first = 0;
1159             int last = 0;
1160             for (int i = 0; i < childCount; i++) {
1161                 final View v = getChildAt(i);
1162                 if (v.hasFocus() || (v.getRight() >= 0 && v.getLeft() <= parentWidth &&
1163                         v.getBottom() >= 0 && v.getTop() <= parentHeight)) {
1164                     if (!foundFirst) {
1165                         first = i;
1166                         foundFirst = true;
1167                     }
1168                     last = i;
1169                 }
1170             }
1171             for (int i = childCount - 1; i > last; i--) {
1172                 removeAndRecycleViewAt(i, recycler);
1173             }
1174             for (int i = first - 1; i >= 0; i--) {
1175                 removeAndRecycleViewAt(i, recycler);
1176             }
1177             if (getChildCount() == 0) {
1178                 mFirstPosition = 0;
1179             } else if (first > 0) {
1180                 mPushFirstHigher = true;
1181                 mFirstPosition += first;
1182             }
1183         }
1184 
getFirstPosition()1185         public int getFirstPosition() {
1186             return mFirstPosition;
1187         }
1188 
1189         @Override
onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter)1190         public void onAdapterChanged(RecyclerView.Adapter oldAdapter,
1191                 RecyclerView.Adapter newAdapter) {
1192             removeAllViews();
1193         }
1194     }
1195 
1196     /**
1197      * Interface for receiving callbacks when WearableListView children become or cease to be the
1198      * central item.
1199      */
1200     public interface OnCenterProximityListener {
1201         /**
1202          * Called when this view becomes central item of the WearableListView.
1203          *
1204          * @param animate Whether you should animate your transition of the View to become the
1205          *                central item. If false, this is the initial setting and you should
1206          *                transition immediately.
1207          */
onCenterPosition(boolean animate)1208         void onCenterPosition(boolean animate);
1209 
1210         /**
1211          * Called when this view stops being the central item of the WearableListView.
1212          * @param animate Whether you should animate your transition of the View to being
1213          *                non central item. If false, this is the initial setting and you should
1214          *                transition immediately.
1215          */
onNonCenterPosition(boolean animate)1216         void onNonCenterPosition(boolean animate);
1217     }
1218 
1219     /**
1220      * Interface for listening for click events on WearableListView.
1221      */
1222     public interface ClickListener {
1223         /**
1224          * Called when the central child of the WearableListView is tapped.
1225          * @param view View that was clicked.
1226          */
onClick(ViewHolder view)1227         public void onClick(ViewHolder view);
1228 
1229         /**
1230          * Called when the user taps the top third of the WearableListView and no item is present
1231          * there. This can happen when you are in initial state and the first, top-most item of the
1232          * WearableListView is centered.
1233          */
onTopEmptyRegionClick()1234         public void onTopEmptyRegionClick();
1235     }
1236 
1237     /**
1238      * @hide
1239      */
1240     public interface OnOverScrollListener {
onOverScroll()1241         public void onOverScroll();
1242     }
1243 
1244     /**
1245      * Interface for listening to WearableListView content scrolling.
1246      */
1247     public interface OnScrollListener {
1248         /**
1249          * Called when the content is scrolled, reporting the relative scroll value.
1250          * @param scroll Amount the content was scrolled. This is a delta from the previous
1251          *               position to the new position.
1252          */
onScroll(int scroll)1253         public void onScroll(int scroll);
1254 
1255         /**
1256          * Called when the content is scrolled, reporting the absolute scroll value.
1257          *
1258          * @deprecated BE ADVISED DO NOT USE THIS This might provide wrong values when contents
1259          * of a RecyclerView change.
1260          *
1261          * @param scroll Absolute scroll position of the content inside the WearableListView.
1262          */
1263         @Deprecated
onAbsoluteScrollChange(int scroll)1264         public void onAbsoluteScrollChange(int scroll);
1265 
1266         /**
1267          * Called when WearableListView's scroll state changes.
1268          *
1269          * @param scrollState The updated scroll state. One of {@link #SCROLL_STATE_IDLE},
1270          *                    {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}.
1271          */
onScrollStateChanged(int scrollState)1272         public void onScrollStateChanged(int scrollState);
1273 
1274         /**
1275          * Called when the central item of the WearableListView changes.
1276          *
1277          * @param centralPosition Position of the item in the Adapter.
1278          */
onCentralPositionChanged(int centralPosition)1279         public void onCentralPositionChanged(int centralPosition);
1280     }
1281 
1282     /**
1283      * A listener interface that can be added to the WearableListView to get notified when the
1284      * central item is changed.
1285      */
1286     public interface OnCentralPositionChangedListener {
1287         /**
1288          * Called when the central item of the WearableListView changes.
1289          *
1290          * @param centralPosition Position of the item in the Adapter.
1291          */
onCentralPositionChanged(int centralPosition)1292         void onCentralPositionChanged(int centralPosition);
1293     }
1294 
1295     /**
1296      * Base class for adapters providing data for the WearableListView. For details refer to
1297      * RecyclerView.Adapter.
1298      */
1299     public static abstract class Adapter extends RecyclerView.Adapter<ViewHolder> {
1300     }
1301 
1302     private static class SmoothScroller extends LinearSmoothScroller {
1303 
1304         private static final float MILLISECONDS_PER_INCH = 100f;
1305 
1306         private final LayoutManager mLayoutManager;
1307 
SmoothScroller(Context context, WearableListView.LayoutManager manager)1308         public SmoothScroller(Context context, WearableListView.LayoutManager manager) {
1309             super(context);
1310             mLayoutManager = manager;
1311         }
1312 
1313         @Override
onStart()1314         protected void onStart() {
1315             super.onStart();
1316         }
1317 
1318         // TODO: (mindyp): when flinging, return the dydt that triggered the fling.
1319         @Override
calculateSpeedPerPixel(DisplayMetrics displayMetrics)1320         protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
1321             return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
1322         }
1323 
1324         @Override
calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int snapPreference)1325         public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int
1326                 snapPreference) {
1327             // Snap to center.
1328             return (boxStart + boxEnd) / 2 - (viewStart + viewEnd) / 2;
1329         }
1330 
1331         @Override
computeScrollVectorForPosition(int targetPosition)1332         public PointF computeScrollVectorForPosition(int targetPosition) {
1333             if (targetPosition < mLayoutManager.getFirstPosition()) {
1334                 return new PointF(0, -1);
1335             } else {
1336                 return new PointF(0, 1);
1337             }
1338         }
1339     }
1340 
1341     /**
1342      * Wrapper around items displayed in the list view. {@link .Adapter} must return objects that
1343      * are instances of this class. Consider making the wrapped View implement
1344      * {@link .OnCenterProximityListener} if you want to receive a callback when it becomes or
1345      * ceases to be the central item in the WearableListView.
1346      */
1347     public static class ViewHolder extends RecyclerView.ViewHolder {
ViewHolder(View itemView)1348         public ViewHolder(View itemView) {
1349             super(itemView);
1350         }
1351 
1352         /**
1353          * Called when the wrapped view is becoming or ceasing to be the central item of the
1354          * WearableListView.
1355          *
1356          * Retained as protected for backwards compatibility.
1357          *
1358          * @hide
1359          */
onCenterProximity(boolean isCentralItem, boolean animate)1360         protected void onCenterProximity(boolean isCentralItem, boolean animate) {
1361             if (!(itemView instanceof OnCenterProximityListener)) {
1362                 return;
1363             }
1364             OnCenterProximityListener item = (OnCenterProximityListener) itemView;
1365             if (isCentralItem) {
1366                 item.onCenterPosition(animate);
1367             } else {
1368                 item.onNonCenterPosition(animate);
1369             }
1370         }
1371     }
1372 
1373     private class SetScrollVerticallyProperty extends Property<WearableListView, Integer> {
SetScrollVerticallyProperty()1374         public SetScrollVerticallyProperty() {
1375             super(Integer.class, "scrollVertically");
1376         }
1377 
1378         @Override
get(WearableListView wearableListView)1379         public Integer get(WearableListView wearableListView) {
1380             return wearableListView.mLastScrollChange;
1381         }
1382 
1383         @Override
set(WearableListView wearableListView, Integer value)1384         public void set(WearableListView wearableListView, Integer value) {
1385             wearableListView.setScrollVertically(value);
1386         }
1387     }
1388 }
1389