Skip to content

Commit

Permalink
[Carousel] When navigating with keyboard, scroll focused item to near…
Browse files Browse the repository at this point in the history
…est focal keyline, not the first focal keyline

PiperOrigin-RevId: 573024609
  • Loading branch information
imhappi authored and drchen committed Oct 13, 2023
1 parent 74ac87c commit fb9c1c6
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 13 deletions.
176 changes: 164 additions & 12 deletions lib/java/com/google/android/material/carousel/CarouselLayoutManager.java
Expand Up @@ -401,6 +401,24 @@ private void addViewsStart(Recycler recycler, int startPosition) {
}
}

/**
* Adds a child view to the RecyclerView at the given {@code childIndex}, regardless of whether or
* not the view is in bounds.
*
* @param recycler current recycler that is attached to the {@link RecyclerView}
* @param startPosition the position of the adapter whose view is to be added
* @param childIndex the index of the RecyclerView's children that the view should be added at
*/
private void addViewAtPosition(@NonNull Recycler recycler, int startPosition, int childIndex) {
if (startPosition < 0 || startPosition >= getItemCount()) {
return;
}
float start = calculateChildStartForFill(startPosition);
ChildCalculations calculations = makeChildCalculations(recycler, start, startPosition);
// Add this child to the given child index of the RecyclerView.
addAndLayoutView(calculations.child, /* index= */ childIndex, calculations);
}

/**
* Adds views to the RecyclerView, moving towards the end of the carousel container, until
* potentially new items are no longer in bounds or the end of the adapter list is reached.
Expand Down Expand Up @@ -1073,6 +1091,27 @@ private int getScrollOffsetForPosition(int position, KeylineState keylineState)
}
}

private int getSmallestScrollOffsetToFocalKeyline(
int position, @NonNull KeylineState keylineState) {
int smallestScrollOffset = Integer.MAX_VALUE;
for (Keyline keyline : keylineState.getFocalKeylines()) {
float offsetWithoutKeylines = position * keylineState.getItemSize();
float halfFocalKeylineSize = keylineState.getItemSize() / 2F;
float offsetWithKeylines = offsetWithoutKeylines + halfFocalKeylineSize;

int positionOffsetDistanceFromKeyline =
isLayoutRtl()
? (int) ((getContainerSize() - keyline.loc) - offsetWithKeylines)
: (int) (offsetWithKeylines - keyline.loc);
positionOffsetDistanceFromKeyline -= scrollOffset;

if (Math.abs(smallestScrollOffset) > Math.abs(positionOffsetDistanceFromKeyline)) {
smallestScrollOffset = positionOffsetDistanceFromKeyline;
}
}
return smallestScrollOffset;
}

@Nullable
@Override
public PointF computeScrollVectorForPosition(int targetPosition) {
Expand Down Expand Up @@ -1214,32 +1253,145 @@ public int scrollVerticallyBy(int dy, Recycler recycler, State state) {
return canScrollVertically() ? scrollBy(dy, recycler, state) : 0;
}

/**
* Helper class to encapsulate information about the layout direction in relation to the focus
* direction.
*/
private static class LayoutDirection {
private static final int LAYOUT_START = -1;

private static final int LAYOUT_END = 1;

private static final int INVALID_LAYOUT = Integer.MIN_VALUE;
}

