Android TV RecyclerView实现无限居中滑动并上下带渐隐效果

3,662 阅读5分钟

先上实现效果图

一、TV开发中自定义RecyclerView解决若干问题

1、快速滑动焦点乱跑问题

在Android TV端使用原生RecyclerView在快速滑动的过程会发现焦点不见了很莫名奇怪,通过阅读相关源码发现,我们只需要自定义的查找焦点的逻辑即可 可以看看这篇大佬写的焦点分析Android TV开发总结【焦点】 我在查阅其他大神的解决方案中总结了两种,供大家参考
1、重写RecyclerView的focusSearch方法

        @Override
    public View focusSearch(View focused, int direction) {
        View realNextFocus = super.focusSearch(focused, direction);
        View nextFocus = FocusFinder.getInstance().findNextFocus(this, focused, direction);
        switch (direction) {
            case FOCUS_RIGHT:
            case FOCUS_LEFT:
                // 调用移出的监听
                if (nextFocus == null && !canScrollHorizontally(-1)) {
                    if (mCanFocusOutHorizontal) {
                        if (mFocusLostListener != null) {
                            mFocusLostListener.onFocusLost(focused, direction);
                        }
                        return realNextFocus;
                    } else {
                        return focused;
                    }
                }
                break;
            case FOCUS_UP:
            case FOCUS_DOWN:
                if (nextFocus == null && !canScrollVertically(1)) {
                    if (mCanFocusOutVertical) {
                        return realNextFocus;
                    } else {
                        return focused;
                    }
                }
                break;
        }
        return realNextFocus;
    }

2、重写dispatchKeyEvent方法

    @Override
    public boolean dispatchKeyEvent(KeyEvent event) {
        boolean result = super.dispatchKeyEvent(event);
        View focusView = this.getFocusedChild();
        if (focusView == null) {
            return result;
        } 
        int dy = 0;
        int dx = 0;
        if (getChildCount() > 0) {
            View firstView = this.getChildAt(0);
            dy = firstView.getHeight();
            dx = firstView.getWidth();
        }
        if (event.getAction() == KeyEvent.ACTION_UP) {
            if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
               return super.dispatchKeyEvent(event);
            }
            return true;
         } else {
             switch (event.getKeyCode()) {
                 case KeyEvent.KEYCODE_DPAD_RIGHT:
                     View rightView = FocusFinder.getInstance().findNextFocus(this, focusView, View.FOCUS_RIGHT);
                     Log.i(TAG, "rightView is null:" + (rightView == null));
                     if (rightView != null) {
                         rightView.requestFocus();
                         return true;
                     } else {
                         this.smoothScrollBy(dx, 0);
                         return true;
                     }
                 case KeyEvent.KEYCODE_DPAD_LEFT:
                     View leftView = FocusFinder.getInstance().findNextFocus(this, focusView, View.FOCUS_LEFT);
                     Log.i(TAG, "leftView is null:" + (leftView == null));
                     if (leftView != null) {
                         leftView.requestFocus();
                         return true;
                     } else {
                         this.smoothScrollBy(-dx, 0);
                         return true;
                     }
                 case KeyEvent.KEYCODE_DPAD_DOWN:
                     View downView = FocusFinder.getInstance().findNextFocus(this, focusView, View.FOCUS_DOWN);
                     Log.i(TAG, " downView is null:" + (downView == null));
                     if (downView != null) {
                         downView.requestFocus();
                         return true;
                     } else {
                         this.smoothScrollBy(0, dy);
                         return true;
                     }
                 case KeyEvent.KEYCODE_DPAD_UP:
                     View upView = FocusFinder.getInstance().findNextFocus(this, focusView, View.FOCUS_UP);
                     Log.i(TAG, "upView is null:" + (upView == null));
                     if (upView != null) {
                         upView.requestFocus();
                         return true;
                     } else {
                         this.smoothScrollBy(0, -dy);
                         return true;
                     }
             }
        }
        return result;
    }

2、实现焦点记忆功能

如果想实现焦点记忆功能的话可以重写如下RecyclerView函数

private View mLastFocusView = null;
// 最后一次聚焦的位置
private int mLastFocusPosition = 0;

@Override
public void requestChildFocus(View child, View focused) {
    Log.i(TAG, "requestChildFocus nextchild= " + child + ",focused = " + focused);
    Log.i(TAG, "requestChildFocus  focusPos = " + mLastFocusPosition);
    super.requestChildFocus(child, focused);
    mLastFocusView = focused;
    //执行过super.requestChildFocus之后hasFocus会变成true
    mLastFocusPosition = getChildViewHolder(child).getBindingAdapterPosition();
    Log.i(TAG, "requestChildFocus  focusPos = " + mLastFocusPosition);
}


