Skip to content

Commit

Permalink
[Carousel] Add CarouselSnapHelper
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 529457461
  • Loading branch information
imhappi authored and leticiarossi committed May 4, 2023
1 parent 8ff92eb commit 8938da8
Show file tree
Hide file tree
Showing 5 changed files with 407 additions and 35 deletions.
Expand Up @@ -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";

Expand Down Expand Up @@ -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)
Expand All @@ -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.
*
* <p>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();
Expand All @@ -877,21 +900,15 @@ 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
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);
}
};
Expand Down Expand Up @@ -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.
Expand Down
197 changes: 197 additions & 0 deletions 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;
}
}
Expand Up @@ -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();
}
}
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}

0 comments on commit 8938da8

Please sign in to comment.