/**
* Converts a focusDirection to a layout direction.
*
* @param focusDirection One of {@link View#FOCUS_UP}, {@link View#FOCUS_DOWN}, {@link
* View#FOCUS_LEFT}, {@link View#FOCUS_RIGHT}, {@link View#FOCUS_BACKWARD}, {@link
* View#FOCUS_FORWARD} or 0 for not applicable
* @return {@link LayoutDirection#LAYOUT_START} or {@link LayoutDirection#LAYOUT_END} if focus
* direction is applicable to current state, {@link LayoutDirection#INVALID_LAYOUT} otherwise.
*/
private int convertFocusDirectionToLayoutDirection(int focusDirection) {
int orientation = getOrientation();
switch (focusDirection) {
case View.FOCUS_BACKWARD:
return LayoutDirection.LAYOUT_START;
case View.FOCUS_FORWARD:
return LayoutDirection.LAYOUT_END;
case View.FOCUS_UP:
return orientation == VERTICAL
? LayoutDirection.LAYOUT_START
: LayoutDirection.INVALID_LAYOUT;
case View.FOCUS_DOWN:
return orientation == VERTICAL
? LayoutDirection.LAYOUT_END
: LayoutDirection.INVALID_LAYOUT;
case View.FOCUS_LEFT:
if (orientation == HORIZONTAL) {
return isLayoutRtl() ? LayoutDirection.LAYOUT_END : LayoutDirection.LAYOUT_START;
}
return LayoutDirection.INVALID_LAYOUT;
case View.FOCUS_RIGHT:
if (orientation == HORIZONTAL) {
return isLayoutRtl() ? LayoutDirection.LAYOUT_START : LayoutDirection.LAYOUT_END;
}
return LayoutDirection.INVALID_LAYOUT;
default:
Log.d(TAG, "Unknown focus request:" + focusDirection);
return LayoutDirection.INVALID_LAYOUT;
}
}

@Nullable
@Override
public View onFocusSearchFailed(
@NonNull View focused, int focusDirection, @NonNull Recycler recycler, @NonNull State state) {
if (getChildCount() == 0) {
return null;
}

final int layoutDir = convertFocusDirectionToLayoutDirection(focusDirection);
if (layoutDir == LayoutDirection.INVALID_LAYOUT) {
return null;
}

final View nextFocus;
if (layoutDir == LayoutDirection.LAYOUT_START) {
if (getPosition(focused) == 0) {
return null;
}
int firstPosition = getPosition(getChildAt(0));
addViewAtPosition(recycler, firstPosition - 1, 0);
nextFocus = getChildClosestToStart();
} else {
if (getPosition(focused) == getItemCount() - 1) {
return null;
}
int lastPosition = getPosition(getChildAt(getChildCount() - 1));
addViewAtPosition(recycler, lastPosition + 1, -1);
nextFocus = getChildClosestToEnd();
}

return nextFocus;
}

/**
* Convenience method to find the child closes to start. Caller should check if it has enough
* children.
*
* @return The child closest to start of the layout from user's perspective.
*/
private View getChildClosestToStart() {
return getChildAt(isLayoutRtl() ? getChildCount() - 1 : 0);
}

/**
* Convenience method to find the child closes to end. Caller should check if it has enough
* children.
*
* @return The child closest to end of the layout from user's perspective.
*/
private View getChildClosestToEnd() {
return getChildAt(isLayoutRtl() ? 0 : getChildCount() - 1);
}

