A few days ago, I came across this design on Pinterest, and it got me excited. There are many ways to build this, and the approach I took is a bit unusual, slightly complex, and not the most performant. But for a first version? It looks good.

Overview

We start by creating a Bitmap and drawing text onto it. Why a bitmap, you ask? Because a bitmap is essentially a 2D array of pixels, where each pixel holds RGBA values and that’s exactly what we need for this design: pixelated text.
Once we have the bitmap, we scale it down to match the number of dots we want to render using a fixed number of rows and columns. This gives us a smaller, low-res version of the original bitmap. More dots (i.e., higher pixel density) means a sharper, more accurate image.
From this scaled-down bitmap, we extract a dotMatrix a 2D array where each value represents the alpha (transparency) of that pixel. We then loop through this matrix, and for each pixel:
- If it has visible content (alpha > 0), we display a colored dot.
- If it’s fully transparent or black, we skip it or render it as empty.
Creating a Bitmap with Text
Since Jetpack Compose doesn’t support direct text rendering on bitmaps, we use the android.graphics.Canvas API to draw text manually. We create a mutable bitmap, attach it to a Canvas, and draw centered text using drawTextand we return this bitmap
| @Composable | |
| fun DottedText( | |
| modifier: Modifier, | |
| text: String, | |
| textScale: Float | |
| ) { | |
| BoxWithConstraints(modifier) { | |
| //Create a Bitmap with text | |
| val bitmap = remember(text, textScale) { | |
| renderTextToBitmap(text, textScale, this.maxWidth.value, this.maxHeight.value) | |
| } | |
| //Create a Dot Matrix | |
| val dotMatrix = remember(bitmap) { bitmapToDotMatrix(bitmap, 30, 30) } | |
| //Render Dot Matrix | |
| DotMatrixDisplay( | |
| modifier = Modifier.fillMaxSize(), | |
| dotMatrix = dotMatrix, | |
| dotSize = 10.dp | |
| ) | |
| } | |
| } | |
| fun renderTextToBitmap( | |
| renderText: String, | |
| renderTextScale: Float, | |
| width: Float, | |
| height: Float | |
| ): Bitmap { | |
| val bitmap = createBitmap(width.toInt(), height.toInt(), Bitmap.Config.ARGB_8888) | |
| val canvas = Canvas(bitmap) | |
| val textPaint = Paint().apply { | |
| color = Color.WHITE | |
| textSize = (Math.min(width, height) * renderTextScale) | |
| isAntiAlias = true | |
| textAlign = Paint.Align.CENTER | |
| } | |
| val x = width / 2f | |
| val y = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f) | |
| canvas.drawText(renderText, x, y, textPaint) | |
| return bitmap | |
| } |
Converting Bitmap to Dot Matrix
We scale the bitmap to a fixed size (rows × cols) to control the number of dots rendered on the screen. Think of it like converting a high-resolution image into a low-res version. We then iterate through each cell in the scaled-down bitmap and record the alpha value at each pixel to construct a 2D matrix. This matrix helps us decide which dots should be visible (non-zero alpha) and which should be skipped (zero alpha).
| fun bitmapToDotMatrix(bitmap: Bitmap, rows: Int, cols: Int): List<List<Int>> { | |
| //scale bitmap based on the number of rows and cols we want | |
| val scaledBitmap = Bitmap.createScaledBitmap(bitmap, cols, rows, true) | |
| val result = mutableListOf<List<Int>>() | |
| for (y in 0 until rows) { | |
| val row = mutableListOf<Int>() | |
| for (x in 0 until cols) { | |
| val pixel = scaledBitmap[x, y] | |
| val red = Color.red(pixel) | |
| val green = Color.green(pixel) | |
| val blue = Color.blue(pixel) | |
| val alpha = Color.alpha(pixel) | |
| print(" $alpha ") | |
| row.add(alpha) | |
| } | |
| println() | |
| result.add(row) | |
| } | |
| return result | |
| } |
Displaying the Dot Matrix
We simply iterate through the matrix row by row, and for each cell. If the alpha value is greater than 0, we show a colored dot. Otherwise, we render it with the background color.
Job Offers
| @Composable | |
| fun DotMatrixDisplay( | |
| modifier: Modifier = Modifier, | |
| dotMatrix: List<List<Int>>, | |
| dotSize: Dp | |
| ) { | |
| Column( | |
| modifier = modifier, | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| verticalArrangement = Arrangement.Center | |
| ) { | |
| dotMatrix.forEach { row -> | |
| Row { | |
| row.forEach { alpha -> | |
| val targetColor = if (alpha > 0) { | |
| MaterialTheme.colorScheme.primary | |
| } else { | |
| MaterialTheme.colorScheme.background | |
| } | |
| Box( | |
| modifier = Modifier | |
| .size(dotSize) | |
| .padding(1.dp) | |
| .background( | |
| color = targetColor, | |
| shape = CircleShape | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } |
That’s it! You can check out the GitHub repo below. I’ve also included another simple and intuitive approach to achieve the same effect, though it might need a few tweaks to match the exact output
This article was previously published on proandroiddev.com.


