如何使用 MaterialShapeDrawable 模仿 MaterialCardView 的视觉效果

How to imitate the visuals of MaterialCardView using a MaterialShapeDrawable

我试过什么?

在简要浏览了 MaterailCardViewHelper source 之后,我尝试复制它绘制相关联的 Drawable 的方式。不幸的是,它会产生一个带有一些“经过处理”的角的黑色形状,看起来一点也不像 MaterialCardView。我知道 MaterialCardViewHelper 将背景和前景应用到实际的 CardView 并且在查看了源代码之后,它似乎没有做任何特别的事情,也就是说,它似乎只是在调用setBackgroundDrawable(我在 someView 上做的,如下所示)。

我正在使用 Xamarin,所以我的代码是用 C# 编写的。我基本上已经将 Java 源代码(MaterialCardViewHelper 的)转换为它的 C# 等价物,在适当的地方将“materialCardView”的引用替换为 MaterialCardDrawable

我尽量使代码与原始 Java 源代码保持接近,以确保阅读本文的任何人都可以轻松地将原始代码与我的进行比较。我所做的更改仅足以使代码编译。主要区别在于“绘制”方法,我认为这是我的问题所在。

public sealed class MaterialCardDrawable : MaterialShapeDrawable
{
    private static readonly int[] CHECKED_STATE_SET = { Android.Resource.Attribute.StateChecked };
    private static readonly int DEFAULT_STROKE_VALUE = -1;
    private static readonly double COS_45 = Math.Cos(Math.ToRadians(45));
    private static readonly float CARD_VIEW_SHADOW_MULTIPLIER = 1.5f;
    private static readonly int CHECKED_ICON_LAYER_INDEX = 2;

    // this class will act as MaterialCardView (so any references to "materialCardView" will just be referenced to this class instead)
    //private readonly MaterialCardView materialCardView; 

    private readonly Rect userContentPadding = new Rect();
    private readonly MaterialShapeDrawable bgDrawable;
    private readonly MaterialShapeDrawable foregroundContentDrawable;

    private int checkedIconMargin;
    private int checkedIconSize;
    private int strokeWidth;

    private Drawable fgDrawable;
    private Drawable checkedIcon;
    private ColorStateList rippleColor;
    private ColorStateList checkedIconTint;
    private ShapeAppearanceModel shapeAppearanceModel;
    private ColorStateList strokeColor;
    private Drawable rippleDrawable;
    private LayerDrawable clickableForegroundDrawable;
    private MaterialShapeDrawable compatRippleDrawable;
    private MaterialShapeDrawable foregroundShapeDrawable;

    private bool isBackgroundOverwritten = false;
    private bool checkable;

    public MaterialCardDrawable(Context context)
    {
        bgDrawable = new MaterialShapeDrawable(context, null, 0, 0); // different
        bgDrawable.InitializeElevationOverlay(context);
        bgDrawable.SetShadowColor(Color.DarkGray/*potentially different*/);
        ShapeAppearanceModel.Builder shapeAppearanceModelBuilder = bgDrawable.ShapeAppearanceModel.ToBuilder();
        shapeAppearanceModelBuilder.SetAllCornerSizes(DimensionHelper.GetPixels(4)); // different, use 4 as opposed to 0 as default (converts dp to pixels)
        foregroundContentDrawable = new MaterialShapeDrawable();
        setShapeAppearanceModel(shapeAppearanceModelBuilder.Build());

        loadFromAttributes(context);
    }

    // assuming responsibility for drawing the rest of the drawables
    public override void Draw(Canvas canvas)
    {
        bgDrawable?.Draw(canvas);
        clickableForegroundDrawable?.Draw(canvas);
        compatRippleDrawable?.Draw(canvas);
        fgDrawable?.Draw(canvas);
        foregroundContentDrawable?.Draw(canvas);
        foregroundShapeDrawable?.Draw(canvas);
        rippleDrawable?.Draw(canvas);
    }

