Skip to content

Commit 9d81cac

Browse files
committedAug 23, 2023
[Carousel] Add left-aligned uncontained strategy
PiperOrigin-RevId: 559197283
1 parent 7d8681f commit 9d81cac

11 files changed

+741
-110
lines changed
 

‎lib/java/com/google/android/material/carousel/CarouselLayoutManager.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,8 @@ private void recalculateKeylineStateList(Recycler recycler) {
294294
KeylineState keylineState = carouselStrategy.onFirstChildMeasuredWithMargins(this, firstChild);
295295
keylineStateList =
296296
KeylineStateList.from(
297-
this, isLayoutRtl() ? KeylineState.reverse(keylineState) : keylineState);
297+
this,
298+
isLayoutRtl() ? KeylineState.reverse(keylineState, getContainerSize()) : keylineState);
298299
}
299300

300301
/**

‎lib/java/com/google/android/material/carousel/CarouselStrategyHelper.java

+6-6
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ static KeylineState createLeftAlignedKeylineState(
101101
float largeMask = 0F;
102102

103103
KeylineState.Builder builder =
104-
new KeylineState.Builder(arrangement.largeSize)
105-
.addKeyline(extraSmallHeadCenterX, extraSmallMask, extraSmallChildWidth)
104+
new KeylineState.Builder(arrangement.largeSize, availableSpace)
105+
.addAnchorKeyline(extraSmallHeadCenterX, extraSmallMask, extraSmallChildWidth)
106106
.addKeylineRange(
107107
largeStartCenterX, largeMask, arrangement.largeSize, arrangement.largeCount, true);
108108
if (arrangement.mediumCount > 0) {
@@ -112,7 +112,7 @@ static KeylineState createLeftAlignedKeylineState(
112112
builder.addKeylineRange(
113113
smallStartCenterX, smallMask, arrangement.smallSize, arrangement.smallCount);
114114
}
115-
builder.addKeyline(extraSmallTailCenterX, extraSmallMask, extraSmallChildWidth);
115+
builder.addAnchorKeyline(extraSmallTailCenterX, extraSmallMask, extraSmallChildWidth);
116116
return builder.build();
117117
}
118118

@@ -193,8 +193,8 @@ static KeylineState createCenterAlignedKeylineState(
193193
float largeMask = 0F;
194194

195195
KeylineState.Builder builder =
196-
new KeylineState.Builder(arrangement.largeSize)
197-
.addKeyline(extraSmallHeadCenterX, extraSmallMask, extraSmallChildWidth);
196+
new KeylineState.Builder(arrangement.largeSize, availableSpace)
197+
.addAnchorKeyline(extraSmallHeadCenterX, extraSmallMask, extraSmallChildWidth);
198198
if (arrangement.smallCount > 0) {
199199
builder.addKeylineRange(
200200
halfSmallStartCenterX,
@@ -229,7 +229,7 @@ static KeylineState createCenterAlignedKeylineState(
229229
(int) Math.ceil(arrangement.smallCount / 2F));
230230
}
231231

232-
builder.addKeyline(extraSmallTailCenterX, extraSmallMask, extraSmallChildWidth);
232+
builder.addAnchorKeyline(extraSmallTailCenterX, extraSmallMask, extraSmallChildWidth);
233233
return builder.build();
234234
}
235235

‎lib/java/com/google/android/material/carousel/KeylineState.java

+205-12
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616

1717
package com.google.android.material.carousel;
1818

19+
import static java.lang.Math.max;
20+
import static java.lang.Math.min;
21+
1922
import androidx.annotation.FloatRange;
2023
import androidx.annotation.NonNull;
24+
import androidx.annotation.Nullable;
2125
import com.google.android.material.animation.AnimationUtils;
2226
import com.google.errorprone.annotations.CanIgnoreReturnValue;
2327
import java.util.ArrayList;
@@ -107,6 +111,30 @@ Keyline getLastKeyline() {
107111
return keylines.get(keylines.size() - 1);
108112
}
109113

114+
/** Returns the first non-anchor keyline. */
115+
@Nullable
116+
Keyline getFirstNonAnchorKeyline() {
117+
for (int i = 0; i < keylines.size(); i++) {
118+
Keyline keyline = keylines.get(i);
119+
if (!keyline.isAnchor) {
120+
return keyline;
121+
}
122+
}
123+
return null;
124+
}
125+
126+
/** Returns the last non-anchor keyline. */
127+
@Nullable
128+
Keyline getLastNonAnchorKeyline() {
129+
for (int i = keylines.size()-1; i >= 0; i--) {
130+
Keyline keyline = keylines.get(i);
131+
if (!keyline.isAnchor) {
132+
return keyline;
133+
}
134+
}
135+
return null;
136+
}
137+
110138
/**
111139
* Linearly interpolate between two {@link KeylineState}s.
112140
*
@@ -149,11 +177,14 @@ static KeylineState lerp(KeylineState from, KeylineState to, float progress) {
149177
* <p>This is used to reverse a keyline state for RTL layouts.
150178
*
151179
* @param keylineState the {@link KeylineState} to reverse
180+
* @param availableSpace the space in which the keylines calculate whether or not they are cut
181+
* off.
152182
* @return a new {@link KeylineState} that has all keylines reversed.
153183
*/
154-
static KeylineState reverse(KeylineState keylineState) {
184+
static KeylineState reverse(KeylineState keylineState, float availableSpace) {
155185

156-
KeylineState.Builder builder = new KeylineState.Builder(keylineState.getItemSize());
186+
KeylineState.Builder builder =
187+
new KeylineState.Builder(keylineState.getItemSize(), availableSpace);
157188

158189
float start =
159190
keylineState.getFirstKeyline().locOffset
@@ -198,6 +229,8 @@ static final class Builder {
198229

199230
private final float itemSize;
200231

232+
private final float availableSpace;
233+
201234
// A list of keylines that hold all values except the Keyline#loc which needs to be calculated
202235
// in the build method.
203236
private final List<Keyline> tmpKeylines = new ArrayList<>();
@@ -208,21 +241,54 @@ static final class Builder {
208241

209242
private float lastKeylineMaskedSize = 0F;
210243

244+
private int latestAnchorKeylineIndex = NO_INDEX;
245+
246+
211247
/**
212248
* Creates a new {@link KeylineState.Builder}.
213249
*
214250
* @param itemSize The size of a fully unmaksed item. This is the size that will be used by the
215251
* carousel to measure and lay out all children, overriding each child's desired size.
252+
* @param availableSpace The available space of the carousel the keylines calculate cutoffs by.
216253
*/
217-
Builder(float itemSize) {
254+
Builder(float itemSize, float availableSpace) {
218255
this.itemSize = itemSize;
256+
this.availableSpace = availableSpace;
219257
}
220258

221259
/**
222-
* Adds a keyline along the scrolling axis where an object should be masked by the given {@code
223-
* mask} and positioned at {@code offsetLoc}.
260+
* Adds a non-anchor keyline along the scrolling axis where an object should be masked by the
261+
* given {@code mask} and positioned at {@code offsetLoc}. Non-anchor keylines shift when
262+
* keylines shift due to scrolling.
263+
*
264+
* <p>Note that calls to {@link #addKeyline(float, float, float, boolean)} and {@link
265+
* #addKeylineRange(float, float, float, int)} are added in order. Typically, this means
266+
* keylines should be added in order of ascending {@code offsetLoc}.
267+
*
268+
* @param offsetLoc The location of this keyline along the scrolling axis. An offsetLoc of 0
269+
* will be at the start of the scroll container.
270+
* @param mask The percentage of a child's full size that it should be masked by when its center
271+
* is at {@code offsetLoc}. 0 is fully unmasked and 1 is fully masked.
272+
* @param maskedItemSize The total size of this item when masked. This might differ from {@code
273+
* itemSize - (itemSize * mask)} depending on how margins are included in the {@code mask}.
274+
* @param isFocal Whether this keyline is considered part of the focal range. Typically, this is
275+
* when {@code mask} is equal to 0.
276+
*/
277+
@NonNull
278+
@CanIgnoreReturnValue
279+
Builder addKeyline(
280+
float offsetLoc,
281+
@FloatRange(from = 0.0F, to = 1.0F) float mask,
282+
float maskedItemSize,
283+
boolean isFocal) {
284+
return addKeyline(offsetLoc, mask, maskedItemSize, isFocal, /* isAnchor= */ false);
285+
}
286+
287+
/**
288+
* Adds a non-anchor keyline along the scrolling axis where an object should be masked by the
289+
* given {@code mask} and positioned at {@code offsetLoc}.
224290
*
225-
* @see #addKeyline(float, float, float, boolean)
291+
* @see #addKeyline(float, float, float, boolean, boolean)
226292
*/
227293
@NonNull
228294
@CanIgnoreReturnValue
@@ -235,9 +301,14 @@ Builder addKeyline(
235301
* Adds a keyline along the scrolling axis where an object should be masked by the given {@code
236302
* mask} and positioned at {@code offsetLoc}.
237303
*
238-
* <p>Note that calls to {@link #addKeyline(float, float, float, boolean)} and {@link
304+
* <p>Note that calls to {@link #addKeyline(float, float, float, boolean, boolean)} and {@link
239305
* #addKeylineRange(float, float, float, int)} are added in order. Typically, this means
240-
* keylines should be added in order of ascending {@code offsetLoc}.
306+
* keylines should be added in order of ascending {@code offsetLoc}. The first and last keylines
307+
* added are 'anchor' keylines that mark the start and ends of the keylines. These keylines do
308+
* not shift when scrolled.
309+
*
310+
* <p>Note also that {@code isFocal} and {@code isAnchor} cannot be true at the same time as
311+
* anchor keylines refer to keylines offscreen that dictate the ends of the keylines.
241312
*
242313
* @param offsetLoc The location of this keyline along the scrolling axis. An offsetLoc of 0
243314
* will be at the start of the scroll container.
@@ -247,19 +318,36 @@ Builder addKeyline(
247318
* itemSize - (itemSize * mask)} depending on how margins are included in the {@code mask}.
248319
* @param isFocal Whether this keyline is considered part of the focal range. Typically, this is
249320
* when {@code mask} is equal to 0.
321+
* @param isAnchor Whether this keyline is an anchor keyline. Anchor keylines do not shift when
322+
* keylines are shifted.
323+
* @param cutoff How much the keyline item is out the bounds of the available space.
250324
*/
251325
@NonNull
252326
@CanIgnoreReturnValue
253327
Builder addKeyline(
254328
float offsetLoc,
255329
@FloatRange(from = 0.0F, to = 1.0F) float mask,
256330
float maskedItemSize,
257-
boolean isFocal) {
331+
boolean isFocal,
332+
boolean isAnchor,
333+
float cutoff) {
258334
if (maskedItemSize <= 0F) {
259335
return this;
260336
}
337+
if (isAnchor) {
338+
if (isFocal) {
339+
throw new IllegalArgumentException(
340+
"Anchor keylines cannot be focal.");
341+
}
342+
if (latestAnchorKeylineIndex != NO_INDEX && latestAnchorKeylineIndex != 0) {
343+
throw new IllegalArgumentException(
344+
"Anchor keylines must be either the first or last keyline.");
345+
}
346+
latestAnchorKeylineIndex = tmpKeylines.size();
347+
}
261348

262-
Keyline tmpKeyline = new Keyline(UNKNOWN_LOC, offsetLoc, mask, maskedItemSize);
349+
Keyline tmpKeyline =
350+
new Keyline(UNKNOWN_LOC, offsetLoc, mask, maskedItemSize, isAnchor, cutoff);
263351
if (isFocal) {
264352
if (tmpFirstFocalKeyline == null) {
265353
tmpFirstFocalKeyline = tmpKeyline;
@@ -294,6 +382,81 @@ Builder addKeyline(
294382
return this;
295383
}
296384

385+
/**
386+
* Adds a keyline along the scrolling axis where an object should be masked by the given {@code
387+
* mask} and positioned at {@code offsetLoc}. This method also calculates the amount that a
388+
* keyline may be cut off by the bounds of the available space given.
389+
*
390+
* <p>Note that calls to {@link #addKeyline(float, float, float, boolean, boolean)} and {@link
391+
* #addKeylineRange(float, float, float, int)} are added in order. Typically, this means
392+
* keylines should be added in order of ascending {@code offsetLoc}. The first and last keylines
393+
* added are 'anchor' keylines that mark the start and ends of the keylines. These keylines do
394+
* not shift when scrolled.
395+
*
396+
* <p>Note also that {@code isFocal} and {@code isAnchor} cannot be true at the same time as
397+
* anchor keylines refer to keylines offscreen that dictate the ends of the keylines.
398+
*
399+
* @param offsetLoc The location of this keyline along the scrolling axis. An offsetLoc of 0
400+
* will be at the start of the scroll container.
401+
* @param mask The percentage of a child's full size that it should be masked by when its center
402+
* is at {@code offsetLoc}. 0 is fully unmasked and 1 is fully masked.
403+
* @param maskedItemSize The total size of this item when masked. This might differ from {@code
404+
* itemSize - (itemSize * mask)} depending on how margins are included in the {@code mask}.
405+
* @param isFocal Whether this keyline is considered part of the focal range. Typically, this is
406+
* when {@code mask} is equal to 0.
407+
* @param isAnchor Whether this keyline is an anchor keyline. Anchor keylines do not shift when
408+
* keylines are shifted.
409+
*/
410+
@NonNull
411+
@CanIgnoreReturnValue
412+
Builder addKeyline(
413+
float offsetLoc,
414+
@FloatRange(from = 0.0F, to = 1.0F) float mask,
415+
float maskedItemSize,
416+
boolean isFocal,
417+
boolean isAnchor) {
418+
float cutoff = 0;
419+
// Calculate if the item will be cut off on either side. Currently we do not support an item
420+
// cut off on both sides as we do not not support that use case. If an item is cut off on both
421+
// sides, only the end cutoff will be included in the cutoff.
422+
float keylineStart = offsetLoc - maskedItemSize/2F;
423+
float keylineEnd = offsetLoc + maskedItemSize/2F;
424+
if (keylineEnd > availableSpace) {
425+
cutoff = Math.abs(keylineEnd - max(keylineEnd - maskedItemSize, availableSpace));
426+
} else if (keylineStart < 0) {
427+
cutoff = Math.abs(keylineStart - min(keylineStart + maskedItemSize, 0));
428+
}
429+
430+
return addKeyline(offsetLoc, mask, maskedItemSize, isFocal, isAnchor, cutoff);
431+
}
432+
433+
/**
434+
* Adds an anchor keyline along the scrolling axis where an object should be masked by the given
435+
* {@code mask} and positioned at {@code offsetLoc}.
436+
*
437+
* <p>Anchor keylines are keylines that are added to increase motion of carousel items going
438+
* out of bounds of the carousel, and are 'anchored' (ie. does not shift). These keylines must
439+
* be at the start or end of all keylines.
440+
*
441+
* <p>Note that calls to {@link #addKeyline(float, float, float, boolean)} and {@link
442+
* #addKeylineRange(float, float, float, int)} are added in order. This method should be called
443+
* first, or last of all the `addKeyline` calls.
444+
*
445+
* @param offsetLoc The location of this keyline along the scrolling axis. An offsetLoc of 0
446+
* will be at the start of the scroll container.
447+
* @param mask The percentage of a child's full size that it should be masked by when its center
448+
* is at {@code offsetLoc}. 0 is fully unmasked and 1 is fully masked.
449+
* @param maskedItemSize The total size of this item when masked. This might differ from {@code
450+
* itemSize - (itemSize * mask)} depending on how margins are included in the {@code mask}.
451+
*/
452+
@NonNull
453+
@CanIgnoreReturnValue
454+
Builder addAnchorKeyline(
455+
float offsetLoc, @FloatRange(from = 0.0F, to = 1.0F) float mask, float maskedItemSize) {
456+
return addKeyline(
457+
offsetLoc, mask, maskedItemSize, /* isFocal= */ false, /* isAnchor= */ true);
458+
}
459+
297460
/**
298461
* Adds a range of keylines along the scrolling axis where an item should be masked by {@code
299462
* mask} when its center is between {@code offsetLoc} and {@code offsetLoc + (maskedItemSize *
@@ -366,7 +529,9 @@ KeylineState build() {
366529
tmpFirstFocalKeyline.locOffset, itemSize, firstFocalKeylineIndex, i),
367530
tmpKeyline.locOffset,
368531
tmpKeyline.mask,
369-
tmpKeyline.maskedItemSize);
532+
tmpKeyline.maskedItemSize,
533+
tmpKeyline.isAnchor,
534+
tmpKeyline.cutoff);
370535
keylines.add(keyline);
371536
}
372537

@@ -401,9 +566,11 @@ static final class Keyline {
401566
final float locOffset;
402567
final float mask;
403568
final float maskedItemSize;
569+
final boolean isAnchor;
570+
final float cutoff;
404571

405572
/**
406-
* Creates a keyline along a scroll axis.
573+
* Creates a non-anchor keyline along a scroll axis.
407574
*
408575
* @param loc Where this item will be along the scroll axis if it were laid out end-to-end when
409576
* it should be in the state defined by {@code locOffset} and {@code mask}.
@@ -414,10 +581,36 @@ static final class Keyline {
414581
* @param maskedItemSize The size of this item when masked.
415582
*/
416583
Keyline(float loc, float locOffset, float mask, float maskedItemSize) {
584+
this(loc, locOffset, mask, maskedItemSize, /* isAnchor= */ false, 0);
585+
}
586+
587+
/**
588+
* Creates a keyline along a scroll axis.
589+
*
590+
* @param loc Where this item will be along the scroll axis if it were laid out end-to-end when
591+
* it should be in the state defined by {@code locOffset} and {@code mask}.
592+
* @param locOffset The location within the carousel where an item should be when its center is
593+
* at {@code loc}.
594+
* @param mask The percentage of this items full size that it should be masked by when its
595+
* center is at {@code loc}.
596+
* @param maskedItemSize The size of this item when masked.
597+
* @param isAnchor Whether or not the keyline is an anchor keyline (keylines at the end that do
598+
* not shift).
599+
* @param cutoff The amount by which the keyline item is cut off by the bounds of the carousel.
600+
*/
601+
Keyline(
602+
float loc,
603+
float locOffset,
604+
float mask,
605+
float maskedItemSize,
606+
boolean isAnchor,
607+
float cutoff) {
417608
this.loc = loc;
418609
this.locOffset = locOffset;
419610
this.mask = mask;
420611
this.maskedItemSize = maskedItemSize;
612+
this.isAnchor = isAnchor;
613+
this.cutoff = cutoff;
421614
}
422615

423616
/** Linearly interpolates between two keylines and returns the interpolated object. */

‎lib/java/com/google/android/material/carousel/KeylineStateList.java

+66-37
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ private KeylineStateList(
8383
/** Creates a new {@link KeylineStateList} from a {@link KeylineState}. */
8484
static KeylineStateList from(Carousel carousel, KeylineState state) {
8585
return new KeylineStateList(
86-
state, getStateStepsStart(state), getStateStepsEnd(carousel, state));
86+
state, getStateStepsStart(carousel, state), getStateStepsEnd(carousel, state));
8787
}
8888

8989
/** Returns the default state for this state list. */
@@ -334,21 +334,23 @@ private static boolean isFirstFocalItemAtLeftOfContainer(KeylineState state) {
334334
* last state will be the start state or the state that has the focal range at the beginning of
335335
* the carousel.
336336
*/
337-
private static List<KeylineState> getStateStepsStart(KeylineState defaultState) {
337+
private static List<KeylineState> getStateStepsStart(
338+
Carousel carousel, KeylineState defaultState) {
338339
List<KeylineState> steps = new ArrayList<>();
339340
steps.add(defaultState);
340-
int firstInBoundsKeylineIndex = findFirstInBoundsKeylineIndex(defaultState);
341+
int firstNonAnchorKeylineIndex = findFirstNonAnchorKeylineIndex(defaultState);
341342
// If the first focal item is already at the left of the container or there are no in bounds
342343
// keylines, return a list of steps that only includes the default state (there is nowhere to
343344
// shift).
344345
if (isFirstFocalItemAtLeftOfContainer(defaultState)
345-
|| firstInBoundsKeylineIndex == NO_INDEX) {
346+
|| firstNonAnchorKeylineIndex == NO_INDEX) {
346347
return steps;
347348
}
348349

349-
int start = firstInBoundsKeylineIndex;
350+
int start = firstNonAnchorKeylineIndex;
350351
int end = defaultState.getFirstFocalKeylineIndex() - 1;
351352
int numberOfSteps = end - start;
353+
float cutoffs = 0;
352354

353355
float originalStart =
354356
defaultState.getFirstKeyline().locOffset
@@ -361,6 +363,7 @@ private static List<KeylineState> getStateStepsStart(KeylineState defaultState)
361363
// Otherwise, use it's adjacent item's mask to find suitable index on the other side of the
362364
// focal range where it can be placed.
363365
int dstIndex = defaultState.getKeylines().size() - 1;
366+
cutoffs += defaultState.getKeylines().get(itemOrigIndex).cutoff;
364367
if (itemOrigIndex - 1 >= 0) {
365368
float originalAdjacentMaskLeft = defaultState.getKeylines().get(itemOrigIndex - 1).mask;
366369
dstIndex =
@@ -374,11 +377,14 @@ private static List<KeylineState> getStateStepsStart(KeylineState defaultState)
374377
KeylineState shifted =
375378
moveKeylineAndCreateKeylineState(
376379
prevStepState,
377-
/* keylineSrcIndex= */ firstInBoundsKeylineIndex,
380+
/* keylineSrcIndex= */ firstNonAnchorKeylineIndex,
378381
/* keylineDstIndex= */ dstIndex,
379-
originalStart,
382+
originalStart + cutoffs,
380383
newFirstFocalIndex,
381-
newLastFocalIndex);
384+
newLastFocalIndex,
385+
carousel.isHorizontal()
386+
? carousel.getContainerWidth()
387+
: carousel.getContainerHeight());
382388
steps.add(shifted);
383389
}
384390
return steps;
@@ -391,17 +397,18 @@ private static List<KeylineState> getStateStepsStart(KeylineState defaultState)
391397
* @param carousel the {@link Carousel} associated with this {@link KeylineStateList}.
392398
* @param state the state to check for right item position
393399
* @return true if the {@code state}'s first focal item has its right aligned with the right of
394-
* the {@code carousel} container
400+
* the {@code carousel} container and is fully visible.
395401
*/
396-
private static boolean isLastFocalItemAtRightOfContainer(Carousel carousel, KeylineState state) {
402+
private static boolean isLastFocalItemVisibleAtRightOfContainer(
403+
Carousel carousel, KeylineState state) {
397404
int containerSize = carousel.getContainerHeight();
398405
if (carousel.isHorizontal()) {
399406
containerSize = carousel.getContainerWidth();
400407
}
401-
float firstFocalItemRight =
408+
float lastFocalItemRight =
402409
state.getLastFocalKeyline().locOffset + (state.getLastFocalKeyline().maskedItemSize / 2F);
403-
return firstFocalItemRight >= containerSize
404-
|| state.getLastFocalKeyline() == state.getLastKeyline();
410+
return lastFocalItemRight <= containerSize
411+
&& state.getLastFocalKeyline() == state.getLastNonAnchorKeyline();
405412
}
406413

407414
/**
@@ -424,26 +431,49 @@ private static List<KeylineState> getStateStepsEnd(
424431
Carousel carousel, KeylineState defaultState) {
425432
List<KeylineState> steps = new ArrayList<>();
426433
steps.add(defaultState);
427-
int lastInBoundsKeylineIndex = findLastInBoundsKeylineIndex(carousel, defaultState);
428-
// If the focal end item is already at the end of the container or there are no in bounds
429-
// keylines, return a list of steps that only includes the default state (there is nowhere to
430-
// shift).
431-
if (isLastFocalItemAtRightOfContainer(carousel, defaultState)
432-
|| lastInBoundsKeylineIndex == NO_INDEX) {
434+
int lastNonAnchorKeylineIndex = findLastNonAnchorKeylineIndex(defaultState);
435+
436+
// If the focal end item is already at the end of the container and is fully visible or there
437+
// are no in bounds keylines, return a list of steps that only includes the default state
438+
// (there is nowhere to shift).
439+
if (isLastFocalItemVisibleAtRightOfContainer(carousel, defaultState)
440+
|| lastNonAnchorKeylineIndex == NO_INDEX) {
433441
return steps;
434442
}
435443

436-
int start = defaultState.getLastFocalKeylineIndex();
437-
int end = lastInBoundsKeylineIndex;
438-
int numberOfSteps = end - start;
444+
float carouselSize =
445+
carousel.isHorizontal() ? carousel.getContainerWidth() : carousel.getContainerHeight();
439446

440447
float originalStart =
441448
defaultState.getFirstKeyline().locOffset
442449
- (defaultState.getFirstKeyline().maskedItemSize / 2F);
443450

451+
// If we are here, it means that the last keyline is focal, but is cut off
452+
// since it is not visible. If this is the case, we want to add one step where
453+
// the keylines are shifted so that the last keyline is visible.
454+
if (defaultState.getLastFocalKeyline() == defaultState.getLastNonAnchorKeyline()) {
455+
KeylineState shifted =
456+
moveKeylineAndCreateKeylineState(
457+
defaultState,
458+
/* keylineSrcIndex= */ defaultState.getLastFocalKeylineIndex(),
459+
/* keylineDstIndex= */ defaultState.getFirstFocalKeylineIndex(),
460+
originalStart - defaultState.getLastFocalKeyline().cutoff,
461+
defaultState.getFirstFocalKeylineIndex(),
462+
defaultState.getLastFocalKeylineIndex(),
463+
carouselSize);
464+
steps.add(shifted);
465+
return steps;
466+
}
467+
468+
int start = defaultState.getLastFocalKeylineIndex();
469+
int end = lastNonAnchorKeylineIndex;
470+
int numberOfSteps = end - start;
471+
float cutoffs = 0;
472+
444473
for (int i = 0; i < numberOfSteps; i++) {
445474
KeylineState prevStepState = steps.get(steps.size() - 1);
446475
int itemOrigIndex = end - i;
476+
cutoffs += defaultState.getKeylines().get(itemOrigIndex).cutoff;
447477
// If this is the last item from the original state, place it at the start of the dest state.
448478
// Otherwise, use it's adjacent item's mask to find suitable index on the other side of the
449479
// focal range where it can be placed.
@@ -458,15 +488,15 @@ private static List<KeylineState> getStateStepsEnd(
458488
// The index of the start and end focal keylines in this step's keyline state.
459489
int newFirstFocalIndex = defaultState.getFirstFocalKeylineIndex() + i + 1;
460490
int newLastFocalIndex = defaultState.getLastFocalKeylineIndex() + i + 1;
461-
462491
KeylineState shifted =
463492
moveKeylineAndCreateKeylineState(
464493
prevStepState,
465-
/* keylineSrcIndex= */ lastInBoundsKeylineIndex,
494+
/* keylineSrcIndex= */ lastNonAnchorKeylineIndex,
466495
/* keylineDstIndex= */ dstIndex,
467-
originalStart,
496+
originalStart - cutoffs,
468497
newFirstFocalIndex,
469-
newLastFocalIndex);
498+
newLastFocalIndex,
499+
carouselSize);
470500
steps.add(shifted);
471501
}
472502

@@ -492,20 +522,23 @@ private static KeylineState moveKeylineAndCreateKeylineState(
492522
int keylineDstIndex,
493523
float startOffset,
494524
int newFirstFocalIndex,
495-
int newLastFocalIndex) {
525+
int newLastFocalIndex,
526+
float carouselSize) {
496527

497528
List<Keyline> tmpKeylines = new ArrayList<>(state.getKeylines());
498529
Keyline item = tmpKeylines.remove(keylineSrcIndex);
499530
tmpKeylines.add(keylineDstIndex, item);
500531

501-
KeylineState.Builder builder = new KeylineState.Builder(state.getItemSize());
532+
KeylineState.Builder builder = new KeylineState.Builder(state.getItemSize(), carouselSize);
502533

503534
for (int j = 0; j < tmpKeylines.size(); j++) {
504535
Keyline k = tmpKeylines.get(j);
505536
float offset = startOffset + (k.maskedItemSize / 2F);
506537

507538
boolean isFocal = j >= newFirstFocalIndex && j <= newLastFocalIndex;
508-
builder.addKeyline(offset, k.mask, k.maskedItemSize, isFocal);
539+
// We must keep the same cutoff value from the default keylines instead of re-calculating
540+
// them based on the new offset.
541+
builder.addKeyline(offset, k.mask, k.maskedItemSize, isFocal, k.isAnchor, k.cutoff);
509542
startOffset += k.maskedItemSize;
510543
}
511544

@@ -534,23 +567,19 @@ private static int findLastIndexBeforeFirstFocalKeylineWithMask(KeylineState sta
534567
return 0;
535568
}
536569

537-
private static int findFirstInBoundsKeylineIndex(KeylineState state) {
570+
private static int findFirstNonAnchorKeylineIndex(KeylineState state) {
538571
for (int i = 0; i < state.getKeylines().size(); i++) {
539-
if (state.getKeylines().get(i).locOffset >= 0) {
572+
if (!state.getKeylines().get(i).isAnchor) {
540573
return i;
541574
}
542575
}
543576

544577
return NO_INDEX;
545578
}
546579

547-
private static int findLastInBoundsKeylineIndex(Carousel carousel, KeylineState state) {
548-
int containerSize = carousel.getContainerHeight();
549-
if (carousel.isHorizontal()) {
550-
containerSize = carousel.getContainerWidth();
551-
}
580+
private static int findLastNonAnchorKeylineIndex(KeylineState state) {
552581
for (int i = state.getKeylines().size() - 1; i >= 0; i--) {
553-
if (state.getKeylines().get(i).locOffset <= containerSize) {
582+
if (!state.getKeylines().get(i).isAnchor) {
554583
return i;
555584
}
556585
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.android.material.carousel;
18+
19+
import static com.google.android.material.carousel.CarouselStrategyHelper.getExtraSmallSize;
20+
import static java.lang.Math.max;
21+
22+
import android.content.Context;
23+
import androidx.recyclerview.widget.RecyclerView.LayoutParams;
24+
import android.view.View;
25+
import androidx.annotation.NonNull;
26+
import androidx.annotation.RestrictTo;
27+
import androidx.annotation.RestrictTo.Scope;
28+
29+
/**
30+
* A {@link CarouselStrategy} that does not resize the original item width and fits as many as it
31+
* can into the container, cutting off the rest. Cut off items may be resized in order to show an
32+
* effect of items getting smaller at the ends.
33+
*
34+
* Note that this strategy does not adjust the size of large items. Item widths are taken
35+
* from the {@link androidx.recyclerview.widget.RecyclerView} item width.
36+
*
37+
* <p>This class will automatically be reversed by {@link CarouselLayoutManager} if being laid out
38+
* right-to-left and does not need to make any account for layout direction itself.
39+
*
40+
* <p>For more information, see the <a
41+
* href="https://github.com/material-components/material-components-android/blob/master/docs/components/Carousel.md">component
42+
* developer guidance</a> and <a href="https://material.io/components/carousel/overview">design
43+
* guidelines</a>.
44+
*/
45+
public final class UncontainedCarouselStrategy extends CarouselStrategy {
46+
47+
@RestrictTo(Scope.LIBRARY_GROUP)
48+
public UncontainedCarouselStrategy() {
49+
}
50+
51+
@Override
52+
@NonNull
53+
KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNull View child) {
54+
float availableSpace =
55+
carousel.isHorizontal() ? carousel.getContainerWidth() : carousel.getContainerHeight();
56+
57+
LayoutParams childLayoutParams = (LayoutParams) child.getLayoutParams();
58+
float childMargins = childLayoutParams.topMargin + childLayoutParams.bottomMargin;
59+
float measuredChildSize = child.getMeasuredHeight();
60+
61+
if (carousel.isHorizontal()) {
62+
childMargins = childLayoutParams.leftMargin + childLayoutParams.rightMargin;
63+
measuredChildSize = child.getMeasuredWidth();
64+
}
65+
66+
float largeChildSize = measuredChildSize + childMargins;
67+
float mediumChildSize = getExtraSmallSize(child.getContext()) + childMargins;
68+
float xSmallChildSize = getExtraSmallSize(child.getContext()) + childMargins;
69+
70+
// Calculate how much space there is remaining after squeezing in as many large items as we can.
71+
int largeCount = (int) Math.floor(availableSpace/largeChildSize);
72+
float remainingSpace = availableSpace - largeCount*largeChildSize;
73+
int mediumCount = 0;
74+
// If the keyline location for the next large size would be within the remaining space,
75+
// then we can place a large child there as the last non-anchor keyline because visually
76+
// keylines will become smaller as it goes past the large keyline location.
77+
// Otherwise, we want to add a medium item instead so that visually there will still
78+
// be the effect of the item getting smaller closer to the end.
79+
if (remainingSpace > largeChildSize/2F) {
80+
largeCount += 1;
81+
} else {
82+
mediumCount = 1;
83+
// We want the medium size to be large enough to be at least 1/3 of the way cut
84+
// off.
85+
mediumChildSize = max(remainingSpace + remainingSpace/2F, mediumChildSize);
86+
}
87+
88+
return createKeylineState(
89+
child.getContext(),
90+
childMargins,
91+
availableSpace,
92+
largeChildSize,
93+
largeCount,
94+
mediumChildSize,
95+
mediumCount,
96+
xSmallChildSize);
97+
}
98+
99+
private KeylineState createKeylineState(
100+
Context context,
101+
float childMargins,
102+
float availableSpace,
103+
float largeSize,
104+
int largeCount,
105+
float mediumSize,
106+
int mediumCount,
107+
float xSmallSize) {
108+
109+
float extraSmallMask =
110+
getChildMaskPercentage(xSmallSize, largeSize, childMargins);
111+
float mediumMask =
112+
getChildMaskPercentage(mediumSize, largeSize, childMargins);
113+
float largeMask = 0F;
114+
115+
float start = 0F;
116+
float extraSmallHeadCenterX = start - (xSmallSize / 2F);
117+
118+
float largeStartCenterX = largeSize/2F;
119+
// We exclude one large item from the large count so we can calculate the correct
120+
// cutoff value for the last large item if it is getting cut off.
121+
int excludedLargeCount = max(largeCount - 1, 1);
122+
start += excludedLargeCount * largeSize;
123+
// Where to start laying the last possibly-cutoff large item.
124+
float lastEndStart = start + largeSize / 2F;
125+
126+
// Add xsmall keyline, and then if there is more than 1 large keyline, add
127+
// however many large keylines there are except for the last one that may be cut off.
128+
KeylineState.Builder builder =
129+
new KeylineState.Builder(largeSize, availableSpace)
130+
.addAnchorKeyline(extraSmallHeadCenterX, extraSmallMask, xSmallSize)
131+
.addKeylineRange(
132+
largeStartCenterX,
133+
largeMask,
134+
largeSize,
135+
excludedLargeCount,
136+
/* isFocal= */ true);
137+
138+
// If we have more than 1 large item, then here we include the last large item that is
139+
// possibly getting cut off.
140+
if (largeCount > 1) {
141+
start += largeSize;
142+
builder.addKeyline(
143+
lastEndStart,
144+
largeMask,
145+
largeSize,
146+
/* isFocal= */ true);
147+
}
148+
149+
if (mediumCount > 0) {
150+
float mediumCenterX = start + mediumSize / 2F;
151+
start += mediumSize;
152+
builder.addKeyline(
153+
mediumCenterX,
154+
mediumMask,
155+
mediumSize,
156+
/* isFocal= */ false);
157+
}
158+
159+
float xSmallCenterX = start + getExtraSmallSize(context) / 2F;
160+
builder.addAnchorKeyline(xSmallCenterX, extraSmallMask, xSmallSize);
161+
return builder.build();
162+
}
163+
164+
@Override
165+
boolean isContained() {
166+
return false;
167+
}
168+
}

‎lib/javatests/com/google/android/material/carousel/CarouselHelper.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ static KeylineState getTestCenteredKeylineState() {
382382
float smallMask = getKeylineMaskPercentage(smallSize, largeSize);
383383
float mediumMask = getKeylineMaskPercentage(mediumSize, largeSize);
384384

385-
return new KeylineState.Builder(450F)
385+
return new KeylineState.Builder(450F, 1320F)
386386
.addKeyline(5F, extraSmallMask, extraSmallSize)
387387
.addKeylineRange(38F, smallMask, smallSize, 2)
388388
.addKeyline(166F, mediumMask, mediumSize)
@@ -403,7 +403,7 @@ static KeylineState getTestCenteredVerticalKeylineState() {
403403
float smallMask = getKeylineMaskPercentage(smallSize, largeSize);
404404
float mediumMask = getKeylineMaskPercentage(mediumSize, largeSize);
405405

406-
return new KeylineState.Builder(100F)
406+
return new KeylineState.Builder(100F, 200F)
407407
.addKeyline(9F, smallMask, smallSize)
408408
.addKeyline(25F, mediumMask, mediumSize)
409409
.addKeyline(66F, 0F, largeSize, true)

‎lib/javatests/com/google/android/material/carousel/CarouselLayoutManagerRtlTest.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ public void testScrollBeyondMaxHorizontalScroll_shouldLimitToMaxScrollOffset() t
9797
scrollToPosition(recyclerView, layoutManager, 200);
9898

9999
KeylineState leftState =
100-
KeylineStateList.from(layoutManager, KeylineState.reverse(keylineState)).getStartState();
100+
KeylineStateList.from(
101+
layoutManager, KeylineState.reverse(keylineState, DEFAULT_RECYCLER_VIEW_WIDTH))
102+
.getStartState();
101103

102104
MaskableFrameLayout child =
103105
(MaskableFrameLayout) recyclerView.getChildAt(recyclerView.getChildCount() - 1);
@@ -182,7 +184,7 @@ private static KeylineState getTestCenteredKeylineState() {
182184
float smallMask = 1F - (smallSize / largeSize);
183185
float mediumMask = 1F - (mediumSize / largeSize);
184186

185-
return new KeylineState.Builder(450F)
187+
return new KeylineState.Builder(450F, DEFAULT_RECYCLER_VIEW_WIDTH)
186188
.addKeyline(5F, extraSmallMask, extraSmallSize)
187189
.addKeylineRange(38F, smallMask, smallSize, 2)
188190
.addKeyline(166F, mediumMask, mediumSize)

‎lib/javatests/com/google/android/material/carousel/CarouselLayoutManagerTest.java

+5-5
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public void testMaskedChild_isStillGivenFullWidthBounds() throws Throwable {
9393
@Override
9494
KeylineState onFirstChildMeasuredWithMargins(
9595
@NonNull Carousel carousel, @NonNull View child) {
96-
return new KeylineState.Builder(DEFAULT_ITEM_WIDTH)
96+
return new KeylineState.Builder(DEFAULT_ITEM_WIDTH, DEFAULT_RECYCLER_VIEW_WIDTH)
9797
.addKeyline(225F, .5F, 225F, true)
9898
.build();
9999
}
@@ -112,7 +112,7 @@ public void testMaskedChild_isMaskedToCorrectSize() throws Throwable {
112112
@Override
113113
KeylineState onFirstChildMeasuredWithMargins(
114114
@NonNull Carousel carousel, @NonNull View child) {
115-
return new KeylineState.Builder(DEFAULT_ITEM_WIDTH)
115+
return new KeylineState.Builder(DEFAULT_ITEM_WIDTH, DEFAULT_RECYCLER_VIEW_WIDTH)
116116
.addKeyline(225F, .8F, 90F, true)
117117
.build();
118118
}
@@ -675,11 +675,11 @@ KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNul
675675
float focal = largeSize / 2F;
676676
float smallTail = focal + (largeSize / 2F) + (smallSize / 2F);
677677
float xSmallTail = availableSpace + (xSmallSize / 2F);
678-
return new KeylineState.Builder(largeSize)
679-
.addKeyline(xSmallHead, getKeylineMaskPercentage(xSmallSize, largeSize), xSmallSize)
678+
return new KeylineState.Builder(largeSize, availableSpace)
679+
.addAnchorKeyline(xSmallHead, getKeylineMaskPercentage(xSmallSize, largeSize), xSmallSize)
680680
.addKeyline(focal, 0F, largeSize, true)
681681
.addKeyline(smallTail, getKeylineMaskPercentage(smallSize, largeSize), smallSize)
682-
.addKeyline(xSmallTail, getKeylineMaskPercentage(xSmallSize, largeSize), xSmallSize)
682+
.addAnchorKeyline(xSmallTail, getKeylineMaskPercentage(xSmallSize, largeSize), xSmallSize)
683683
.build();
684684
}
685685

‎lib/javatests/com/google/android/material/carousel/KeylineStateListTest.java

+100-28
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public void testCenterArrangement_shouldShiftStart() {
4343
new float[] {20F, 60F, 90F, 110F, 125F, 135F}
4444
};
4545
KeylineState state =
46-
new KeylineState.Builder(40F)
46+
new KeylineState.Builder(40F, 140)
4747
.addKeyline(5F, getKeylineMaskPercentage(10F, 40F), 10F)
4848
.addKeyline(20F, getKeylineMaskPercentage(20F, 40F), 20F)
4949
.addKeylineRange(50F, 0F, 40F, 2, true)
@@ -72,7 +72,7 @@ public void testCenterArrangement_shouldCreateIntermediaryStates() {
7272
new float[] {20F, 60F, 90F, 110F, 125F, 135F}
7373
};
7474
KeylineState state =
75-
new KeylineState.Builder(40F)
75+
new KeylineState.Builder(40F, 140)
7676
.addKeyline(5F, getKeylineMaskPercentage(10F, 40F), 10F)
7777
.addKeyline(20F, getKeylineMaskPercentage(20F, 40F), 20F)
7878
.addKeylineRange(50F, 0F, 40F, 2, true)
@@ -103,7 +103,7 @@ public void testCenterArrangement_shouldShiftEnd() {
103103
};
104104

105105
KeylineState state =
106-
new KeylineState.Builder(40F)
106+
new KeylineState.Builder(40F, 140)
107107
.addKeyline(5F, getKeylineMaskPercentage(10F, 40F), 10F)
108108
.addKeyline(20F, getKeylineMaskPercentage(20F, 40F), 20F)
109109
.addKeylineRange(50F, 0F, 40F, 2, true)
@@ -126,7 +126,7 @@ public void testCenterArrangement_shouldShiftEnd() {
126126
@Test
127127
public void testCenterArrangement_shouldNotShift() {
128128
KeylineState state =
129-
new KeylineState.Builder(40F)
129+
new KeylineState.Builder(40F, 140)
130130
.addKeyline(5F, getKeylineMaskPercentage(10F, 40F), 10F)
131131
.addKeyline(20F, getKeylineMaskPercentage(20F, 40F), 20F)
132132
.addKeylineRange(50F, 0F, 40F, 2, true)
@@ -144,7 +144,7 @@ public void testCenterArrangement_shouldNotShift() {
144144
@Test
145145
public void testStartArrangement_shouldShiftStart() {
146146
KeylineState state =
147-
new KeylineState.Builder(40F)
147+
new KeylineState.Builder(40F, 70)
148148
.addKeyline(20F, 0F, 40F, true)
149149
.addKeyline(50F, getKeylineMaskPercentage(20F, 40F), 20F)
150150
.addKeyline(65F, getKeylineMaskPercentage(10F, 40F), 10F)
@@ -169,7 +169,7 @@ public void testStartArrangement_shouldShiftEnd() {
169169
};
170170

171171
KeylineState state =
172-
new KeylineState.Builder(40F)
172+
new KeylineState.Builder(40F, 70)
173173
.addKeyline(20F, 0F, 40F, true)
174174
.addKeyline(50F, getKeylineMaskPercentage(20F, 40F), 20F)
175175
.addKeyline(65F, getKeylineMaskPercentage(10F, 40F), 10F)
@@ -191,16 +191,17 @@ public void testStartArrangement_shouldShiftEnd() {
191191
public void testStartArrangementWithOutOfBoundsKeylines_shouldShiftStart() {
192192
float[][] startStepsLocOffsets =
193193
new float[][] {
194-
new float[] {-10F, 10F, 40F, 70F, 85F},
195-
new float[] {-10F, 20F, 50F, 70F, 85F}
194+
new float[] {-10F, 10F, 40F, 70F, 90F},
195+
new float[] {-10F, 20F, 50F, 70F, 90F}
196196
};
197197

198198
KeylineState state =
199-
new KeylineState.Builder(40F)
200-
.addKeylineRange(-10F, getKeylineMaskPercentage(20F, 40F), 20F, 2)
199+
new KeylineState.Builder(40F, 90)
200+
.addAnchorKeyline(-10F, getKeylineMaskPercentage(20F, 40F), 20F)
201+
.addKeyline(10F, getKeylineMaskPercentage(20F, 40F), 20F, false)
201202
.addKeyline(40F, 0F, 40F, true)
202203
.addKeyline(70F, getKeylineMaskPercentage(20F, 40F), 20F)
203-
.addKeyline(85F, getKeylineMaskPercentage(10F, 40F), 10F)
204+
.addKeyline(90F, getKeylineMaskPercentage(20F, 40F), 20F)
204205
.build();
205206
KeylineStateList stateList = KeylineStateList.from(createCarouselWithWidth(90), state);
206207

@@ -220,17 +221,21 @@ public void testStartArrangementWithOutOfBoundsKeylines_shouldShiftStart() {
220221
public void testStartArrangementWithOutOfBoundsKeyline_shouldShiftEnd() {
221222
float[][] endStepsLocOffsets =
222223
new float[][] {
223-
new float[] {-10F, 10F, 40F, 70F, 90F, 110F, 125F},
224-
new float[] {-10F, 10F, 30F, 60F, 90F, 110F, 125F},
225-
new float[] {-10F, 10F, 30F, 50F, 80F, 110F, 125F},
224+
// keyline sizes are as follows: {20, 20, 20, 20, 40, 20, 20}
225+
new float[] {-50F, -30F, -10F, 10F, 40F, 70F, 90F},
226+
// keyline sizes are as follows: {20, 20, 20, 20, 20, 40, 20}
227+
new float[] {-50F, -30F, -10F, 10F, 30F, 60F, 90F},
228+
// keyline sizes are as follows: {20, 20, 20, 20, 20, 20, 40}
229+
new float[] {-50F, -30F, -10F, 10F, 30F, 50F, 80F},
226230
};
227231

228232
KeylineState state =
229-
new KeylineState.Builder(40F)
230-
.addKeylineRange(-10F, getKeylineMaskPercentage(20F, 40F), 20F, 2)
233+
new KeylineState.Builder(40F, 100F)
234+
.addAnchorKeyline(-10F, getKeylineMaskPercentage(20F, 40F), 20F)
235+
.addKeyline(10F, getKeylineMaskPercentage(20F, 40F), 20F, false)
231236
.addKeyline(40F, 0F, 40F, true)
232237
.addKeylineRange(70F, getKeylineMaskPercentage(20F, 40F), 20F, 3)
233-
.addKeyline(125F, getKeylineMaskPercentage(10F, 40F), 10F)
238+
.addKeyline(130F, getKeylineMaskPercentage(20F, 40F), 20F)
234239
.build();
235240
KeylineStateList stateList = KeylineStateList.from(createCarouselWithWidth(100), state);
236241

@@ -239,7 +244,8 @@ public void testStartArrangementWithOutOfBoundsKeyline_shouldShiftEnd() {
239244
float maxScroll = 5 * 40F;
240245

241246
for (int j = 0; j < scrollSteps.length; j++) {
242-
KeylineState s = stateList.getShiftedState(maxScroll - scrollSteps[j], minScroll, maxScroll);
247+
KeylineState s =
248+
stateList.getShiftedState(maxScroll - scrollSteps[j], minScroll, maxScroll, true);
243249
for (int i = 0; i < s.getKeylines().size(); i++) {
244250
assertThat(s.getKeylines().get(i).locOffset).isEqualTo(endStepsLocOffsets[j][i]);
245251
}
@@ -255,7 +261,7 @@ public void testEndArrangement_shouldShiftStart() {
255261
new float[] {20F, 50F, 65F}
256262
};
257263
KeylineState state =
258-
new KeylineState.Builder(40F)
264+
new KeylineState.Builder(40F, 70)
259265
.addKeyline(5F, getKeylineMaskPercentage(10F, 40F), 10F)
260266
.addKeyline(20F, getKeylineMaskPercentage(20F, 40F), 20F)
261267
.addKeyline(50F, 0F, 40F, true)
@@ -276,7 +282,7 @@ public void testEndArrangement_shouldShiftStart() {
276282
@Test
277283
public void testEndArrangement_shouldNotShiftEnd() {
278284
KeylineState state =
279-
new KeylineState.Builder(40F)
285+
new KeylineState.Builder(40F, 70)
280286
.addKeyline(5F, getKeylineMaskPercentage(10F, 40F), 10F)
281287
.addKeyline(20F, getKeylineMaskPercentage(20F, 40F), 20F)
282288
.addKeyline(50F, 0F, 40F, true)
@@ -290,14 +296,14 @@ public void testEndArrangement_shouldNotShiftEnd() {
290296
}
291297

292298
@Test
293-
public void testFullScreenArrangementWithOutOfBoundsKeylines_nothingShifts() {
299+
public void testFullScreenArrangementWithAnchorKeylines_nothingShifts() {
294300
float[] locOffsets = new float[] {-5F, 20F, 45F};
295301

296302
KeylineState state =
297-
new KeylineState.Builder(40F)
298-
.addKeyline(-5F, getKeylineMaskPercentage(210F, 40F), 10F)
303+
new KeylineState.Builder(40F, 40)
304+
.addAnchorKeyline(-5F, getKeylineMaskPercentage(210F, 40F), 10F)
299305
.addKeyline(20F, 0F, 40F, true)
300-
.addKeyline(45F, getKeylineMaskPercentage(10F, 40F), 10F)
306+
.addAnchorKeyline(45F, getKeylineMaskPercentage(10F, 40F), 10F)
301307
.build();
302308
KeylineStateList stateList = KeylineStateList.from(createCarouselWithWidth(40), state);
303309

@@ -312,7 +318,7 @@ public void testFullScreenArrangementWithOutOfBoundsKeylines_nothingShifts() {
312318
@Test
313319
public void testMultipleFocalItems_shiftsFocalRange() {
314320
KeylineState state =
315-
new KeylineState.Builder(100F)
321+
new KeylineState.Builder(100F, 500)
316322
.addKeyline(25F, .5F, 50F)
317323
.addKeylineRange(100F, 0F, 100F, 4, true)
318324
.addKeyline(475F, .5F, 50F)
@@ -326,7 +332,7 @@ public void testMultipleFocalItems_shiftsFocalRange() {
326332
@Test
327333
public void testKeylineStateForPosition() {
328334
KeylineState state =
329-
new KeylineState.Builder(100F)
335+
new KeylineState.Builder(100F, 500)
330336
.addKeyline(25F, .5F, 50F)
331337
.addKeylineRange(100F, 0F, 100F, 4, true)
332338
.addKeyline(475F, .5F, 50F)
@@ -354,7 +360,7 @@ public void testKeylineStateForPosition() {
354360
@Test
355361
public void testKeylineStateForPositionRTL() {
356362
KeylineState state =
357-
new KeylineState.Builder(100F)
363+
new KeylineState.Builder(100F, 500)
358364
.addKeyline(25F, .5F, 50F)
359365
.addKeylineRange(100F, 0F, 100F, 4, true)
360366
.addKeyline(475F, .5F, 50F)
@@ -383,7 +389,7 @@ public void testKeylineStateForPositionRTL() {
383389
@Test
384390
public void testKeylineStateForPositionVertical() {
385391
KeylineState state =
386-
new KeylineState.Builder(100F)
392+
new KeylineState.Builder(100F, 500)
387393
.addKeyline(25F, .5F, 50F)
388394
.addKeylineRange(100F, 0F, 100F, 4, true)
389395
.addKeyline(475F, .5F, 50F)
@@ -408,4 +414,70 @@ public void testKeylineStateForPositionVertical() {
408414
}
409415
assertThat(latestKeylineLoc).isEqualTo(stateList.getEndState().getFirstFocalKeyline().loc);
410416
}
417+
418+
@Test
419+
public void testCutoffEndKeylines_changeEndKeylineLocOffsets() {
420+
float[][] endStepsLocOffsets =
421+
new float[][] {
422+
// keyline sizes are as follows: {large, large, cutoff-large}
423+
new float[] {-5F, 20F, 60F, 100F, 125F},
424+
// keyline sizes are as follows: {cutoff-large, large, large}
425+
new float[] {-25F, 0F, 40F, 80F, 105F},
426+
};
427+
428+
// Carousel size is 100, with 2 larges and a cutoff large
429+
KeylineState state =
430+
new KeylineState.Builder(40F, 100)
431+
.addAnchorKeyline(-5F, getKeylineMaskPercentage(10F, 40F), 10F)
432+
.addKeyline(20F, 0F, 40F, /* isFocal= */ true)
433+
.addKeyline(60F, 0F, 40F, /* isFocal= */ true)
434+
.addKeyline(100F, 0F, 40F, /* isFocal= */ true)
435+
.addAnchorKeyline(125F, getKeylineMaskPercentage(10F, 40F), 10F)
436+
.build();
437+
KeylineStateList stateList = KeylineStateList.from(createCarouselWithWidth(100), state);
438+
439+
float[] scrollSteps = new float[] {40F, 0F};
440+
float minScroll = 0F;
441+
float maxScroll = 5 * 80F;
442+
443+
for (int j = 0; j < scrollSteps.length; j++) {
444+
KeylineState s = stateList.getShiftedState(maxScroll - scrollSteps[j], minScroll, maxScroll);
445+
for (int i = 0; i < s.getKeylines().size(); i++) {
446+
assertThat(s.getKeylines().get(i).locOffset).isEqualTo(endStepsLocOffsets[j][i]);
447+
}
448+
}
449+
}
450+
451+
@Test
452+
public void testCutoffStartKeylines_doesNotChangeEndKeylineLocOffsets() {
453+
float[][] endStepsLocOffsets =
454+
new float[][] {
455+
// keyline sizes are as follows: {cutoff-large, large, large}
456+
new float[] {-25F, 0F, 40F, 80F, 105F},
457+
// keyline sizes are as follows: {cutoff-large, large, large}
458+
new float[] {-25F, 0F, 40F, 80F, 105F},
459+
};
460+
461+
// Carousel size is 100, with cutoff large and 2 larges
462+
KeylineState state =
463+
new KeylineState.Builder(40F, 100)
464+
.addAnchorKeyline(-25F, getKeylineMaskPercentage(10F, 40F), 10F)
465+
.addKeyline(0F, 0F, 40F, /* isFocal= */ true)
466+
.addKeyline(40F, 0F, 40F, /* isFocal= */ true)
467+
.addKeyline(80F, 0F, 40F, /* isFocal= */ true)
468+
.addAnchorKeyline(105F, getKeylineMaskPercentage(10F, 40F), 10F)
469+
.build();
470+
KeylineStateList stateList = KeylineStateList.from(createCarouselWithWidth(100), state);
471+
472+
float[] scrollSteps = new float[] {40F, 0F};
473+
float minScroll = 0F;
474+
float maxScroll = 5 * 80F;
475+
476+
for (int j = 0; j < scrollSteps.length; j++) {
477+
KeylineState s = stateList.getShiftedState(maxScroll - scrollSteps[j], minScroll, maxScroll);
478+
for (int i = 0; i < s.getKeylines().size(); i++) {
479+
assertThat(s.getKeylines().get(i).locOffset).isEqualTo(endStepsLocOffsets[j][i]);
480+
}
481+
}
482+
}
411483
}

‎lib/javatests/com/google/android/material/carousel/KeylineStateTest.java

+61-17
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ public final class KeylineStateTest {
4141
public void testNoFocalRange_throwsException() {
4242
assertThrows(
4343
IllegalStateException.class,
44-
() -> new KeylineState.Builder(100F).addKeyline(0, .5F, 50F).build());
44+
() -> new KeylineState.Builder(100F, 0).addKeyline(0, .5F, 50F).build());
4545
}
4646

4747
@Test
4848
public void testZeroSizedKeyline_shouldNotAddKeyline() {
4949
KeylineState keylineState =
50-
new KeylineState.Builder(100F)
50+
new KeylineState.Builder(100F, 0)
5151
.addKeyline(50F, 0F, 100F, true)
5252
.addKeyline(100F, 1F, 0F)
5353
.build();
@@ -57,7 +57,7 @@ public void testZeroSizedKeyline_shouldNotAddKeyline() {
5757
@Test
5858
public void testZeroCountKeylineRange_shouldNotAddKeylines() {
5959
KeylineState keylineState =
60-
new KeylineState.Builder(100F)
60+
new KeylineState.Builder(100F, 0)
6161
.addKeyline(50F, 0F, 100F, true)
6262
.addKeylineRange(110F, .8F, 20F, 0)
6363
.build();
@@ -69,7 +69,7 @@ public void testStartFocalItemWithDifferentSize_shouldThrowException() {
6969
assertThrows(
7070
IllegalArgumentException.class,
7171
() ->
72-
new KeylineState.Builder(100F)
72+
new KeylineState.Builder(100F, 0)
7373
.addKeyline(25F, .5F, 50F, true)
7474
.addKeyline(100F, 0F, 100F)
7575
.addKeyline(200F, 0F, 100F, true)
@@ -81,7 +81,7 @@ public void testEndFocalItemWithDifferentSize_shouldThrowException() {
8181
assertThrows(
8282
IllegalArgumentException.class,
8383
() ->
84-
new KeylineState.Builder(100F)
84+
new KeylineState.Builder(100F, 0)
8585
.addKeyline(50F, 0F, 100F, true)
8686
.addKeyline(150F, 0F, 100F)
8787
.addKeyline(275F, .5F, 50F, true)
@@ -93,7 +93,7 @@ public void testMiddleFocalItemWithDifferentSize_shouldThrowException() {
9393
assertThrows(
9494
IllegalArgumentException.class,
9595
() ->
96-
new KeylineState.Builder(100F)
96+
new KeylineState.Builder(100F, 0)
9797
.addKeyline(50F, 0F, 100F, true)
9898
.addKeyline(125F, .5F, 50F)
9999
.addKeyline(200F, 0F, 100F, true)
@@ -109,23 +109,24 @@ public void testKeylines_areSortedByOffsetLocation() {
109109

110110
@Test
111111
public void testReverseKeylines_shouldReverse() {
112+
int recyclerWidth = 100;
112113
// Extra small items are 10F, Small items are 50F, large items are 100F
113114
KeylineState keylineState =
114-
new KeylineState.Builder(100F)
115+
new KeylineState.Builder(100F, recyclerWidth)
115116
.addKeyline(-5F, getKeylineMaskPercentage(10F, 100F), 10F)
116117
.addKeyline(50F, 0F, 100F, true)
117118
.addKeyline(125F, getKeylineMaskPercentage(50F, 100F), 50F)
118119
.addKeyline(155F, getKeylineMaskPercentage(10F, 100F), 10F)
119120
.build();
120121

121122
KeylineState expectedState =
122-
new KeylineState.Builder(100F)
123+
new KeylineState.Builder(100F, recyclerWidth)
123124
.addKeyline(-5F, getKeylineMaskPercentage(10F, 100F), 10F)
124125
.addKeyline(25F, getKeylineMaskPercentage(50, 100F), 50F)
125126
.addKeyline(100F, 0F, 100F, true)
126127
.addKeyline(155F, getKeylineMaskPercentage(10F, 100F), 10F)
127128
.build();
128-
KeylineState reversedState = KeylineState.reverse(keylineState);
129+
KeylineState reversedState = KeylineState.reverse(keylineState, recyclerWidth);
129130

130131
assertThat(reversedState.getKeylines()).hasSize(expectedState.getKeylines().size());
131132
for (int i = 0; i < reversedState.getKeylines().size(); i++) {
@@ -155,7 +156,7 @@ public void testCenteredArrangement_calculatesOffsetLocations() {
155156
public void testStartArrangement_hasFocalRangeAtFrontOfList() {
156157
// Create a keyline state that has a [e-e-p-p] arrangement.
157158
KeylineState keylineState =
158-
new KeylineState.Builder(100F)
159+
new KeylineState.Builder(100F, 0)
159160
.addKeylineRange(50F, 0F, 100F, 2, true)
160161
.addKeylineRange(325F, .5F, 50F, 2)
161162
.build();
@@ -167,27 +168,27 @@ public void testStartArrangement_hasFocalRangeAtFrontOfList() {
167168
@Test
168169
public void testAddKeyline_onlyAddsSingleKeyline() {
169170
KeylineState keylineState =
170-
new KeylineState.Builder(100F).addKeyline(50F, 0F, 100F, true).build();
171+
new KeylineState.Builder(100F, 0).addKeyline(50F, 0F, 100F, true).build();
171172
assertThat(keylineState.getKeylines()).hasSize(1);
172173
}
173174

174175
@Test
175176
public void testAddKeylineRange_addsTwoKeylines() {
176177
KeylineState keylineState =
177-
new KeylineState.Builder(100F).addKeylineRange(50F, 0F, 100F, 2, true).build();
178+
new KeylineState.Builder(100F, 0).addKeylineRange(50F, 0F, 100F, 2, true).build();
178179
assertThat(keylineState.getKeylines()).hasSize(2);
179180
}
180181

181182
@Test
182183
public void testLerpKeylineStates_statesHaveDifferentNumberOfKeylinesShouldThrowException() {
183184
KeylineState keylineDefaultState =
184-
new KeylineState.Builder(100F)
185+
new KeylineState.Builder(100F, 0)
185186
.addKeyline(25F, .5F, 50F)
186187
.addKeylineRange(100F, 0F, 100F, 2, true)
187188
.addKeyline(275F, .5F, 50F)
188189
.build();
189190
KeylineState keylineStartState =
190-
new KeylineState.Builder(100F)
191+
new KeylineState.Builder(100F, 0)
191192
.addKeylineRange(50F, 0F, 100F, 2, true)
192193
.addKeylineRange(225F, .5F, 50F, 3)
193194
.build();
@@ -200,13 +201,13 @@ public void testLerpKeylineStates_statesHaveDifferentNumberOfKeylinesShouldThrow
200201
@Test
201202
public void testLerpKeylineStates_focalIndicesShiftAtHalfWayPoint() {
202203
KeylineState keylineDefaultState =
203-
new KeylineState.Builder(100F)
204+
new KeylineState.Builder(100F, 0)
204205
.addKeyline(25F, .5F, 50F)
205206
.addKeylineRange(100F, 0F, 100F, 2, true)
206207
.addKeyline(275F, .5F, 50F)
207208
.build();
208209
KeylineState keylineStartState =
209-
new KeylineState.Builder(100F)
210+
new KeylineState.Builder(100F, 0)
210211
.addKeylineRange(50F, 0F, 100F, 2, true)
211212
.addKeylineRange(225F, .5F, 50F, 2)
212213
.build();
@@ -239,6 +240,49 @@ public void testLerpKeylineStates_focalIndicesShiftAtHalfWayPoint() {
239240
.isEqualTo(keylineDefaultState.getLastFocalKeylineIndex());
240241
}
241242

243+
@Test
244+
public void testAddingAnchorKeyline_mustBeAtStartOrEnd() {
245+
assertThrows(
246+
IllegalArgumentException.class,
247+
() ->
248+
new KeylineState.Builder(100F, 0)
249+
.addAnchorKeyline(25F, .5F, 50F)
250+
.addAnchorKeyline(100F, 0F, 100F)
251+
.addAnchorKeyline(200F, 0F, 100F)
252+
.build());
253+
}
254+
255+
@Test
256+
public void testGetFirstNonAnchorKeyline() {
257+
KeylineState keylineDefaultState =
258+
new KeylineState.Builder(100F, 0)
259+
.addAnchorKeyline(25F, .5F, 50F)
260+
.addKeylineRange(100F, 0F, 100F, 2, true)
261+
.build();
262+
assertThat(keylineDefaultState.getFirstNonAnchorKeyline().maskedItemSize).isEqualTo(100F);
263+
}
264+
265+
@Test
266+
public void testGetLastNonAnchorKeyline() {
267+
KeylineState keylineDefaultState =
268+
new KeylineState.Builder(100F, 0)
269+
.addAnchorKeyline(25F, .5F, 50F)
270+
.addKeyline(100F, 0F, 100F, true)
271+
.addAnchorKeyline(175F, .5F, 50F)
272+
.build();
273+
assertThat(keylineDefaultState.getFirstNonAnchorKeyline().maskedItemSize).isEqualTo(100F);
274+
}
275+
276+
@Test
277+
public void testAnchorKeyline_cannotBeFocal() {
278+
assertThrows(
279+
IllegalArgumentException.class,
280+
() ->
281+
new KeylineState.Builder(100F, 0)
282+
.addKeyline(25F, .5F, 50F, /* isFocal= */ true, /* isAnchor= */ true)
283+
.build());
284+
}
285+
242286
/**
243287
* Creates a {@link KeylineState.Builder} that has a centered focal range with three large items,
244288
* and one medium item, two small items, and one extra small item on each side of the focal range.
@@ -256,7 +300,7 @@ private KeylineState.Builder createKeylineStateBuilder() {
256300
float smallMask = 1F - (smallSize / largeSize);
257301
float mediumMask = 1F - (mediumSize / largeSize);
258302

259-
return new KeylineState.Builder(largeSize)
303+
return new KeylineState.Builder(largeSize, 2470)
260304
.addKeyline(5F, extraSmallMask, extraSmallSize)
261305
.addKeylineRange(85F, smallMask, smallSize, 2)
262306
.addKeyline(435F, mediumMask, mediumSize)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.android.material.carousel;
17+
18+
import com.google.android.material.test.R;
19+
20+
import static com.google.android.material.carousel.CarouselHelper.createCarouselWithWidth;
21+
import static com.google.android.material.carousel.CarouselHelper.createViewWithSize;
22+
import static com.google.common.truth.Truth.assertThat;
23+
24+
import android.view.View;
25+
import androidx.test.core.app.ApplicationProvider;
26+
import com.google.common.collect.Iterables;
27+
import org.junit.Test;
28+
import org.junit.runner.RunWith;
29+
import org.robolectric.RobolectricTestRunner;
30+
31+
/** Tests for {@link UncontainedCarouselStrategy}. */
32+
@RunWith(RobolectricTestRunner.class)
33+
public class UncontainedCarouselStrategyTest {
34+
35+
@Test
36+
public void testLargeItem_withFullCarouselWidth() {
37+
Carousel carousel = createCarouselWithWidth(400);
38+
UncontainedCarouselStrategy config = new UncontainedCarouselStrategy();
39+
View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 400, 400);
40+
41+
KeylineState keylineState = config.onFirstChildMeasuredWithMargins(carousel, view);
42+
float xSmallSize =
43+
view.getResources().getDimension(R.dimen.m3_carousel_gone_size);
44+
45+
// A fullscreen layout should be [xSmall-large-xSmall-xSmall] where the xSmall items are
46+
// outside the bounds of the carousel container and the large item takes up the
47+
// containers full width.
48+
assertThat(keylineState.getKeylines()).hasSize(4);
49+
assertThat(keylineState.getKeylines().get(0).locOffset).isLessThan(0F);
50+
assertThat(keylineState.getKeylines().get(1).mask).isEqualTo(0F);
51+
assertThat(keylineState.getKeylines().get(2).locOffset).isEqualTo(carousel.getContainerWidth() + xSmallSize/2F);
52+
assertThat(Iterables.getLast(keylineState.getKeylines()).locOffset)
53+
.isGreaterThan((float) carousel.getContainerWidth());
54+
}
55+
56+
@Test
57+
public void testLargeItem_largerThanFullCarouselWidth() {
58+
Carousel carousel = createCarouselWithWidth(400);
59+
UncontainedCarouselStrategy config = new UncontainedCarouselStrategy();
60+
int cutOff = 10;
61+
View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 400 + cutOff, 400);
62+
63+
KeylineState keylineState = config.onFirstChildMeasuredWithMargins(carousel, view);
64+
65+
// The layout should be [xSmall-large-xSmall] where the xSmall items are
66+
// outside the bounds of the carousel container and the large item takes up the
67+
// containers full width.
68+
assertThat(keylineState.getKeylines()).hasSize(3);
69+
assertThat(keylineState.getKeylines().get(0).locOffset).isLessThan(0F);
70+
assertThat(keylineState.getKeylines().get(1).mask).isEqualTo(0F);
71+
assertThat(Iterables.getLast(keylineState.getKeylines()).locOffset)
72+
.isGreaterThan((float) carousel.getContainerWidth());
73+
}
74+
75+
@Test
76+
public void testRemainingSpaceWithItemSize_fitsItemWithThirdCutoff() {
77+
Carousel carousel = createCarouselWithWidth(400);
78+
UncontainedCarouselStrategy config = new UncontainedCarouselStrategy();
79+
// With size 125px, 3 large items can fit with in 400px, with 25px left over which is less than
80+
// half 125px. This means that a large keyline will not fit in the remaining space
81+
// such that any motion is seen when scrolling past the keyline, so a medium item
82+
// should be added.
83+
View view = createViewWithSize(ApplicationProvider.getApplicationContext(), 125, 400);
84+
85+
KeylineState keylineState = config.onFirstChildMeasuredWithMargins(carousel, view);
86+
87+
// The layout should be [xSmall-large-large-large-medium-xSmall] where medium is a size
88+
// such that a third of it is cut off.
89+
assertThat(keylineState.getKeylines()).hasSize(6);
90+
assertThat(keylineState.getKeylines().get(0).locOffset).isLessThan(0F);
91+
assertThat(keylineState.getKeylines().get(1).mask).isEqualTo(0F);
92+
assertThat(keylineState.getKeylines().get(2).mask).isEqualTo(0F);
93+
assertThat(keylineState.getKeylines().get(3).mask).isEqualTo(0F);
94+
assertThat(keylineState.getKeylines().get(4).locOffset)
95+
.isLessThan((float) carousel.getContainerWidth());
96+
assertThat(Iterables.getLast(keylineState.getKeylines()).locOffset)
97+
.isGreaterThan((float) carousel.getContainerWidth());
98+
}
99+
100+
@Test
101+
public void testRemainingSpaceWithItemSize_fitsLargeItemWithCutoff() {
102+
Carousel carousel = createCarouselWithWidth(400);
103+
UncontainedCarouselStrategy config = new UncontainedCarouselStrategy();
104+
int itemSize = 105;
105+
// With size 105px, 3 large items can fit with in 400px, with 85px left over which is more than
106+
// half 105px. This means that an extra large keyline will fit in the remaining space such
107+
// that motion is seen when scrolling past the keyline, so it should add a large item.
108+
View view = createViewWithSize(ApplicationProvider.getApplicationContext(), itemSize, 400);
109+
110+
KeylineState keylineState = config.onFirstChildMeasuredWithMargins(carousel, view);
111+
112+
// The layout should be [xSmall-large-large-large-large-xSmall]
113+
assertThat(keylineState.getKeylines()).hasSize(6);
114+
assertThat(keylineState.getKeylines().get(0).locOffset).isLessThan(0F);
115+
assertThat(keylineState.getKeylines().get(1).mask).isEqualTo(0F);
116+
assertThat(keylineState.getKeylines().get(2).mask).isEqualTo(0F);
117+
assertThat(keylineState.getKeylines().get(3).mask).isEqualTo(0F);
118+
assertThat(keylineState.getKeylines().get(4).mask).isEqualTo(0F);
119+
assertThat(Iterables.getLast(keylineState.getKeylines()).locOffset)
120+
.isGreaterThan((float) carousel.getContainerWidth());
121+
}
122+
}

0 commit comments

Comments
 (0)
Please sign in to comment.