/* * Copyright (c) 2016 Mobvoi Inc. * * 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 ticwear.design.widget; import android.content.Context; import android.os.Handler; import android.support.annotation.IntDef; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.Adapter; import android.support.v7.widget.RecyclerView.ItemAnimator.ItemAnimatorFinishedListener; import android.support.v7.widget.RecyclerView.Recycler; import android.support.v7.widget.RecyclerView.State; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.Interpolator; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.Target; import java.util.ArrayList; import java.util.List; import java.util.Locale; import ticwear.design.DesignConfig; import ticwear.design.R; import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.ElementType.LOCAL_VARIABLE; import static java.lang.annotation.ElementType.METHOD; import static java.lang.annotation.ElementType.PARAMETER; import static java.lang.annotation.RetentionPolicy.SOURCE; /** * Enhanced LayoutManager that support focused status. * * Created by tankery on 4/13/16. */ public class FocusableLinearLayoutManager extends LinearLayoutManager implements TicklableLayoutManager { static final String TAG = "FocusableLLM"; /** * Invalid focus state */ public static final int FOCUS_STATE_INVALID = -1; /** * Focus state on normal (not tickled). */ public static final int FOCUS_STATE_NORMAL = 0; /** * Focus state on central, means the item is focused when tickled. */ public static final int FOCUS_STATE_CENTRAL = 1; /** * Focus state on non central, means the item is not focused when tickled. */ public static final int FOCUS_STATE_NON_CENTRAL = 2; private final Context mContext; private final Handler mUiHandler; @Nullable private TicklableRecyclerView mTicklableRecyclerView; @Nullable private FocusLayoutHelper mFocusLayoutHelper; private ScrollVelocityTracker mScrollVelocityTracker; private boolean mScrollResetting; private final FocusStateRequest mFocusStateRequest = new FocusStateRequest(); private final List<OnCentralPositionChangedListener> mOnCentralPositionChangedListeners; private int mPreviousCentral; /** * To make-sure we have focus change when coordinate with {@link AppBarLayout}, * We should use a scroll to mock the offset. */ private int mScrollOffset; private static final int INVALID_SCROLL_OFFSET = Integer.MAX_VALUE; private final AppBarScrollController mAppBarScrollController; public FocusableLinearLayoutManager(Context context) { super(context, VERTICAL, false); mContext = context; mUiHandler = new Handler(); mOnCentralPositionChangedListeners = new ArrayList<>(); mPreviousCentral = RecyclerView.NO_POSITION; mScrollOffset = INVALID_SCROLL_OFFSET; mAppBarScrollController = new AppBarScrollController(mTicklableRecyclerView); setInFocusState(false); } /** * 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); } /** * Clear all listeners that listening the central item of the list changes event. */ public void clearOnCentralPositionChangedListener() { mOnCentralPositionChangedListeners.clear(); } private void setInFocusState(boolean toFocus) { boolean inFocus = mFocusLayoutHelper != null; if (inFocus == toFocus) { return; } if (toFocus && mTicklableRecyclerView != null) { mFocusLayoutHelper = new FocusLayoutHelper(mTicklableRecyclerView, this); } else if (mFocusLayoutHelper != null) { mFocusLayoutHelper.destroy(); mFocusLayoutHelper = null; } // Restore offset restoreOffset(); // Set flag so we will request focus state change on next layout. // Or, we will notify immediately. mFocusStateRequest.notifyOnNextLayout = true; if (getChildCount() > 0) { if (mFocusLayoutHelper != null) { requestNotifyChildrenAboutFocus(); } else { requestNotifyChildrenAboutExit(); } } requestSimpleAnimationsInNextLayout(); if (mTicklableRecyclerView != null && mTicklableRecyclerView.getAdapter() != null) { mTicklableRecyclerView.getAdapter().notifyDataSetChanged(); } } private void restoreOffset() { if (mTicklableRecyclerView == null) return; mScrollResetting = true; if (mFocusLayoutHelper != null) { mScrollOffset = mTicklableRecyclerView.getTop(); mTicklableRecyclerView.offsetTopAndBottom(-mScrollOffset); mTicklableRecyclerView.scrollBy(0, -mScrollOffset); } else { if (mScrollOffset != INVALID_SCROLL_OFFSET) { mTicklableRecyclerView.offsetTopAndBottom(mScrollOffset); mTicklableRecyclerView.scrollBy(0, mScrollOffset); mScrollOffset = INVALID_SCROLL_OFFSET; } } mScrollResetting = false; } @Override public void setTicklableRecyclerView(TicklableRecyclerView ticklableRecyclerView) { mTicklableRecyclerView = ticklableRecyclerView; } @Override public boolean validAdapter(Adapter adapter) { // TODO: find a better way to valid adapter instead of instance a ViewHolder. if (adapter != null && adapter.getItemCount() > 0) { RecyclerView.ViewHolder viewHolder = adapter.createViewHolder(mTicklableRecyclerView, adapter.getItemViewType(0)); if (!(viewHolder instanceof ViewHolder)) { String msg = "adapter's ViewHolder should be instance of FocusableLinearLayoutManager.ViewHolder"; if (DesignConfig.DEBUG) { throw new IllegalArgumentException(msg); } else { Log.w(TAG, msg); return false; } } } return true; } @Override public boolean interceptPreScroll() { return mFocusLayoutHelper != null && mFocusLayoutHelper.interceptPreScroll(); } @Override public boolean useScrollAsOffset() { return mFocusLayoutHelper != null; } @Override public int getScrollOffset() { return mScrollOffset == INVALID_SCROLL_OFFSET ? 0 : mScrollOffset; } /** * Update offset to scroll. * * This will calculate the delta of previous offset and new offset, then apply it to scroll. * * @param scrollOffset new offset to scroll. * * @return the unconsumed offset (that needs to appending on raw offset). */ @Override public int updateScrollOffset(int scrollOffset) { if (mTicklableRecyclerView == null || this.mScrollOffset == INVALID_SCROLL_OFFSET || mAppBarScrollController.isAppBarChanging()) { this.mScrollOffset = scrollOffset; return 0; } if (this.mScrollOffset == scrollOffset) { return 0; } int delta = scrollOffset - this.mScrollOffset; int scroll = -delta; int[] consumed = new int[2]; // Temporary disable nested scrolling. mTicklableRecyclerView.scrollBySkipNestedScroll(0, scroll, consumed); this.mScrollOffset -= consumed[1]; return scrollOffset - this.mScrollOffset; } @Override public void onAttachedToWindow(RecyclerView view) { super.onAttachedToWindow(view); } @Override public void onDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) { getHandler().removeCallbacks(exitFocusStateRunnable); super.onDetachedFromWindow(view, recycler); } @Override public void onLayoutChildren(Recycler recycler, State state) { super.onLayoutChildren(recycler, state); if (mFocusStateRequest.notifyOnNextLayout) { mFocusStateRequest.notifyOnNextLayout = false; // If the notify has a animation, we should make sure the notify is later than // RecyclerView's animation. So they will not conflict. if (mFocusStateRequest.animate) { postOnAnimation(new Runnable() { @Override public void run() { // We wait for animation time begin and notify on next main loop, // So we can sure the notify is follow the state change animation. notifyAfterLayoutOnNextMainLoop(); } }); } else { requestNotifyChildrenStateChanged(mFocusStateRequest); } } } private void notifyAfterLayoutOnNextMainLoop() { mUiHandler.post(new Runnable() { @Override public void run() { requestNotifyChildrenStateChanged(mFocusStateRequest); } }); } @Override public RecyclerView.LayoutParams generateDefaultLayoutParams() { return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); } @Override public boolean getClipToPadding() { return mFocusLayoutHelper == null && super.getClipToPadding(); } @Override public int getPaddingTop() { return mFocusLayoutHelper != null ? mFocusLayoutHelper.getVerticalPadding() : super.getPaddingTop(); } @Override public int getPaddingBottom() { return mFocusLayoutHelper != null ? mFocusLayoutHelper.getVerticalPadding() : super.getPaddingBottom(); } @Override public boolean canScrollVertically() { return getChildCount() > 0; } @Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { int scrolled = super.scrollVerticallyBy(dy, recycler, state); if (scrolled == dy) { onScrollVerticalBy(dy); } return scrolled; } @Override public void onScrollStateChanged(int state) { if (mFocusLayoutHelper != null) { mFocusLayoutHelper.onScrollStateChanged(state); } else { super.onScrollStateChanged(state); } } private void onScrollVerticalBy(int dy) { if (mScrollVelocityTracker == null && mFocusLayoutHelper != null) { int itemHeight = mFocusLayoutHelper.getCentralItemHeight(); mScrollVelocityTracker = new ScrollVelocityTracker(mContext, itemHeight); } boolean scrollFast = mScrollVelocityTracker != null && mScrollVelocityTracker.addScroll(dy); if (getChildCount() > 0) { if (mFocusLayoutHelper != null) { requestNotifyChildrenAboutScroll(!scrollFast); } else { requestNotifyChildrenAboutExit(); } } } @Override public boolean dispatchTouchEvent(MotionEvent e) { exitFocusStateIfNeed(e); return false; } @Override public boolean dispatchTouchSidePanelEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: enterFocusStateIfNeed(ev); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: getHandler().postDelayed(exitFocusStateRunnable, // after this period of time without focus state action (side panel event), // we should exit focus state. mContext.getResources().getInteger(R.integer.design_time_action_idle_timeout)); break; } return mFocusLayoutHelper != null && mFocusLayoutHelper.dispatchTouchSidePanelEvent(ev); } private void enterFocusStateIfNeed(@Nullable MotionEvent ev) { getHandler().removeCallbacks(exitFocusStateRunnable); if (mFocusLayoutHelper != null) { return; } setInFocusState(true); // Fix touch offset according to scroll-offset // When enter focus state, offset of this view will transfer to scroll. // So we must calculate the offset change into touch event. if (ev != null && mScrollOffset != INVALID_SCROLL_OFFSET && ev.getAction() == MotionEvent.ACTION_DOWN) { ev.offsetLocation(0, mScrollOffset); } } private void exitFocusStateIfNeed() { exitFocusStateIfNeed(null); } private void exitFocusStateIfNeed(@Nullable MotionEvent ev) { getHandler().removeCallbacks(exitFocusStateRunnable); if (mFocusLayoutHelper == null) { return; } // Fix touch offset according to scroll-offset // When exit focus state, scroll of this view will transfer to offset. // So we must calculate the offset change into touch event. if (ev != null && mScrollOffset != INVALID_SCROLL_OFFSET && ev.getAction() == MotionEvent.ACTION_DOWN) { ev.offsetLocation(0, -mScrollOffset); } setInFocusState(false); } private Runnable exitFocusStateRunnable = new Runnable() { @Override public void run() { exitFocusStateIfNeed(); } }; void requestNotifyChildrenAboutScroll(boolean animate) { if (mScrollResetting || mFocusLayoutHelper == null) { return; } mFocusStateRequest.centerIndex = mFocusLayoutHelper.findCenterViewIndex(); mFocusStateRequest.animate = animate; mFocusStateRequest.scroll = true; requestNotifyChildrenStateChanged(mFocusStateRequest); } void requestNotifyChildrenAboutFocus() { if (mScrollResetting || mFocusLayoutHelper == null) { return; } mFocusStateRequest.centerIndex = mFocusLayoutHelper.findCenterViewIndex(); mFocusStateRequest.animate = true; mFocusStateRequest.scroll = false; requestNotifyChildrenStateChanged(mFocusStateRequest); } void requestNotifyChildrenAboutExit() { if (mScrollResetting) { return; } mFocusStateRequest.centerIndex = RecyclerView.NO_POSITION; mFocusStateRequest.animate = true; mFocusStateRequest.scroll = false; requestNotifyChildrenStateChanged(mFocusStateRequest); } private void requestNotifyChildrenStateChanged(final FocusStateRequest request) { if (request.notifyOnNextLayout) { return; } boolean isRunning = mTicklableRecyclerView != null && mTicklableRecyclerView.getItemAnimator().isRunning( new ItemAnimatorFinishedListener() { @Override public void onAnimationsFinished() { if (mTicklableRecyclerView != null) { notifyChildrenStateChanged(mTicklableRecyclerView, request); } } }); if (DesignConfig.DEBUG_RECYCLER_VIEW) { Log.v(TAG, "request state changed with item anim running? " + isRunning); } } private void notifyChildrenStateChanged(@NonNull TicklableRecyclerView listView, FocusStateRequest request) { int top = ViewPropertiesHelper.getTop(listView); int bottom = ViewPropertiesHelper.getBottom(listView); int center = ViewPropertiesHelper.getCenterYPos(listView); for (int index = 0; index < getChildCount(); ++index) { View view = getChildAt(index); int focusState = FOCUS_STATE_NORMAL; if (request.centerIndex != RecyclerView.NO_POSITION) { focusState = index == request.centerIndex ? FOCUS_STATE_CENTRAL : FOCUS_STATE_NON_CENTRAL; } final boolean animateStateChange = view.isShown() && request.animate; notifyChildFocusStateChanged(listView, focusState, animateStateChange, view); if (focusState == FOCUS_STATE_NORMAL) { continue; } int childCenter = ViewPropertiesHelper.getCenterYPos(view); int halfChildHeight = view.getHeight() / 2; float progress = getCentralProgress(top + halfChildHeight, bottom - halfChildHeight, center, childCenter); ViewHolder viewHolder = (ViewHolder) listView.getChildViewHolder(view); final boolean animateProgressChange = view.isShown() && request.animate && !request.scroll; notifyChildProgressUpdated(viewHolder, progress, animateProgressChange); } notifyOnCentralPositionChanged(listView, request.centerIndex); } private float getCentralProgress(int top, int bottom, int center, int childCenter) { if (childCenter < top) { childCenter = top; } if (childCenter > bottom) { childCenter = bottom; } float progress; if (childCenter > center) { progress = (float) (bottom - childCenter) / (bottom - center); } else { progress = (float) (childCenter - top) / (center - top); } return progress; } private void notifyChildProgressUpdated(ViewHolder viewHolder, float progress, boolean animate) { long defaultDuration = viewHolder.getDefaultAnimDuration(); long duration; // We have a animation in progress. if (viewHolder.animationStartTime > 0) { long timePassed = System.currentTimeMillis() - viewHolder.animationStartTime; viewHolder.animationPlayedTime += timePassed; if (viewHolder.animationPlayedTime >= defaultDuration) { // animation end. viewHolder.animationStartTime = 0; viewHolder.animationPlayedTime = 0; duration = animate ? defaultDuration : 0; } else { // animation in progress, always play the rest duration. duration = animate ? defaultDuration : defaultDuration - viewHolder.animationPlayedTime; } } else { duration = animate ? defaultDuration : 0; if (animate) { // If we update progress with animation and no anim before, // we enter the animation mode with a start time set. viewHolder.animationStartTime = System.currentTimeMillis(); viewHolder.animationPlayedTime = 0; } } viewHolder.onCentralProgressUpdated(progress, duration); } private void notifyChildFocusStateChanged(@NonNull TicklableRecyclerView listView, int focusState, boolean animate, View view) { ViewHolder viewHolder = (ViewHolder) listView.getChildViewHolder(view); // Only call focus state change once. if (viewHolder.prevFocusState != focusState) { if (focusState == FOCUS_STATE_NORMAL) { viewHolder.itemView.setFocusable(false); viewHolder.itemView.setFocusableInTouchMode(false); } else { viewHolder.itemView.setFocusable(true); viewHolder.itemView.setFocusableInTouchMode(true); } viewHolder.onFocusStateChanged(focusState, animate); viewHolder.prevFocusState = focusState; view.setClickable(focusState != FOCUS_STATE_NON_CENTRAL); } } private void notifyOnCentralPositionChanged(@NonNull TicklableRecyclerView listView, int centerIndex) { int centerPosition = centerIndex == RecyclerView.NO_POSITION ? RecyclerView.NO_POSITION : listView.getChildAdapterPosition(getChildAt(centerIndex)); if (centerPosition != mPreviousCentral) { for (OnCentralPositionChangedListener listener : mOnCentralPositionChangedListeners) { listener.onCentralPositionChanged(centerPosition); } mPreviousCentral = centerPosition; } } public Handler getHandler() { return mUiHandler; } private static class FocusStateRequest { public boolean notifyOnNextLayout; public int centerIndex; public boolean animate; public boolean scroll; public FocusStateRequest() { notifyOnNextLayout = false; centerIndex = -1; animate = false; scroll = false; } @Override public String toString() { return "FocusStateRequest@" + hashCode() + "{" + "notifyOnNext " + notifyOnNextLayout + ", center " + centerIndex + ", animate " + animate + ", scroll " + scroll + "}"; } } /** * Denotes that an integer parameter, field or method return value is expected * to be a focus state value (e.g. {@link #FOCUS_STATE_CENTRAL}). */ @Documented @Retention(SOURCE) @Target({METHOD, PARAMETER, FIELD, LOCAL_VARIABLE}) @IntDef({FOCUS_STATE_INVALID, FOCUS_STATE_NORMAL, FOCUS_STATE_CENTRAL, FOCUS_STATE_NON_CENTRAL}) public @interface FocusState {} /** * An listener that receive messages for focus state change. * * @see #FOCUS_STATE_NORMAL * @see #FOCUS_STATE_CENTRAL * @see #FOCUS_STATE_NON_CENTRAL */ public interface OnFocusStateChangedListener { /** * Item's focus state has changed. * @param focusState state of focus. can be {@link #FOCUS_STATE_NORMAL}, * {@link #FOCUS_STATE_CENTRAL}, {@link #FOCUS_STATE_NON_CENTRAL} * @param animate interact with animation? */ void onFocusStateChanged(@FocusState int focusState, boolean animate); } public interface OnCentralProgressUpdatedListener { /** * When we are in focus state, item will be notified by the progress of going central. * * @param progress progress from edge to center. The value is in [0, 1], * 1 means right on the view's center, 0 means on view's edge. * @param animateDuration animate duration to that progress, if 0, means * no animate at all. */ void onCentralProgressUpdated(float progress, long animateDuration); } private static class ScrollVelocityTracker { private long mLastScrollTime = -1; private final float mFastScrollVelocity; ScrollVelocityTracker(@NonNull Context context, int itemHeight) { long animationTime = context.getResources() .getInteger(R.integer.design_anim_list_item_state_change); mFastScrollVelocity = 1.5f * itemHeight / animationTime; } public boolean addScroll(int dy) { boolean scrollFast = false; long currentTime = System.currentTimeMillis(); if (mLastScrollTime > 0) { long duration = currentTime - mLastScrollTime; float velocity = (float) Math.abs(dy) / duration; if (velocity > mFastScrollVelocity) { scrollFast = true; } } mLastScrollTime = currentTime; return scrollFast; } } /** * An OnCentralPositionChangedListener can be set on a TicklableRecyclerView to receive messages * when a central position changed event has occurred on that TicklableRecyclerView when tickled. * * @see #addOnCentralPositionChangedListener(OnCentralPositionChangedListener) */ public interface OnCentralPositionChangedListener { /** * Callback method to be invoked when TicklableRecyclerView's central item changed. * * @param position The adapter position of the central item, can be {@link RecyclerView#NO_POSITION}. * If is {@link RecyclerView#NO_POSITION}, means the tickle state is changed to normal, * so there is no central item. */ void onCentralPositionChanged(int position); } public static class ViewHolder extends RecyclerView.ViewHolder { @FocusState private int prevFocusState; private long animationStartTime; private long animationPlayedTime; private final long defaultAnimDuration; private final Interpolator focusInterpolator; public ViewHolder(View itemView) { super(itemView); prevFocusState = FOCUS_STATE_INVALID; animationStartTime = 0; animationPlayedTime = 0; defaultAnimDuration = itemView.getContext().getResources() .getInteger(R.integer.design_anim_list_item_state_change); focusInterpolator = new AccelerateDecelerateInterpolator(); } /** * When we are in focus state, item will be notified by the progress of going central. * Override this method to do more smooth animation. * * @param progress progress from edge to center. The value is in [0, 1], * 1 means right on the view's center, 0 means on view's edge. * @param animateDuration animate duration to that progress, if 0, means */ protected void onCentralProgressUpdated(float progress, long animateDuration) { if (itemView instanceof OnCentralProgressUpdatedListener) { OnCentralProgressUpdatedListener item = (OnCentralProgressUpdatedListener) itemView; item.onCentralProgressUpdated(progress, animateDuration); } else { float scaleMin = 1.0f; float scaleMax = 1.1f; float alphaMin = 0.6f; float alphaMax = 1.0f; float scale = scaleMin + (scaleMax - scaleMin) * progress; float alphaProgress = getFocusInterpolator().getInterpolation(progress); float alpha = alphaMin + (alphaMax - alphaMin) * alphaProgress; transform(scale, alpha, animateDuration); } } /** * Focus state of view bind to this ViewHolder is changed. * * @param focusState new focus state of view. * @param animate should apply a animate for this change? If not, just change * the view immediately. */ protected void onFocusStateChanged(@FocusState int focusState, boolean animate) { if (DesignConfig.DEBUG_RECYCLER_VIEW) { Log.d(TAG, getLogPrefix() + "focus state to " + focusState + ", animate " + animate + getLogSuffix()); } if (itemView instanceof OnFocusStateChangedListener) { OnFocusStateChangedListener item = (OnFocusStateChangedListener) itemView; item.onFocusStateChanged(focusState, animate); } else { if (focusState == FOCUS_STATE_NORMAL) { transform(1.0f, 1.0f, animate ? getDefaultAnimDuration() : 0); } } } private void transform(float scale, float alpha, long duration) { itemView.animate().cancel(); if (duration > 0) { itemView.animate() .setDuration(duration) .alpha(alpha) .scaleX(scale) .scaleY(scale) .start(); } else { itemView.setScaleX(scale); itemView.setScaleY(scale); itemView.setAlpha(alpha); } } /** * If the transform needs a animation, use this duration for default. */ public long getDefaultAnimDuration() { return defaultAnimDuration; } /** * get the interpolator for focus usage, use the interpolator to interpolation * the progress, so you can get a more obvious focus effect. * * @see #onCentralProgressUpdated */ public Interpolator getFocusInterpolator() { return focusInterpolator; } protected final String getLogPrefix() { int layoutPosition = getLayoutPosition(); int adapterPosition = getAdapterPosition(); return String.format(Locale.getDefault(), "<%d,%d %8x,%8x>: ", adapterPosition, layoutPosition, hashCode(), itemView.hashCode()); } protected final String getLogSuffix() { return " with " + this + ", view " + itemView; } } }