    public override void SetBounds(int left, int top, int right, int bottom)
    {
        base.SetBounds(left, top, right, bottom);
        bgDrawable?.SetBounds(left, top, right, bottom);
        clickableForegroundDrawable?.SetBounds(left, top, right, bottom);
        compatRippleDrawable?.SetBounds(left, top, right, bottom);
        fgDrawable?.SetBounds(left, top, right, bottom);
        foregroundContentDrawable?.SetBounds(left, top, right, bottom);
        foregroundShapeDrawable?.SetBounds(left, top, right, bottom);
        rippleDrawable?.SetBounds(left, top, right, bottom);
    }

    void loadFromAttributes(Context context)
    {
        // this is very different to the original source
        // just use default values            
        strokeColor = ColorStateList.ValueOf(new Color(DEFAULT_STROKE_VALUE));

        strokeWidth = 0;
        checkable = false;
        // ignore checkedIcon related calls for testing purposes

        TypedArray attributes = context.ObtainStyledAttributes(new int[] { Android.Resource.Attribute.ColorControlHighlight, Android.Resource.Attribute.ColorForeground });

        rippleColor = ColorStateList.ValueOf(attributes.GetColor(0, 0));

        ColorStateList foregroundColor = attributes.GetColorStateList(1);
        setCardForegroundColor(foregroundColor);

        updateRippleColor();
        updateElevation();
        updateStroke();

        fgDrawable = /*materialCardView.*/isClickable() ? getClickableForeground() : foregroundContentDrawable;
    }

    // original source calls "materialCardView" but this class simply mimicks the MaterialCardView so this method exists here
    bool isClickable()
    {
        return false;
    }

    // original source calls "materialCardView" but this class simply mimicks the MaterialCardView so this method exists here
    float getMaxCardElevation()
    {
        // apparently used for when dragging to clamp the shadow
        // using this as a default value
        return DimensionHelper.GetPixels(12);
    }

    // original source calls "materialCardView" but this class simply mimicks the MaterialCardView so this method exists here
    float getCardViewRadius()
    {
        // just using a radius of 4dp for now
        return DimensionHelper.GetPixels(4);
    }

    // original source calls "materialCardView" but this class simply mimicks the MaterialCardView so this method exists here
    bool getUseCompatPadding()
    {
        // no effect when API version is Lollipop and beyond
        return false;
    }

    // original source calls "materialCardView" but this class simply mimicks the MaterialCardView so this method exists here
    bool getPreventCornerOverlap()
    {
        // no effect when API version is Lollipop and beyond
        return false;
    }

    bool getIsBackgroundOverwritten()
    {
        return isBackgroundOverwritten;
    }

    void setBackgroundOverwritten(bool isBackgroundOverwritten)
    {
        this.isBackgroundOverwritten = isBackgroundOverwritten;
    }

    void setStrokeColor(ColorStateList strokeColor)
    {
        if (this.strokeColor == strokeColor)
        {
            return;
        }

        this.strokeColor = strokeColor;
        updateStroke();
    }


    int getStrokeColor()
    {
        return strokeColor == null ? DEFAULT_STROKE_VALUE : strokeColor.DefaultColor;
    }

    ColorStateList getStrokeColorStateList()
    {
        return strokeColor;
    }

    void setStrokeWidth(int strokeWidth)
    {
        if (strokeWidth == this.strokeWidth)
        {
            return;
        }
        this.strokeWidth = strokeWidth;
        updateStroke();
    }


    int getStrokeWidth()
    {
        return strokeWidth;
    }

    MaterialShapeDrawable getBackground()
    {
        return bgDrawable;
    }

    void setCardBackgroundColor(ColorStateList color)
    {
        bgDrawable.FillColor = color;
    }

    ColorStateList getCardBackgroundColor()
    {
        return bgDrawable.FillColor;
    }

    void setCardForegroundColor(ColorStateList foregroundColor)
    {
        foregroundContentDrawable.FillColor = foregroundColor == null ? ColorStateList.ValueOf(Color.Transparent) : foregroundColor;
    }

    ColorStateList getCardForegroundColor()
    {
        return foregroundContentDrawable.FillColor;
    }

    void setUserContentPadding(int left, int top, int right, int bottom)
    {
        userContentPadding.Set(left, top, right, bottom);
        updateContentPadding();
    }

    Rect getUserContentPadding()
    {
        return userContentPadding;
    }

