Material 3 的 Carousel 控件

概述

Carousel 直译为圆形传送带,在 Material 3 的设计规范 1 中,主要的特点是

  • Carousel 中多个项目 水平滚动
  • Carousel 主要是展示视图,并且可以选择包含简短的文本
  • Carousel 展示为不同尺寸,滚动时每一项的尺寸动态改变

目前在 Google 官方支持中,MDC-Android 首先支持,本文在于介绍 MDC-Android 中如何结合 RecyclerView 来实现 Carousel 的展示。

基于 MDC-Android 1.9.0 2 。目前 Carousel 仍处于一个试验性阶段,还有一些设计规范内的细节未被正确实现。


动效展示:


如何使用 Carousel 请参考 MDC-Android 中 Carousel 快速上手,下文中以已经成功使用了 Carousel 为前提来介绍。

Carousel 的主要特点是可变的尺寸和内容的视差变化,从官方设计文档的主要示例上来看,主要演示在相册中作为相簿的应用。总结一下,可以认为,如果某个列表入口,信息简要且带有图片信息,可以用 Carousel 来作为展示入口。

虽然它的主体展示内容是图片,但是并不建议使用 Carousel 做纯粹的图片展示,有几个原因

  1. 图片被 MaskableFrameLayout 包裹后,可能被裁切,若要写图片的转场动画时,交互体验不太好(如果只是展示而不需要有点击后的下一步操作也可以忽略这一点);
  2. 目前的 Carousel 控件要求每一个 item 的宽高都是固定的,纯图片的场景不一定能符合要求。
  • CarouselLayoutManager :用于设置给 RecyclerView ,计算布局位置
  • MaskableFrameLayout :作为每个 item 的容器,用于展现动态尺寸改变和视差效果

CarouselLayoutManager

在 MDC-Android 的 Carousel 文档中描述了 item 尺寸的含义。

