【问题标题】:How can we fix the elevation shadows on transparent/translucent Views without changing the existing setup?我们如何在不改变现有设置的情况下修复透明/半透明视图上的高程阴影?
【发布时间】:2022-01-10 15:02:33
【问题描述】:

自从 Lollipop 和 Material Design 的引入以来,Android 中的一个众所周知的问题是,带有透明背景的高架 Views 会从其背后的阴影中显示出丑陋的伪影。正如您所料,多年来这里已经发布了许多关于它的问题,尽管它们都是由于相同的根本原因造成的,但由此产生的视觉效果可能会有所不同:

这些视觉故障都是由于通过在渐变上“缩小”来调整阴影以增加高度,这导致其内部边界在View 的范围内向内收缩,通常在具有不透明的背景。那里没有进行剪辑,而且我在源代码中遇到的每一项相关检查都会在看到该工件时完全关闭阴影,因此剪辑似乎是故意省略的,可能是为了提高效率。

确实,关闭它或以某种方式完全避免它似乎是用户发现的唯一普遍有效的解决方案:

各种定制解决方法可用于一些常见设置,一些用户忽略它或没有意识到它是什么,有些甚至将效果集成到他们的设计中,但我仍然没有找到一个例子真正固定。我还没有深入研究本机图形代码,但我也找不到 SDK 源代码中的任何实例,除了在可能可见的情况下禁用阴影之外,如果我们不能够要在应用程序级别执行此操作,低级图形功能可以做什么并不重要。

似乎没有太多关于一般问题的信息,但最近一些顽强且可能相当英俊的开发人员在this old question concerning CardView 上分享了一些关于它的信息,包括一些解决方法和一些说明性示例。但是,由于View 的阴影由其父级处理,that answer 中给出的所有示例都是自定义ViewGroups,它们修改或附加其子级绘图。

不幸的是,这意味着这些特定的解决方法在许多情况下由于各种原因而无法使用;例如,如果你不能改变现有的层次结构,如果你不知道最终的父级会是什么,等等。

有什么方法可以应用这些变通方法而不必使用这些具体示例?


1 图片由makovkastarElevation + transparency bug on Android Lollipop修改,授权CC BY-SA 3.0

2 图片由timothyjc修改自How to create a shape with a transparent background and a shadow, but the shadow should not be visible behind the shape outline?,授权CC BY-SA 4.0

3 图片由rosenthal修改自CardView with transparent background issue on > API 21,授权于CC BY-SA 3.0

【问题讨论】:

  • 显然,我的回答已经解决了我自己的问题,所以我将其标记为已接受只是为了表明这一点。如果有人应该给出更好的答案和/或知道核心问题的实际、适当的解决方案,我非常乐意移动该复选标记。

标签: android android-view


【解决方案1】:

是的,我们可以使用类似于RenderNode 解决方法的技术,并将阴影绘制到ViewGroup 的叠加层中,这使得这适用于任何View 内的任何ViewGroup。总体思路是关闭目标View 上的阴影并替换我们自己的阴影,并在顶部进行适当的裁剪和绘制。这个解决方案有几个移动部件,这个答案很可能像泥土一样令人生畏/令人困惑/无聊,所以为了激发你的兴趣——至少复制/粘贴1并尝试它出来了——这(应该)是你现有的View应用它所要做的所有事情:

yourElevatedView.moveShadowToOverlay()

就是这样。其他一切都是自动处理的,应该给出如下结果:

概述

整个过程的高级描述如下:当第一次为View 请求覆盖阴影时,会为其创建ShadowManager 并将其作为标记附加到其父ViewGroupShadowManager 然后为 Android 版本加载正确的 OverlayController 并将其添加到 ViewGroup 的覆盖中。然后OverlayControllerView 创建一个OverlayShadow 并将其添加到其内部列表中。同一个父 ViewGroup 中请求影子的下一个 View 将通过现有的 ShadowManager 添加到活动的 OverlayController,因此每个兄弟影子都不需要自己的实例。如果OverlayController 的所有阴影都被移除,ShadowManager 将移除它并自行清理。

正如my first answer 中所述,我发现的最佳选择是使用空的RenderNodes 来绘制我们自己裁剪的阴影,但这仅在 API 级别 29 之后公开可用。对于之前的版本,我们使用空的Views 作为一个稍微笨重的选项来实现相同的效果,2 因此,此解决方案实际上是将两种解决方法打包成一个。每个都有自己独特的障碍,我们将在进行过程中对其进行介绍,希望每个设计细节在上下文中都很清晰。

为了便于复制,代码分为三个主要块,每个块都可以创建自己的文件。入口点是第一个开头的ViewViewGroup 上的少数扩展。每个完整块后面都有摘要。

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/ 文件中的&lt;resources&gt;,真的:

<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 来禁用目标的阴影,如果ViewOutline 用于其他用途,例如剪裁自身,则保留所有其他内容。 detachFromTargetView() 函数只是在目标上恢复原来的ViewOutlineProvider,并在需要时取消其标记以进行清理。

同样在OverlayShadow中,定义了一个属性来暴露目标Viewz,就是简单的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>

以及使用和不使用 &lt;FrameLayout&gt; 包裹 &lt;CardView&gt; 时的外观:

注意事项

  • 为简单起见,此示例要求 View 已附加到父级以启用修复。这对于大多数基本用法应该没问题——例如,在ActivityViews 上,在其setContentView() 调用之后,在手动膨胀的Dialog 布局的子级上,等等——但这也意味着例如,我们不能将它按原样插入LayoutInflaterFactoryRecyclerView 项目的根目录中。这些功能当然是可能的,但这件事一开始就足够复杂。如果有足够的兴趣,我可能会发布后续内容来展示我们如何推迟影子附件。

  • 这是minSdkVersion 21,所以没有检查Build.VERSION_CODES.LOLLIPOP。如果您支持低于该版本的版本,您可能需要添加一些。


1我最终会以某种形式在 GitHub 上发布这个,但我是一个懒惰的人,罗杰。

2 如果反射没有问题,可能值得在旧版本上测试RenderNodes。我没有做任何分析或任何事情,但View 基本上是几个RenderNodes,周围有很多其他东西,如果消除这些东西超过弥补反射开销,我不会感到惊讶.

【讨论】:

    猜你喜欢
    • 2022-06-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多