All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.ovh.plrapps.mapcompose.ui.state.ZoomPanRotateState.kt Maven / Gradle / Ivy

Go to download

A Compose Multiplatform library to display tiled maps, with support for markers, paths, and rotation

The newest version!
package ovh.plrapps.mapcompose.ui.state

import androidx.compose.animation.core.*
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.*
import ovh.plrapps.mapcompose.core.GestureConfiguration
import ovh.plrapps.mapcompose.ui.layout.*
import ovh.plrapps.mapcompose.utils.*
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.math.*

internal class ZoomPanRotateState(
    val fullWidth: Int,
    val fullHeight: Int,
    private val stateChangeListener: ZoomPanRotateStateListener,
    minimumScaleMode: MinimumScaleMode,
    maxScale: Float,
    scale: Float,
    rotation: AngleDegree,
    gestureConfiguration: GestureConfiguration
) : GestureListener, LayoutSizeChangeListener {
    private var scope: CoroutineScope? = null
    private var onLayoutContinuations = mutableListOf>()

    /**
     * Suspends until the view is laid out. To do that, we use the [scope] as flag.
     *
     * _Contract_:
     * On layout change, [scope] and [layoutSize] are initialized, and queued continuations
     * are resumed.
     */
    internal suspend fun awaitLayout() {
        if (scope != null) return
        suspendCoroutine {
            onLayoutContinuations.add(it)
        }
    }

    internal var minimumScaleMode: MinimumScaleMode = minimumScaleMode
        set(value) {
            field = value
            recalculateMinScale()
        }

    private val areGesturesEnabled by derivedStateOf { isRotationEnabled || isScrollingEnabled || isZoomingEnabled }
    internal var isRotationEnabled by mutableStateOf(false)
    internal var isScrollingEnabled by mutableStateOf(true)
    internal var isZoomingEnabled by mutableStateOf(true)
    internal var isFlingZoomEnabled by mutableStateOf(true)

    /* Only source of truth. Don't mutate directly, use appropriate setScale(), setRotation(), etc. */
    internal var scale by mutableFloatStateOf(scale)
    internal var rotation: AngleDegree by mutableFloatStateOf(rotation)
    internal var scrollX by mutableFloatStateOf(0f)
    internal var scrollY by mutableFloatStateOf(0f)

    internal var pivotX: Double by mutableDoubleStateOf(0.0)
    internal var pivotY: Double by mutableDoubleStateOf(0.0)

    internal var centroidX: Double by mutableDoubleStateOf(0.0)
    internal var centroidY: Double by mutableDoubleStateOf(0.0)

    internal var layoutSize by mutableStateOf(IntSize(0, 0))

    internal var visibleAreaPadding = VisibleAreaPadding(0, 0, 0, 0)

    internal var minScale by mutableFloatStateOf(0f)   // should only be changed through MinimumScaleMode

    var maxScale = maxScale
        set(value) {
            field = value
            setScale(scale)
        }

    internal var shouldLoopScale by mutableStateOf(false)

    internal var scrollOffsetRatio = Offset(0f, 0f)
        set(value) {
            if (value.x in 0f..1f && value.y in 0f..1f) {
                field = value
                /* Update the scroll to constrain it */
                setScroll(
                    scrollX = scrollX,
                    scrollY = scrollY
                )
            } else throw IllegalArgumentException("The offset ratio should have values in 0f..1f range")
        }

    // For user gestures animations
    private val userFloatAnimatable = Animatable(0f)
    private val userAnimatable: Animatable =
        Animatable(Offset.Zero, Offset.VectorConverter)

    // For api-based animations
    private val apiAnimatable = Animatable(0f)

    private val doubleTapSpec =
        TweenSpec(durationMillis = 300, easing = LinearOutSlowInEasing)
    private val flingZoomSpec =
        FloatExponentialDecaySpec(
            frictionMultiplier = gestureConfiguration.flingZoomFriction
        ).generateDecayAnimationSpec()

    @Suppress("unused")
    fun setScale(scale: Float, notify: Boolean = true) {
        this.scale = constrainScale(scale)
        updateCentroid()
        if (notify) notifyStateChanged()
    }

    @Suppress("unused")
    fun setScroll(scrollX: Float, scrollY: Float) {
        this.scrollX = constrainScrollX(scrollX)
        this.scrollY = constrainScrollY(scrollY)
        updateCentroid()
        notifyStateChanged()
    }

    @Suppress("unused")
    fun setRotation(angle: AngleDegree, notify: Boolean = true) {
        this.rotation = angle.modulo()
        updateCentroid()
        if (notify) notifyStateChanged()
    }

    /**
     * Scales the layout with animated scale, without maintaining scroll position.
     *
     * @param scale The final scale value the layout should animate to.
     * @param animationSpec The [AnimationSpec] the animation should use.
     */
    @Suppress("unused")
    suspend fun smoothScaleTo(
        scale: Float,
        animationSpec: AnimationSpec = SpringSpec(stiffness = Spring.StiffnessLow)
    ): Boolean {
        return invokeAndCheckSuccess {
            val currScale = [email protected]
            if (currScale > 0) {
                apiAnimatable.snapTo(0f)
                apiAnimatable.animateTo(1f, animationSpec) {
                    setScale(lerp(currScale, scale, value))
                }
            }
        }
    }

    suspend fun smoothRotateTo(
        angle: AngleDegree,
        animationSpec: AnimationSpec
    ): Boolean {
        /* We don't have to stop scrolling animation while doing that */
        return invokeAndCheckSuccess {
            val currRotation = [email protected]
            var targetAngle = (angle % 360)
            if (abs(targetAngle - currRotation) > 180) {
                targetAngle += if (targetAngle > currRotation) -360 else 360
            }
            apiAnimatable.snapTo(0f)
            apiAnimatable.animateTo(1f, animationSpec) {
                setRotation(lerp(currRotation, targetAngle, value))
            }
        }
    }

    /**
     * Animates the scroll to the destination value.
     *
     * @return `true` if the operation completed without being cancelled.
     */
    suspend fun smoothScrollTo(
        destScrollX: Float,
        destScrollY: Float,
        animationSpec: AnimationSpec
    ): Boolean {
        val startScrollX = this.scrollX
        val startScrollY = this.scrollY

        return invokeAndCheckSuccess {
            userAnimatable.stop()
            apiAnimatable.snapTo(0f)
            apiAnimatable.animateTo(1f, animationSpec) {
                setScroll(
                    scrollX = lerp(startScrollX, destScrollX, value),
                    scrollY = lerp(startScrollY, destScrollY, value)
                )
            }
        }
    }

    /**
     * Animates the scroll and the scale together with the supplied destination values.
     *
     * @param destScrollX Horizontal scroll of the destination point.
     * @param destScrollY Vertical scroll of the destination point.
     * @param destScale The final scale value the layout should animate to.
     * @param animationSpec The [AnimationSpec] the animation should use.
     */
    suspend fun smoothScrollScaleRotate(
        destScrollX: Float,
        destScrollY: Float,
        destScale: Float,
        animationSpec: AnimationSpec
    ): Boolean {
        val startScrollX = this.scrollX
        val startScrollY = this.scrollY
        val startScale = this.scale

        return invokeAndCheckSuccess {
            userAnimatable.stop()
            apiAnimatable.snapTo(0f)
            apiAnimatable.animateTo(1f, animationSpec) {
                setScale(lerp(startScale, destScale, value))
                setScroll(
                    scrollX = lerp(startScrollX, destScrollX, value),
                    scrollY = lerp(startScrollY, destScrollY, value)
                )
            }
        }
    }

    /**
     * Animates the scroll, the scale, and the rotation together with the supplied destination values.
     *
     * @param destScrollX Horizontal scroll of the destination point.
     * @param destScrollY Vertical scroll of the destination point.
     * @param destScale The final scale value the layout should animate to.
     * @param destAngle The final angle in decimal degrees the layout should animate to.
     * @param animationSpec The [AnimationSpec] the animation should use.
     */
    suspend fun smoothScrollScaleRotate(
        destScrollX: Float,
        destScrollY: Float,
        destScale: Float,
        destAngle: AngleDegree,
        animationSpec: AnimationSpec
    ): Boolean {
        val startScrollX = this.scrollX
        val startScrollY = this.scrollY
        val startScale = this.scale

        val currRotation = [email protected]
        var targetAngle = (destAngle % 360)
        if (abs(targetAngle - currRotation) > 180) {
            targetAngle += if (targetAngle > currRotation) -360 else 360
        }

        return invokeAndCheckSuccess {
            userAnimatable.stop()
            apiAnimatable.snapTo(0f)
            apiAnimatable.animateTo(1f, animationSpec) {
                setScale(lerp(startScale, destScale, value))
                setScroll(
                    scrollX = lerp(startScrollX, destScrollX, value),
                    scrollY = lerp(startScrollY, destScrollY, value)
                )
                setRotation(lerp(currRotation, targetAngle, value))
            }
        }
    }

    /**
     * Animates the layout to the scale provided, while maintaining position determined by the
     * the provided focal point.
     *
     * @param focusX The horizontal focal point to maintain, relative to the layout.
     * @param focusY The vertical focal point to maintain, relative to the layout.
     * @param destScale The final scale value the layout should animate to.
     * @param animationSpec The [AnimationSpec] the animation should use.
     */
    suspend fun smoothScaleWithFocalPoint(
        focusX: Float,
        focusY: Float,
        destScale: Float,
        animationSpec: AnimationSpec
    ): Boolean {
        val destScaleCst = constrainScale(destScale)
        val startScale = scale
        if (startScale == destScale) return true
        val startScrollX = scrollX
        val startScrollY = scrollY
        val destScrollX = getScrollAtOffsetAndScale(startScrollX, focusX, destScaleCst / startScale)
        val destScrollY = getScrollAtOffsetAndScale(startScrollY, focusY, destScaleCst / startScale)

        return smoothScrollScaleRotate(destScrollX, destScrollY, destScale, animationSpec)
    }

    /**
     * Invoke [block] and return whether the operation completed without being cancelled.
     */
    private suspend fun invokeAndCheckSuccess(block: suspend () -> Unit): Boolean {
        var success = true
        scope?.launch {
            block()
        }?.also {
            it.invokeOnCompletion { t ->
                if (t != null) success = false
            }
        }?.join()

        return success
    }

    suspend fun stopAnimations() {
        apiAnimatable.stop()
        userAnimatable.stop()
        userFloatAnimatable.stop()
    }

    override fun onScaleRatio(scaleRatio: Float, centroid: Offset) {
        if (!isZoomingEnabled) return

        val formerScale = scale
        setScale(scale * scaleRatio)

        /* Pinch and zoom magic */
        val effectiveScaleRatio = scale / formerScale
        val angleRad = -rotation.toRad()
        val centroidRotated = rotateFocalPoint(centroid, angleRad)
        setScroll(
            scrollX = getScrollAtOffsetAndScale(scrollX, centroidRotated.x, effectiveScaleRatio),
            scrollY = getScrollAtOffsetAndScale(scrollY, centroidRotated.y, effectiveScaleRatio)
        )
    }

    private fun getScrollAtOffsetAndScale(scroll: Float, offSet: Float, scaleRatio: Float): Float {
        return (scroll + offSet) * scaleRatio - offSet
    }

    /**
     * Rotates a focal point around the center of the layout.
     */
    private fun rotateFocalPoint(point: Offset, angleRad: AngleRad): Offset {
        val x = if (angleRad == 0f) point.x else {
            layoutSize.height / 2 * sin(angleRad) + layoutSize.width / 2 * (1 - cos(angleRad)) +
                    point.x * cos(angleRad) - point.y * sin(angleRad)
        }

        val y = if (angleRad == 0f) point.y else {
            layoutSize.height / 2 * (1 - cos(angleRad)) - layoutSize.width / 2 * sin(angleRad) +
                    point.x * sin(angleRad) + point.y * cos(angleRad)
        }
        return Offset(x, y)
    }

    override fun onRotationDelta(rotationDelta: Float) {
        if (!isRotationEnabled) return

        setRotation(rotation + rotationDelta)
    }

    override fun onScrollDelta(scrollDelta: Offset) {
        if (!isScrollingEnabled) return

        var scrollX = scrollX
        var scrollY = scrollY

        val rotRad = -rotation.toRad()
        scrollX -= if (rotRad == 0f) scrollDelta.x else {
            scrollDelta.x * cos(rotRad) - scrollDelta.y * sin(rotRad)
        }
        scrollY -= if (rotRad == 0f) scrollDelta.y else {
            scrollDelta.x * sin(rotRad) + scrollDelta.y * cos(rotRad)
        }
        setScroll(scrollX, scrollY)
    }

    override fun onFling(flingSpec: DecayAnimationSpec, velocity: Velocity) {
        if (!isScrollingEnabled) return

        val rotRad = -rotation.toRad()
        val velocityX = if (rotRad == 0f) velocity.x else {
            velocity.x * cos(rotRad) - velocity.y * sin(rotRad)
        }
        val velocityY = if (rotRad == 0f) velocity.y else {
            velocity.x * sin(rotRad) + velocity.y * cos(rotRad)
        }

        scope?.launch {
            userAnimatable.snapTo(Offset(scrollX, scrollY))
            userAnimatable.animateDecay(
                initialVelocity = -Offset(velocityX, velocityY),
                animationSpec = flingSpec,
            ) {
                setScroll(
                    scrollX = value.x,
                    scrollY = value.y
                )
            }
        }
    }

    override fun onFlingZoom(velocity: Float, centroid: Offset) {
        if (!isZoomingEnabled || !isFlingZoomEnabled) return

        scope?.launch {
            userFloatAnimatable.snapTo(scale)
            userFloatAnimatable.animateDecay(
                initialVelocity = velocity,
                animationSpec = flingZoomSpec,
            ) {
                onScaleRatio(value / scale, centroid)
            }
        }
    }

    override fun onTouchDown() {
        if (!areGesturesEnabled) return

        scope?.launch {
            stopAnimations()
        }
        stateChangeListener.onTouchDown()
    }

    override fun onPress() {
        stateChangeListener.onPress()
    }

    override fun onTap(focalPt: Offset) {
        if (!stateChangeListener.detectsTap()) return
        offsetToRelative(focalPt) { x, y ->
            stateChangeListener.onTap(x, y)
        }
    }

    override fun onLongPress(focalPt: Offset) {
        if (!stateChangeListener.detectsLongPress()) return
        offsetToRelative(focalPt) { x, y ->
            stateChangeListener.onLongPress(x, y)
        }
    }

    private fun  offsetToRelative(focalPt: Offset, block: (Double, Double) -> T): T {
        val angleRad = -rotation.toRad()
        val focalPtRotated = rotateFocalPoint(focalPt, angleRad)
        val x = (scrollX + focalPtRotated.x).toDouble() / (scale * fullWidth)
        val y = (scrollY + focalPtRotated.y).toDouble() / (scale * fullHeight)
        return block(x, y)
    }

    private fun  relativeToMarkerLayoutCoords(x: Double, y: Double, block: (Int, Int) -> T): T {
        val xFullPx = x * fullWidth * scale
        val yFullPx = y * fullHeight * scale
        val centerX = centroidX * fullWidth * scale
        val centerY = centroidY * fullHeight * scale

        val angleRad = rotation.toRad()
        val xPx = (rotateCenteredX(
            xFullPx,
            yFullPx,
            centerX,
            centerY,
            angleRad
        )).toInt()

        val yPx = (rotateCenteredY(
            xFullPx,
            yFullPx,
            centerX,
            centerY,
            angleRad
        )).toInt()

        return block(xPx, yPx)
    }

    override fun onDoubleTap(focalPt: Offset) {
        if (!isZoomingEnabled) return

        val destScale = (
                2.0.pow(floor(ln((scale * 2).toDouble()) / ln(2.0))).toFloat()
                ).let {
                if (shouldLoopScale && it > maxScale) minScale else it
            }

        val angleRad = -rotation.toRad()
        val focalPtRotated = rotateFocalPoint(focalPt, angleRad)

        scope?.launch {
            smoothScaleWithFocalPoint(
                focalPtRotated.x,
                focalPtRotated.y,
                destScale,
                doubleTapSpec
            )
        }
    }

    override fun onTwoFingersTap(focalPt: Offset) {
        if (!isZoomingEnabled) return

        val destScale = 2.0.pow(floor(ln((scale / 2).toDouble()) / ln(2.0))).toFloat()

        val angleRad = -rotation.toRad()
        val focalPtRotated = rotateFocalPoint(focalPt, angleRad)

        scope?.launch {
            smoothScaleWithFocalPoint(
                focalPtRotated.x,
                focalPtRotated.y,
                destScale,
                doubleTapSpec
            )
        }
    }

    override fun isListeningForGestures(): Boolean = areGesturesEnabled

    override fun shouldConsumeTapGesture(focalPt: Offset): Boolean {
        return offsetToRelative(focalPt) { x, y ->
            relativeToMarkerLayoutCoords(x, y) { xPx, yPx ->
                stateChangeListener.interceptsTap(x, y, xPx, yPx)
            }
        }
    }

    override fun shouldConsumeLongPress(focalPt: Offset): Boolean {
        return offsetToRelative(focalPt) { x, y ->
            relativeToMarkerLayoutCoords(x, y) { xPx, yPx ->
                stateChangeListener.interceptsLongPress(x, y, xPx, yPx)
            }
        }
    }

    override fun onSizeChanged(composableScope: CoroutineScope, size: IntSize) {
        scope = composableScope

        /* When the size changes, typically on device rotation, the scroll needs to be adapted so
         * that we keep the same location at the center of the screen. Don't do that when layout
         * hasn't been done yet. */
        if (layoutSize != IntSize.Zero) {
            setScroll(
                scrollX = scrollX + (layoutSize.width - size.width) / 2,
                scrollY = scrollY + (layoutSize.height - size.height) / 2
            )
        }

        layoutSize = size
        recalculateMinScale()
        setScale(scale)

        /* Layout was done at least once, resume continuations */
        for (ct in onLayoutContinuations) {
            ct.resume(Unit)
        }
        onLayoutContinuations.clear()
    }

    private fun constrainScrollX(scrollX: Float): Float {
        val angle = rotation.toRad()

        val layoutDimension =
            polarRadius(layoutSize.width.toFloat(), layoutSize.height.toFloat(), angle)
        val bias = (layoutDimension - layoutSize.width) / 2

        return if (fullWidth * scale < layoutDimension) {
            val offset = scrollOffsetRatio.x * fullWidth * scale
            scrollX.coerceIn(fullWidth * scale - layoutDimension - offset + bias, offset + bias)
        } else {
            val offset = scrollOffsetRatio.x * layoutDimension
            scrollX.coerceIn(
                -offset + bias,
                offset + bias + fullWidth * scale - layoutDimension
            )
        }
    }

    private fun constrainScrollY(scrollY: Float): Float {
        val angle = rotation.toRad()

        val layoutDimension =
            polarRadius(layoutSize.height.toFloat(), layoutSize.width.toFloat(), angle)
        val bias = (layoutDimension - layoutSize.height) / 2

        return if (fullHeight * scale < layoutDimension) {
            val offset = scrollOffsetRatio.y * fullHeight * scale
            scrollY.coerceIn(fullHeight * scale - layoutDimension - offset + bias, offset + bias)
        } else {
            val offset = scrollOffsetRatio.y * layoutDimension
            scrollY.coerceIn(
                -offset + bias,
                offset + bias + fullHeight * scale - layoutDimension
            )
        }
    }

    internal fun constrainScale(scale: Float): Float {
        return scale.coerceIn(max(minScale, Float.MIN_VALUE), maxScale.coerceAtLeast(minScale))
    }

    private fun updateCentroid() {
        pivotX = layoutSize.width.toDouble() / 2
        pivotY = layoutSize.height.toDouble() / 2

        centroidX = (scrollX + pivotX) / (fullWidth * scale)
        centroidY = (scrollY + pivotY) / (fullHeight * scale)
    }

    private fun recalculateMinScale() {
        val minScaleX = layoutSize.width.toFloat() / fullWidth
        val minScaleY = layoutSize.height.toFloat() / fullHeight
        val mode = minimumScaleMode
        minScale = when (mode) {
            Fit -> min(minScaleX, minScaleY)
            Fill -> max(minScaleX, minScaleY)
            is Forced -> mode.scale
        }
        setScale(scale)
    }

    private fun notifyStateChanged() {
        if (layoutSize != IntSize.Zero) {
            stateChangeListener.onStateChanged()
        }
    }

    private fun polarRadius(a: Float, b: Float, angle: AngleRad): Float {
        return a * b / sqrt((a * sin(angle)).pow(2) + (b * cos(angle)).pow(2))
    }
}

/**
 * The padding to apply when some UI is obscuring the map on it's borders.
 */
internal data class VisibleAreaPadding(val left: Int, val top: Int, val right: Int, val bottom: Int)

interface ZoomPanRotateStateListener {
    fun onStateChanged()
    fun onTouchDown()
    fun onPress()
    fun onLongPress(x: Double, y: Double)
    fun onTap(x: Double, y: Double)
    fun detectsTap(): Boolean
    fun detectsLongPress(): Boolean
    fun interceptsTap(x: Double, y: Double, xPx: Int, yPx: Int): Boolean
    fun interceptsLongPress(x: Double, y: Double, xPx: Int, yPx: Int): Boolean
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy