Math and Graphics Can Be Fun
A fractal is a geometric object, usually described by a formula or a simple recursive rule. Fractals have interesting properties: they have self-similarity, they are infinite, and, last but not least, fractals can just be beautiful.
In this article, I will show how to make an Android application that will allow us to pan and zoom the fractal on any scale, limited only by the accuracy of floating point operations. As we will see further, effective fractal generation can be challenging even on modern smartphones and will require some knowledge of Kotlin and Android architecture.
Let’s get started!
1. A Bit of Theory
In this article, we will draw a Mandelbrot set. This set was first defined and drawn by Robert W. Brooks and Peter Matelski in 1978. A year later, Benoit Mandelbrot made high-quality visualizations of the set while working at IBM’s Thomas J. Watson Research Center.
The Mandelbrot set is described by a simple recursive formula:
Zo = 0 Zn+1 = Zn*Zn + C
The rule is straightforward: a point C belongs to the set, if this sequence remains bounded. For example, for C = 0.5, the output looks like this:
Z0 = 0 Z1 = 0.5 Z2 = 0.75 Z3 = 1.0625 Z4 = 1.62890625 Z5 = 3.153335571 Z6 = 10.443525225 Z7 = 109.567219128
As we can see, the output grows pretty fast, thus this point does not belong to the Mandelbrot set. For C = 0.1, the result is different:
Z0 = 0 Z1 = 0.1 Z2 = 0.110 Z3 = 0.1121 Z4 = 0.11256641 Z5 = 0.112671196660 Z6 = 0.112694798556 Z7 = 0.112700117621
Practically, it is enough to make 100–200 iterations to see if the value exceeds the threshold. A threshold can be set to an arbitrary value, like 4 or 10.
Every complex number has two digits (real and imaginary parts), and if we draw different points on the XY plane, we will get a monochrome image like this:
However, a much more informative and beautiful picture we can get, if we use the number of iterations for each point. In our example, for C=0.5, the number of iterations is 7. This allows us to add a “color dimension” and see the edges and refined details of the fractal:
Interestingly, the fractal image not only looks nice but also has some interesting properties:
- The full Mandelbrot set fits in a circle with a radius of two.
- The Mandelbrot set area is about 1.5065918, and the exact number is not known yet. Even more, the fractal’s area is finite but its perimeter is infinite (a similar effect was measured in practice, see a Coastline Paradox page for a more detailed explanation).
- Last but not least, we can infinitely zoom the Mandelbrot set to any scale, and get an infinite number of nice pictures like this:
Now, let’s explore the fractals in Kotlin.
2. Prototype: Kotlin Notebook
A Jupyter Notebook is a popular tool in Python development and data science. The notebook allows us to easily test different ideas without making a full-scale app. It’s a sort of “REPL on steroids,” where we can not only run Kotlin code, but also see images, graphs, re-run different cells, and so on.
Jupyter was originally designed for Python, however, we can do the same in Kotlin. First, we need to install the Kotlin environment (here, I use Conda as a package manager):
conda activate kotlin conda install -c jetbrains kotlin-jupyter-kernel
After that, we can run the notebook in the console:
jupyter lab
If everything was installed correctly, a web browser page will be opened:
After that, we can create a new notebook, type the Kotlin code, and immediately execute it:
This setup is minimalistic, it works fast and does not even require Android Studio or JetBrains IDE, everything works in the web browser. The execution is almost instant; no time is required to build the app or run the emulator. We can easily re-run the code by pressing Ctrl+Enter; this approach is good for prototyping and testing different algorithms.
Using the Kotlin notebook, we can draw a fractal in less than 50 lines of code — something that is impossible with a full-size Android app!
First, let’s create a PointD data class, that will store a complex point value:
data class PointD( val x: Double, val y: Double, ) { // Complex number point with Double precision operator fun plus(c: PointD) = PointD(x + c.x, y + c.y) operator fun times(c: PointD) = PointD(x * c.x - y * c.y, x * c.y + y * c.x) fun abs(): Double = x * x + y * y }
Second, let’s create a calculatePointIterations function, that will return a number of iterations for a fractal point, as was discussed before:
fun calculatePointIterations(c: PointD, maxIterations: Int = 255): Int { val THRESHOLD = 20 var z = PointD(0.0, 0.0) for (iteration in 0..<maxIterations) { z = z * z + c if (z.abs() > THRESHOLD) return iteration } return -1 }
These two methods are already enough to generate the fractal, but we also want to see it. Let’s create a method to generate the image:
import java.awt.Color import java.awt.image.BufferedImage fun iterationsToColor(i: Int): Int { if (i == -1) return Color.BLACK.getRGB() return Color(8*i % 255, i % 255, 0).getRGB() } val width = 512 val height = 512 val scale = 2 val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) for (x in 0..<width) { for (y in 0..<height) { val frX = scale*(x - 0.5*width)/width val frY = scale*(y - 0.5*height)/height val iterations = calculatePointIterations(PointD(frX, frY)) img.setRGB(x, y, iterationsToColor(iterations)) } } DISPLAY(img)
Here, I created a 512×512 image and used the iterationsToColor method to set pixel colors. A DISPLAY method shows the output in the browser.
The result looks nice:
These three methods are enough to draw the fractal and test different parameters. Readers are welcome to change the iterationsToColor, THRESHOLD, center, and scale, and experiment with other types of color conversions.
Alas, the main disadvantage of using Jupyter, is that it only runs on a desktop. The old Java slogan from the 90s “Write Once, Run Everywhere” practically does not work. For example, the java.awt library, used in this example, is not available on Android, and many Android libraries are not available on the desktop. At least, for testing the algorithms and Kotlin code, Jupyter Notebooks can be useful.
Now, when our Kotlin code works, let’s port it into a “real” Android app.
3. Android
Obviously, a full-fledged Android app requires much more code, compared to Jupyter Notebook. We need a code to generate fractals (that was tested before); we need a View Model to store the data; we need a Jetpack Compose to make the UI, and we need a code to enable user interaction like drag and zoom.
Let’s get started and make it step-by-step!
3.1 Fractal Generation
We already tested the prototype, but for the real app, we need a more flexible architecture. It is possible to create different types of fractals (Mandelbrot set, Julia set, etc), so let’s make a universal interface:
interface FractalGenerator { fun getTitle(): String fun calculatePointColor(c: PointD): Int }
With this approach, we can create different fractals and use them with a Factory or Dependency Injection pattern.
As an example, this is an implementation of the Mandelbrot set:
class MandelbrotSet( private val colorConverter: ColorConverter ): FractalGenerator { companion object { const val THRESHOLD = 20 } override fun getTitle() = "The Mandelbrot Set" override fun calculatePointColor(c: PointD): Int { val iterations = calculatePointIterations(c) return colorConverter.iterationsToColor(iterations) } private fun calculatePointIterations(c: PointD, maxIterations: Int = 255): Int { var z = PointD(0.0, 0.0) for (iteration in 0..<maxIterations) { z = z * z + c if (z.abs() > THRESHOLD) return iteration } return -1 } }
Here, I reused the same PointD class and a calculatePointIterations method, that was discussed before.
As we can see in the code, a MandelbrotSet requires a ColorConverter object. As was discussed before, we need a color conversion to represent the number of iterations as RGB. Here, different ways are available. For example, we can directly map the number of iterations to RGB values, then the image will be grayscale. The colorful image may look better; for example, the HSV conversion gives interesting results.
Let’s create a ColorConverter class. I prepared some methods here, and readers are welcome to do experiments on their own:
class ColorConverter { fun iterationsToColor(iterations: Int): Int { return hsvToRGB(iterations) } private fun hsvToRGB(i: Int): Int { if (i == -1) return Color.Black.toArgb() return Color.hsv(i.toFloat(), 1.0f, 0.5f).toArgb() } private fun colToRGB(i: Int): Int { if (i == -1) return Color.Black.toArgb() return Color(red = i % 64 * 4, green = i % 32 * 8, blue = i % 128 * 2).toArgb() } private fun linearToRGB(i: Int): Int { if (i == -1) return Color.Black.toArgb() return Color(red = i % 255, green = i % 255, blue = i % 128).toArgb() } }
Job Offers
3.2 ViewModel
Jetpack Compose is a reactive framework, and we need to store our data in a view model. First, let’s create a data class, representing a UI state object:
data class FractalUIState( val image: ImageBitmap? = null, val offset: IntOffset = IntOffset.Zero, val zoom: Float = 1.0f, val processingTime: Float = 0.0f, val isProcessing: Boolean = false, ) { val imageSize: IntSize? get() = image?.let { IntSize(it.width, it.height) } }
Here, the image contains the generated picture of the fractal; offset and zoom variables are used for screen interaction. First, I tried to draw a fractal in real-time, but even on a modern smartphone, this did not work well. The generation takes 1–2 seconds, which is too slow. After that, I switched to a combined approach. When the user is zooming or dragging an image, we only apply a transformation to the bitmap; only at the end of the tap or zoom, a new fractal image is generated.
On my phone, Android Canvas has a size of 1080×2274. Fractal points have a range of about -1..1, and we also need to keep the fractal’s position in its original scale:
data class FractalParams( val center: PointD = PointD(0.0, 0.0), val areaPerPixel: Double = DEFAULT_SCALE, ) { companion object { const val DEFAULT_SCALE = 4.0/2274 } }
When the user ends up dragging the picture, the application updates the coordinates and makes a new fractal; a similar approach works for zooming.
Now, we have enough information to create a View Model:
import androidx.lifecycle.ViewModel class MainViewModel( private val fractalGenerator: FractalGenerator ) : ViewModel() { private var fractalParams: FractalParams? = null private var canvasSize = IntSize.Zero private val _fractalState = MutableStateFlow(FractalUIState()) val fractalState: StateFlow<FractalUIState> = _fractalState.asStateFlow() ... }
3.3 Calculation: Coroutines and Model Scope
As was discussed before, fractal generation itself is simple, but not fast — even on a modern smartphone, the process takes several seconds. To make things faster, I created a method that generates an arbitrary part of the fractal:
private fun generateFractalPart( yRange: IntRange, params: FractalParams ): IntArray { val width = canvasSize.width val result = IntArray(width*(yRange.last - yRange.first)) for (py in yRange.first..<yRange.last) { for (px in 0..<width) { val fx = params.center.x + (px - canvasSize.width/2)*params.areaPerPixel val fy = params.center.y + (py - canvasSize.height/2)*params.areaPerPixel val color = fractalGenerator.calculatePointColor(PointD(fx, fy)) result[(py - yRange.first)*width + px] = color } } return result }
Here, the yRange value specifies which part of the fractal we want to make. The output is the IntArray, which contains the pixel colors of the image.
Now, we can create several parallel async jobs:
private suspend fun generateFractal( params: FractalParams ) { withContext(Dispatchers.Default) { val bitmap = generateFractalImage(params) withContext(Dispatchers.Main) { _fractalState.value = FractalUIState( image = bitmap, isProcessing = false, ) } } } private suspend fun generateFractalImage( params: FractalParams ): ImageBitmap = withContext(Dispatchers.Default) { val nChunks = 4 val blockSize = canvasSize.height/nChunks val requests = ArrayList<Deferred<IntArray>>() for (i in 0..<nChunks) { val job = async { generateFractalPart( IntRange(blockSize*i, blockSize*(i + 1)), params ) } requests.add(job) } val chunks: List<IntArray> = requests.awaitAll() return@withContext createBitmapFromChunks(chunks) } private fun createBitmapFromChunks( chunks: List<IntArray> ): ImageBitmap { val bitmap = Bitmap.createBitmap( canvasSize.width, canvasSize.height, Bitmap.Config.ARGB_8888 ) val nChunks = chunks.size val blockSize = canvasSize.height/nChunks for ((index, chunk) in chunks.withIndex()) { bitmap.setPixels( chunk, 0, canvasSize.width, 0, index*blockSize, canvasSize.width, blockSize ) } return bitmap.asImageBitmap()
A Dispatchers.Default scope is better optimized for CPU-intensive tasks. In this scope, I call a generateFractalImage method, that creates a number of async tasks. When the bitmap is ready, I update the Android UI in the Main CoroutineScope.
The execution results are interesting, but also a bit confusing:
First, multiprocessing does not work properly on the Android emulator, (the Runtime.getRuntime().availableProcessors() method on the emulator always returns 1). So, if we need to test the performance, we cannot do it properly on the emulator.
Second, on the real phone, it works, but not as good as I expected. My Galaxy Flip phone has 8 cores, but even with 8 async tasks, I got only a 45% reduction in execution time. Each task is independent, there is no interprocess communication, so the reason is not 100% clear to me. Probably, the amount of CPU time that Android can provide to the app is strictly limited, and we will never see an 8x performance boost even on the 8-core CPU. If readers know the reason in more detail, please write in the comments below.
3.4 Jetpack Compose: UI
At this point, we created a View Model that can generate a fractal and update the model state when ready. Let’s make the UI for it:
Our main layout will have a fractal image and a helper text:
@Composable fun MainLayout(modifier: Modifier = Modifier) { Box(modifier = modifier) { val viewModel: MainViewModel = viewModel(factory = MainViewModel.Factory) val fractalState by viewModel.fractalState.collectAsStateWithLifecycle() val drawModifier = Modifier.fillMaxSize().onGloballyPositioned( { coordinates -> viewModel.setCanvasSize(coordinates.size) }.clipToBounds() FractalLayout(fractalState, drawModifier) TextLayout( viewModel.getTitle(), fractalState, viewModel.getFractalZoom() ) } }
Here, I also inform the ViewModel about the canvas size using the onGloballyPositioned callback; this information is needed to make an image of the proper size.
A FractalLayout contains a Canvas with a drawImage call, that actually displays the fractal image:
@Composable fun FractalLayout(fractalState: FractalUIState, modifier: Modifier) { Canvas(modifier = modifier) { drawRect( color = Color.DarkGray, size = size ) fractalState.image?.let { val width = it.width*fractalState.zoom val height = it.height*fractalState.zoom val moveOffset = fractalState.offset*fractalState.zoom val zoomOffset = Offset((width - it.width)/2, (height - it.height)/2) val offsetX = -zoomOffset.x + moveOffset.x val offsetY = -zoomOffset.y + moveOffset.y drawImage(it, dstOffset = IntOffset(offsetX.toInt(), offsetY.toInt()), dstSize = IntSize(width.toInt(), height.toInt()) ) } } }
A TextLayout shows the information about the fractal and its current zoom:
@Composable fun TextLayout(title: String, fractalState: FractalUIState, fractalZoom: Int) { Column(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { val mainColor = MaterialTheme.colorScheme.primaryContainer val secondaryColor = MaterialTheme.colorScheme.secondaryContainer Text( text = title, color = mainColor, fontSize = with(LocalDensity.current) { 32.dp.toSp() }, fontWeight = FontWeight.Bold, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), textAlign = TextAlign.Center ) Text( text = "Zoom: $fractalZoom%", color = secondaryColor, ) if (fractalState.isProcessing) { Text( text = "Rendering, please wait...", color = secondaryColor, ) Spacer(modifier = Modifier.height(16.dp)) LinearProgressIndicator( modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp), trackColor = MaterialTheme.colorScheme.surfaceVariant ) } else { Text( text = "Processing: ${fractalState.processingTime}s", color = secondaryColor ) } } }
(for simplicity reasons, I placed the text strings in code instead of a separate resource)
If everything was made correctly, the output should look like this:
Video by author
(a screen recording was made on the emulator, on a real phone it is faster)
3.5 Jetpack Compose: Drag & Zoom
One of the amazing fractal properties — it can be zoomed to any scale! Let’s add a drag and zoom functionality to the app. To achieve that in Jetpack Compose, we need to add a modifier.
val viewModel: MainViewModel = viewModel(factory = MainViewModel.Factory) val fractalState by viewModel.fractalState.collectAsStateWithLifecycle() val drawModifier = Modifier.fillMaxSize().onGloballyPositioned( { coordinates -> viewModel.setCanvasSize(coordinates.size) }).pointerInput(Unit, { awaitEachGesture { awaitFirstDown() Log.d("UI", "Down") do { val event: PointerEvent = awaitPointerEvent() } while (event.changes.any { it.pressed }) Log.d("UI", "Up") viewModel.updateFractal() } }).pointerInput(Unit, { detectTransformGestures { centroid, pan, zoom, rotation -> Log.d("UI", "Transform: C=${centroid}, Zoom=${zoom}, Pan=${pan}") viewModel.setTransform(zoom, pan) } }).clipToBounds() FractalLayout(fractalState, drawModifier)
I must admit that the process is not intuitive. A PointerInputScope class has a detectTransformGestures method that can detect pan, zoom, and rotation. However, there are no start and end events there. To achieve that, we need to subscribe to the pointerInput twice and use an asynchronous awaitPointerEvent method to detect when the gesture ends. Alas, the process is not only non-intuitive — this part of Jetpack Compose is relatively new. I had a feeling that half of the methods are marked as “experimental”, and another half is marked as “deprecated”; many code samples and tutorials do not work anymore.
If everything was done correctly, the UI should look like this:
Video by author
The fractal zoom is, in theory, infinite. However, practically, we will be limited by the accuracy of the Double data type. I was not patient enough to zoom in until that point; apparently, the app would crash with a “Division by zero” exception. We can increase the accuracy further by switching to an arbitrary precision data type. Kotlin does not have built-in support for that, but external libraries like BigNum are easy to find.
Conclusion
In this article, we did a Jetpack Compose app that can display and zoom the Mandelbrot fractal. Fractals are interesting mathematical objects, and readers are welcome to do experiments on their own — change the color palette, try different fractals, different precision types, etc. As another improvement, this app can be easily converted to Kotlin Multiplatform — it will allow to run this code not only on Android but also on iOS and as a web application. The code is already 95% multiplatform-compatible. Probably, the only method that needs to be changed is createBitmapFromChunks; there, an android.graphics.Bitmap class is used.
If you enjoyed this story, feel free to subscribe to Medium, and you will get notifications when my new articles will be published, as well as full access to thousands of stories from other authors. You are also welcome to connect via LinkedIn, where I periodically publish smaller posts that are not big enough for a full article. If you want to get the full source code for this and other posts, feel free to visit my Patreon page.
Thanks for reading.
This article is previously published on proandroiddev.com.