From 8938da8c2828e36fe68b86d295b3f879e84a1d24 Mon Sep 17 00:00:00 2001 From: rightnao Date: Thu, 4 May 2023 13:58:46 -0400 Subject: [PATCH] [Carousel] Add CarouselSnapHelper PiperOrigin-RevId: 529457461 --- .../carousel/CarouselLayoutManager.java | 43 ++-- .../material/carousel/CarouselSnapHelper.java | 197 ++++++++++++++++++ .../material/carousel/CarouselHelper.java | 21 ++ .../carousel/CarouselLayoutManagerTest.java | 22 +- .../carousel/CarouselSnapHelperTest.java | 159 ++++++++++++++ 5 files changed, 407 insertions(+), 35 deletions(-) create mode 100644 lib/java/com/google/android/material/carousel/CarouselSnapHelper.java create mode 100644 lib/javatests/com/google/android/material/carousel/CarouselSnapHelperTest.java diff --git a/lib/java/com/google/android/material/carousel/CarouselLayoutManager.java b/lib/java/com/google/android/material/carousel/CarouselLayoutManager.java index c0f6a99a594..617e08cfb4c 100644 --- a/lib/java/com/google/android/material/carousel/CarouselLayoutManager.java +++ b/lib/java/com/google/android/material/carousel/CarouselLayoutManager.java @@ -61,7 +61,8 @@ * measured and it's desired size will be used to determine an appropriate size for all items in the * carousel. */ -public class CarouselLayoutManager extends LayoutManager implements Carousel { +public class CarouselLayoutManager extends LayoutManager + implements Carousel, RecyclerView.SmoothScroller.ScrollVectorProvider { private static final String TAG = "CarouselLayoutManager"; @@ -844,7 +845,8 @@ public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent event) { * than the min and max scroll offsets but this will be clamped in {@link #scrollBy(int, Recycler, * State)} (Recycler, State)} by {@link #calculateShouldHorizontallyScrollBy(int, int, int, int)}. */ - private int getScrollOffsetForPosition(KeylineState keylineState, int position) { + private int getScrollOffsetForPosition(int position) { + KeylineState keylineState = keylineStateList.getDefaultState(); if (isLayoutRtl()) { return (int) ((getContainerWidth() - keylineState.getLastFocalKeyline().loc) @@ -858,13 +860,34 @@ private int getScrollOffsetForPosition(KeylineState keylineState, int position) } } + @Nullable + @Override + public PointF computeScrollVectorForPosition(int targetPosition) { + if (keylineStateList == null) { + return null; + } + + return new PointF(getOffsetToScrollToPosition(targetPosition), 0F); + } + + /** + * Gets the offset needed to scroll to a position from the current scroll offset. + * + *

