CardView 扩展 FrameLayout 类并让您能够显示卡片内的信息,这些信息在整个平台中拥有一致的呈现方式。CardView 小部件可拥有阴影和圆角。
如果要使用阴影创建卡片,请使用 card_view:cardElevation 属性。CardView 在 Android 5.0(API 级别 21)及更高版本中使用真实高度与动态阴影,而在早期的 Android 版本中则返回编程阴影实现
复制代码
CardView的常用属性:
属性 | 作用 |
---|---|
card_view:cardElevation | 阴影的大小 |
card_view:cardMaxElevation | 阴影最大高度 |
card_view:cardBackgroundColor | 卡片的背景色 |
card_view:cardCornerRadius | 卡片的圆角大小 |
card_view:contentPadding | 卡片内容于边距的间隔 |
card_view:contentPaddingBottom | 卡片内容与底部的边距 |
card_view:contentPaddingTop | 卡片内容与顶部的边距 |
card_view:contentPaddingLeft | 卡片内容与左边的边距 |
card_view:contentPaddingRight | 卡片内容与右边的边距 |
card_view:contentPaddingStart | 卡片内容于边距的间隔起始 |
card_view:contentPaddingEnd | 卡片内容于边距的间隔终止 |
card_view:cardUseCompatPadding | 设置内边距,android5.0(代号:Lollipop,API level 21)及以上的版本和之前的版本仍旧具有一样的计算方式 |
card_view:cardPreventConrerOverlap | 在android5.0之前的版本中添加内边距,这个属性为了防止内容和边角的重叠 |
CardView的使用比较简单,网上也有相当多的文章可以参考,本文在此不做过多的阐述。
CardView的兼容性考虑,在不同版本的系统上实现有差异,大家在使用时要考虑到这一点,我们先看几个小Demo。cardPreventConrerOverlap属性:
Lollipop以下版本,cardPreventConrerOverlap = false,不设置contentPadding,如图,内容和圆角重叠。
Lollipop以下版本,cardPreventConrerOverlap = true,不设置contentPadding,如图,添加了额外padding防止内容和圆角为重叠。
cardUseCompatPadding属性:
为了展示出效果,我将elevation设置的比较大,测试设备nexus4 768x1280。
下图左侧为Lollipop以下版本,右侧为Lollipop版本,cardUseCompatPadding = false。可见,如果想让Lollipop版本及以上的内边距和Lollipop版本以下相同,就需要把该属性设置为true.。
但该属性的影响没你想的那么简单。
我们再往布局中添加一个控件,cardUseCompatPadding= false。什么内容区域大小居然不一样!这种问题要被测试美眉发现岂不是太没面子,怎么解决呢?设置cardUseCompatPadding属性为true。
同理这这个问题也可以通过不同系统版本dimens.xml来适配。
总结一下
CardView通过elevation属性来设置view的阴影,但Lollipop之前的版本是模拟实现,即实现方式不同。
因为裁剪比较耗费性能,所以Lollipop之前的版本不对内部View进行裁剪,通过添加padding的方式避免内部View和圆角重叠。使用setPreventCornerOverlap方法或对应xml card_view:cardPreventConrerOverlap属性可更改这一行为,该属性默认为true。
Lollipop之前的版本,CardView和内容之间添加边距,并在该区域绘制阴影,两边的间距为maxCardElevation + (1 - cos45) cornerRadius,上下的间距为maxCardElevation 1.5 + (1 - cos45) * cornerRadius。
因为padding属性被用来做偏移绘制阴影,所以不能使用CardView的padding属性,如果想设置CardView和其子View之间的边距,可使用setContentPadding(int, int, int, int)方法或对应的xml属性。
如果对CardView设置了明确的尺寸,因为阴影的缘故,其内容区域在Lollipop版本和之前的版本上显示不同,你可以通过不同系统版本使用不同资源值或设置useCompatPadding属性为true的方式来避免此问题。
通过setCardElevation(float)以兼容的方式设置CardView的elevation,CardView会使用Lollipop下或之前版本下的elevation API,进而改变阴影的尺寸。为防止改变阴影尺寸时,view发生移动,阴影大小不会超过MaxCardElevation,如果想在CardView初始化后动态改变阴影大小,应使用setMaxCardElevation(float)方法。
解剖源码。
结构图如下:
CardView内部根据不同版本系统实例化对应的CardViewImpl对象,CardViewImpl对象通过CardViewDelegate对象与CardView交互。
CardView的静态代码块中根据系统版本实例化对应的实现。
static { if (Build.VERSION.SDK_INT >= 21) { IMPL = new CardViewApi21(); } else if (Build.VERSION.SDK_INT >= 17) { IMPL = new CardViewJellybeanMr1(); } else { IMPL = new CardViewGingerbread(); } IMPL.initStatic(); }复制代码
这里调用了initStatic(),API21中即CardViewApi21类中是空实现,API17中实现如下(CardViewJellybeanMr1):
public void initStatic() { RoundRectDrawableWithShadow.sRoundRectHelper = new RoundRectDrawableWithShadow.RoundRectHelper() { @Override public void drawRoundRect(Canvas canvas, RectF bounds, float cornerRadius, Paint paint) { canvas.drawRoundRect(bounds, cornerRadius, cornerRadius, paint); } }; }复制代码
API17之前的版本实现如下(CardViewGingerbread):
public void initStatic() { //使用7步绘制操作来绘制出圆角矩形,在API17之前的版本此种方式要比canvas.drawRoundRect快, //因为API 11-16使用了alpha蒙版纹理去绘制 RoundRectDrawableWithShadow.sRoundRectHelper = new RoundRectDrawableWithShadow.RoundRectHelper() { @Override public void drawRoundRect(Canvas canvas, RectF bounds, float cornerRadius, Paint paint) { final float twoRadius = cornerRadius * 2; final float innerWidth = bounds.width() - twoRadius - 1; final float innerHeight = bounds.height() - twoRadius - 1; if (cornerRadius >= 1f) { // increment corner radius to account for half pixels. float roundedCornerRadius = cornerRadius + .5f; sCornerRect.set(-roundedCornerRadius, -roundedCornerRadius, roundedCornerRadius, roundedCornerRadius); int saved = canvas.save(); canvas.translate(bounds.left + roundedCornerRadius, bounds.top + roundedCornerRadius); canvas.drawArc(sCornerRect, 180, 90, true, paint); canvas.translate(innerWidth, 0); canvas.rotate(90); canvas.drawArc(sCornerRect, 180, 90, true, paint); canvas.translate(innerHeight, 0); canvas.rotate(90); canvas.drawArc(sCornerRect, 180, 90, true, paint); canvas.translate(innerWidth, 0); canvas.rotate(90); canvas.drawArc(sCornerRect, 180, 90, true, paint); canvas.restoreToCount(saved); //绘制上下两部分 canvas.drawRect(bounds.left + roundedCornerRadius - 1f, bounds.top, bounds.right - roundedCornerRadius + 1f, bounds.top + roundedCornerRadius, paint); canvas.drawRect(bounds.left + roundedCornerRadius - 1f, bounds.bottom - roundedCornerRadius, bounds.right - roundedCornerRadius + 1f, bounds.bottom, paint); } // 绘制中间部分 canvas.drawRect(bounds.left, bounds.top + cornerRadius, bounds.right, bounds.bottom - cornerRadius , paint); } }; }复制代码
API17和之前版本的阴影实现差异主要在这里,因为效率问题,API17之前的版本使用分步绘制测方式绘制圆角矩形。
CardView的构造器中会调用initialize(...),该方法中主要拿到各属性,然后调用具体实现的初始化方法。
private void initialize(Context context, AttributeSet attrs, int defStyleAttr) { TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CardView, defStyleAttr, R.style.CardView); ColorStateList backgroundColor; if (a.hasValue(R.styleable.CardView_cardBackgroundColor)) { backgroundColor = a.getColorStateList(R.styleable.CardView_cardBackgroundColor); } else { // 没有设置背景则从当前主题中提取 final TypedArray aa = getContext().obtainStyledAttributes(COLOR_BACKGROUND_ATTR); final int themeColorBackground = aa.getColor(0, 0); aa.recycle(); //若主题中的colorBackground是浅色,使用cardview_light_background,否则使用cardview_dark_background final float[] hsv = new float[3]; Color.colorToHSV(themeColorBackground, hsv); backgroundColor = ColorStateList.valueOf(hsv[2] > 0.5f ? getResources().getColor(R.color.cardview_light_background) : getResources().getColor(R.color.cardview_dark_background)); } float radius = a.getDimension(R.styleable.CardView_cardCornerRadius, 0); ... mUserSetMinHeight = a.getDimensionPixelSize(R.styleable.CardView_android_minHeight, 0); a.recycle(); IMPL.initialize(mCardViewDelegate, context, backgroundColor, radius, elevation, maxElevation); }复制代码
接下来我们带着问题来看源码。
- API19之前版本如何实现阴影?
- API19及以上版本如何实现View裁切?
- API19及之后版本如何实现阴影?
- API19之前版本cardPreventConrerOverlap属性的影响?
- API19及以上版本受cardUseCompatPadding属性的影响?
- 为什么阴影在在x轴方向和y轴方向发生了位移,而不是均匀分布在view四周?
问题1:API19之前版本如何实现阴影?
接下来我们先看API19之前是怎么实现阴影的(CardViewGingerbread类)。
@Override public void initialize(CardViewDelegate cardView, Context context, ColorStateList backgroundColor, float radius, float elevation, float maxElevation) { RoundRectDrawableWithShadow background = createBackground(context, backgroundColor, radius, elevation, maxElevation); background.setAddPaddingForCorners(cardView.getPreventCornerOverlap()); cardView.setCardBackground(background); updatePadding(cardView); }复制代码
看来它的阴影是由RoundRectDrawableWithShadow类来实现的。
我们来看看来它的阴影是由RoundRectDrawableWithShadow类来实现的onDraw,第一次调用要走buildComponents方法。public void draw(Canvas canvas) { if (mDirty) { buildComponents(getBounds()); mDirty = false; } canvas.translate(0, mRawShadowSize / 2); drawShadow(canvas); canvas.translate(0, -mRawShadowSize / 2); sRoundRectHelper.drawRoundRect(canvas, mCardBounds, mCornerRadius, mPaint); }复制代码
buildComponents方法中,mRawMaxShadowSize其实就是maxElevation,此处确定了cardview的边界,上下左右都进行了偏移,空出来的区域是为了绘制阴影。
private void buildComponents(Rect bounds) { // Card is offset SHADOW_MULTIPLIER * maxShadowSize to account for the shadow shift. // We could have different top-bottom offsets to avoid extra gap above but in that case // center aligning Views inside the CardView would be problematic. final float verticalOffset = mRawMaxShadowSize * SHADOW_MULTIPLIER; mCardBounds.set(bounds.left + mRawMaxShadowSize, bounds.top+verticalOffset, bounds.right - mRawMaxShadowSize, bounds.bottom - verticalOffset); buildShadowCorners(); }复制代码
buildShadowCorners方法中初始化了绘制边阴影和角阴影的path。
private void buildShadowCorners() { RectF innerBounds = new RectF(-mCornerRadius, -mCornerRadius, mCornerRadius, mCornerRadius); RectF outerBounds = new RectF(innerBounds); outerBounds.inset(-mShadowSize, -mShadowSize); if (mCornerShadowPath == null) { mCornerShadowPath = new Path(); } else { mCornerShadowPath.reset(); } mCornerShadowPath.setFillType(Path.FillType.EVEN_ODD); mCornerShadowPath.moveTo(-mCornerRadius, 0); mCornerShadowPath.rLineTo(-mShadowSize, 0); // outer arc mCornerShadowPath.arcTo(outerBounds, 180f, 90f, false); // inner arc mCornerShadowPath.arcTo(innerBounds, 270f, -90f, false); mCornerShadowPath.close(); float startRatio = mCornerRadius / (mCornerRadius + mShadowSize); mCornerShadowPaint.setShader(new RadialGradient(0, 0, mCornerRadius + mShadowSize, new int[]{mShadowStartColor, mShadowStartColor, mShadowEndColor}, new float[]{ 0f, startRatio, 1f} , Shader.TileMode.CLAMP)); // we offset the content shadowSize/2 pixels up to make it more realistic. // this is why edge shadow shader has some extra space // When drawing bottom edge shadow, we use that extra space. mEdgeShadowPaint.setShader(new LinearGradient(0, -mCornerRadius + mShadowSize, 0, -mCornerRadius - mShadowSize, new int[]{mShadowStartColor, mShadowStartColor, mShadowEndColor}, new float[]{ 0f, .5f, 1f}, Shader.TileMode.CLAMP)); mEdgeShadowPaint.setAntiAlias(false); }复制代码
接下来看drawShadow方法,这里基本的canvas操作。
private void drawShadow(Canvas canvas) { final float edgeShadowTop = -mCornerRadius - mShadowSize; final float inset = mCornerRadius + mInsetShadow + mRawShadowSize / 2; final boolean drawHorizontalEdges = mCardBounds.width() - 2 * inset > 0; final boolean drawVerticalEdges = mCardBounds.height() - 2 * inset > 0; // LT int saved = canvas.save(); canvas.translate(mCardBounds.left + inset, mCardBounds.top + inset); canvas.drawPath(mCornerShadowPath, mCornerShadowPaint); if (drawHorizontalEdges) { canvas.drawRect(0, edgeShadowTop, mCardBounds.width() - 2 * inset, -mCornerRadius, mEdgeShadowPaint); } canvas.restoreToCount(saved); // RB saved = canvas.save(); canvas.translate(mCardBounds.right - inset, mCardBounds.bottom - inset); canvas.rotate(180f); canvas.drawPath(mCornerShadowPath, mCornerShadowPaint); if (drawHorizontalEdges) { canvas.drawRect(0, edgeShadowTop, mCardBounds.width() - 2 * inset, -mCornerRadius + mShadowSize, mEdgeShadowPaint); } canvas.restoreToCount(saved); // LB saved = canvas.save(); canvas.translate(mCardBounds.left + inset, mCardBounds.bottom - inset); canvas.rotate(270f); canvas.drawPath(mCornerShadowPath, mCornerShadowPaint); if (drawVerticalEdges) { canvas.drawRect(0, edgeShadowTop, mCardBounds.height() - 2 * inset, -mCornerRadius, mEdgeShadowPaint); } canvas.restoreToCount(saved); // RT saved = canvas.save(); canvas.translate(mCardBounds.right - inset, mCardBounds.top + inset); canvas.rotate(90f); canvas.drawPath(mCornerShadowPath, mCornerShadowPaint); if (drawVerticalEdges) { canvas.drawRect(0, edgeShadowTop, mCardBounds.height() - 2 * inset, -mCornerRadius, mEdgeShadowPaint); } canvas.restoreToCount(saved); }复制代码
可见API19之前阴影的实现是由canvas+path+RadialGradient绘制角阴影,canvas+path+LinearGradient绘制边阴影。
问题2:API19及以上版本如何实现View裁切?
进入CardViewApi21类,
@Override public void initialize(CardViewDelegate cardView, Context context, ColorStateList backgroundColor, float radius, float elevation, float maxElevation) { final RoundRectDrawable background = new RoundRectDrawable(backgroundColor, radius); cardView.setCardBackground(background); View view = cardView.getCardView(); view.setClipToOutline(true); view.setElevation(elevation); setMaxElevation(cardView, maxElevation); }复制代码
这里实例化了RoundRectDrawable作为cardview的背景。RoundRectDrawable是用来绘制背景圆角矩形,
cardView.getCardView()拿到了CardView对象,前面我们说了CardView是继承自FrameLayout的,所以CardView即是ViewGroup也是View,view.setClipToOutline(true)是什么意思呢?android5.0之后允许自定义视图阴影与轮廓
视图的背景可绘制对象的边界将决定其阴影的默认形状。轮廓代表图形对象的外形并定义触摸反馈的波纹区域。下面举一个以背景可绘制对象定义的视图示例:
复制代码
背景可绘制对象被定义为一个拥有圆角的矩形:
复制代码
视图将投射一个带有圆角的阴影,因为背景可绘制对象将定义视图的轮廓。 如果提供一个自定义轮廓,则此轮廓将替换视图阴影的默认形状。
如果要为代码中的视图定义自定义轮廓:
扩展 ViewOutlineProvider 类别。替代 getOutline() 方法。利用 View.setOutlineProvider() 方法向您的视图指定新的轮廓提供程序。您可使用 Outline 类别中的方法创建带有圆角的椭圆形和矩形轮廓。视图的默认轮廓提供程序将从视图背景取得轮廓。 如果要防止视图投射阴影,请将其轮廓提供程序设置为 null。裁剪视图
裁剪视图让您能够轻松改变视图形状。您可以裁剪视图,以便与其他设计元素保持一致,也可以根据用户输入改变视图形状。您可使用 View.setClipToOutline() 方法或 android:clipToOutline 属性将视图裁剪至其轮廓区域。 由 Outline.canClip() 方法所决定,仅有矩形、圆形和圆角矩形轮廓支持裁剪。如果要将视图裁剪至可绘制对象的形状,请将可绘制对象设置为视图背景(如上所示)并调用 View.setClipToOutline() 方法。
问题3: API19及之后版本如何实现阴影?
CardViewApi21的initialize方法中调用了view.setElevation(elevation),
public void setElevation(float elevation) { if (elevation != getElevation()) { invalidateViewProperty(true, false); mRenderNode.setElevation(elevation); invalidateViewProperty(false, true); invalidateParentIfNeededAndWasQuickRejected(); } }复制代码
再看mRenderNode.setElevation(elevation);
public boolean setElevation(float lift) { return nSetElevation(mNativeRenderNode, lift); }复制代码
nSetElevation(mNativeRenderNode, lift)是个native方法,由此可见android5.0开始所有的view都可以显示阴影,而且是根据elevation属性直接有native方法来实现。
android5.0开始因为加入了Material Design,Material Design 为 UI 元素引入高度,为View加上了Z属性。
由 Z 属性所表示的视图高度将决定其阴影的视觉外观:拥有较高 Z 值的视图将投射更大且更柔和的阴影。 拥有较高 Z 值的视图将挡住拥有较低 Z 值的视图;不过视图的 Z 值并不影响视图的大小。
视图的 Z 值包含两个组件:
高度:静态组件。
转换:用于动画的动态组件。Z = elevation + translationZ所以影响View阴影的因素有两个elevation和translationZ.
在 Material Design Guidelines 中有建议卡片、按钮这类元素触摸时应当有一个浮起的效果,也就是增大 Z 轴位移,我们怎么实现这个效果呢?只需要借助 Lollipop 的一个新属性 android:stateListAnimator,创建一个 TranslationZ 的变换动画放在 /res/anim,自己取一个名(如 touch_raise.xml),加入以下内容:
复制代码
然后为你需要添加效果的 CardView(其他 View 同理)所在的 Layout XML 复制多一份到 /res/layout-v21,然后在新的那份 XML 的 CardView 中加入属性 android:stateListAnimator="@anim/touch_raise"。这样,你的卡片按住时就会有浮起(阴影加深)的效果了。
至于波纹效果只需要给CardView加上android:foreground="?attr/selectableItemBackground" 属性即可。问题4:API19之前版本cardPreventConrerOverlap属性的影响?
CardView的setPreventCornerOverlap方法。
public void setPreventCornerOverlap(boolean preventCornerOverlap) { if (preventCornerOverlap != mPreventCornerOverlap) { mPreventCornerOverlap = preventCornerOverlap; IMPL.onPreventCornerOverlapChanged(mCardViewDelegate); } }复制代码
然后看CardViewGingerbread的onPreventCornerOverlapChanged方法。
@Override public void onPreventCornerOverlapChanged(CardViewDelegate cardView) { getShadowBackground(cardView).setAddPaddingForCorners(cardView.getPreventCornerOverlap()); updatePadding(cardView); }复制代码
一路跟踪下来,发现关键点在RoundRectDrawableWithShadow中,addPaddingForCorners即为传过来的preventCornerOverlap,当preventCornerOverlap为true时,内边距增加了(1 - COS_45) * cornerRadius),这样CardView的子View就不会和圆角重叠了。
static float calculateVerticalPadding(float maxShadowSize, float cornerRadius, boolean addPaddingForCorners) { if (addPaddingForCorners) { return (float) (maxShadowSize * SHADOW_MULTIPLIER + (1 - COS_45) * cornerRadius); } else { return maxShadowSize * SHADOW_MULTIPLIER; } }复制代码
问题5 API19及以上版本受cardUseCompatPadding属性的影响?
CardView的setUseCompatPadding方法。
public void setUseCompatPadding(boolean useCompatPadding) { if (mCompatPadding != useCompatPadding) { mCompatPadding = useCompatPadding; IMPL.onCompatPaddingChanged(mCardViewDelegate); } }复制代码
进入CardViewApi21。
@Override public void onCompatPaddingChanged(CardViewDelegate cardView) { setMaxElevation(cardView, getMaxElevation(cardView)); }复制代码
最后跟踪到RoundRectDrawable,这里的mInsetForPadding就是cardUseCompatPadding属性的值,当cardUseCompatPadding属性为true时,会设置内边距,calculateVerticalPadding和calculateHorizontalPadding方法是RoundRectDrawableWithShadow的静态方法,如此5.0和之前版本就具有相同的内边距计算方式了。
private void updateBounds(Rect bounds) { if (bounds == null) { bounds = getBounds(); } mBoundsF.set(bounds.left, bounds.top, bounds.right, bounds.bottom); mBoundsI.set(bounds); if (mInsetForPadding) { float vInset = calculateVerticalPadding(mPadding, mRadius, mInsetForRadius); float hInset = calculateHorizontalPadding(mPadding, mRadius, mInsetForRadius); mBoundsI.inset((int) Math.ceil(hInset), (int) Math.ceil(vInset)); // to make sure they have same bounds. mBoundsF.set(mBoundsI); } }复制代码
在CardViewApi21的updatePadding方法也可以看到,如果不设置cardUseCompatPadding,其阴影内边距为0,这也就解释了前文中的现象。
@Override public void updatePadding(CardViewDelegate cardView) { if (!cardView.getUseCompatPadding()) { cardView.setShadowPadding(0, 0, 0, 0); return; } float elevation = getMaxElevation(cardView); final float radius = getRadius(cardView); int hPadding = (int) Math.ceil(RoundRectDrawableWithShadow .calculateHorizontalPadding(elevation, radius, cardView.getPreventCornerOverlap())); int vPadding = (int) Math.ceil(RoundRectDrawableWithShadow .calculateVerticalPadding(elevation, radius, cardView.getPreventCornerOverlap())); cardView.setShadowPadding(hPadding, vPadding, hPadding, vPadding); }复制代码
问题6 为什么阴影在在x轴方向和y轴方向发生了位移,而不是均匀分布在view四周?
在RoundRectDrawableWithShadow的draw方法中,我们看到,在绘制阴影前,画布向y轴正方向进行了位移,这就使得阴影的方向发生了变化。
@Override public void draw(Canvas canvas) { if (mDirty) { buildComponents(getBounds()); mDirty = false; } canvas.translate(0, mRawShadowSize / 2); drawShadow(canvas); canvas.translate(0, -mRawShadowSize / 2); sRoundRectHelper.drawRoundRect(canvas, mCardBounds, mCornerRadius, mPaint); }复制代码
如果项目的设计符合Material Design,那最好,如果设计有一天让你实现四周带相同尺寸阴影的效果呢?我们也知道怎么做了吧!
这里我把实现方式放到上了,有需要欢迎关注。参考资料: