Custom / Gesture Handling / ScaleGestureDetector / View

Mandelbrot: Zoom

In this series we’re going to build an app to view the Mandelbrot set. While this is not necessarily something that is likely to be of direct use to the majority of app developers (except for those that have a desire to develop their own Mandelbrot set viewing apps), there are some techniques that we’ll explore along the way that most certainly will be of relevance to many.

Previously we actually rendered the Mandelbrot set in an app , bot the image we generated was static and didn’t allow the user to actually explore the Mandelbrot set. This restriction actually meant that there was no reason whatsoever to generate the image on the fly. But we’re not going to stop there. In this article we’ll add zoom support to allow the user to properly explore.

We’ll start by adding gesture handling to our ImageView. To allow delegation of this functionality, we’ll first introduce a TouchHandler interface:

class MandelbrotView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defaultThemeAttr: Int = -1
) : AppCompatImageView(context, attrs, defaultThemeAttr) {

    var delegate: TouchHandler? = null

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        delegate?.onTouchEvent(event)
        return true
    }
}

We”l keep things simple, and defer the logic for handling touch events to an implementation of TouchHandler. This will require a minor tweak to our layout:




    


The next thing we need to do is actually simplify our MandelbrotLifecycleObserver, as we’re going to move some logic out of there as well:

class MandelbrotLifecycleObserver(
    context: Context,
    lifecycleOwner: LifecycleOwner,
    imageView: MandelbrotView
) : CoroutineScope, LifecycleObserver {

    private val job = Job()
    override val coroutineContext: CoroutineContext
        get() = job + Dispatchers.Main

    private val renderer: MandelbrotRenderer = MandelbrotRenderer(context)
    private val viewportDelegate: ViewportDelegate

    init {
        lifecycleOwner.lifecycle.addObserver(this)
        viewportDelegate = ViewportDelegate(context, coroutineContext, imageView, renderer)
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    @Suppress("Unused")
    fun onDestroy() {
        job.cancel()
        renderer.destroy()
    }
}

The responsibility for responding to changes in size of the ImageView (or, more correctly, the MandelbrotView that we just created), and triggering the image rendering is now handled by the new ViewportDelegate which we’ll look in a moment.

Next we have a utility class that will be used by ViewportDelegate:

internal class RectD {
    var left: Double = 0.0
        private set
    var right: Double = 0.0
        private set
    var top: Double = 0.0
        private set
    var bottom: Double = 0.0
        private set

    constructor() : this(0.0, 0.0, 0.0, 0.0)

    constructor(l: Double, t: Double, r: Double, b: Double) {
        set(l, t, r, b)
    }

    fun set(l: Double, t: Double, r: Double, b: Double) {
        left = l
        top = t
        right = r
        bottom = b
    }

    val width: Double
        get() = right - left

    val height: Double
        get() = bottom - top
}

This is a simple copy of some limited functionality from the Android Framework RectF class, only it used Double values instead of Float. Now we come to ViewportDelegate which is the main workhorse. The fundamental concept behind this is that we will create a view port on to the Mandelbrot set. Up until now the view port has covered the entire set and is of a fixed size. The view port can never go outside of these initial boundaries, but zoom in operations will make the view port smaller – showing a smaller section of the Mandelbrot set; and scroll operations will move the view port around.

The initial view port dimensions will represent to bounds of the area that the user can explore, but as the user zooms and pans, the view port will change to be a smaller section of this, but the aspect ratio will remain unchanged. Thus the aspect ratio of the view port will always match that of the ImageView itself.

The remainder of this article will focus on how we can translate pinch, double tap, and long tap gestures to alter the size of this viewport, effectively zooming in and out of the Mandelbrot set.

It is the view port area that will actually be rendered, and the ViewportDelegate is responsible for controlling the size and location of the view port. There are a number of different areas, so let’s look at them individually:

    init {
        imageView.apply {
            delegate = this@ViewportDelegate
            addOnLayoutChangeListener { _, l, t, r, b, lOld, tOld, rOld, bOld ->
                if (r - l != rOld - lOld || b - t != bOld - tOld) {
                    viewPort.set(l.toDouble(), t.toDouble(), r.toDouble(), b.toDouble())
                    renderer.setSize(r - l, b - t)
                    renderImage()
                }
            }
        }
    }

    override fun onTouchEvent(event: MotionEvent) {
        scaleDetector.onTouchEvent(event)
        gestureDetector.onTouchEvent(event)
    }

In the init block we first set the delegate of the MandelbrotView so that it forwards touch events, and we also set the addOnChangeListener to reset the view port, calls setSize() on the renderer, and renders the image when the size of the ImageView changes.

Then we have the onTouchEvent which overrides the TouchHandler method. This will invoke both the ScaleDetector and GestureDetector implementations that we just created. These onTouchEvent() handlers will each return a Boolean indicating whether the event was handled or not. In some cases it is necessary to check these and only continue if the event was not handled. However, in this case there are times when touch events may indicate both scale and scroll and we want to be able to handle both at the same time, so we always allow both detectors to do their stuff.

The next set of methods handle the callbacks from the OnScaleGestureListener:

    override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true

    override fun onScale(detector: ScaleGestureDetector): Boolean {
        scale(
            detector.focusX.toDouble(),
            detector.focusY.toDouble(),
            detector.scaleFactor.toDouble()
        )
        return true
    }

    private fun scale(focusX: Double, focusY: Double, factor: Double) {
        val newViewportWidth = viewPort.width / factor
        val newViewportHeight = viewPort.height / factor
        val xFraction = focusX / imageView.width.toDouble()
        val yFraction = focusY / imageView.height.toDouble()
        val newLeft =
            viewPort.left + viewPort.width * xFraction - newViewportWidth * xFraction
        val newTop =
            viewPort.top + viewPort.height * yFraction - newViewportHeight * yFraction
        updateViewport(newLeft, newTop, newViewportWidth, newViewportHeight)
        renderImage()
    }

    override fun onScaleEnd(detector: ScaleGestureDetector) { /* NO-OP */ }

The only one worthy of explanation is the onScale() callback which will be received whenever a scale operation (i.e. the user is doing a pinch-to-zoom). This passes some parameters from the detector to the scale() method, which will be used elsewhere, hence having a separate method here. these parameters are focusX and focusY which represent the centre point between the users’ fingers for the pinch, and the scaleFactor which represents how much the scale has changed since the last time this callback was made.

In the scale() method we first calculate a new width and height of the view port by multiplying the existing width & height of the view port by the scale factor. Next we calculate the relative offset of the focus point as a fraction of the overall width & height of the ImageView. This is necessary because the focusX and focusY values are with relative to the ImageView and we need to work out where this point would fall within the current view port. We then use this to calculate the new left and top positions of the view port. We are effectively adjusting the left and top offsets of the view port so that the scaling is centred around the focus point within the view port – this makes it feel like the scaling is happening around the user’s gesture. Finally we call updateViewport to update the view port dimensions with these new values, and then render a new image for this updated view port – we’ll take a look at the mechanics of this in the next article.

Next we have the SimpleOnGestureListener overrides:

    override fun onDoubleTap(e: MotionEvent): Boolean {
        scale(e.x.toDouble(), e.y.toDouble(), 2.0)
        return true
    }

    override fun onLongPress(e: MotionEvent?) {
        updateViewport(0.0, 0.0, imageView.width.toDouble(), imageView.height.toDouble())
        renderImage()
    }

The onDoubleTap() method enables us to provide a quick zoom option whenever the user double taps on the screen. It utilises the scroll() method that we just looked at by zooming in by a factor of 2 around the point where the double tap was detected. Now the reason for abstracting the scale() method should be obvious – it saves us from having to duplicate this logic.

The OnLongPress() method will reset the view port back to the start position. This can be useful if the user gets lost while exploring and just wants to reset back to the start point. It calls updateViewport() with the zero offsets and the dimensions of the ImageView – which is the start position – and then renders the image.

That covers how we resize the view port, but we also need to allow the user to scroll around as well. In the next article we’ll cover that, and then apply the changes to the view port to the actual image rendering which will allow the user to properly explore the Mandelbrot set.

Usually I like to publish working code along with each article, but in this case there was simply too much to squeeze in to a single post (believe me, I tried – this article alone is already pretty big). So there’s no code available for this article. However in the next article we’ll finish getting the pan & zoom functionality working and the code will be published along with that.

© 2019, Mark Allison. All rights reserved.

Copyright © 2019 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.