How do launchers change the shape of an adaptive icon, including removal of background?
Asked Answered
D

7

32

Background

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 to be a shape of the launcher/user's choice, while the OS has a default shape for it too.

Here's an example of what Nova Launcher allows to do:

enter image description here

As you can see, it allows not only to choose which shape to use, but also avoid a shape at all (in "prefer legacy icons").

Here are some links about it:

The problem

While I know how to create a AdaptiveIconDrawable instance, and I'm aware of the wizard that helps creating one for the current app, I don't get how, given an AdaptiveIconDrawable instance, launchers change the shape.

Not only that, but I remember I saw a launcher or two that allows to not have any shape.

Sadly I can't find any information about this part, maybe because this is a relatively very new feature. There isn't even a keyword for it here on StackOverflow.

What I've tried

I tried reading about adaptive icons, but couldn't find a reference to the receiver side.

I know it has the 2 drawables within it:

I know, at least, how to get an AdaptiveIconDrawable instance out of a third party app (assuming it has one) :

PackageManager pm = context.getPackageManager();
Intent launchIntentForPackage = pm.getLaunchIntentForPackage(packageName);
String fullPathToActivity = launchIntentForPackage.getComponent().getClassName();
ActivityInfo activityInfo = pm.getActivityInfo(new ComponentName(packageName, fullPathToActivity), 0);
int iconRes = activityInfo.icon;
Drawable drawable = pm.getDrawable(packageName, iconRes, activityInfo.applicationInfo); // will be AdaptiveIconDrawable, if the app has it

The questions

  1. Given a AdaptiveIconDrawable instance, how do you shape it, to be of a circular shape, rectangle, rounded rectangle, tear, and so on?

  2. How do I remove the shape and still have a valid size of the icon (using its foreground drawable in it) ? The official size of an app icon for launchers is 48 dp, while the official ones for AdaptiveIconDrawable inner drawables are 72dp (foreground), 108dp (background). I guess this would mean taking the foreground drawable, resize it somehow, and convert to a bitmap.

  3. In which case exactly is it useful to use IconCompat.createWithAdaptiveBitmap() ? It was written that "If you’re building a dynamic shortcut using a Bitmap, you might find the Support Library 26.0.0-beta2’s IconCompat.createWithAdaptiveBitmap() useful in ensuring that your Bitmap is masked correctly to match other adaptive icons." , but I don't get which cases it's useful for.


EDIT: In order to create a bitmap out of the foreground part of the adaptive icon, while resizing to a proper size, I think this could be a good solution:

val foregroundBitmap = convertDrawableToBitmap(drawable.foreground)
val targetSize = convertDpToPixels(this, ...).toInt()
val scaledBitmap = ThumbnailUtils.extractThumbnail(foregroundBitmap, targetSize, targetSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT)

fun convertDrawableToBitmap(drawable: Drawable?): Bitmap? {
    if (drawable == null)
        return null
    if (drawable is BitmapDrawable) {
        return drawable.bitmap
    }
    val bounds = drawable.bounds
    val width = if (!bounds.isEmpty) bounds.width() else drawable.intrinsicWidth
    val height = if (!bounds.isEmpty) bounds.height() else drawable.intrinsicHeight
    val bitmap = Bitmap.createBitmap(if (width <= 0) 1 else width, if (height <= 0) 1 else height,
            Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    drawable.setBounds(0, 0, canvas.width, canvas.height)
    drawable.draw(canvas)
    drawable.bounds = bounds;
    return bitmap
}

fun convertDpToPixels(context: Context, dp: Float): Float = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.resources.displayMetrics)

Might be able to avoid having 2 bitmaps at the same time, but this is ok I think.

About the creation of a shaped drawable of various types, I'm still not sure how to do it. Only solution I've seen by the answers below is of using a rounded rectangle or a circle, but there are other shapes (for example the tear) that can come to mind.


EDIT: I was told as some point by Google (here) that I should use AdaptiveIconDrawable.getIconMask(), but I wasn't given any further information. However, I've found a nice article about this here.

Doyle answered 2/12, 2017 at 19:2 Comment(8)
Why downvote without even an explanation? – Doyle
I cannot understand the reason of downvotes too. Hmm πŸ€” – Zollverein
Here is an example: github.com/nickbutcher/AdaptiveIconPlayground – Dispensary
@androiddeveloper Have you found how to get the default system shape? – Forego
@Forego Sadly no idea. I'm still not even sure how to make the shape be applied on the background. I wonder if it's as others written here, using a canvas, or that there is some masking file that people can use. – Doyle
@androiddeveloper canvas it is. Here is a link github.com/fennifith/AdaptiveIconView, although there are some other problems in that code. – Forego
@Forego Actually I was told by Google as some point that I can use this to get the system's icon shape: developer.android.com/reference/android/graphics/drawable/… . They didn't explain further though, but I've found this : medium.com/@Tarek360/… . I've updated now my post. – Doyle
@Forego I've made a POC and showed how to do it, but it has 2 issues. If you know how to solve them, please let me know. If you solve them, you can create a new answer that I will accept. :) – Doyle
T
3

I don't get how, given an AdaptiveIconDrawable instance, launchers change the shape.

Launchers are just apps, so they simply draw the background in the shape they want (or the user selected) and then draw the foreground on top.

I don't have a sample project of my own, but Nick Butcher made a great sample project and series of blog posts: AdaptiveIconPlayground.


Given a AdaptiveIconDrawable instance, how do you shape it, to be of a circular shape, rectangle, rounded rectangle, tear, and so on?

The simplest way is to rasterize the drawable and draw the bitmap using a shader like it is done in Nick's AdaptiveIconView:

private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val background: Bitmap

// ...

background = Bitmap.createBitmap(layerSize, layerSize, Bitmap.Config.ARGB_8888)
backgroundPaint.shader = BitmapShader(background, CLAMP, CLAMP)

// < rasterize drawable onto `background` >

// draw desired shape(s)
canvas.drawRoundRect(0f, 0f, iconSize.toFloat(), iconSize.toFloat(),
                cornerRadius, cornerRadius, backgroundPaint)

How do I remove the shape and still have a valid size of the icon (using its foreground drawable in it) ? The official size of an app icon for launchers is 48 dp, while the official ones for AdaptiveIconDrawable inner drawables are 72dp (foreground), 108dp (background). I guess this would mean taking the foreground drawable, resize it somehow, and convert to a bitmap.

If you don't want a background, just don't draw it. You're in full control. The size does not really matter, because you usually know how big your icons should be drawn. The documentation states that foreground and background should be 108dp, so you can simply downscale your drawing. If foreground/background use vector graphics, then size really does not matter, as you can just draw them however big you like.

If you rasterize the foreground, then you can do custom drawing as seen above, or choose Canvas#drawBitmap(...), which also offers multiple options to draw a Bitmap, including to pass in a transformation matrix, or simply some bounds.

If you don't rasterize your drawable you can also use drawable.setBounds(x1, y1, x2, y2), where you can set the bounds on where the drawable should draw itself. This should also work.

In which case exactly is it useful to use IconCompat.createWithAdaptiveBitmap() ? It was written that "If you’re building a dynamic shortcut using a Bitmap, you might find the Support Library 26.0.0-beta2’s IconCompat.createWithAdaptiveBitmap() useful in ensuring that your Bitmap is masked correctly to match other adaptive icons." , but I don't get which cases it's useful for.

ShortCutInfo.Builder has a setIcon(Icon icon) method where you need to pass it in. (And the same applies for the compat versions)

It seems that Icon is used to have control over the kind of Bitmap that gets passed in as an icon. Right now I could not find any other usage for Icon. I don't think that you would use this when creating a launcher.


More information reflecting the last comment

Do you wrap the AdaptiveIconDrawable class with your own drawable? I just want to convert it somehow to something I can use, to both an ImageView and a Bitmap, and I wish to control the shape, using all shapes I've shown on the screenshot above. How would I do it?

If you follow the links above you can see a custom AdaptiveIconView that draws the AdaptiveIconDrawable, so doing a custom view is definitely an option, but everything mentioned can be moved just as easily into a custom Drawable, which you then could also use with a basic ImageView.

You can achieve the various different backgrounds by using the methods available on Canvas along with a BitmapShader as shown above, e.g. additionally to drawRoundRect we would have

canvas.drawCircle(centerX, centerY, radius, backgroundPaint) // circle
canvas.drawRect(0f, 0f, width, height, backgroundPaint) // rect
canvas.drawPath(path, backgroundPaint) // more complex shapes

To switch between background shapes you could use anything from if/else, over composition, to inheritance, and just draw the shape you like.

Terse answered 19/12, 2017 at 18:59 Comment(3)
The code you wrote seems to belong to some class: where's drawRoundRect ? Do you wrap the AdaptiveIconDrawable class with your own drawable? I just want to convert it somehow to something I can use, to both an ImageView and a Bitmap, and I wish to control the shape, using all shapes I've shown on the screenshot above. How would I do it? – Doyle
@androiddeveloper thanks, fixed the error and added a bit more information – Terse
I want to be able to change the shape to all of those that are shown on the screenshots. This includes knowing the system one, the tear, and the Squircle. I also want to be able to avoid the shape, and draw the result into a bitmap that's suited to be an app icon. – Doyle
D
1

OK I got something to work, but for some reason the inner icon seems smaller than what's done with the AdaptiveIconDrawable. Also for some reason, on the way, it affected the original AdaptiveIconDrawable (even if I used mutate on any drawable I used) so I had to create a new one to demonstrate the original vs new one. Another small annoyance is that to create the masked bitmap, I had to have 2 Bitmap instances (drawable converted to one, and needed an output too).

I wonder if it's possible to convert the drawable directly to a Bitmap/Drawable that has the given shape, so I asked about this here.

So, suppose you have a Path instance. You can get one from the AdaptiveIconDrawable.getIconMask function (which is the one of the system), or you can create one yourself, such as the one used here (repository here) or here.

If anyone knows how to solve those issues I've mentioned above (smaller foreground and affects original drawable, and maybe a better conversion), please let me know. For now, you can either use this solution, or use a library like here.

