2

I'm facing a layout issue with a vertical RecyclerView whose items use are MaterialCards with custom shapeAppearanceOverlay which has asymmetric corners (top-left corner radius = 0dp, all other corners = 16dp).

When the cards are tall and appear near the bottom of the screen, the outline spot shadow becomes noticeably vertically shifted. I understand this is how Android renders shadows, but the offset becomes too large in this specific case, and produces visual artifacts.

enter image description here enter image description here

This issue is reproducible in minimal project: Activity + RecyclerView. Just create new project in Android studio and add the code from the sections below:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        setupRecycler(generateItems())
    }

    private fun generateItems(): List<CardAdapter.CardItem> = List(15) { index ->
        CardAdapter.CardItem(
            title = "Card ${index + 1}",
            description = buildString {
                repeat((15..40).random()) { append("Test line\n") }
            }
        )
    }

    private fun setupRecycler(cardItems: List<CardAdapter.CardItem>) = binding.recyclerView.apply {
        layoutManager = LinearLayoutManager(context)
        adapter = CardAdapter(cardItems)
        val spacingInPixels = resources.getDimensionPixelSize(R.dimen.card_spacing)
        addItemDecoration(SpacingItemDecoration(spacingInPixels))
    }
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipChildren="false"
        android:clipToPadding="false" />

</LinearLayout>
class CardAdapter(private val items: List<CardItem>) : RecyclerView.Adapter<CardAdapter.CardViewHolder>() {

    data class CardItem(
        val title: String,
        val description: String
    )