<译> 改变 Carousel 外观的主要手段是通过设置你的 RecyclerView 的高度和 item 的 MaskableFrameLayout 的宽度。CarouselLayoutManager 将使用 item 布局中设置的宽度,来确定项目在完全展示时应该有的尺寸。这个宽度需要被设置为特定的 dp 值,不能被设置为 wrap_contentCarouselLayoutManager 会尝试使用一个尽可能接近你的项目布局的指定宽度的尺寸,但可能会根据 RecyclerView 的可用空间增加或减少这个尺寸。这是在 RecyclerView 的范围内创建一个令人愉快的项目安排所需要的。此外,CarouselLayoutManager` 将只读取和使用第一个列表项上设置的宽度。所有剩下的项目将使用第一个项目的宽度来布置。

简而言之,item 布局中必须声明一个特定的 dp 值,才能进行下一步的操作,尽管这个值并非一定是最终被使用的宽度

CarouselLayoutManager 中,一个十分重要的类是 CarouselStrategy ,它用于计算每一个 item 当前应当是什么宽度。

/**
 * A class responsible for creating a model used by a carousel to mask and offset views as they move
 * along a scrolling axis.
 */
public abstract class CarouselStrategy {

  abstract KeylineState onFirstChildMeasuredWithMargins(
      @NonNull Carousel carousel, @NonNull View child);

  @FloatRange(from = 0F, to = 1F)
  static float getChildMaskPercentage(float maskedSize, float unmaskedSize, float childMargins) {
    return 1F - ((maskedSize - childMargins) / (unmaskedSize - childMargins));
  }
}

Google 对此抽象类的实现是 MultiBrowseCarouselStrategy

其中对计算应绘制到屏幕上的 item 的关键代码为 onFirstChildMeasuredWithMargins

KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNull View child) {
  float availableSpace = carousel.getContainerWidth();

  LayoutParams childLayoutParams = (LayoutParams) child.getLayoutParams();
  float childHorizontalMargins = childLayoutParams.leftMargin + childLayoutParams.rightMargin;

  float smallChildWidthMin = getSmallSizeMin(child.getContext()) + childHorizontalMargins;
  float smallChildWidthMax = getSmallSizeMax(child.getContext()) + childHorizontalMargins;

  float measuredChildWidth = child.getMeasuredWidth();
  float targetLargeChildWidth = min(measuredChildWidth + childHorizontalMargins, availableSpace);
  // Ideally we would like to create a balanced arrangement where a small item is 1/3 the size of
  // the large item and medium items are sized between large and small items. Clamp the small
  // target size within our min-max range and as close to 1/3 of the target large item size as
  // possible.
  float targetSmallChildWidth =
      MathUtils.clamp(
          measuredChildWidth / 3F + childHorizontalMargins,
          getSmallSizeMin(child.getContext()) + childHorizontalMargins,
          getSmallSizeMax(child.getContext()) + childHorizontalMargins);
  float targetMediumChildWidth = (targetLargeChildWidth + targetSmallChildWidth) / 2F;

  // Create arrays representing the possible count of small, medium, and large items. These are
  // not in an asc./dec. order but are in order of priority. A small count array of { 2, 3, 1 }
  // says that ideally an arrangement with 2 small items is found, then 3 is next most desirable,
  // then finally 1.
  int[] smallCounts = SMALL_COUNTS;
  int[] mediumCounts = forceCompactArrangement ? MEDIUM_COUNTS_COMPACT : MEDIUM_COUNTS;
  // Find the minimum space left for large items after filling the carousel with the most
  // permissible medium and small items to determine a plausible minimum large count.
  float minAvailableLargeSpace =
      availableSpace
          - (targetMediumChildWidth * maxValue(mediumCounts))
          - (smallChildWidthMax * maxValue(smallCounts));
  int largeCountMin = (int) max(1, floor(minAvailableLargeSpace / targetLargeChildWidth));
  int largeCountMax = (int) ceil(availableSpace / targetLargeChildWidth);
  int[] largeCounts = new int[largeCountMax - largeCountMin + 1];
  for (int i = 0; i < largeCounts.length; i++) {
    largeCounts[i] = largeCountMax - i;
  }

  Arrangement arrangement =
      findLowestCostArrangement(
          availableSpace,
          targetSmallChildWidth,
          smallChildWidthMin,
          smallChildWidthMax,
          smallCounts,
          targetMediumChildWidth,
          mediumCounts,
          targetLargeChildWidth,
          largeCounts);

  float extraSmallChildWidth = getExtraSmallSize(child.getContext()) + childHorizontalMargins;

  float start = 0F;
  float extraSmallHeadCenterX = start - (extraSmallChildWidth / 2F);

  float largeStartCenterX = start + (arrangement.largeSize / 2F);
  float largeEndCenterX =
      largeStartCenterX + (max(0, arrangement.largeCount - 1) * arrangement.largeSize);
  start = largeEndCenterX + arrangement.largeSize / 2F;

  float mediumCenterX =
      arrangement.mediumCount > 0 ? start + (arrangement.mediumSize / 2F) : largeEndCenterX;
  start = arrangement.mediumCount > 0 ? mediumCenterX + (arrangement.mediumSize / 2F) : start;

  float smallStartCenterX =
      arrangement.smallCount > 0 ? start + (arrangement.smallSize / 2F) : mediumCenterX;

  float extraSmallTailCenterX = carousel.getContainerWidth() + (extraSmallChildWidth / 2F);

  float extraSmallMask =
      getChildMaskPercentage(extraSmallChildWidth, arrangement.largeSize, childHorizontalMargins);
  float smallMask =
      getChildMaskPercentage(
          arrangement.smallSize, arrangement.largeSize, childHorizontalMargins);
  float mediumMask =
      getChildMaskPercentage(
          arrangement.mediumSize, arrangement.largeSize, childHorizontalMargins);
  float largeMask = 0F;

  KeylineState.Builder builder =
      new KeylineState.Builder(arrangement.largeSize)
          .addKeyline(extraSmallHeadCenterX, extraSmallMask, extraSmallChildWidth)
          .addKeylineRange(
              largeStartCenterX, largeMask, arrangement.largeSize, arrangement.largeCount, true);
  if (arrangement.mediumCount > 0) {
    builder.addKeyline(mediumCenterX, mediumMask, arrangement.mediumSize);
  }
  if (arrangement.smallCount > 0) {
    builder.addKeylineRange(
        smallStartCenterX, smallMask, arrangement.smallSize, arrangement.smallCount);
  }
  builder.addKeyline(extraSmallTailCenterX, extraSmallMask, extraSmallChildWidth);
  return builder.build();
}

其中用于估算出尺寸占用的关键变量:

变量作用
targetLargeChildWidth表示当 item 尺寸最大时宽度应为多少,取 item 的固定宽和 RecyclerView 宽度二者的最小值
targetSmallChildWidth表示当 item 尺寸最小时的宽度应为多少,默认为 item 的固定宽度的三分之一
targetMediumChildWidth上述两者和的一半

这里涉及到一个分配关系,在上述代码中,会计算当前应该显示多少个 item ,先根据容器的宽度计算出可容纳多少 大、中、小 尺寸的 item 。

  1. 容器
  2. 大尺寸 item
  3. 中尺寸 item
  4. 小尺寸 item

代码中根据一个 int 数组来规划 item 如何分布。

其中,中尺寸和小尺寸的分布规则是固定的静态数组,如下。

private static final int[] SMALL_COUNTS = new int[] {1};
private static final int[] MEDIUM_COUNTS = new int[] {1, 0};

大尺寸的数组则是,先根据以上规则算出可分配给大尺寸 item 的空间是多少,再由剩余空间确定大尺寸可展示的 item 数量。上述数组的含义为

  • SMALL_COUNTS 代表不论如何都要有 1 个小尺寸的 item
  • MEDIUM_COUNTS 代表可以有 1 个中尺寸 item ,也可以没有
float minAvailableLargeSpace =
    availableSpace
        - (targetMediumChildWidth * maxValue(mediumCounts))
        - (smallChildWidthMax * maxValue(smallCounts));
int largeCountMin = (int) max(1, floor(minAvailableLargeSpace / targetLargeChildWidth));
int largeCountMax = (int) ceil(availableSpace / targetLargeChildWidth);
int[] largeCounts = new int[largeCountMax - largeCountMin + 1];
for (int i = 0; i < largeCounts.length; i++) {
  largeCounts[i] = largeCountMax - i;
}

可以看到,minAvailableLargeSpace 是容器宽度减去小尺寸 item 总宽,再减去中尺寸 item 宽。由于目前 MultiBrowseCarouselStrategy 中,中小尺寸的分布规则是固定的,所以他们的数量都是 1 。

其中小尺寸 item 较特殊,在实际交互中它的尺寸是从 smallChildWidthMaxsmallChildWidthMin 变化的,因此计算时使用其最大值来算空间。

<dimen name="m3_carousel_small_item_size_min">40dp</dimen>
<dimen name="m3_carousel_small_item_size_max">56dp</dimen>

经过上述步骤获取出估算的容器可容纳的布局后,进入 Arrangement.findLowestCostArrangement 根据上述数量,算出可用的、尺寸消耗最小的排列方式。

Arrangement.findLowestCostArrangement
↓
Arrangement()
↓
Arrangement.fit
↓
Arrangement.calculateLargeSize

fit 这一步中 item 的尺寸也会被重新分配。

largeSize =
    calculateLargeSize(availableSpace, smallCount, smallSize, mediumCount, largeCount);
mediumSize = (largeSize + smallSize) / 2F;

calculateLargeSize 函数中,换算出在当前搭配下,大尺寸 item 的宽度应是多少。

private float calculateLargeSize(
        float availableSpace, int smallCount, float smallSize, int mediumCount, int largeCount) {
  // Zero out small size if there are no small items
  smallSize = smallCount > 0 ? smallSize : 0F;
  return (availableSpace - (((float) smallCount) + ((float) mediumCount) / 2F) * smallSize)
      / (((float) largeCount) + ((float) mediumCount) / 2F);
}

逐字分析一下, 容器空间 - ((小尺寸数量 + 中尺寸数量 / 2) * 小尺寸宽度) / (大尺寸数量 + 中尺寸数量 / 2)

算出来后,如果 largeSizetargetLargeSize 对不上,则进行以下处理。

if (mediumCount > 0 && largeSize != targetLargeSize) {
  float targetAdjustment = (targetLargeSize - largeSize) * largeCount;
  float availableMediumFlex = (mediumSize * MEDIUM_ITEM_FLEX_PERCENTAGE) * mediumCount;
  float distribute = min(abs(targetAdjustment), availableMediumFlex);
  if (targetAdjustment > 0F) {
    // Reduce the size of the medium item and give it back to the large items
    mediumSize -= (distribute / mediumCount);
    largeSize += (distribute / largeCount);
  } else {
    // Increase the size of the medium item and take from the large items
    mediumSize += (distribute / mediumCount);
    largeSize -= (distribute / largeCount);
  }
}

和文首提到的一致,item 尺寸会在绘制前计算出最终的值,和布局中设置的固定 dp 值并不会完全相同。

至此,绘制到屏幕上的首个画面所需的尺寸数据就已经充分了。

而在滑动的过程中,会不断的调用 layoutChildren 来触发界面重新布局。此时一个重要的类是 KeylineStateList ,其中保存着每一个 item 的 Keyline 。滑动过程中,layoutChildren 会根据计算得出的 Keyline 来绘制对应的 item 位置和大小。

/**
 * A data class that represents a state an item should be in when its center is at a position
 * along the scroll axis.
 */
static final class Keyline {
  final float loc;
  final float locOffset;
  final float mask;
  final float maskedItemSize;
  // ...
}
成员作用
locitem 在滚动轴中的位置。
locOffset当 item 在 carousel 中心时,它在滚动轴中的位置。
maskitem 被遮盖的百分比。
maskedItemSize当 item 被遮盖时的尺寸。

这个计算过程的调用栈为

LayoutManager.layoutChildren
↓
CarouselLayoutManager.updateCurrentKeylineStateForScrollOffset
↓
KeylineStateList.getShiftedState
↓
KeylineStateList.lerp
↓
KeylineState.lerp
↓
Keyline.lerp

最后出现的 lerp 是通过 起始值、结束值、动画百分比,来推算出当前值的函数。

MaskableFrameLayout

MaskableFrameLayout 主要是实现了 Maskable 接口。

/** Interface for any view that can clip itself and all children to a percentage of its size. */
interface Maskable {

  /**
   * Set the percentage by which this {@link View} should mask itself along the x axis.
   *
   * @param percentage 0 when this view is fully unmasked. 1 when this view is fully masked.
   */
  void setMaskXPercentage(@FloatRange(from = 0F, to = 1F) float percentage);

  /**
   * Gets the percentage by which this {@link View} should mask itself along the x axis.
   *
   * @return a float between 0 and 1 where 0 is fully unmasked and 1 is fully masked.
   */
  @FloatRange(from = 0F, to = 1F)
  float getMaskXPercentage();

  /** Gets a {@link RectF} that this {@link View} is masking itself by. */
  @NonNull
  RectF getMaskRectF();

  /**
   * Sets an {@link OnMaskChangedListener}.
   *
   * @param listener a listener to receive callbacks for changes in the mask or null to clear the
   *     listener.
   */
  void setOnMaskChangedListener(@Nullable OnMaskChangedListener listener);
}

此处的关键点在于 setMaskXPercentage ,在 CarouselLayoutManager.updateChildMaskForLocation 中就是通过这一函数来更新每个 item 的遮罩范围的。最终触发了 maskableDelegate 来对遮罩进行更新。

private void onMaskChanged() {
  if (getWidth() == 0) {
    return;
  }
  // Translate the percentage into an actual pixel value of how much of this view should be
  // masked away.
  float maskWidth = AnimationUtils.lerp(0f, getWidth() / 2F, 0f, 1f, maskXPercentage);
  maskRect.set(maskWidth, 0F, (getWidth() - maskWidth), getHeight());
  maskableDelegate.onMaskChanged(this, maskRect);
  if (onMaskChangedListener != null) {
    onMaskChangedListener.onMaskChanged(maskRect);
  }
}

对于 maskableDelegate 我们可以看到主要是对不同 API 层上做的适配。

private MaskableDelegate createMaskableDelegate() {
  if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
    return new MaskableDelegateV33(this);
  } else if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP_MR1) {
    return new MaskableDelegateV22(this);
  } else {
    return new MaskableDelegateV14();
  }
}

参考

  1. Carousel – 设计文档
  2. MDC-Android 代码仓库
chevron_left

Join the conversation

comment 3 comments
  • Steve Swindler

    Hello just wanted to give you a quick heads up.

    The words in your post seem to be running off the screen in Safari.
    I’m not sure if this is a format issue or something to do with web browser compatibility but I thought
    I’d post to let you know. The layout look great though!

    Hope you get the issue resolved soon. Many thanks

    • seasonyuu

      Thank you so much. This might be the theme issue.

  • Pedro Gaddy

    I will immediately grasp your rss as I can’t find your email subscription hyperlink or
    e-newsletter service. Do you have any? Please
    let me recognize so that I could subscribe. Thanks.

Leave a comment

您的邮箱地址不会被公开。 必填项已用 * 标注

Comment
Name
Email
Website