Search code examples
javac#xamarin.androidmaterial-components

How to imitate the visuals of MaterialCardView using a MaterialShapeDrawable


What I've tried?

After having a brief look through the MaterailCardViewHelper source, I tried to replicate the way it draws the associated Drawables. Unfortunately, it results in a black shape with some "treated" corners and looks nothing like the MaterialCardView. I understand the MaterialCardViewHelper applies the background and foreground on the actual CardView and after having looked at the source for that, it doesn't appear to be doing anything special, that is, it just seems to call setBackgroundDrawable (which I am doing on someView, as shown below).

I am using Xamarin so my code is written in C#. I've essentially converted the Java source (of the MaterialCardViewHelper) to its C# equivalent, replacing references of "materialCardView" to MaterialCardDrawable where appropriate.

I've tried to keep the code as close to the original Java source to ensure anyone reading this can easily compare the original with mine. I've changed only enough to make the code compile. The main difference is the "Draw" method which I assume is where my issue lies.

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;
        }
    }

And usage as follows (for testing purposes):

someView.Background = new MaterialCardDrawable(context);

I know there are simpler ways to achieve the look of a CardView (using layer-list, etc), however, I specifically want to achieve the look of the MaterialCardView (as they do visually differ, in my experience). I know the MaterialCardView/MaterialCardViewHelper attempt to blend shadows with the background and other stuff which does make it look different (and different enough to be noticeable).

I am adamant on this as I am using an actual MaterialCardView just before where I intend to use this "fake" MaterialCardView. And, as such, I wish to ensure they look identical.

Why am I doing this?

I am using a RecyclerView with varying ViewHolders and one ViewHolder is a MaterialCardView (only shown once), however, the other two are not and these are the ViewHolders that are shown the most. A MaterialTextView (which acts as a title) and a bunch of Chips (which vary in number, per title). I plan to wrap them using that MaterialCardDrawable to ensure optimal "recycling" by the RecyclerView (which wouldn't be case if I did use an actual MaterialCardView to wrap them).

What I'm trying to achieve?

Replicate the visuals of the MaterialCardView accurately, using a simple MaterialShapeDrawable to be used with RecyclerView's ItemDecoration.

I am happy for an alternative solution that can accurately replicate the visuals of the MaterialCardView, as well.

PS: I will also accept answers written in Java (it doesn't have to be written in C#).


Solution

  • Had a similar situation and got it working with something like this:

    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)
      }
    }