    void updateClickable()
    {
        Drawable previousFgDrawable = fgDrawable;
        fgDrawable = /*materialCardView.*/isClickable() ? getClickableForeground() : foregroundContentDrawable;
        if (previousFgDrawable != fgDrawable)
        {
            updateInsetForeground(fgDrawable);
        }
    }

    void setCornerRadius(float cornerRadius)
    {
        setShapeAppearanceModel(shapeAppearanceModel.WithCornerSize(cornerRadius));
        fgDrawable.InvalidateSelf();
        if (shouldAddCornerPaddingOutsideCardBackground()
            || shouldAddCornerPaddingInsideCardBackground())
        {
            updateContentPadding();
        }

        if (shouldAddCornerPaddingOutsideCardBackground())
        {
            updateInsets();
        }
    }

    float getCornerRadius()
    {
        return bgDrawable.TopLeftCornerResolvedSize;
    }

    void setProgress(float progress)
    {
        bgDrawable.Interpolation = progress;
        if (foregroundContentDrawable != null)
        {
            foregroundContentDrawable.Interpolation = progress;
        }

        if (foregroundShapeDrawable != null)
        {
            foregroundShapeDrawable.Interpolation = progress;
        }
    }

    float getProgress()
    {
        return bgDrawable.Interpolation;
    }

    void updateElevation()
    {
        bgDrawable.Elevation = 4; // different for simplicity's sake use a default value of 4
    }

    void updateInsets()
    {
        // No way to update the inset amounts for an InsetDrawable, so recreate insets as needed.
        if (!getIsBackgroundOverwritten())
        {
            // this is unavailable outside of "material-components" package
            //materialCardView.setBackgroundInternal(insetDrawable(bgDrawable));                
            // maybe a call to
            // InvalidateSelf()
            // works in place of the above?
        }
        // can't find this in the original "MaterialCardView" or "CardView" source, any ideas?
        // I assume it's on a base class, like "FrameLayout" but I couldn't find it there either
        //materialCardView.setForeground(insetDrawable(fgDrawable));
        // don't know enough about the above to provide a replacement call, any ideas?
    }

    void updateStroke()
    {
        foregroundContentDrawable.SetStroke(strokeWidth, strokeColor);
    }

    void updateContentPadding()
    {
        bool includeCornerPadding = shouldAddCornerPaddingInsideCardBackground() || shouldAddCornerPaddingOutsideCardBackground();
        // The amount with which to adjust the user provided content padding to account for stroke and
        // shape corners.
        int contentPaddingOffset = (int)((includeCornerPadding ? calculateActualCornerPadding() : 0) - getParentCardViewCalculatedCornerPadding());

        // this is unavailable outside of "material-components" package
        // and possibly not required to simulate this
        //materialCardView.setAncestorContentPadding(
        //    userContentPadding.left + contentPaddingOffset,
        //    userContentPadding.top + contentPaddingOffset,
        //    userContentPadding.right + contentPaddingOffset,
        //    userContentPadding.bottom + contentPaddingOffset);
    }

    void setCheckable(bool checkable)
    {
        this.checkable = checkable;
    }

    bool isCheckable()
    {
        return checkable;
    }

    void setRippleColor(ColorStateList rippleColor)
    {
        this.rippleColor = rippleColor;
        updateRippleColor();
    }

    void setCheckedIconTint(ColorStateList checkedIconTint)
    {
        this.checkedIconTint = checkedIconTint;
        if (checkedIcon != null)
        {
            DrawableCompat.SetTintList(checkedIcon, checkedIconTint);
        }
    }

    ColorStateList getCheckedIconTint()
    {
        return checkedIconTint;
    }

    ColorStateList getRippleColor()
    {
        return rippleColor;
    }

    Drawable getCheckedIcon()
    {
        return checkedIcon;
    }

    void setCheckedIcon(Drawable checkedIcon)
    {
        this.checkedIcon = checkedIcon;
        if (checkedIcon != null)
        {
            this.checkedIcon = DrawableCompat.Wrap(checkedIcon.Mutate());
            DrawableCompat.SetTintList(this.checkedIcon, checkedIconTint);
        }

        if (clickableForegroundDrawable != null)
        {
            Drawable checkedLayer = createCheckedIconLayer();
            clickableForegroundDrawable.SetDrawableByLayerId(Resource.Id.mtrl_card_checked_layer_id, checkedLayer);
        }
    }

