Blog Infos
Author
Published
Topics
, , , ,
Published
Math and Graphics Can Be Fun
Image by author

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:

Image by author

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:

Image by author

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:
Image by author

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:

Image by author

After that, we can create a new notebook, type the Kotlin code, and immediately execute it:

Image by author

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:

Image by author

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 setJulia 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

No results found.

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()

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:

 

Image by author

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.

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())
            )
        }
    }
}

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.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE
Menu