@Override
public void addFocusables(ArrayList<View> views, int direction, int focusableMode) {
    Log.i(TAG, "addFocusables--focusPos = " + mLastFocusPosition);
    if (this.hasFocus() || mLastFocusView == null) {
        //在recyclerview内部焦点切换
        super.addFocusables(views, direction, focusableMode);
    } else {
        //将当前的view放到Focusable views列表中,再次移入焦点时会取到该view,实现焦点记忆功能
        views.add(getLayoutManager().findViewByPosition(mLastFocusPosition));
    }
}

3、实现Item焦点放大不被遮挡

如果想实现Item放大不被遮挡的话,需要重写getChildDrawingOrder函数

@Override
protected int getChildDrawingOrder(int childCount, int position) {
    View focusedView = getFocusedChild();
    if (null != focusedView) {
        int pos = indexOfChild(focusedView);
        /* 这是最后一个需要刷新的item */
        if (position == childCount - 1) {
            if (pos > position) {
                pos = position;
            }
            return pos;
        }
        else if (pos == position) {
            /* 这是原本要在最后一个刷新的item */
            return childCount - 1;
        }
    }
    return position;
}

4、实现Item居中滑动效果

如果想实现居中滑动效果,有两种方式 1、重写RecyclerView的requestChildFocus和requestChildRectangleOnScreen方法

//焦点是否居中
private boolean mSelectedItemCentered = true;
private int mSelectedItemOffsetStart = 0;
private int mSelectedItemOffsetEnd = 0;

@Override
public void requestChildFocus(View child, View focused) {
   Log.i(TAG, "nextchild= " + child + ",focused = " + focused);
    //计算控制recyclerview 选中item的居中从参数
   if (mSelectedItemCentered && child != null) {
       mSelectedItemOffsetStart = !isVertical() ? (getFreeWidth() - child.getWidth()) : (getFreeHeight() - child.getHeight());
       mSelectedItemOffsetStart /= 2;
       mSelectedItemOffsetEnd = mSelectedItemOffsetStart;
   }
   Log.i(TAG, "requestChildFocus  focusPos = " + mCurrentFocusPosition);
   super.requestChildFocus(child, focused);
   //执行过super.requestChildFocus之后hasFocus会变成true
   mCurrentFocusPosition = getChildViewHolder(child).getBindingAdapterPosition();
   Log.i(TAG, "requestChildFocus  focusPos = " + mCurrentFocusPosition);
}

/**
 * 通过该方法设置选中的item居中
 * <p>
 * 该方法能够确定在布局中滚动或者滑动时候,子item和parent之间的位置
 * dy,dx的实际意义就是在滚动中下滑和左右滑动的距离
 * 而这个值的确定会严重影响滑动的流畅程度
 */
@Override
public boolean requestChildRectangleOnScreen(View child, Rect rect, boolean immediate) {
    Log.i(TAG, "requestChildRectangleOnScreen= " + child + ",pos = " + immediate);
    final int parentLeft = getPaddingLeft();
    final int parentRight = getWidth() - getPaddingRight();

    final int parentTop = getPaddingTop();
    final int parentBottom = getHeight() - getPaddingBottom();

    final int childLeft = child.getLeft() + rect.left;
    final int childTop = child.getTop() + rect.top;

    final int childRight = childLeft + rect.width();
    final int childBottom = childTop + rect.height();

    final int offScreenLeft = Math.min(0, childLeft - parentLeft - mSelectedItemOffsetStart);
    final int offScreenRight = Math.max(0, childRight - parentRight + mSelectedItemOffsetEnd);

    final int offScreenTop = Math.min(0, childTop - parentTop - mSelectedItemOffsetStart);
    final int offScreenBottom = Math.max(0, childBottom - parentBottom + mSelectedItemOffsetEnd);


    final boolean canScrollHorizontal = getLayoutManager().canScrollHorizontally();
    final boolean canScrollVertical = getLayoutManager().canScrollVertically();

    // Favor the "start" layout direction over the end when bringing one side or the other
    // of a large rect into view. If we decide to bring in end because start is already
    // visible, limit the scroll such that start won't go out of bounds.
    final int dx;
    if (canScrollHorizontal) {
        if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL) {
            dx = offScreenRight != 0 ? offScreenRight
                    : Math.max(offScreenLeft, childRight - parentRight);
        } else {
            dx = offScreenLeft != 0 ? offScreenLeft
                    : Math.min(childLeft - parentLeft, offScreenRight);
        }
    } else {
        dx = 0;
    }
    // Favor bringing the top into view over the bottom. If top is already visible and
    // we should scroll to make bottom visible, make sure top does not go out of bounds.
    final int dy;
    if (canScrollVertical) {
        dy = offScreenTop != 0 ? offScreenTop : Math.min(childTop - parentTop, offScreenBottom);
    } else {
        dy = 0;
    }

    if (dx != 0 || dy != 0) {
        scrollBy(dx, dy);
        if (immediate) {
            scrollBy(dx, dy);
        } else {
            smoothScrollBy(dx, dy);
        }
        // 重绘是为了选中item置顶,具体请参考getChildDrawingOrder方法
        postInvalidate();
        return true;
    }
    return false;
}

2、自定义LayoutManager,来实现居中滑动

