Starting from Android O, apps can have adaptive icons, which are 2 layers of drawables: foreground and a background. The background is a mask that gets
I made a custom ImageView that can have a path set to clip the background/drawable and apply the proper shadow via a custom outline provider, which includes support for reading the system preference (as confirmed on my Pixel 4/emulators, changing system icon shape is propagated to my app.)
View:
import android.content.Context
import android.graphics.*
import android.graphics.drawable.Drawable
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import androidx.appcompat.widget.AppCompatImageView
open class AdaptiveImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
// Reusable to reduce object allocation
private val resizeRect = RectF()
private val srcResizeRect = RectF()
private val resizeMatrix = Matrix()
private val adaptivePathPreference = Path()
private val adaptivePathResized = Path()
private var backgroundDelegate: Drawable? = null
// Paint to clear area outside adaptive path
private val clearPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
init {
// Use the adaptive path as an outline provider
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setConvexPath(adaptivePathResized)
}
}
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updatePathBounds()
}
// We use saveLayer/clear rather than clipPath so we get anti-aliasing
override fun onDraw(canvas: Canvas) {
val count = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null)
backgroundDelegate?.draw(canvas)
super.onDraw(canvas)
canvas.drawPath(adaptivePathResized, clearPaint)
canvas.restoreToCount(count)
}
// Background doesn't play nice with our clipping, so hold drawable and null out so
// we can handle ourselves later.
override fun setBackground(background: Drawable?) {
backgroundDelegate = background?.apply {
if (isStateful) state = drawableState
}
if (isLaidOut) updatePathBounds()
// Null out so noone else tries to draw it (incorrectly)
super.setBackground(null)
}
override fun drawableStateChanged() {
super.drawableStateChanged()
backgroundDelegate?.apply {
if (isStateful) state = drawableState
}
}
fun setAdaptivePath(path: Path?) {
path?.let { adaptivePathPreference.set(it) } ?: adaptivePathPreference.reset()
updatePathBounds()
}
private fun updatePathBounds() {
resizePath(
left = paddingLeft.toFloat(),
top = paddingTop.toFloat(),
right = width - paddingRight.toFloat(),
bottom = height - paddingBottom.toFloat()
)
backgroundDelegate?.apply {
setBounds(
paddingLeft,
paddingTop,
width,
height
)
}
invalidate()
invalidateOutline()
}
// No object allocations
private fun resizePath(left: Float, top: Float, right: Float, bottom: Float) {
resizeRect.set(left, top, right, bottom)
adaptivePathResized.set(adaptivePathPreference)
srcResizeRect.set(0f, 0f, 0f, 0f)
adaptivePathResized.computeBounds(srcResizeRect, true)
resizeMatrix.reset()
resizeMatrix.setRectToRect(srcResizeRect, resizeRect, Matrix.ScaleToFit.CENTER)
adaptivePathResized.transform(resizeMatrix)
// We want to invert the path so we can clear it later
adaptivePathResized.fillType = Path.FillType.INVERSE_EVEN_ODD
}
}
Path enum/functions:
private val circlePath = Path().apply {
arcTo(RectF(0f, 0f, 50f, 50f), 0f, 359f)
close()
}
private val squirclePath = Path().apply { set(PathParser.createPathFromPathData("M 50,0 C 10,0 0,10 0,50 C 0,90 10,100 50,100 C 90,100 100,90 100,50 C 100,10 90,0 50,0 Z")) }
private val roundedPath = Path().apply { set(PathParser.createPathFromPathData("M 50,0 L 70,0 A 30,30,0,0 1 100,30 L 100,70 A 30,30,0,0 1 70,100 L 30,100 A 30,30,0,0 1 0,70 L 0,30 A 30,30,0,0 1 30,0 z")) }
private val squarePath = Path().apply {
lineTo(0f, 50f)
lineTo(50f, 50f)
lineTo(50f, 0f)
lineTo(0f, 0f)
close()
}
private val tearDropPath = Path().apply { set(PathParser.createPathFromPathData("M 50,0 A 50,50,0,0 1 100,50 L 100,85 A 15,15,0,0 1 85,100 L 50,100 A 50,50,0,0 1 50,0 z")) }
private val shieldPath = Path().apply { set(PathParser.createPathFromPathData("m6.6146,13.2292a6.6146,6.6146 0,0 0,6.6146 -6.6146v-5.3645c0,-0.6925 -0.5576,-1.25 -1.2501,-1.25L6.6146,-0 1.2501,-0C0.5576,0 0,0.5575 0,1.25v5.3645A6.6146,6.6146 0,0 0,6.6146 13.2292Z")) }
private val lemonPath = Path().apply { set(PathParser.createPathFromPathData("M1.2501,0C0.5576,0 0,0.5576 0,1.2501L0,6.6146A6.6146,6.6146 135,0 0,6.6146 13.2292L11.9791,13.2292C12.6716,13.2292 13.2292,12.6716 13.2292,11.9791L13.2292,6.6146A6.6146,6.6146 45,0 0,6.6146 0L1.2501,0z")) }
enum class IconPath(val path: () -> Path?) {
SYSTEM(
path = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val adaptive = AdaptiveIconDrawable(null, null)
adaptive.iconMask
} else {
null
}
}
),
CIRCLE(path = { circlePath }),
SQUIRCLE(path = { squirclePath }),
ROUNDED(path = { roundedPath }),
SQUARE(path = { squarePath }),
TEARDROP(path = { tearDropPath }),
SHIELD(path = { shieldPath }),
LEMON(path = { lemonPath });
}
The key to copying the system preference is just to create an empty AdaptiveIconDrawable and read out the icon mask (which we later adjust the size of for use in the view. This will always return the current system icon shape path.
Sample usage:
myAdapativeImageView.setAdaptivePath(IconPath.SYSTEM.path())
Example: