是的,我们可以使用类似于RenderNode 解决方法的技术,并将阴影绘制到ViewGroup 的叠加层中,这使得这适用于任何View 内的任何ViewGroup。总体思路是关闭目标View 上的阴影并替换我们自己的阴影,并在顶部进行适当的裁剪和绘制。这个解决方案有几个移动部件,这个答案很可能像泥土一样令人生畏/令人困惑/无聊,所以为了激发你的兴趣——至少复制/粘贴1并尝试它出来了——这(应该)是你现有的View应用它所要做的所有事情:
yourElevatedView.moveShadowToOverlay()
就是这样。其他一切都是自动处理的,应该给出如下结果:
概述
整个过程的高级描述如下:当第一次为View 请求覆盖阴影时,会为其创建ShadowManager 并将其作为标记附加到其父ViewGroup。 ShadowManager 然后为 Android 版本加载正确的 OverlayController 并将其添加到 ViewGroup 的覆盖中。然后OverlayController 为View 创建一个OverlayShadow 并将其添加到其内部列表中。同一个父 ViewGroup 中请求影子的下一个 View 将通过现有的 ShadowManager 添加到活动的 OverlayController,因此每个兄弟影子都不需要自己的实例。如果OverlayController 的所有阴影都被移除,ShadowManager 将移除它并自行清理。
正如my first answer 中所述,我发现的最佳选择是使用空的RenderNodes 来绘制我们自己裁剪的阴影,但这仅在 API 级别 29 之后公开可用。对于之前的版本,我们使用空的Views 作为一个稍微笨重的选项来实现相同的效果,2 因此,此解决方案实际上是将两种解决方法打包成一个。每个都有自己独特的障碍,我们将在进行过程中对其进行介绍,希望每个设计细节在上下文中都很清晰。
为了便于复制,代码分为三个主要块,每个块都可以创建自己的文件。入口点是第一个开头的View 和ViewGroup 上的少数扩展。每个完整块后面都有摘要。
Common.kt
val View.isShadowInOverlay
get() = getTag(R.id.tag_overlay_shadow) is OverlayShadow
fun View.moveShadowToOverlay(): Boolean {
if (!isShadowInOverlay && outlineProvider != null) {
parentViewGroup?.let {
it.shadowManager.moveShadowToOverlay(this)
return true
}
}
return false
}
fun View.removeShadowFromOverlay(): Boolean {
if (isShadowInOverlay) {
parentViewGroup?.let {
it.shadowManager.removeShadowFromOverlay(this)
return true
}
}
return false
}
private val View.parentViewGroup
get() = parent as? ViewGroup
private val ViewGroup.shadowManager
get() = getTag(R.id.tag_overlay_shadow_manager) as? ShadowManager ?: ShadowManager(this)
private class ShadowManager(private val viewGroup: ViewGroup) {
private val controller: OverlayController<*> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
RenderNodeShadowController(viewGroup)
} else {
ViewShadowController(viewGroup)
}
init {
viewGroup.setTag(R.id.tag_overlay_shadow_manager, this)
}
private fun removeSelf() {
viewGroup.setTag(R.id.tag_overlay_shadow_manager, null)
controller.detach()
}
fun moveShadowToOverlay(view: View) {
controller.newShadow(view)
}
fun removeShadowFromOverlay(view: View) {
if (controller.removeShadow(view)) removeSelf()
}
}
internal sealed interface OverlayController<T : OverlayShadow> {
val shadows: MutableList<T>
fun detach() {}
fun createShadow(view: View): T
fun newShadow(view: View) {
val shadow = createShadow(view)
shadow.attachToTargetView()
onNewShadow(shadow)
shadows.add(shadow)
}
fun onNewShadow(shadow: T)
@Suppress("UNCHECKED_CAST")
fun removeShadow(view: View): Boolean {
val shadow = view.getTag(R.id.tag_overlay_shadow) as T
shadow.detachFromTargetView()
onRemoveShadow(shadow)
shadows.remove(shadow)
return shadows.isEmpty()
}
fun onRemoveShadow(shadow: T)
}
internal sealed class OverlayShadow(protected val targetView: View) {
private val outlineBounds = Rect()
private var radius: Float = 0.0F
protected val originalProvider: ViewOutlineProvider = targetView.outlineProvider
fun attachToTargetView() {
targetView.let { target ->
target.outlineProvider = ProviderWrapper(originalProvider, this::setOutline)
target.setTag(R.id.tag_overlay_shadow, this)
}
}
@Suppress("LiftReturnOrAssignment")
open fun setOutline(outline: Outline) {
if (getRect(outline, outlineBounds)) {
// outlineBounds set in getRect()
radius = getRadius(outline)
} else {
outlineBounds.setEmpty()
radius = 0.0F
}
}
fun detachFromTargetView() {
targetView.let { target ->
target.outlineProvider = originalProvider
target.setTag(R.id.tag_overlay_shadow, null)
}
}
val z get() = targetView.z
fun prepareForDraw(path: Path): Boolean {
if (!outlineBounds.isEmpty) {
// Update shadow.
val target = targetView
update(
target.left, target.top, target.right, target.bottom,
target.elevation, target.translationZ
)
// Set path for clip.
val boundsF = Cache.boundsF
boundsF.set(outlineBounds)
boundsF.offset(target.left.toFloat(), target.top.toFloat())
path.rewind()
path.addRoundRect(boundsF, radius, radius, Path.Direction.CW)
return true
} else {
return false
}
}
abstract fun update(
left: Int, top: Int, right: Int, bottom: Int,
elevation: Float, translationZ: Float
)
}
internal object Cache {
val path = Path()
val boundsF = RectF()
}
private class ProviderWrapper(
private val provider: ViewOutlineProvider,
private val callback: (Outline) -> Unit
) : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
provider.getOutline(view, outline)
callback(outline)
outline.alpha = 0.0F
}
}
private val getRadius: (Outline) -> Float =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { outline -> outline.radius }
Reflector.isValid -> { outline -> Reflector.getRadius(outline) }
else -> { _ -> 0F }
}
private val getRect: (Outline, Rect) -> Boolean =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { outline, rect -> outline.getRect(rect) }
Reflector.isValid -> { outline, rect -> Reflector.getRect(outline, rect) }
else -> { _, _ -> false }
}
@SuppressLint("DiscouragedPrivateApi", "SoonBlockedPrivateApi")
private object Reflector {
val isValid: Boolean
private val mRectField: Field by lazy { Outline::class.java.getDeclaredField("mRect") }
private val mRadiusField: Field by lazy { Outline::class.java.getDeclaredField("mRadius") }
init {
isValid = Build.VERSION.SDK_INT < Build.VERSION_CODES.N && try {
mRectField
mRadiusField
true
} catch (e: Exception) {
false
}
}
fun getRect(outline: Outline, outRect: Rect): Boolean {
val rect = mRectField.get(outline) as Rect?
return rect?.let {
outRect.set(rect)
true
} ?: false
}
fun getRadius(outline: Outline) = mRadiusField.getFloat(outline)
}
我们将对象作为键标记存储在View 及其父对象上,以保持这一切都是独立的。为此,您需要在资源中的某处定义这些 ID;例如,在values/ids.xml 文件中,但他们可以进入任何values/ 文件中的<resources>,真的:
<item name="tag_overlay_shadow" type="id" />
<item name="tag_overlay_shadow_manager" type="id" />
也就是说,isShadowInOverlay 属性是不言自明的,moveShadowToOverlay() 函数首先检查它,然后确保View 具有ViewOutlineProvider。然后它会检查View 是否有父对象,并为该ViewGroup 获取ShadowManager,并在必要时按需创建。
在这些扩展之后是 ShadowManager,这是一个相对简单的类,它为 API 级别加载正确的 OverlayController,并将添加和删除调用转发给它。 OverlayController 是一个简短的 interface,有几个默认函数来处理实现的添加和删除阴影。
接下来,OverlayShadow 是一个抽象类,允许我们在两个版本之间共享一些状态处理,这是我们了解实际细节的地方。控制器为每个目标View 创建一个OverlayShadow 实例。附加后,它会将自己添加为目标View 上的标签,并将其ViewOutlineProvider 包装在一个关闭它的影子并进行回调,让我们在更新时查看目标当前的Outline。在回调之后,通过将Outline 的alpha 设置为0 来禁用目标的阴影,如果View 将Outline 用于其他用途,例如剪裁自身,则保留所有其他内容。 detachFromTargetView() 函数只是在目标上恢复原来的ViewOutlineProvider,并在需要时取消其标记以进行清理。
同样在OverlayShadow中,定义了一个属性来暴露目标View的z,就是简单的elevation加上translationZ,用于在绘制前对阴影进行排序。 prepareForDraw() 函数使用目标的当前边界和 z 偏移更新实现,并调整 Path 以裁剪出适当的区域,该区域总是可以以某种方式表示为圆角矩形;例如,圆是半径为边长一半的圆角正方形。
我们在这里为几个只在单个线程上使用过的临时对象保留了一个Cache,上面描述了ProviderWrapper 的函数。
最后几项只是一点点反思,如果您不想使用它,则需要稍微修改一下。我不希望 my original RenderNode approach 中使用的大量反射阻止任何人使用此解决方案,但我也觉得如果我们不能在 API 上使用两个特定的 Outline 方法,这个解决方案将失去一些普遍适用性级别 21、22 和 23。但是,这些功能当然可以替换为我链接答案中 updatePathForView() 函数中所示的内容。完全由您决定。
这是第一个文件块的结尾。
Api21Shadows.kt
@SuppressLint("ViewConstructor")
internal class ViewShadowController(private val viewGroup: ViewGroup) :
ViewGroup(viewGroup.context), View.OnLayoutChangeListener, OverlayController<ViewShadow> {
private val relay = InvalidateRelayDrawable(this)
override val shadows = mutableListOf<ViewShadow>()
init {
viewGroup.let { group ->
if (group.isLaidOut) layout(0, 0, group.width, group.height)
group.addOnLayoutChangeListener(this)
group.overlay.add(this)
group.overlay.add(relay)
}
}
override fun detach() {
viewGroup.let { group ->
group.removeOnLayoutChangeListener(this)
group.overlay.remove(this)
group.overlay.remove(relay)
}
}
override fun createShadow(view: View) = ViewShadow(view)
override fun onNewShadow(shadow: ViewShadow) {
shadow.addShadowView(this)
}
override fun onRemoveShadow(shadow: ViewShadow) {
shadow.removeShadowView(this)
}
override fun dispatchDraw(canvas: Canvas) {
val saveCount = canvas.save()
shadows.sortedBy { it.z }.forEach {
val path = Cache.path
if (it.prepareForDraw(path)) clipOutPath(canvas, path)
}
super.dispatchDraw(canvas)
canvas.restoreToCount(saveCount)
}
override fun onLayoutChange(
v: View, l: Int, t: Int, r: Int, b: Int, ol: Int, ot: Int, or: Int, ob: Int
) {
val newWidth = r - l
val newHeight = b - t
if (width != newWidth || height != newHeight) {
layout(0, 0, newWidth, newHeight)
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
/* noop. Child layout is handled manually elsewhere. */
}
}
@SuppressLint("ClickableViewAccessibility")
internal class ViewShadow(view: View) : OverlayShadow(view), View.OnTouchListener {
private val shadowView = View(targetView.context).apply {
background = EmptyDrawable
outlineProvider = SurrogateViewProviderWrapper(originalProvider, targetView)
clipToOutline = true
elevation = targetView.elevation
translationZ = targetView.translationZ
}
init {
targetView.let { target ->
target.stateListAnimator?.let {
shadowView.stateListAnimator = it.clone()
target.setOnTouchListener(this)
}
}
}
override fun onTouch(view: View, event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
shadowView.isPressed = true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
shadowView.isPressed = false
}
}
return false
}
override fun update(
left: Int, top: Int, right: Int, bottom: Int,
elevation: Float, translationZ: Float
) {
shadowView.layout(left, top, right, bottom)
shadowView.elevation = elevation
shadowView.translationZ = translationZ
}
fun addShadowView(controller: ViewShadowController) {
controller.addView(shadowView)
}
fun removeShadowView(controller: ViewShadowController) {
controller.removeView(shadowView)
}
}
private object EmptyDrawable : Drawable() {
override fun draw(canvas: Canvas) {}
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(filter: ColorFilter?) {}
override fun getOpacity() = PixelFormat.TRANSLUCENT
}
private class InvalidateRelayDrawable(private val controller: ViewShadowController) : Drawable() {
override fun draw(canvas: Canvas) {
controller.invalidate()
}
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(filter: ColorFilter?) {}
override fun getOpacity() = PixelFormat.TRANSLUCENT
}
private class SurrogateViewProviderWrapper(
private val provider: ViewOutlineProvider,
private val surrogate: View
) : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
provider.getOutline(surrogate, outline)
}
}
private val clipOutPath: (Canvas, Path) -> Unit =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { canvas, path ->
canvas.clipOutPath(path)
} else { canvas, path ->
@Suppress("DEPRECATION")
canvas.clipPath(path, Region.Op.DIFFERENCE)
}
如前所述,对于 API 级别 21 到 28,我们使用空的 Views 来表示阴影,而 OverlayController 本身就是 ViewGroup。这个额外的ViewGroup 可能看起来是多余的,因为我们可以将空的Views 直接添加到叠加层中,但是我们需要它作为他们的容器,以便能够在其dispatchDraw() 覆盖中剪辑他们的阴影绘制,原因我谈到了在我的第一篇文章中。在实例化时,它会将自己添加到叠加层并设置为根据需要调整大小,因为叠加层中的所有内容都必须手动布局。
另外添加到叠加层的是我称之为InvalidateRelayDrawable 的东西,它可以让我们为叠加层中的可绘制对象设置“更好的刷新率”,因为这个控制器——由于是View——是'不会在每个覆盖失效时重绘。不过,所有可绘制对象每次都是手动绘制的,这个中继允许我们将明确的invalidate() 发送回控制器。
除此之外,OverlayController 覆盖很容易破译,dispatchDraw() 在调用super 之前会进行上述剪辑,在该调用中将绘制子项(空阴影Views)通过正常机制。
此实现的阴影类是ViewShadow,在实例化时它会创建空阴影View,并将其设置为模仿目标的形状和位置。 View 必须具有非空背景才能绘制其固有的阴影,尽管有人可能认为透明颜色最简单,但 EmptyDrawable 实际上更简单。它基本上是另一个只在 UI 线程上使用过的缓存临时对象,因此我们可以将其设为单例 object。
由于阴影View 正在有效地处理绘图,我们需要它具有正确的Outline,但实际上没有办法直接在其上设置一个。为了解决这个问题,我们将目标View 的原始ViewOutlineProvider 包装在一个我们可以设置在阴影View 上的一个中,这将为它提供目标的Outline。 (这就是我们将目标View 传递给SurrogateViewProviderWrapper 中它自己的提供者的原因;否则影子View 将在那里传递。)
在init 块中,如果目标有一个StateListAnimator,我们在影子View 上设置它的克隆,然后在目标上设置一个“直通”OnTouchListener,它对一些触摸事件进入阴影的 pressed 状态。这至少可以让我们的阴影在点击和长按时拥有适当的高度动画。我承认这部分,以及它在 RenderNode 实现中的等价部分,在我看来是所有这些中最不完善的部分,我已经离开它而临时希望有些人更愿意改变它显着,或者完全撕掉它。我要指出,您可能还希望复制其他几个州,including focused and enabled,但我们不会在这里讨论。
这里最后包含的是clipOutPath 实用函数。剪裁区域的推荐方法在 Oreo 中有所更改,但这并不是我们的功能所特有的;我们把它作为一个单独的助手。
Api29Shadows.kt
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("ViewConstructor")
internal class RenderNodeShadowController(private val viewGroup: ViewGroup) :
View(viewGroup.context), View.OnTouchListener, OverlayController<RenderNodeShadow> {
override val shadows = mutableListOf<RenderNodeShadow>()
override fun createShadow(view: View) = RenderNodeShadow(view)
override fun newShadow(view: View) {
super.newShadow(view)
shadows.sortedBy { it.z }.forEach { it.addToOverlay(viewGroup.overlay) }
}
override fun onNewShadow(shadow: RenderNodeShadow) {
shadows.forEach { it.removeFromOverlay(viewGroup.overlay) }
shadow.registerForTouchDispatch(this)
}
override fun onRemoveShadow(shadow: RenderNodeShadow) {
shadow.removeFromOverlay(viewGroup.overlay)
shadow.unregisterFromTouchDispatch()
}
private var currentShadow: RenderNodeShadow? = null
override fun onTouch(view: View, event: MotionEvent): Boolean {
currentShadow.let { shadow ->
if (shadow == null || !shadow.isForView(view)) {
currentShadow = shadows.firstOrNull { it.isForView(view) }
stateListAnimator = view.stateListAnimator?.clone()
elevation = view.elevation
translationZ = view.translationZ
}
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
isPressed = true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
isPressed = false
}
}
return false
}
override fun setElevation(elevation: Float) {
currentShadow?.setElevation(elevation)
}
override fun setTranslationZ(translationZ: Float) {
currentShadow?.setTranslationZ(translationZ)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
internal class RenderNodeShadow(view: View) : OverlayShadow(view) {
private val renderNodeDrawable = RenderNodeDrawable()
override fun setOutline(outline: Outline) {
super.setOutline(outline)
renderNodeDrawable.setOutline(if (outline.isEmpty) null else outline)
}
fun addToOverlay(overlay: ViewOverlay) {
overlay.add(renderNodeDrawable)
}
fun removeFromOverlay(overlay: ViewOverlay) {
overlay.remove(renderNodeDrawable)
}
private var registeredForDispatch = false
fun registerForTouchDispatch(controller: RenderNodeShadowController) {
if (targetView.stateListAnimator != null) {
targetView.setOnTouchListener(controller)
registeredForDispatch = true
}
}
fun unregisterFromTouchDispatch() {
if (registeredForDispatch) targetView.setOnTouchListener(null)
}
fun setElevation(elevation: Float) {
renderNodeDrawable.setElevation(elevation)
}
fun setTranslationZ(translationZ: Float) {
renderNodeDrawable.setTranslationZ(translationZ)
}
fun isForView(view: View) = targetView == view
override fun update(
left: Int, top: Int, right: Int, bottom: Int,
elevation: Float, translationZ: Float
) {
renderNodeDrawable.setBounds(left, top, right, bottom)
renderNodeDrawable.setElevation(elevation)
renderNodeDrawable.setTranslationZ(translationZ)
}
inner class RenderNodeDrawable : Drawable() {
private val renderNode = RenderNode(targetView.toString())
fun setElevation(elevation: Float) {
renderNode.elevation = elevation
}
fun setTranslationZ(translationZ: Float) {
renderNode.translationZ = translationZ
}
fun setOutline(outline: Outline?) {
renderNode.setOutline(outline)
}
override fun onBoundsChange(bounds: Rect) {
renderNode.setPosition(bounds)
}
override fun draw(canvas: Canvas) {
val path = Cache.path
if (prepareForDraw(path)) {
val saveCount = canvas.save()
canvas.enableZ()
canvas.clipOutPath(path)
canvas.drawRenderNode(renderNode)
canvas.disableZ()
canvas.restoreToCount(saveCount)
}
}
override fun getOpacity() = PixelFormat.TRANSLUCENT
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(filter: ColorFilter?) {}
}
}
从 Q 开始,我们可以使用 RenderNodes 来制作阴影,从而消除了很多不必要的开销。控制器是一个普通的View,它甚至没有添加到覆盖层本身,但仅用于触摸事件调度,因为StateListAnimators 仅适用于Views。要绘制,阴影对象使用自定义RenderNodeDrawables,每个对象都单独添加到叠加层中。由于我们可以使用这种方法在每次绘制周围进行剪辑和恢复,我们可以使阴影更好地协同工作——即,它们可以显示在兄弟目标Views 的范围内——所以几个@987654435 @ overrides 使drawables 保持正确的z 顺序。
RenderNodeShadow 本质上只是管理一个内部RenderNodeDrawable,它本身就是一个简单的围绕RenderNode 的裁剪包装器。
限制
正如上面RenderNode 描述中提到的,ViewShadow 版本确实必须同时为所有目标剪裁Canvas,如果有任何重叠,它们的交点内将没有阴影。但是,您也许可以将一个或多个重叠的 Views 包裹在其他东西中,例如 FrameLayout,这样可以有效地分别剪辑它们的阴影。
例如,这个带有 FloatingActionButton 的 sn-p 与 CardView 重叠:
<RelativeLayout
android:id="@+id/container"
android:layout_width="125dp"
android:layout_height="125dp"
android:background="@drawable/grid">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.cardview.widget.CardView
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="30dp"
app:cardBackgroundColor="#22FF0000"
app:cardCornerRadius="15dp"
app:cardElevation="8dp" />
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_margin="15dp"
app:backgroundTint="#220000FF"
app:elevation="12dp" />
</RelativeLayout>
以及使用和不使用 <FrameLayout> 包裹 <CardView> 时的外观:
注意事项
-
为简单起见,此示例要求 View 已附加到父级以启用修复。这对于大多数基本用法应该没问题——例如,在Activity 的Views 上,在其setContentView() 调用之后,在手动膨胀的Dialog 布局的子级上,等等——但这也意味着例如,我们不能将它按原样插入LayoutInflater 的Factory 或RecyclerView 项目的根目录中。这些功能当然是可能的,但这件事一开始就足够复杂。如果有足够的兴趣,我可能会发布后续内容来展示我们如何推迟影子附件。
-
这是minSdkVersion 21,所以没有检查Build.VERSION_CODES.LOLLIPOP。如果您支持低于该版本的版本,您可能需要添加一些。
1我最终会以某种形式在 GitHub 上发布这个,但我是一个懒惰的人,罗杰。
2 如果反射没有问题,可能值得在旧版本上测试RenderNodes。我没有做任何分析或任何事情,但View 基本上是几个RenderNodes,周围有很多其他东西,如果消除这些东西超过弥补反射开销,我不会感到惊讶.