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