如何使用 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
,其中一个 ViewHolder
是 MaterialCardView
(仅显示一次),但是,其他两个不是并且这些是显示最多的 ViewHolder
。一个 MaterialTextView
(作为标题)和一堆 Chip
(每个标题的数量不同)。
我计划使用 MaterialCardDrawable
来包装它们,以确保 RecyclerView
实现最佳“回收”(如果我确实使用实际的 MaterialCardView
来包装它们,情况就不会如此)。
我想达到什么目的?
准确复制 MaterialCardView
的视觉效果,使用 简单的 MaterialShapeDrawable
与 RecyclerView
的 ItemDecoration
.
我很高兴有一个替代解决方案可以准确地复制 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)
}
}
我试过什么?
在简要浏览了 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
,其中一个 ViewHolder
是 MaterialCardView
(仅显示一次),但是,其他两个不是并且这些是显示最多的 ViewHolder
。一个 MaterialTextView
(作为标题)和一堆 Chip
(每个标题的数量不同)。
我计划使用 MaterialCardDrawable
来包装它们,以确保 RecyclerView
实现最佳“回收”(如果我确实使用实际的 MaterialCardView
来包装它们,情况就不会如此)。
我想达到什么目的?
准确复制 MaterialCardView
的视觉效果,使用 简单的 MaterialShapeDrawable
与 RecyclerView
的 ItemDecoration
.
我很高兴有一个替代解决方案可以准确地复制 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)
}
}