    inner class CardViewHolder(private val binding: ItemCardBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(item: CardItem) {
            binding.cardTitle.text = item.title
            binding.cardDescription.text = item.description
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder {
        val binding = ItemCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return CardViewHolder(binding)
    }

    override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
        holder.bind(items[position])
    }

    override fun getItemCount(): Int = items.size
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:clipChildren="false"
    android:clipToPadding="false"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <com.google.android.material.card.MaterialCardView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="16dp"
        android:outlineSpotShadowColor="#FF0000"
        app:cardElevation="6dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:shapeAppearanceOverlay="@style/ShapeAppearance.App.Card">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:padding="16dp">

            <TextView
                android:id="@+id/card_title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="Card Title"
                android:textAppearance="?attr/textAppearanceHeadline6"
                android:textStyle="bold" />

            <TextView
                android:id="@+id/card_description"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:text="This is a material card with a minimum height of 200dp"
                android:textAppearance="?attr/textAppearanceBody2" />

        </LinearLayout>

    </com.google.android.material.card.MaterialCardView>

</androidx.constraintlayout.widget.ConstraintLayout>
    <style name="ShapeAppearance.App.Card" parent="">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSizeTopLeft">0dp</item>
        <item name="cornerSizeTopRight">16dp</item>
        <item name="cornerSizeBottomLeft">16dp</item>
        <item name="cornerSizeBottomRight">16dp</item>
    </style>

What I've tried:

  • Replaced MaterialCardView with a regular LinearLayout + shape drawable background → shadow issue still occurs (so it doesn't seem to be a MaterialCardView bug)
  • Tested multiple versions of the Material Components library
  • Tried XML attributes such as: app:useCompatPadding="true" app:preventCornerOverlap="true" android:clipToPadding="false" / clipToOutline variations
  • Experimented with different elevations, padding, and outline settings None of these helped — the shadow still gets pushed downward too far when the view has uneven corner radii.

Question: Is there any known workaround to prevent or reduce this vertical shadow offset when using asymmetric rounded-corner shapes as items in a RecyclerView? Any suggestions or insights would be greatly appreciated.

2
  • 1
    I'm not yet sure what's causing that. As for a workaround, the shadow's shape is set by the ViewOutlineProvider. You could replace it with one that sets the same outline but moves the bottom bound. A couple of examples. Since I don't know the cause, the offset was determined by eye, so it's a tad hacky. It will also affect the shadows everywhere on screen, but it's hardly noticeable. There might be other ways; that's just the first that came to mind. Commented Dec 4, 2025 at 21:22
  • 1
    Thank you, that worked. As there is no other suitable solutions, I believe your comment could be an accepted answer. Post it as the answer, and I will accept it since I am unable to do it with your comment. Thank you again Commented Dec 9, 2025 at 7:55

1 Answer 1

1

I don't yet know the cause of that. At this point, I would assume that it's a bug in the underlying Skia graphics stuff, but I'm not familiar enough with that to go digging through its source. The only thing I could find on the issue tracker that sounds like it could possibly produce the defect here is this one, but the author links to the commit they believe introduced it, and it was only five days earlier than the issue's posting, in May of this year, so probably not it.

As for a workaround, that shadow's shape is ultimately set by the View's ViewOutlineProvider, and there are a couple of points around that where we can insert a custom class that will allow us to set our own adjusted Outline. The usual default provider, the one in play here, is BACKGROUND, which simply relays getOutline() calls to the background Drawable. We can either replace the provider or wrap the drawable. Unless you have a specific need to do the latter, a ViewOutlineProvider involves a bit less overhead and is probably preferable, so we'll use that for the basic illustration.

First we'll define our custom provider. To make things easy for the base example, we're just going to define the shape from scratch, since it's not at all complicated. Further on below, I will discuss a couple of other ways to get that shape, including how to load and process the style directly, and how we can grab it from the background's MaterialShapeDrawable.

import android.content.Context
import android.graphics.Outline
import android.graphics.Path
import android.graphics.Rect
import android.view.View
import android.view.ViewOutlineProvider
import androidx.annotation.RequiresApi
import androidx.core.graphics.toRectF

@RequiresApi(30) // <- Just to make setPath() simple for this one.
class OutlineProvider(context: Context) : ViewOutlineProvider() {

    private val radii =
        (16 * context.resources.displayMetrics.density).let { radius ->
            FloatArray(8) { if (it in 0..1) 0F else radius }
        }

    private val path = Path()

    override fun getOutline(view: View, outline: Outline) {
        val bounds = view.run { Rect(0, 0, right - left, bottom - top) }
        val newBounds = bounds.toRectF()
        newBounds.bottom -= 5F  // <-- Our adjustment.

        path.run {
            rewind()
            addRoundRect(newBounds, radii, Path.Direction.CW)
            outline.setPath(this)
        }
    }
}

As mentioned, I've not yet found the cause of this issue so the adjustment was determined by eye. It seems sufficient for all of the emulators I've tested, phones and tablets. This will of course affect shadows at every location onscreen, though I think it's hardly noticeable. If you find otherwise, you could conceivably adjust that 5F value dynamically, depending on the View's screen location, though you'd need to wire up some kind of update mechanism, like a scroll listener or such.

There is one catch with this solution: since we're adjusting the Outline for the View itself, if it has its clipToOutline set to true, we're going to screw up the View with our adjustment. Unfortunately, CardView and its subclasses all do set that in their constructors, so we'll either have to turn it off in code, or move the shadow to a ViewGroup wrapper around the MaterialCardView.

For the base example, we'll do the former and turn off the card's clip setting. We're using the question's layout here with the <ConstraintLayout> removed. That is, the <MaterialCardView> is the root. This gist has the edited XML, for reference. Other than that, we just replace the provider and disable the clip:

class CardViewHolder(private val binding: ItemCardBinding) :
    RecyclerView.ViewHolder(binding.root) {

    fun bind(item: CardItem) {
        binding.cardTitle.text = item.title
        binding.cardDescription.text = item.description

        val context = binding.root.context
        binding.root.outlineProvider = OutlineProvider(context)

        // This must be set in code; CardView sets it to true in its ctor.
        binding.root.clipToOutline = false
    }
}

I've also another very similar example that shows the other option of moving the shadow to a wrapper ViewGroup, should you require MaterialCardView's clipToOutline be enabled. Both solutions give the exact same results:

Before and after images showing the defect and the fix.

The shadows in the above images have been darkened quite a bit beyond the default in order to better highlight the difference. This was accomplished by setting <item name="android:spotShadowAlpha">0.5</item> in the Activity's theme.


If you have multiple shapes for your cards, redefining them all in code could get a bit tedious. Another approach here would be to load a ShapeAppearanceModel from the style, and use a ShapeAppearancePathProvider to calculate the path from the adjusted bounds. This example also shows a proper setPath() setup.

import android.content.Context
import android.graphics.Outline
import android.graphics.Path
import android.graphics.Rect
import android.os.Build
import android.view.View
import android.view.ViewOutlineProvider
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.annotation.StyleRes
import androidx.core.graphics.toRectF
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.shape.ShapeAppearancePathProvider

class OutlineProvider(
    context: Context,
    @param:StyleRes private val shapeStyleId: Int
) : ViewOutlineProvider() {

    private val shapeAppearanceModel =
        ShapeAppearanceModel.builder(context, shapeStyleId, 0).build()

    // If you're going to end up with a bunch of instances of the provider, you
    // might consider making a ThreadLocal for this, since it'd be safe here.
    // The drawable wrapper example linked in the next section shows how.
    private val pathProvider = ShapeAppearancePathProvider()

    private val path = Path()

    override fun getOutline(view: View, outline: Outline) {
        val bounds = view.run { Rect(0, 0, right - left, bottom - top) }
        val newBounds = bounds.toRectF()
        newBounds.bottom -= 5F

        // Assumes the drawable's interpolation is always 1F, which
        // I think should be the case for most basic cards, at least.
        pathProvider.calculatePath(shapeAppearanceModel, 1F, newBounds, path)

        outline.setPathCompat(path)
    }
}

internal fun Outline.setPathCompat(path: Path) {
    if (Build.VERSION.SDK_INT >= 30) {
        OutlineVerificationHelper.setPath(this, path)
    } else if (Build.VERSION.SDK_INT == 29) {
        try {
            // Apparently some Q versions fumbled this.
            @Suppress("DEPRECATION")
            this.setConvexPath(path)
        } catch (_: IllegalArgumentException) {
            // ignore
        }
    } else {
        @Suppress("DEPRECATION")
        if (path.isConvex) this.setConvexPath(path)
    }
}

// I'm assuming minSdk is at least 21, otherwise a verification
// helper should be created for Outline#setConvexPath() as well.

@RequiresApi(30)
private object OutlineVerificationHelper {

    @DoNotInline
    fun setPath(outline: Outline, path: Path) =
        outline.setPath(path)
}

Usage is again straightforward:

class CardViewHolder(private val binding: ItemCardBinding) :
    RecyclerView.ViewHolder(binding.root) {

    fun bind(item: CardItem) {
        binding.cardTitle.text = item.title
        binding.cardDescription.text = item.description

        val context = binding.root.context
        binding.root.outlineProvider =
            OutlineProvider(context, R.style.ShapeAppearance_App_Card)

        binding.root.clipToOutline = false

    }
}

The last option I've come up with so far is a wrapper Drawable that finds the card's MaterialShapeDrawable from its original background and then uses its ShapeAppearanceModel to calculate the exact Path with bounds that we've adjusted beforehand, allowing for an automatic, one-size-fits-all solution. It's a little longer than I would care to squeeze in here after all the other snippets, so I'll just link to the gist.

The MaterialShapeDrawableUtils file is from a library of mine that I happen to be updating at the moment, so it might be a bit overkill for us here. However, I'm not sure that MaterialCardView makes any guarantees about its background's exact makeup, so some sort of find routine, generic or not, is likely prudent, in case it's wrapped in an InsetDrawable, for example. None of that's been thoroughly tested yet, so fair warning, but I will definitely update it if changes need be made after I've done so.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.