Now, suppose you get the AdaptiveIconDrawable instance, and you want to shape it in the same shape as of the Path instance.

So, what you can do is something like what's below (PathUtils is converted to Kotlin from either repositories) , and the result:

enter image description here

MainActivity.kt

class MainActivity : AppCompatActivity(R.layout.activity_main) {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val appIcon = applicationInfo.loadIcon(packageManager)
        originalIconImageView.setImageDrawable(applicationInfo.loadIcon(packageManager))
        if (appIcon is AdaptiveIconDrawable) {
            val iconMask = getPath(PATH_SQUIRCLE)
            val maskedBitmap = getMaskedBitmap(appIcon.background, iconMask)
            val foreground = appIcon.foreground
            val layerDrawable = LayerDrawable(arrayOf(BitmapDrawable(resources, maskedBitmap), foreground))
            maskedImageView.setImageDrawable(layerDrawable)
        }
    }

    companion object {
        const val PATH_CIRCLE = 0
        const val PATH_SQUIRCLE = 1
        const val PATH_ROUNDED_SQUARE = 2
        const val PATH_SQUARE = 3
        const val PATH_TEARDROP = 4

        fun resizePath(path: Path, width: Float, height: Float): Path {
            val bounds = RectF(0f, 0f, width, height)
            val resizedPath = Path(path)
            val src = RectF()
            resizedPath.computeBounds(src, true)
            val resizeMatrix = Matrix()
            resizeMatrix.setRectToRect(src, bounds, Matrix.ScaleToFit.CENTER)
            resizedPath.transform(resizeMatrix)
            return resizedPath
        }

        fun getMaskedBitmap(src: Bitmap, path: Path, resizePathToMatchBitmap: Boolean = true): Bitmap {
            val pathToUse = if (resizePathToMatchBitmap) resizePath(path, src.width.toFloat(), src.height.toFloat()) else path
            val output = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888)
            val canvas = Canvas(output)
            val paint = Paint(Paint.ANTI_ALIAS_FLAG)
            paint.color = 0XFF000000.toInt()
            canvas.drawPath(pathToUse, paint)
            paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
            canvas.drawBitmap(src, 0f, 0f, paint)
            return output
        }

        fun getMaskedBitmap(drawable: Drawable, path: Path, resizePathToMatchBitmap: Boolean = true): Bitmap = getMaskedBitmap(drawable.toBitmap(), path, resizePathToMatchBitmap)

        fun getPath(pathType: Int): Path {
            val path = Path()
            val pathSize = Rect(0, 0, 50, 50)
            when (pathType) {
                PATH_CIRCLE -> {
                    path.arcTo(RectF(pathSize), 0f, 359f)
                    path.close()
                }
                PATH_SQUIRCLE -> path.set(PathUtils.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"))
                PATH_ROUNDED_SQUARE -> path.set(PathUtils.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"))
                PATH_SQUARE -> {
                    path.lineTo(0f, 50f)
                    path.lineTo(50f, 50f)
                    path.lineTo(50f, 0f)
                    path.lineTo(0f, 0f)
                    path.close()
                }
                PATH_TEARDROP -> path.set(PathUtils.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"))
            }
            return path
        }

    }
}

activity_main.xml

<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:gravity="center"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Original:" />

    <ImageView
        android:id="@+id/originalIconImageView"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_marginTop="16dp" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="Masked:" />

    <ImageView
        android:id="@+id/maskedImageView"
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_marginTop="16dp" />
</LinearLayout>

PathUtils.kt

object PathUtils {
    /**
     * @param pathData The string representing a path, the same as "d" string in svg file.
     * @return the generated Path object.
     */
    fun createPathFromPathData(pathData: String): Path {
        val path = Path()
        val nodes = createNodesFromPathData(pathData)
        PathDataNode.nodesToPath(nodes, path)
        return path
    }

    /**
     * @param pathData The string representing a path, the same as "d" string in svg file.
     * @return an array of the PathDataNode.
     */
    fun createNodesFromPathData(pathData: String): Array<PathDataNode> {
        var start = 0
        var end = 1
        val list = ArrayList<PathDataNode>()
        while (end < pathData.length) {
            end = nextStart(pathData, end)
            val s = pathData.substring(start, end)
            val `val` = getFloats(s)
            addNode(list, s[0], `val`)
            start = end
            end++
        }
        if (end - start == 1 && start < pathData.length) {
            addNode(list, pathData[start], FloatArray(0))
        }
        return list.toTypedArray()
    }

    private fun nextStart(s: String, inputEnd: Int): Int {
        var end = inputEnd
        var c: Char
        while (end < s.length) {
            c = s[end]
            if ((c - 'A') * (c - 'Z') <= 0 || (c - 'a') * (c - 'z') <= 0) return end
            end++
        }
        return end
    }

    private fun addNode(list: ArrayList<PathDataNode>, cmd: Char, `val`: FloatArray) {
        list.add(PathDataNode(cmd, `val`))
    }

    /**
     * Parse the floats in the string.
     * This is an optimized version of parseFloat(s.split(",|\\s"));
     *
     * @param s the string containing a command and list of floats
     * @return array of floats
     */
    @Throws(NumberFormatException::class)
    private fun getFloats(s: String): FloatArray {
        if (s[0] == 'z' || s[0] == 'Z')
            return FloatArray(0)
        val tmp = FloatArray(s.length)
        var count = 0
        var pos = 1
        var end: Int
        while (extract(s, pos).also { end = it } >= 0) {
            if (pos < end) tmp[count++] = s.substring(pos, end).toFloat()
            pos = end + 1
        }
        // handle the final float if there is one
        if (pos < s.length) tmp[count++] = s.substring(pos).toFloat()
        return tmp.copyOf(count)
    }

    /**
     * Calculate the position of the next comma or space
     *
     * @param s     the string to search
     * @param start the position to start searching
     * @return the position of the next comma or space or -1 if none found
     */
    private fun extract(s: String, start: Int): Int {
        val space = s.indexOf(' ', start)
        val comma = s.indexOf(',', start)
        if (space == -1) return comma
        return if (comma == -1) space else Math.min(comma, space)
    }

    class PathDataNode(private val type: Char, private var params: FloatArray) {
        @Suppress("unused")
        constructor(n: PathDataNode) : this(n.type, n.params.copyOf(n.params.size))

        companion object {
            fun nodesToPath(node: Array<PathDataNode>, path: Path) {
                val current = FloatArray(4)
                var previousCommand = 'm'
                for (pathDataNode in node) {
                    addCommand(path, current, previousCommand, pathDataNode.type, pathDataNode.params)
                    previousCommand = pathDataNode.type
                }
            }

            private fun addCommand(path: Path, current: FloatArray, inputPreviousCmd: Char, cmd: Char, floats: FloatArray) {
                var previousCmd = inputPreviousCmd
                var incr = 2
                var currentX = current[0]
                var currentY = current[1]
                var ctrlPointX = current[2]
                var ctrlPointY = current[3]
                var reflectiveCtrlPointX: Float
                var reflectiveCtrlPointY: Float
                when (cmd) {
                    'z', 'Z' -> {
                        path.close()
                        return
                    }
                    'm', 'M', 'l', 'L', 't', 'T' -> incr = 2
                    'h', 'H', 'v', 'V' -> incr = 1
                    'c', 'C' -> incr = 6
                    's', 'S', 'q', 'Q' -> incr = 4
                    'a', 'A' -> incr = 7
                }
                var k = 0
                while (k < floats.size) {
                    when (cmd) {
                        'm' -> {
                            path.rMoveTo(floats[k], floats[k + 1])
                            currentX += floats[k]
                            currentY += floats[k + 1]
                        }
                        'M' -> {
                            path.moveTo(floats[k], floats[k + 1])
                            currentX = floats[k]
                            currentY = floats[k + 1]
                        }
                        'l' -> {
                            path.rLineTo(floats[k], floats[k + 1])
                            currentX += floats[k]
                            currentY += floats[k + 1]
                        }
                        'L' -> {
                            path.lineTo(floats[k], floats[k + 1])
                            currentX = floats[k]
                            currentY = floats[k + 1]
                        }
                        'h' -> {
                            path.rLineTo(floats[k], 0f)
                            currentX += floats[k]
                        }
                        'H' -> {
                            path.lineTo(floats[k], currentY)
                            currentX = floats[k]
                        }
                        'v' -> {
                            path.rLineTo(0f, floats[k])
                            currentY += floats[k]
                        }
                        'V' -> {
                            path.lineTo(currentX, floats[k])
                            currentY = floats[k]
                        }
                        'c' -> {
                            path.rCubicTo(floats[k], floats[k + 1], floats[k + 2], floats[k + 3], floats[k + 4], floats[k + 5])
                            ctrlPointX = currentX + floats[k + 2]
                            ctrlPointY = currentY + floats[k + 3]
                            currentX += floats[k + 4]
                            currentY += floats[k + 5]
                        }
                        'C' -> {
                            path.cubicTo(floats[k], floats[k + 1], floats[k + 2], floats[k + 3],
                                    floats[k + 4], floats[k + 5])
                            currentX = floats[k + 4]
                            currentY = floats[k + 5]
                            ctrlPointX = floats[k + 2]
                            ctrlPointY = floats[k + 3]
                        }
                        's' -> {
                            reflectiveCtrlPointX = 0f
                            reflectiveCtrlPointY = 0f
                            if (previousCmd == 'c' || previousCmd == 's' || previousCmd == 'C' || previousCmd == 'S') {
                                reflectiveCtrlPointX = currentX - ctrlPointX
                                reflectiveCtrlPointY = currentY - ctrlPointY
                            }
                            path.rCubicTo(reflectiveCtrlPointX, reflectiveCtrlPointY, floats[k], floats[k + 1], floats[k + 2], floats[k + 3])
                            ctrlPointX = currentX + floats[k]
                            ctrlPointY = currentY + floats[k + 1]
                            currentX += floats[k + 2]
                            currentY += floats[k + 3]
                        }
                        'S' -> {
                            reflectiveCtrlPointX = currentX
                            reflectiveCtrlPointY = currentY
                            if (previousCmd == 'c' || previousCmd == 's' || previousCmd == 'C' || previousCmd == 'S') {
                                reflectiveCtrlPointX = 2 * currentX - ctrlPointX
                                reflectiveCtrlPointY = 2 * currentY - ctrlPointY
                            }
                            path.cubicTo(reflectiveCtrlPointX, reflectiveCtrlPointY, floats[k], floats[k + 1], floats[k + 2], floats[k + 3])
                            ctrlPointX = floats[k]
                            ctrlPointY = floats[k + 1]
                            currentX = floats[k + 2]
                            currentY = floats[k + 3]
                        }
                        'q' -> {
                            path.rQuadTo(floats[k], floats[k + 1], floats[k + 2], floats[k + 3])
                            ctrlPointX = currentX + floats[k]
                            ctrlPointY = currentY + floats[k + 1]
                            currentX += floats[k + 2]
                            currentY += floats[k + 3]
                        }
                        'Q' -> {
                            path.quadTo(floats[k], floats[k + 1], floats[k + 2], floats[k + 3])
                            ctrlPointX = floats[k]
                            ctrlPointY = floats[k + 1]
                            currentX = floats[k + 2]
                            currentY = floats[k + 3]
                        }
                        't' -> {
                            reflectiveCtrlPointX = 0f
                            reflectiveCtrlPointY = 0f
                            if (previousCmd == 'q' || previousCmd == 't' || previousCmd == 'Q' || previousCmd == 'T') {
                                reflectiveCtrlPointX = currentX - ctrlPointX
                                reflectiveCtrlPointY = currentY - ctrlPointY
                            }
                            path.rQuadTo(reflectiveCtrlPointX, reflectiveCtrlPointY,
                                    floats[k], floats[k + 1])
                            ctrlPointX = currentX + reflectiveCtrlPointX
                            ctrlPointY = currentY + reflectiveCtrlPointY
                            currentX += floats[k]
                            currentY += floats[k + 1]
                        }
                        'T' -> {
                            reflectiveCtrlPointX = currentX
                            reflectiveCtrlPointY = currentY
                            if (previousCmd == 'q' || previousCmd == 't' || previousCmd == 'Q' || previousCmd == 'T') {
                                reflectiveCtrlPointX = 2 * currentX - ctrlPointX
                                reflectiveCtrlPointY = 2 * currentY - ctrlPointY
                            }
                            path.quadTo(reflectiveCtrlPointX, reflectiveCtrlPointY, floats[k], floats[k + 1])
                            ctrlPointX = reflectiveCtrlPointX
                            ctrlPointY = reflectiveCtrlPointY
                            currentX = floats[k]
                            currentY = floats[k + 1]
                        }
                        'a' -> {
                            // (rx ry x-axis-rotation large-arc-flag sweep-flag x y)
                            drawArc(path, currentX, currentY, floats[k + 5] + currentX, floats[k + 6] + currentY, floats[k],
                                    floats[k + 1], floats[k + 2], floats[k + 3] != 0f, floats[k + 4] != 0f)
                            currentX += floats[k + 5]
                            currentY += floats[k + 6]
                            ctrlPointX = currentX
                            ctrlPointY = currentY
                        }
                        'A' -> {
                            drawArc(path, currentX, currentY, floats[k + 5], floats[k + 6], floats[k], floats[k + 1], floats[k + 2],
                                    floats[k + 3] != 0f, floats[k + 4] != 0f)
                            currentX = floats[k + 5]
                            currentY = floats[k + 6]
                            ctrlPointX = currentX
                            ctrlPointY = currentY
                        }
                    }
                    previousCmd = cmd
                    k += incr
                }
                current[0] = currentX
                current[1] = currentY
                current[2] = ctrlPointX
                current[3] = ctrlPointY
            }

            private fun drawArc(p: Path, x0: Float, y0: Float, x1: Float, y1: Float, a: Float, b: Float, theta: Float, isMoreThanHalf: Boolean, isPositiveArc: Boolean) {
                /* Convert rotation angle from degrees to radians */
                val thetaD = Math.toRadians(theta.toDouble())
                /* Pre-compute rotation matrix entries */
                val cosTheta = Math.cos(thetaD)
                val sinTheta = Math.sin(thetaD)
                /* Transform (x0, y0) and (x1, y1) into unit space */
                /* using (inverse) rotation, followed by (inverse) scale */
                val x0p = (x0 * cosTheta + y0 * sinTheta) / a
                val y0p = (-x0 * sinTheta + y0 * cosTheta) / b
                val x1p = (x1 * cosTheta + y1 * sinTheta) / a
                val y1p = (-x1 * sinTheta + y1 * cosTheta) / b
                /* Compute differences and averages */
                val dx = x0p - x1p
                val dy = y0p - y1p
                val xm = (x0p + x1p) / 2
                val ym = (y0p + y1p) / 2
                /* Solve for intersecting unit circles */
                val dsq = dx * dx + dy * dy
                if (dsq == 0.0) return  /* Points are coincident */
                val disc = 1.0 / dsq - 1.0 / 4.0
                if (disc < 0.0) {
                    val adjust = (Math.sqrt(dsq) / 1.99999).toFloat()
                    drawArc(p, x0, y0, x1, y1, a * adjust, b * adjust, theta, isMoreThanHalf, isPositiveArc)
                    return  /* Points are too far apart */
                }
                val s = Math.sqrt(disc)
                val sdx = s * dx
                val sdy = s * dy
                var cx: Double
                var cy: Double
                if (isMoreThanHalf == isPositiveArc) {
                    cx = xm - sdy
                    cy = ym + sdx
                } else {
                    cx = xm + sdy
                    cy = ym - sdx
                }
                val eta0 = Math.atan2(y0p - cy, x0p - cx)
                val eta1 = Math.atan2(y1p - cy, x1p - cx)
                var sweep = eta1 - eta0
                if (isPositiveArc != sweep >= 0) {
                    if (sweep > 0) {
                        sweep -= 2 * Math.PI
                    } else {
                        sweep += 2 * Math.PI
                    }
                }
                cx *= a.toDouble()
                cy *= b.toDouble()
                val tcx = cx
                cx = cx * cosTheta - cy * sinTheta
                cy = tcx * sinTheta + cy * cosTheta
                arcToBezier(p, cx, cy, a.toDouble(), b.toDouble(), x0.toDouble(), y0.toDouble(), thetaD, eta0, sweep)
            }

            /**
             * Converts an arc to cubic Bezier segments and records them in p.
             *
             * @param p     The target for the cubic Bezier segments
             * @param cx    The x coordinate center of the ellipse
             * @param cy    The y coordinate center of the ellipse
             * @param a     The radius of the ellipse in the horizontal direction
             * @param b     The radius of the ellipse in the vertical direction
             * @param inputE1x   E(eta1) x coordinate of the starting point of the arc
             * @param inputE1y   E(eta2) y coordinate of the starting point of the arc
             * @param theta The angle that the ellipse bounding rectangle makes with horizontal plane
             * @param start The start angle of the arc on the ellipse
             * @param sweep The angle (positive or negative) of the sweep of the arc on the ellipse
             */
            private fun arcToBezier(p: Path, cx: Double, cy: Double, a: Double, b: Double, inputE1x: Double, inputE1y: Double, theta: Double, start: Double, sweep: Double) {
                // Taken from equations at: http://spaceroots.org/documents/ellipse/node8.html
                // and http://www.spaceroots.org/documents/ellipse/node22.html
                // Maximum of 45 degrees per cubic Bezier segment
                var e1x = inputE1x
                var e1y = inputE1y
                val numSegments = Math.abs(Math.ceil(sweep * 4 / Math.PI).toInt())
                var eta1 = start
                val cosTheta = Math.cos(theta)
                val sinTheta = Math.sin(theta)
                val cosEta1 = Math.cos(eta1)
                val sinEta1 = Math.sin(eta1)
                var ep1x = -a * cosTheta * sinEta1 - b * sinTheta * cosEta1
                var ep1y = -a * sinTheta * sinEta1 + b * cosTheta * cosEta1
                val anglePerSegment = sweep / numSegments
                for (i in 0 until numSegments) {
                    val eta2 = eta1 + anglePerSegment
                    val sinEta2 = Math.sin(eta2)
                    val cosEta2 = Math.cos(eta2)
                    val e2x = cx + a * cosTheta * cosEta2 - b * sinTheta * sinEta2
                    val e2y = cy + a * sinTheta * cosEta2 + b * cosTheta * sinEta2
                    val ep2x = -a * cosTheta * sinEta2 - b * sinTheta * cosEta2
                    val ep2y = -a * sinTheta * sinEta2 + b * cosTheta * cosEta2
                    val tanDiff2 = Math.tan((eta2 - eta1) / 2)
                    val alpha = Math.sin(eta2 - eta1) * (Math.sqrt(4 + 3 * tanDiff2 * tanDiff2) - 1) / 3
                    val q1x = e1x + alpha * ep1x
                    val q1y = e1y + alpha * ep1y
                    val q2x = e2x - alpha * ep2x
                    val q2y = e2y - alpha * ep2y
                    p.cubicTo(q1x.toFloat(), q1y.toFloat(), q2x.toFloat(), q2y.toFloat(), e2x.toFloat(), e2y.toFloat())
                    eta1 = eta2
                    e1x = e2x
                    e1y = e2y
                    ep1x = ep2x
                    ep1y = ep2y
                }
            }
        }
    }
}

Doyle answered 1/5, 2020 at 10:53 Comment(0)
M
1

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:

enter image description here

Mesocarp answered 8/5, 2020 at 2:14 Comment(5)
impressive! Could you please offer a way to have a Bitmap to hold the final output (background with foreground) ? Could be useful. It seems you've handled only the background... – Doyle
If you have a look at the list behind the bottom sheet, you can see the foreground is clipped too (those images are not set as a background). – Jacobinism
Noticed issues on API 27 if you apply any padding to the view (I only tested on 29 when I made it), so it could be improved, but other than that it correctly masks and clips the entire view. – Jacobinism
As for writing to a bitmap rather than a view, should just be a matter of creating a canvas for the bitmap, setting the appropriate sizes to the path/drawables, and then manually drawing to the canvas in the same way this example view does (replacing super.onDraw(canvas) with foregroundDrawable.draw(canvas) etc). – Jacobinism
It has the same issue as I have: It makes the image inside smaller for some reason. Here's how it looks: i.imgur.com/LLjiW1e.png . Why does it happen? Maybe they zoom in both foreground and background for some reason? And if I use the same drawables as the one I tried to show as original, it also affects it: i.imgur.com/Pi0jNkW.png . Same here, no idea why this occurs... – Doyle
P
1

I know two ways to build a custom shaped icon from an AdaptiveIconDrawable. I however think that Google should make a public AdaptiveIconDrawable.setMask(Path path) method:

First way (pretty same way as AOSP code):

public Bitmap createBitmap(@NonNull AdaptiveIconDrawable drawable, @NonNull Path path, int outputSize) {

    // make the drawable match the output size and store its bounds to restore later
    final Rect originalBounds = drawable.getBounds();
    drawable.setBounds(0, 0, outputSize, outputSize);

    // rasterize drawable
    final Bitmap outputBitmap = Bitmap.createBitmap(outputSize, outputSize, Bitmap.Config.ARGB_8888);
    final Canvas tmpCanvas = new Canvas(maskBitmap);
    drawable.getBackground().draw(tmpCanvas);
    drawable.getForeground().draw(tmpCanvas);

    // build a paint with shader composed by the rasterized AdaptiveIconDrawable
    final BitmapShader shader = new BitmapShader(outputBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
    final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG |
            Paint.FILTER_BITMAP_FLAG);
    paint.setShader(shader);

    // draw the shader with custom path (shape)
    tmpCanvas.drawPath(path, paint);

    // restore drawable original bounds
    drawable.setBounds(originalBounds);

    return outputBitmap;

}

Second way (the one I like most, because it allows to cache the mask bitmap in case of need using multiple times, avoiding Bitmap, Canvas, BitmapShader, and Paint re-allocation). If you don't understand, make sure you check this link out:

@Nullable private Bitmap mMaskBitmap;
@Nullable private Paint mClearPaint;

@NonNull Canvas mCanvas = new Canvas();

@Nullable Path mCustomShape; // your choice

@Nullable Rect mOldBounds;

public Bitmap createBitmap(@NonNull AdaptiveIconDrawable drawable, int outputSize) {
    final Bitmap outputBitmap = Bitmap.createBitmap(outputSize, outputSize, Bitmap.Config.ARGB_8888);
    mCanvas.setBitmap(outputBitmap);

    // rasterize the AdaptiveIconDrawable
    mOldBounds = drawable.getBounds();
    drawable.setBounds(0, 0, outputSize, outputSize);
    drawable.getBackground().draw(mCanvas);
    drawable.getForeground().draw(mCanvas);

    // finally mask the bitmap, generating the desired output shape
    // this clears all the pixels of the rasterized AdaptiveIconDrawable which
    // fall below the maskBitmap BLACK pixels
    final Bitmap maskBitmap = getMaskBitmap(mCustomShape, outputSize);
    mCanvas.drawBitmap(maskBitmap, 0, 0, mClearPaint);

    // restore original drawable bounds
    drawable.setBounds(mOldBounds);

    return outputBitmap;
}

// results a bitmap with the mask of the @path shape
private Bitmap getMaskBitmap(@Nullable Path path, int iconSize) {
    if (mMaskBitmap != null && mMaskBitmap.getWidth() == iconSize && mMaskBitmap.getHeight() == iconSize)
        // quick return if already cached AND size-compatible
        return mMaskBitmap;

    // just create a plain, black bitmap with the same size of AdaptiveIconDrawable
    mMaskBitmap = Bitmap.createBitmap(iconSize, iconSize, Bitmap.Config.ALPHA_8);
    mMaskBitmap.eraseColor(Color.BLACK);
    final Canvas tmpCanvas = new Canvas(mMaskBitmap);

    // clear the pixels inside the shape (those where the icon will be visible)
    mClearPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
    mClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));
    if (path != null) 
        // if path is null, the output adaptive icon will not be masked (square, full size)
        tmpCanvas.drawPath(path, mClearPaint);

    return mMaskBitmap;
}

I prefer the second way, but the best one depends on the usage. If only one icon is shaped, then the first one would do the job. However, for multiple icons, the second one is better to go. Share your thoughts

Pax answered 23/8, 2020 at 23:32 Comment(4)
Interesting. Why do you draw both the background and the foreground to the bitmap that's supposed to be the background being shaped? If I wanted to convert them together to Bitmap without applying any customized shape, there is a simple Kotlin function "toBitmap" for that... Is there a way to get a shaped Drawable out of the background one of the AdaptiveIconDrawable, instead of using a Bitmap? Also, can you please also share sample usages? – Doyle
@androiddeveloper Not sure where the method toBitmap() can be found (in Java at least). The main reason I first fetch both background and foreground drawables from the AdaptiveIconDrawable is because when you attempt to draw such drawable into a canvas, the system will mask it according the system config. When you do getBackground() and getForeground() they aren't masked so you can later mask with the shape that you want. You can see here android.googlesource.com/platform/frameworks/base/+/refs/heads/… – Pax
@androiddeveloper The sMask is a Path that AdaptiveIconDrawable will use to create a BitmapShader to paint the icon already shaped in the canvas provided to AdaptiveIconDrawable.draw(). It looks like you can't modify the mask used unless you access and modify ´mMaskScaleOnly´ field via reflection, which I do not recommend at all, since it is not defined in lower API levels. You can however draw the custom shaped drawable without allocating Bitmap/BitmapShader, just draw the both layers and then draw the Path into canvas with a PorterDuff.Mode.DST_IN directly in View.onDraw, Which can be slower – Pax
The "toBitmap" is available in Kotlin, but you can use it in Java too (call the Kotlin function, not sure where though) . About the foreground, I just asked if you shape it too, because to me it seems you are, and I'm not sure if it's a good thing to do. I also asked if it's possible to avoid a Bitmap, and just have a new Drawable based on the background and the shape, which you could resize if you wish (Bitmap loses quality when being enlarged). Can you please also share a sample of how you use your code? – Doyle
L
0

Launchers have much less restrictions than applications, so they may use other approaches, but one solution has been nicely showcased in Nick Butcher's Adaptive Icon Playground.

The class you're probably interested in is the Adaptive Icon View which renders adapted versions of the icon by creating a raster of each layer with the background as a canvas bitmap and then drawing those layers as rounded rectangles to implement the clipping.

The linked repo will be much more informative and includes examples of how to transform the layer for movement effects etc., but here is the basic pseudo-code for 'adapting an icon' in an image view:

setIcon() {
    //erase canvas first...
    canvas.setBitmap(background)
    drawable.setBounds(0, 0, layerSize, layerSize)
    drawable.draw(canvas)
}

onDraw() {
    //draw shadow first if needed...
    canvas.drawRoundRect(..., cornerRadius, backgroundPaint)
    canvas.drawRoundRect(..., cornerRadius, foregroundPaint)
}
Lunulate answered 22/12, 2017 at 12:43 Comment(1)
How do you apply the tear shape, and no shape at all? How do you create a drawable and a bitmap out of the AdaptiveIconDrawable, that could be used as an app icon? How do you get the system's default shape? – Doyle
O
0

There's a soooo much simpler way to solve this nowadays using com.google.android.material.imageview.ShapeableImageView:

fun ShapeableImageView.setAdaptiveIcon(drawable: Drawable?) =
    setImageDrawable(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
                         && drawable is AdaptiveIconDrawable) {
        background = drawable.background
        drawable.foreground
    } else {
        drawable 
    })

That's it. Just set whatever shape you need on the ShapeableImageView and the icon will be cropped accordingly.

Note: An AdaptiveIconDrawable's foreground layer is typically inset by 18dp as per documentation. Depending on your use case, you may want to set the contentPadding of the image view to -18dp to offset this.

Overliberal answered 1/12, 2023 at 5:34 Comment(3)
But does ShapeableImageView offer to handle all kinds of shapes? Even customized ones by some VectorDrawable shape or by a masked Bitmap? Which shapes does it support and how to create new ones? Is it possible to do it without a new View, and have a new Drawable/Bitmap that is in the shape I want instead, given a Bitmap and how to make a shape out of it? – Doyle
@androiddeveloper documentation on supported shapes is here: m2.material.io/develop/android/theming/shape and here for M3 differences: m3.material.io/styles/shape/overview – Overliberal
Not sure how many there are here. There are also no screenshots of how each one looks and how to use them. Was expecting a table... Does it support customized shapes? For example if I want to create a star-shape? Or a hexagon-shape? etc... – Doyle
H
-2

Since Launcher is just an Activity, you can draw anything. You can draw application icons like ponies that run on beautiful animated clouds. This is your world, which obeys only your rules.

Further ... There is no magic in the programming world. If you are faced with magic, just use decompilers (with Java it very easy), find the code responsible for magic, document it and write a great blog post about how this magic works.

Given a AdaptiveIconDrawable instance, how do you shape it, to be of a circular shape, rectangle, rounded rectangle, tear, and so on?

You can use AdaptiveIconDrawable.getBackground() and add any mask to it. Actually, you can do anything what you want with icon, AdaptiveIconDrawable is just way, where you can split foreground and background in easy way, without complicated filters or neural networks. Add parallax, animations and many more effects, now you have 2 layer for it.

Homochromatic answered 12/12, 2017 at 3:47 Comment(1)
The question is not if it's possible or requires magic. The question is how to do it. Can you please show in code, how to shape the background (and to remove it), and prepare it to be shown or drawn into a bitmap that can be used as an app icon? – Doyle

© 2022 - 2024 β€” McMap. All rights reserved.