    int getCheckedIconSize()
    {
        return checkedIconSize;
    }

    void setCheckedIconSize(int checkedIconSize)
    {
        this.checkedIconSize = checkedIconSize;
    }

    int getCheckedIconMargin()
    {
        return checkedIconMargin;
    }

    void setCheckedIconMargin(int checkedIconMargin)
    {
        this.checkedIconMargin = checkedIconMargin;
    }

    void onMeasure(int measuredWidth, int measuredHeight)
    {
        if (clickableForegroundDrawable != null)
        {
            int left = measuredWidth - checkedIconMargin - checkedIconSize;
            int bottom = measuredHeight - checkedIconMargin - checkedIconSize;
            bool isPreLollipop = VERSION.SdkInt < Android.OS.BuildVersionCodes.Lollipop;
            if (isPreLollipop || /*materialCardView.*/getUseCompatPadding())
            {
                bottom -= (int)Math.Ceil(2f * calculateVerticalBackgroundPadding());
                left -= (int)Math.Ceil(2f * calculateHorizontalBackgroundPadding());
            }

            int right = checkedIconMargin;
            // potentially not required for this use case
            //if (ViewCompat.GetLayoutDirection(materialCardView) == ViewCompat.LayoutDirectionRtl)
            //{
            //    // swap left and right
            //    int tmp = right;
            //    right = left;
            //    left = tmp;
            //}

            clickableForegroundDrawable.SetLayerInset(CHECKED_ICON_LAYER_INDEX, left, checkedIconMargin /* top */, right, bottom);
        }
    }

    void forceRippleRedraw()
    {
        if (rippleDrawable != null)
        {
            Rect bounds = rippleDrawable.Bounds;
            // Change the bounds slightly to force the layer to change color, then change the layer again.
            // In API 28 the color for the Ripple is snapshot at the beginning of the animation,
            // it doesn't update when the drawable changes to android:state_checked.
            int bottom = bounds.Bottom;
            rippleDrawable.SetBounds(bounds.Left, bounds.Top, bounds.Right, bottom - 1);
            rippleDrawable.SetBounds(bounds.Left, bounds.Top, bounds.Right, bottom);
        }
    }

    void setShapeAppearanceModel(ShapeAppearanceModel shapeAppearanceModel)
    {
        this.shapeAppearanceModel = shapeAppearanceModel;
        bgDrawable.ShapeAppearanceModel = shapeAppearanceModel;
        bgDrawable.SetShadowBitmapDrawingEnable(!bgDrawable.IsRoundRect);
        if (foregroundContentDrawable != null)
        {
            foregroundContentDrawable.ShapeAppearanceModel = shapeAppearanceModel;
        }

        if (foregroundShapeDrawable != null)
        {
            foregroundShapeDrawable.ShapeAppearanceModel = shapeAppearanceModel;
        }

        if (compatRippleDrawable != null)
        {
            compatRippleDrawable.ShapeAppearanceModel = shapeAppearanceModel;
        }
    }

    ShapeAppearanceModel getShapeAppearanceModel()
    {
        return shapeAppearanceModel;
    }

    private void updateInsetForeground(Drawable insetForeground)
    {
        // unsure what getForeground and setForeground is referring to here, perhaps fgDrawable?
        //if (VERSION.SdkInt >= Android.OS.BuildVersionCodes.M && materialCardView.getForeground() is Android.Graphics.Drawables.InsetDrawable)
        //{
        //    ((Android.Graphics.Drawables.InsetDrawable)materialCardView.getForeground()).setDrawable(insetForeground);
        //}
        //else
        //{
        //    materialCardView.setForeground(insetDrawable(insetForeground));
        //}
    }

    private Drawable insetDrawable(Drawable originalDrawable)
    {
        int insetVertical = 0;
        int insetHorizontal = 0;
        bool isPreLollipop = VERSION.SdkInt < Android.OS.BuildVersionCodes.Lollipop;
        if (isPreLollipop || /*materialCardView.*/getUseCompatPadding())
        {
            // Calculate the shadow padding used by CardView
            insetVertical = (int)Math.Ceil(calculateVerticalBackgroundPadding());
            insetHorizontal = (int)Math.Ceil(calculateHorizontalBackgroundPadding());
        }
        // new custom class (see end)
        return new InsetDrawable(originalDrawable, insetHorizontal, insetVertical, insetHorizontal, insetVertical);
    }

    private float calculateVerticalBackgroundPadding()
    {
        return /*materialCardView.*/getMaxCardElevation() * CARD_VIEW_SHADOW_MULTIPLIER + (shouldAddCornerPaddingOutsideCardBackground() ? calculateActualCornerPadding() : 0);
    }

    private float calculateHorizontalBackgroundPadding()
    {
        return /*materialCardView.*/getMaxCardElevation() + (shouldAddCornerPaddingOutsideCardBackground() ? calculateActualCornerPadding() : 0);
    }

    private bool canClipToOutline()
    {
        return VERSION.SdkInt >= Android.OS.BuildVersionCodes.Lollipop && bgDrawable.IsRoundRect;
    }

    private float getParentCardViewCalculatedCornerPadding()
    {
        if (/*materialCardView.*/getPreventCornerOverlap() && (VERSION.SdkInt < Android.OS.BuildVersionCodes.Lollipop || /*materialCardView.*/getUseCompatPadding()))
        {
            return (float)((1 - COS_45) * /*materialCardView.*/getCardViewRadius());
        }
        return 0f;
    }

    private bool shouldAddCornerPaddingInsideCardBackground()
    {
        return /*materialCardView.*/getPreventCornerOverlap() && !canClipToOutline();
    }

    private bool shouldAddCornerPaddingOutsideCardBackground()
    {
        return /*materialCardView.*/getPreventCornerOverlap() && canClipToOutline() && /*materialCardView.*/getUseCompatPadding();
    }

    private float calculateActualCornerPadding()
    {
        return Math.Max(
            Math.Max(
                calculateCornerPaddingForCornerTreatment(
                    shapeAppearanceModel.TopLeftCorner, bgDrawable.TopLeftCornerResolvedSize),
                calculateCornerPaddingForCornerTreatment(
                    shapeAppearanceModel.TopRightCorner,
                    bgDrawable.TopRightCornerResolvedSize)),
            Math.Max(
                calculateCornerPaddingForCornerTreatment(
                    shapeAppearanceModel.BottomRightCorner,
                    bgDrawable.BottomRightCornerResolvedSize),
                calculateCornerPaddingForCornerTreatment(
                    shapeAppearanceModel.BottomLeftCorner,
                    bgDrawable.BottomLeftCornerResolvedSize)));
    }

    private float calculateCornerPaddingForCornerTreatment(CornerTreatment treatment, float size)
    {
        if (treatment is RoundedCornerTreatment)
        {
            return (float)((1 - COS_45) * size);
        }
        else if (treatment is CutCornerTreatment)
        {
            return size / 2;
        }
        return 0;
    }

    private Drawable getClickableForeground()
    {
        if (rippleDrawable == null)
        {
            rippleDrawable = createForegroundRippleDrawable();
        }

        if (clickableForegroundDrawable == null)
        {
            Drawable checkedLayer = createCheckedIconLayer();
            clickableForegroundDrawable = new LayerDrawable(new Drawable[] { rippleDrawable, foregroundContentDrawable, checkedLayer });
            clickableForegroundDrawable.SetId(CHECKED_ICON_LAYER_INDEX, Resource.Id.mtrl_card_checked_layer_id);
        }

        return clickableForegroundDrawable;
    }

    private Drawable createForegroundRippleDrawable()
    {
        if (RippleUtils.UseFrameworkRipple)
        {
            foregroundShapeDrawable = createForegroundShapeDrawable();
            return new RippleDrawable(rippleColor, null, foregroundShapeDrawable);
        }

        return createCompatRippleDrawable();
    }

    private Drawable createCompatRippleDrawable()
    {
        StateListDrawable rippleDrawable = new StateListDrawable();
        compatRippleDrawable = createForegroundShapeDrawable();
        compatRippleDrawable.FillColor = rippleColor;
        rippleDrawable.AddState(new int[] { Android.Resource.Attribute.StatePressed }, compatRippleDrawable);
        return rippleDrawable;
    }