public class CenterLayoutManager extends LinearLayoutManager {
    
    public CenterLayoutManager(Context context) {
        super(context);
    }

    @Override
    public void smoothScrollToPosition(final RecyclerView recyclerView, RecyclerView.State state,final int position) {
        CenterSmoothScroller smoothScroller = new CenterSmoothScroller(recyclerView.getContext(),recyclerView) {
            @Override
            public PointF computeScrollVectorForPosition(int targetPosition) {
                return computeVectorForPosition(targetPosition);
            }
        };
        smoothScroller.setTargetPosition(position);
        startSmoothScroll(smoothScroller);
    }

    public PointF computeVectorForPosition(int targetPosition) {
        return super.computeScrollVectorForPosition(targetPosition);
    }

    abstract class CenterSmoothScroller extends LinearSmoothScroller {
        RecyclerView recyclerView;
        CenterSmoothScroller(Context context,RecyclerView recyclerView) {
            super(context);
            this.recyclerView = recyclerView;
        }

        @Override
        public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int snapPreference) {
            Log.i("du","calculateDtToFit viewStart:" + viewStart + "---viewEnd:" + viewEnd + "---boxStart:" + boxStart + "---boxEnd:" + boxEnd + "----snapPreference:" + snapPreference );
            return (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2);
        }

        /**
         * 滑动完成后,让该targetPosition 处的item获取焦点
         */
        @Override
        protected void onStop() {
            Log.i("du","onStop-Position" + getTargetPosition());
            super.onStop();
            final View itemView = findViewByPosition(getTargetPosition());
            if (null != itemView) {
                itemView.requestFocus();
            }
        }

    }
}


//recyclerview中调用
@Override
public void onBindViewHolder(final @NonNull BaseViewHolder viewHolder, final int i) {
    viewHolder.itemView.setOnFocusChangeListener(new View.OnFocusChangeListener() {
        @Override
        public void onFocusChange(View v, boolean hasFocus) {
            Log.i("du","onBindViewHolder-Position"+ i + "---hasFocus:" + hasFocus);
            if (hasFocus) {
                ViewCompat.animate(viewHolder.itemView).scaleX(1.5f).scaleY(1.5f).start();
                mCenterLayoutManager.smoothScrollToPosition(mRecyclerView,new RecyclerView.State(), i);
            } else {
                ViewCompat.animate(viewHolder.itemView).scaleX(1f).scaleY(1f).start();
            }
        }
    });

二、实现垂直列表的无线循环滑动

在这里参照了网上的方法 在RecyclerView.Adapter的方法中:

@Override
public int getItemCount() {
    return Integer.MAX_VALUE;
}

由于我们需要时需要无线居中滑动而且默认定位,所以我们需要调整下逻辑重写setAdapter

@Override
public void setAdapter(@Nullable Adapter adapter) {
  super.setAdapter(adapter);
  //定位到对应位置
  scrollToPosition(((Integer.MAX_VALUE / 2) - ((Integer.MAX_VALUE / 2) % realCount)) + mCurPos);
  postDelayed(new Runnable() {
      @Override
      public void run() {
          View targetView = getLayoutManager().findViewByPosition(((Integer.MAX_VALUE/2)-((Integer.MAX_VALUE/2)%realCount)) + mCurPos);
          if (targetView != null) {
              targetView.requestFocus();
          }
      }
  },100);
}

三、实现垂直列表的渐隐效果

自定义RecyclerView并重写对应方法即可

private Paint paint;
private int height;
private int width;
private int spanPixel = 100;

@Override
 protected void onSizeChanged(int w, int h, int oldw, int oldh) {
     super.onSizeChanged(w, h, oldw, oldh);
     height = h;
     width = w;
     float spanFactor = spanPixel / (height / 2f);
     // 设置渐隐效果,起始0为0x00000000,中间spanFactor为0xff000000,末尾为0xff000000
     LinearGradient linearGradient = new LinearGradient(0, 0, 0, height / 2,
             new int[]{0x00000000, 0xff000000, 0xff000000}, new float[]{0, spanFactor, 1f}, Shader.TileMode.MIRROR);
     paint.setShader(linearGradient);
 }


 @Override
 public void draw(Canvas c) {
     c.saveLayer(0, 0, width, height, null, Canvas.ALL_SAVE_FLAG);
     super.draw(c);
     c.drawRect(0, 0, width, height, paint);
     c.restore();
 }

四、限制RecyclerView滑动速度

可以使用通过调整dispatchKeyEvent 的输入间隔时间

private long mLastKeyDownTime;
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
    long current = System.currentTimeMillis();
    if (event.getAction() != KeyEvent.ACTION_DOWN || getChildCount() == 0) {
        return super.dispatchKeyEvent(event);
    }
    // 限制两个KEY_DOWN事件的最低间隔为120ms
    if (isComputingLayout() || current - mLastKeyDownTime <= 120) {
        return true;
    }
    mLastKeyDownTime = current;
    return super.dispatchKeyEvent(event);
}