This will calculate the horizontal scroll offset needed to place a child at {@code + * position}'s center at the start-most focal keyline. + */ + int getOffsetToScrollToPosition(int position) { + int targetScrollOffset = getScrollOffsetForPosition(position); + return targetScrollOffset - horizontalScrollOffset; + } + @Override public void scrollToPosition(int position) { if (keylineStateList == null) { return; } horizontalScrollOffset = - getScrollOffsetForPosition(keylineStateList.getDefaultState(), position); + getScrollOffsetForPosition(position); currentFillStartPosition = MathUtils.clamp(position, 0, max(0, getItemCount() - 1)); updateCurrentKeylineStateForScrollOffset(); requestLayout(); @@ -877,13 +900,7 @@ public void smoothScrollToPosition(RecyclerView recyclerView, State state, int p @Nullable @Override public PointF computeScrollVectorForPosition(int targetPosition) { - if (keylineStateList == null) { - return null; - } - - float targetScrollOffset = - getScrollOffsetForPosition(keylineStateList.getDefaultState(), targetPosition); - return new PointF(targetScrollOffset - horizontalScrollOffset, 0F); + return CarouselLayoutManager.this.computeScrollVectorForPosition(targetPosition); } @Override @@ -891,7 +908,7 @@ public int calculateDxToMakeVisible(View view, int snapPreference) { // Override dx calculations so the target view is brought all the way into the focal // range instead of just being made visible. float targetScrollOffset = - getScrollOffsetForPosition(keylineStateList.getDefaultState(), getPosition(view)); + getScrollOffsetForPosition(getPosition(view)); return (int) (horizontalScrollOffset - targetScrollOffset); } }; @@ -920,9 +937,7 @@ public boolean requestChildRectangleOnScreen( return false; } - int offsetForChild = - getScrollOffsetForPosition(keylineStateList.getDefaultState(), getPosition(child)); - int dx = offsetForChild - horizontalScrollOffset; + int dx = getOffsetToScrollToPosition(getPosition(child)); if (!focusedChildVisible) { if (dx != 0) { // TODO(b/266816148): Implement smoothScrollBy when immediate is false. diff --git a/lib/java/com/google/android/material/carousel/CarouselSnapHelper.java b/lib/java/com/google/android/material/carousel/CarouselSnapHelper.java new file mode 100644 index 00000000000..4cb726775cf --- /dev/null +++ b/lib/java/com/google/android/material/carousel/CarouselSnapHelper.java @@ -0,0 +1,197 @@ +/* + * Copyright 2023 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 + * + * https://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 com.google.android.material.carousel; + +import android.graphics.PointF; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.LayoutManager; +import androidx.recyclerview.widget.SnapHelper; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Implementation of the {@link SnapHelper} that supports snapping items to the carousel keylines + * according to the strategy. + */ +public class CarouselSnapHelper extends SnapHelper { + + private final boolean disableFling; + + public CarouselSnapHelper() { + this(true); + } + + public CarouselSnapHelper(boolean disableFling) { + this.disableFling = disableFling; + } + + @Nullable + @Override + public int[] calculateDistanceToFinalSnap( + @NonNull LayoutManager layoutManager, @NonNull View view) { + // If the layout manager is not a CarouselLayoutManager, we return with a zero offset + // as there are no keylines to snap to. + if (!(layoutManager instanceof CarouselLayoutManager)) { + return new int[] {0, 0}; + } + + int offset = 0; + if (layoutManager.canScrollHorizontally()) { + offset = distanceToFirstFocalKeyline(view, (CarouselLayoutManager) layoutManager); + } + // TODO(b/279088745): Implement snap helper for vertical scrolling. + return new int[] {offset, 0}; + } + + private int distanceToFirstFocalKeyline( + @NonNull View targetView, CarouselLayoutManager layoutManager) { + return layoutManager.getOffsetToScrollToPosition(layoutManager.getPosition(targetView)); + } + + @Nullable + @Override + public View findSnapView(LayoutManager layoutManager) { + // TODO(b/279088745): Implement snap helper for vertical scrolling. + if (layoutManager.canScrollHorizontally()) { + return findViewNearestFirstKeyline(layoutManager); + } + return null; + } + + /** + * Return the child view that is currently closest to the first focal keyline. + * + * @param layoutManager The {@link LayoutManager} associated with the attached {@link + * RecyclerView}. + * @return the child view that is currently closest to the first focal keyline. + */ + @Nullable + private View findViewNearestFirstKeyline(LayoutManager layoutManager) { + int childCount = layoutManager.getChildCount(); + if (childCount == 0 || !(layoutManager instanceof CarouselLayoutManager)) { + return null; + } + View closestChild = null; + int absClosest = Integer.MAX_VALUE; + + CarouselLayoutManager carouselLayoutManager = (CarouselLayoutManager) layoutManager; + for (int i = 0; i < childCount; i++) { + final View child = layoutManager.getChildAt(i); + final int position = layoutManager.getPosition(child); + final int offset = + Math.abs(carouselLayoutManager.getOffsetToScrollToPosition(position)); + + // If child center is closer than previous closest, set it as closest + if (offset < absClosest) { + absClosest = offset; + closestChild = child; + } + } + return closestChild; + } + + @Override + public int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY) { + if (!disableFling) { + return RecyclerView.NO_POSITION; + } + + final int itemCount = layoutManager.getItemCount(); + if (itemCount == 0) { + return RecyclerView.NO_POSITION; + } + + // A child that is exactly centered on the first focal keyline is eligible + // for both before and after + View closestChildBeforeKeyline = null; + int distanceBefore = Integer.MIN_VALUE; + View closestChildAfterKeyline = null; + int distanceAfter = Integer.MAX_VALUE; + + // Find the first view before the first focal keyline, and the first view after it + final int childCount = layoutManager.getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = layoutManager.getChildAt(i); + if (child == null) { + continue; + } + final int distance = + distanceToFirstFocalKeyline(child, (CarouselLayoutManager) layoutManager); + + if (distance <= 0 && distance > distanceBefore) { + // Child is before the keyline and closer then the previous best + distanceBefore = distance; + closestChildBeforeKeyline = child; + } + if (distance >= 0 && distance < distanceAfter) { + // Child is after the keyline and closer then the previous best + distanceAfter = distance; + closestChildAfterKeyline = child; + } + } + + // Return the position of the closest child from the first focal keyline, in the direction of + // the fling + final boolean forwardDirection = isForwardFling(layoutManager, velocityX, velocityY); + if (forwardDirection && closestChildAfterKeyline != null) { + return layoutManager.getPosition(closestChildAfterKeyline); + } else if (!forwardDirection && closestChildBeforeKeyline != null) { + return layoutManager.getPosition(closestChildBeforeKeyline); + } + + // There is no child in the direction of the fling (eg. start/end of list). + // Extrapolate from the child that is visible to get the position of the view to + // snap to. + View visibleView = forwardDirection ? closestChildBeforeKeyline : closestChildAfterKeyline; + if (visibleView == null) { + return RecyclerView.NO_POSITION; + } + int visiblePosition = layoutManager.getPosition(visibleView); + int snapToPosition = + visiblePosition + (isReverseLayout(layoutManager) == forwardDirection ? -1 : 1); + + if (snapToPosition < 0 || snapToPosition >= itemCount) { + return RecyclerView.NO_POSITION; + } + return snapToPosition; + } + + private boolean isForwardFling( + RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) { + if (layoutManager.canScrollHorizontally()) { + return velocityX > 0; + } else { + return velocityY > 0; + } + } + + // Calculates the direction of the layout based on the direction of the scroll vector when + // scrolling to the end of the list. This is not equivalent to `isRtl` because the recyclerview + // layout manager may set `reverseLayout`. + private boolean isReverseLayout(RecyclerView.LayoutManager layoutManager) { + final int itemCount = layoutManager.getItemCount(); + if ((layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) { + RecyclerView.SmoothScroller.ScrollVectorProvider vectorProvider = + (RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager; + PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1); + if (vectorForEnd != null) { + return vectorForEnd.x < 0 || vectorForEnd.y < 0; + } + } + return false; + } +} diff --git a/lib/javatests/com/google/android/material/carousel/CarouselHelper.java b/lib/javatests/com/google/android/material/carousel/CarouselHelper.java index 6a002dc31c3..9af210cbc79 100644 --- a/lib/javatests/com/google/android/material/carousel/CarouselHelper.java +++ b/lib/javatests/com/google/android/material/carousel/CarouselHelper.java @@ -285,4 +285,25 @@ public int scrollHorizontallyBy(int dx, Recycler recycler, State state) { return scroll; } } + + static KeylineState getTestCenteredKeylineState() { + float smallSize = 56F; + float extraSmallSize = 10F; + float largeSize = 450F; + float mediumSize = 88F; + + float extraSmallMask = getKeylineMaskPercentage(extraSmallSize, largeSize); + float smallMask = getKeylineMaskPercentage(smallSize, largeSize); + float mediumMask = getKeylineMaskPercentage(mediumSize, largeSize); + + return new KeylineState.Builder(450F) + .addKeyline(5F, extraSmallMask, extraSmallSize) + .addKeylineRange(38F, smallMask, smallSize, 2) + .addKeyline(166F, mediumMask, mediumSize) + .addKeylineRange(435F, 0F, largeSize, 2, true) + .addKeyline(1154F, mediumMask, mediumSize) + .addKeylineRange(1226F, smallMask, smallSize, 2) + .addKeyline(1315F, extraSmallMask, extraSmallSize) + .build(); + } } diff --git a/lib/javatests/com/google/android/material/carousel/CarouselLayoutManagerTest.java b/lib/javatests/com/google/android/material/carousel/CarouselLayoutManagerTest.java index 82b2859b1e4..2e3390b8731 100644 --- a/lib/javatests/com/google/android/material/carousel/CarouselLayoutManagerTest.java +++ b/lib/javatests/com/google/android/material/carousel/CarouselLayoutManagerTest.java @@ -17,6 +17,7 @@ import static com.google.android.material.carousel.CarouselHelper.assertChildrenHaveValidOrder; import static com.google.android.material.carousel.CarouselHelper.createDataSetWithSize; +import static com.google.android.material.carousel.CarouselHelper.getTestCenteredKeylineState; import static com.google.android.material.carousel.CarouselHelper.scrollHorizontallyBy; import static com.google.android.material.carousel.CarouselHelper.scrollToPosition; import static com.google.android.material.carousel.CarouselHelper.setAdapterItems; @@ -276,25 +277,4 @@ private void createAndSetFixtures(int recyclerWidth, int itemWidth) { recyclerView.setLayoutManager(layoutManager); recyclerView.setAdapter(adapter); } - - private static KeylineState getTestCenteredKeylineState() { - float smallSize = 56F; - float extraSmallSize = 10F; - float largeSize = 450F; - float mediumSize = 88F; - - float extraSmallMask = 1F - (extraSmallSize / largeSize); - float smallMask = 1F - (smallSize / largeSize); - float mediumMask = 1F - (mediumSize / largeSize); - - return new KeylineState.Builder(450F) - .addKeyline(5F, extraSmallMask, extraSmallSize) - .addKeylineRange(38F, smallMask, smallSize, 2) - .addKeyline(166F, mediumMask, mediumSize) - .addKeylineRange(435F, 0F, largeSize, 2, true) - .addKeyline(1154F, mediumMask, mediumSize) - .addKeylineRange(1226F, smallMask, smallSize, 2) - .addKeyline(1315F, extraSmallMask, extraSmallSize) - .build(); - } } diff --git a/lib/javatests/com/google/android/material/carousel/CarouselSnapHelperTest.java b/lib/javatests/com/google/android/material/carousel/CarouselSnapHelperTest.java new file mode 100644 index 00000000000..64279a950dd --- /dev/null +++ b/lib/javatests/com/google/android/material/carousel/CarouselSnapHelperTest.java @@ -0,0 +1,159 @@ +/* + * Copyright 2023 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 + * + * https://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 com.google.android.material.carousel; + +import static com.google.android.material.carousel.CarouselHelper.createDataSetWithSize; +import static com.google.android.material.carousel.CarouselHelper.getTestCenteredKeylineState; +import static com.google.android.material.carousel.CarouselHelper.scrollHorizontallyBy; +import static com.google.android.material.carousel.CarouselHelper.scrollToPosition; +import static com.google.android.material.carousel.CarouselHelper.setAdapterItems; +import static com.google.android.material.carousel.CarouselHelper.setViewSize; +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import androidx.recyclerview.widget.RecyclerView; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; +import com.google.android.material.carousel.CarouselHelper.CarouselTestAdapter; +import com.google.android.material.carousel.CarouselHelper.WrappedCarouselLayoutManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link CarouselSnapHelper}. */ +@RunWith(RobolectricTestRunner.class) +public class CarouselSnapHelperTest { + RecyclerView recyclerView; + WrappedCarouselLayoutManager layoutManager; + CarouselTestAdapter adapter; + + private static final int DEFAULT_RECYCLER_VIEW_WIDTH = 1320; + private static final int DEFAULT_RECYCLER_VIEW_HEIGHT = 200; + private static final int DEFAULT_ITEM_WIDTH = 450; + private static final int DEFAULT_ITEM_HEIGHT = 200; + + private final Context context = ApplicationProvider.getApplicationContext(); + + @Before + public void setUp() throws Throwable { + recyclerView = new RecyclerView(context); + setViewSize(recyclerView, DEFAULT_RECYCLER_VIEW_WIDTH, DEFAULT_RECYCLER_VIEW_HEIGHT); + + layoutManager = new WrappedCarouselLayoutManager(); + adapter = new CarouselTestAdapter(DEFAULT_ITEM_WIDTH, DEFAULT_ITEM_HEIGHT); + + recyclerView.setLayoutManager(layoutManager); + recyclerView.setAdapter(adapter); + layoutManager.setCarouselStrategy( + new CarouselStrategy() { + @Override + KeylineState onFirstChildMeasuredWithMargins( + @NonNull Carousel carousel, @NonNull View child) { + return getTestCenteredKeylineState(); + } + }); + setAdapterItems(recyclerView, layoutManager, adapter, createDataSetWithSize(10)); + } + + @Test + public void testSnap_snapsCorrectView() throws Throwable { + CarouselSnapHelper snapHelper = new CarouselSnapHelper(); + snapHelper.attachToRecyclerView(recyclerView); + + // Scroll to position to set the horizontal scroll offset to position 3 + scrollToPosition(recyclerView, layoutManager, 3); + assertThat(layoutManager.getPosition(snapHelper.findSnapView(layoutManager))).isEqualTo(3); + + // The snap view should still be the item at position 3 with a small scroll offset + scrollHorizontallyBy(recyclerView, layoutManager, 50); + assertThat(layoutManager.getPosition(snapHelper.findSnapView(layoutManager))).isEqualTo(3); + + // Similarly, the snap view should still be the item at position 3 with a small scroll offset + scrollHorizontallyBy(recyclerView, layoutManager, -100); + assertThat(layoutManager.getPosition(snapHelper.findSnapView(layoutManager))).isEqualTo(3); + + // If scrolled enough, the snap view should be the item at position 4. + scrollHorizontallyBy(recyclerView, layoutManager, DEFAULT_ITEM_WIDTH); + assertThat(layoutManager.getPosition(snapHelper.findSnapView(layoutManager))).isEqualTo(4); + } + + @Test + public void testSnap_correctDistance() throws Throwable { + CarouselSnapHelper snapHelper = new CarouselSnapHelper(); + snapHelper.attachToRecyclerView(recyclerView); + + // Scroll to position to set the horizontal scroll offset to position 3 + scrollToPosition(recyclerView, layoutManager, 3); + + int[] distance = + snapHelper.calculateDistanceToFinalSnap( + layoutManager, snapHelper.findSnapView(layoutManager)); + assertThat(distance[0]).isEqualTo(0); + + // The snap view should still be the item at position 3 with a small scroll offset + scrollHorizontallyBy(recyclerView, layoutManager, 50); + distance = + snapHelper.calculateDistanceToFinalSnap( + layoutManager, snapHelper.findSnapView(layoutManager)); + assertThat(distance[0]).isEqualTo(-50); + + // Similarly, the snap view should still be the item at position 3 with a small scroll offset + scrollHorizontallyBy(recyclerView, layoutManager, -100); + distance = + snapHelper.calculateDistanceToFinalSnap( + layoutManager, snapHelper.findSnapView(layoutManager)); + assertThat(distance[0]).isEqualTo(50); + + // If scrolled enough, the snap view should be the item at position 4. + scrollHorizontallyBy(recyclerView, layoutManager, DEFAULT_ITEM_WIDTH); + distance = + snapHelper.calculateDistanceToFinalSnap( + layoutManager, snapHelper.findSnapView(layoutManager)); + assertThat(distance[0]).isEqualTo(50); + } + + @Test + public void testSnapHelper_consumesFling() throws Throwable { + CarouselSnapHelper snapHelper = new CarouselSnapHelper(); + snapHelper.attachToRecyclerView(recyclerView); + + scrollToPosition(recyclerView, layoutManager, 3); + + // Set horizontal scroll offset to be halfway between position 3 and 4. + scrollHorizontallyBy(recyclerView, layoutManager, DEFAULT_ITEM_WIDTH / 2); + + // Velocity is negative, so closest item before keyline is position 3. Actual + // velocity is not taken re: flinging. + int position = snapHelper.findTargetSnapPosition(layoutManager, -1000, 0); + assertThat(position).isEqualTo(3); + + // Velocity is positive, so closest item after keyline is position 4. Actual + // velocity is not taken re: flinging. + position = snapHelper.findTargetSnapPosition(layoutManager, 1000, 0); + assertThat(position).isEqualTo(4); + } + + @Test + public void testEnablingFling_doesNotConsumeFling() throws Throwable { + CarouselSnapHelper snapHelper = new CarouselSnapHelper(false); + snapHelper.attachToRecyclerView(recyclerView); + + int position = snapHelper.findTargetSnapPosition(layoutManager, -1, 0); + assertThat(position).isEqualTo(RecyclerView.NO_POSITION); + } +}