    private void updateRippleColor()
    {
        if (RippleUtils.UseFrameworkRipple && rippleDrawable != null)
        {
            ((RippleDrawable)rippleDrawable).SetColor(rippleColor);
        }
        else if (compatRippleDrawable != null)
        {
            compatRippleDrawable.FillColor = rippleColor;
        }
    }

    private Drawable createCheckedIconLayer()
    {
        StateListDrawable checkedLayer = new StateListDrawable();
        if (checkedIcon != null)
        {
            checkedLayer.AddState(CHECKED_STATE_SET, checkedIcon);
        }
        return checkedLayer;
    }

    private MaterialShapeDrawable createForegroundShapeDrawable()
    {
        return new MaterialShapeDrawable(shapeAppearanceModel);
    }

    // used in "insetDrawable" method
    private class InsetDrawable : Android.Graphics.Drawables.InsetDrawable
    {
        public InsetDrawable(Drawable drawable, float inset) : base(drawable, inset) { }

        public InsetDrawable(Drawable drawable, int inset) : base(drawable, inset) { }

        public InsetDrawable(Drawable drawable, float insetLeftFraction, float insetTopFraction, float insetRightFraction, float insetBottomFraction) : base(drawable, insetLeftFraction, insetTopFraction, insetRightFraction, insetBottomFraction) { }

        public InsetDrawable(Drawable drawable, int insetLeft, int insetTop, int insetRight, int insetBottom) : base(drawable, insetLeft, insetTop, insetRight, insetBottom) { }

        public override int MinimumHeight => -1;

        public override int MinimumWidth => -1;

        public override bool GetPadding(Rect padding)
        {
            return false;
        }
    }

以及用法如下(测试用):

someView.Background = new MaterialCardDrawable(context);

知道有更简单的方法来实现CardView的外观(使用layer-list等),但是,我特别想实现MaterialCardView 的外观(根据我的经验,它们在视觉上确实有所不同)。我知道 MaterialCardView/MaterialCardViewHelper 尝试将阴影与背景和其他东西混合,这确实使它看起来不同(并且不同到足以引起注意)。

我坚持这一点,因为我在我打算使用这个“假”MaterialCardView 之前使用一个实际的 MaterialCardView。因此,我希望确保它们看起来完全相同。

我为什么要这样做?

我使用的 RecyclerView 具有不同的 ViewHolder,其中一个 ViewHolderMaterialCardView(仅显示一次),但是,其他两个不是并且这些是显示最多的 ViewHolder。一个 MaterialTextView(作为标题)和一堆 Chip(每个标题的数量不同)。 我计划使用 MaterialCardDrawable 来包装它们,以确保 RecyclerView 实现最佳“回收”(如果我确实使用实际的 MaterialCardView 来包装它们,情况就不会如此)。

我想达到什么目的?

准确复制 MaterialCardView 的视觉效果,使用 简单的 MaterialShapeDrawableRecyclerViewItemDecoration.

我很高兴有一个替代解决方案可以准确地复制 MaterialCardView 的视觉效果。

PS:我也接受用Java写的答案(不一定要用C#写)。

有过类似的情况,并使用类似这样的方式工作:

class CardItemDecorator(
  context: Context,
  @ColorInt color: Int,
  @Px elevation: Float,
  @Px cornerRadius: Float,
) : RecyclerView.ItemDecoration() {

  private val shapeDrawable =
    MaterialShapeDrawable.createWithElevationOverlay(
        context,
        elevation,
    ).apply {
        fillColor = ColorStateList.valueOf(color)
        shadowCompatibilityMode = MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS
        setShadowColor(Color.DKGRAY)
        setCornerSize(cornerRadius)
    }

  override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
    super.onDraw(c, parent, state)
    if (parent.childCount == 0) {
        return
    }

    val firstChild = parent.getChildAt(0)
    val lastChild = parent.getChildAt(parent.childCount - 1)

    shapeDrawable.setBounds(
        parent.left + parent.paddingLeft,
        firstChild.top,
        parent.right - parent.paddingRight,
        lastChild.bottom
    )

    shapeDrawable.draw(c)
  }
}