@Override
public boolean requestChildRectangleOnScreen(
@NonNull RecyclerView parent,
@NonNull View child,
@NonNull Rect rect,
boolean immediate,
boolean focusedChildVisible) {

if (keylineStateList == null) {
return false;
}

int delta =
getOffsetToScrollToPosition(
getSmallestScrollOffsetToFocalKeyline(
getPosition(child), getKeylineStateForPosition(getPosition(child)));
if (!focusedChildVisible) {
// TODO(b/266816148): Implement smoothScrollBy when immediate is false.
if (delta != 0) {
if (isHorizontal()) {
parent.scrollBy(delta, 0);
} else {
parent.scrollBy(0, delta);
}
return true;
}
if (delta == 0) {
return false;
}
// Get the keyline state at the scroll offset, and scroll based on that.
int realDelta = calculateShouldScrollBy(delta, scrollOffset, minScroll, maxScroll);
KeylineState scrolledKeylineState =
keylineStateList.getShiftedState(scrollOffset + realDelta, minScroll, maxScroll);

delta = getSmallestScrollOffsetToFocalKeyline(getPosition(child), scrolledKeylineState);
scrollBy(parent, delta);
return true;
}

private void scrollBy(RecyclerView recyclerView, int delta) {
if (isHorizontal()) {
recyclerView.scrollBy(delta, 0);
} else {
recyclerView.scrollBy(0, delta);
}
return false;
}

/**
Expand Down
Expand Up @@ -101,6 +101,11 @@ int getLastFocalKeylineIndex() {
return lastFocalKeylineIndex;
}

/** Returns list of all the keylines that are focal. **/
List<Keyline> getFocalKeylines() {
return keylines.subList(firstFocalKeylineIndex, lastFocalKeylineIndex + 1);
}

/** Returns the first keyline. */
Keyline getFirstKeyline() {
return keylines.get(0);
Expand Down
Expand Up @@ -16,6 +16,7 @@

package com.google.android.material.carousel;

import androidx.annotation.NonNull;
import androidx.core.math.MathUtils;
import com.google.android.material.animation.AnimationUtils;
import com.google.android.material.carousel.KeylineState.Keyline;
Expand Down Expand Up @@ -55,7 +56,7 @@ class KeylineStateList {
private final float endShiftRange;

private KeylineStateList(
KeylineState defaultState,
@NonNull KeylineState defaultState,
List<KeylineState> startStateSteps,
List<KeylineState> endStateSteps) {
this.defaultState = defaultState;
Expand Down
Expand Up @@ -28,6 +28,7 @@
import static com.google.common.truth.Truth.assertThat;

import android.content.Context;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build.VERSION_CODES;
import androidx.appcompat.app.AppCompatActivity;
Expand Down Expand Up @@ -152,6 +153,20 @@ public void testScrollToEndThenToStart_childrenHaveValidOrder() throws Throwable
CarouselHelper.assertChildrenHaveValidOrder(layoutManager);
}

@Test
public void testRequestChildRectangleOnScreen_doesntScrollIfChildIsFocal() throws Throwable {
setAdapterItems(recyclerView, layoutManager, adapter, createDataSetWithSize(10));
assertThat(layoutManager.scrollOffset).isEqualTo(0);

// Bring second child into focus
layoutManager.requestChildRectangleOnScreen(
recyclerView, recyclerView.getChildAt(1), new Rect(), /* immediate= */ true);

// Test Keyline state has 2 focal keylines at the start; default item with is 450 and
// focal keyline size is 450, so the scroll offset should be 0.
assertThat(layoutManager.scrollOffset).isEqualTo(0);
}

/**
* Assigns explicit sizes to fixtures being used to construct the testing environment.
*
Expand Down
Expand Up @@ -591,6 +591,20 @@ public void testScrollOffset_isNotReset() throws Throwable {
assertThat(layoutManager.computeScrollVectorForPosition(itemCount / 2).y).isEqualTo(0f);
}

@Test
public void testRequestChildRectangleOnScreen_doesntScrollIfChildIsFocal() throws Throwable {
setAdapterItems(recyclerView, layoutManager, adapter, createDataSetWithSize(10));
assertThat(layoutManager.scrollOffset).isEqualTo(0);

// Bring second child into focus
layoutManager.requestChildRectangleOnScreen(
recyclerView, recyclerView.getChildAt(1), new Rect(), /* immediate= */ true);

// Test Keyline state has 2 focal keylines at the start; default item with is 450 and
// focal keyline size is 450, so the scroll offset should be 0.
assertThat(layoutManager.scrollOffset).isEqualTo(0);
}

/**
* Assigns explicit sizes to fixtures being used to construct the testing environment.
*
Expand Down
Expand Up @@ -287,6 +287,20 @@ public void testAnchorKeyline_cannotBeFocal() {
.build());
}

@Test
public void testGetFocalKeylines() {
KeylineState keylineState =
new KeylineState.Builder(100F, 0)
.addKeyline(25F, .5F, 50F)
.addKeylineRange(100F, 0F, 100F, 2, true)
.addKeyline(275F, .5F, 50F)
.build();

List<Keyline> focalKeylines = keylineState.getFocalKeylines();

assertThat(focalKeylines).isEqualTo(keylineState.getKeylines().subList(1, 3));
}

/**
* Creates a {@link KeylineState.Builder} that has a centered focal range with three large items,
* and one medium item, two small items, and one extra small item on each side of the focal range.
Expand Down

0 comments on commit fb9c1c6

Please sign in to comment.