saket / telephoto

Building blocks for designing media experiences in Compose UI
https://saket.github.io/telephoto/
Apache License 2.0
869 stars 28 forks source link

Support `onClickOutside` #57

Closed burntcookie90 closed 5 months ago

burntcookie90 commented 7 months ago

Would be useful for developing screens that have a dismiss action when clicking outside the zoomable image.

Mett-Barr commented 6 months ago

Thank you for your prompt response and assistance. I'd like to provide some updates and clarifications regarding my earlier query:

Callback for Current ZoomableState Updates: It appears that the functionality is already in place within the library. My earlier challenges might have been related to using the Preview mode, but as of now, everything seems to be functioning correctly. This is no longer an issue.

Restricting Zoom and Scroll to X-Axis: I am looking to limit zoom and pan actions strictly to the X-axis, in a manner akin to how graphicsLayer only applies scaleX and translationX, leaving the Y-axis unaffected. Here is an example of what I am aiming for:

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Divider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.Preview
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
import me.saket.telephoto.zoomable.zoomable
import kotlin.math.sin

@Preview
@Composable
fun ZoomTest() {
    val state = rememberZoomableState(zoomSpec = ZoomSpec(10f))

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.White)
    ) {
        Column(
            Modifier
                .fillMaxSize(0.5f)
                .align(Alignment.Center)
        ) {
            Text("Top Limit")
            Divider()
            SineWave(
                modifier = Modifier
                    .weight(1f)
                    .fillMaxWidth()
                    .zoomable(state, clipToBounds = false)
            )
            Divider()
            Text("Bottom Limit")
            Divider()
            Row(Modifier.fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.Absolute.SpaceBetween) {
                repeat(5) {
                    Text(text = it.toString())
                }
            }
        }

        Column(modifier = Modifier.align(Alignment.BottomCenter)) {
            Text(
                text = "Zoom rate : x${state.contentTransformation.scale.scaleX}",
                color = Color.Black,
            )
            Text(
                text = "offset x : ${state.contentTransformation.offset.x}",
                color = Color.Black,
            )
            Text(
                text = "offset y : ${state.contentTransformation.offset.y}",
                color = Color.Black,
            )
        }
    }
}

@Composable
fun SineWave(modifier: Modifier) {
    val infiniteTransition = rememberInfiniteTransition(label = "")
    val phaseShift by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 2f * Math.PI.toFloat(),
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 2000, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ), label = ""
    )

    Canvas(
        modifier = modifier
    ) {
        val amplitude = size.height / 2
        val frequency = 0.05f
        val strokeWidth = 5f

        var previousPoint = Offset(0f, size.height / 2)

        for (x in 1..size.width.toInt()) {
            val y = size.height / 2 + amplitude * sin(frequency * x + phaseShift)
            val currentPoint = Offset(x.toFloat(), y)
            drawLine(
                color = Color.Blue,
                start = previousPoint,
                end = currentPoint,
                strokeWidth = strokeWidth,
                cap = StrokeCap.Round
            )
            previousPoint = currentPoint
        }
    }
}

Demo : https://youtube.com/shorts/DyyTLcdGzS0?feature=share

If there are any issues in executing this code, I will organize a simple project on GitHub to demonstrate it more clearly.

Thank you for your attention to these matters.

saket commented 6 months ago

@Mett-Barr did you mean to post this to https://github.com/saket/telephoto/issues/59?

Mett-Barr commented 6 months ago

@Mett-Barr did you mean to post this to #59?

Sorry, I replied in the wrong place.

Mett-Barr commented 6 months ago

@burntcookie90

In response to the onClickOutside feature for a zoomable image, it seems to me that the requirement itself falls outside the scope of the Zoomable API. This is because the action to be taken when clicking outside is inherently external to the functionalities of the Zoomable API. Incorporating this capability directly into the Zoomable API could potentially violate the Single Responsibility Principle (SRP), suggesting that it might not be the best approach to include it within the API.

A more fitting solution could be to use a combination of other components, like using a container with clickable and padding properties. This would effectively handle the onClickOutside behavior without overloading the Zoomable API with responsibilities beyond its intended purpose. Such an approach maintains a cleaner separation of concerns and aligns more closely with SRP. Like this:

    Box(modifier = Modifier.clickable { /* onClickOutside */ }.padding(10.dp)) {
        YourZoomableImage()
    }
saket commented 5 months ago

@Mett-Barr unfortunately, that doesn't work for full sized images where clicks received outside the visual edges of an image will get intercepted by telephoto. You could work around this by using Modifier.wrapContent(), but it's not great either as it'll prevent you from making gestures outside the image bounds.

saket commented 5 months ago

Addressed by https://github.com/saket/telephoto/commit/0347c5349701fe023a91d1c9ceee8e7b4526ec6c.

https://github.com/saket/telephoto/blob/7666423949e420b08473cf7feb2d622be9d0bb57/zoomable/src/commonMain/kotlin/me/saket/telephoto/zoomable/ZoomableState.kt#L89-L97

Usage:

implementation("me.saket.telephoto:zoomable-image-coil:0.8.0-SNAPSHOT")
val state = rememberZoomableImageState()

Zoomable*Image(
  modifier = Modifier.fillMaxSize(),
  state = state,
  onClick = { offset -> 
    val wasClickedOutside = offset !in state.zoomableState.transformedContentBounds
  },
  …
)