diff --git a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt b/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt deleted file mode 100644 index 775b837077..0000000000 --- a/experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt +++ /dev/null @@ -1,7 +0,0 @@ -package example.imageviewer.view - -import androidx.compose.ui.Modifier -import example.imageviewer.model.ScalableState - -actual fun Modifier.addUserInput(state: ScalableState) = - addTouchUserInput(state) diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt index 1dfd688c75..923bfec98d 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt @@ -1,5 +1,3 @@ package example.imageviewer.model -const val MAX_SCALE = 5f -const val MIN_SCALE = 1f const val TOAST_DURATION = 3000L diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt index 59b003ffaf..725e7714ea 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt @@ -4,81 +4,88 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified -class ScalableState() { - var imageSize by mutableStateOf(IntSize(0, 0)) - var boxSize by mutableStateOf(IntSize(1, 1)) - var offset by mutableStateOf(IntOffset.Zero) +/** + * Encapsulate all transformations about showing some target (an image, relative to its center) + * scaled and shifted in some area (a window, relative to its center) + */ +class ScalableState { + val scaleLimits = 0.2f..10f + + /** + * Offset of the camera before scaling (an offset in pixels in the area coordinate system) + */ + var offset by mutableStateOf(Offset.Zero) + private set var scale by mutableStateOf(1f) -} + private set -val ScalableState.visiblePart - get() : IntRect { - val boxRatio = boxSize.width.toFloat() / boxSize.height - val imageRatio = imageSize.width.toFloat() / imageSize.height.toFloat() + private var areaSize: Size = Size.Unspecified + private var targetSize: Size = Size.Zero - val size: IntSize = - if (boxRatio > imageRatio) { - val height = imageSize.height / scale - val targetWidth = height * boxRatio - IntSize(minOf(imageSize.width, targetWidth.toInt()), height.toInt()) - } else { - val width = imageSize.width / scale - val targetHeight = width / boxRatio - IntSize(width.toInt(), minOf(imageSize.height, targetHeight.toInt())) - } + private var offsetXLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY + private var offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY - return IntRect(offset = offset, size = size) + /** + * Limit the target center position, so: + * - if the size of the target is less than area, + * the center of the target is bound to the center of the area + * - if the size of the target is greater, then limit the center of it, + * so the target will be always in the area + */ + fun limitTargetInsideArea( + areaSize: Size, + targetSize: Size, + ) { + this.areaSize = areaSize + this.targetSize = targetSize + applyLimits() } -fun ScalableState.changeBoxSize(size: IntSize) { - boxSize = size - updateOffsetLimits() -} - -fun ScalableState.setScale(scale: Float) { - this.scale = scale -} - -fun ScalableState.addScale(diff: Float) { - scale = if (scale + diff > MAX_SCALE) { - MAX_SCALE - } else if (scale + diff < MIN_SCALE) { - MIN_SCALE - } else { - scale + diff + private fun applyLimits() { + if (targetSize.isSpecified && areaSize.isSpecified) { + offsetXLimits = centerLimits(targetSize.width * scale, areaSize.width) + offsetYLimits = centerLimits(targetSize.height * scale, areaSize.height) + offset = Offset( + offset.x.coerceIn(offsetXLimits), + offset.y.coerceIn(offsetYLimits), + ) + } else { + offsetXLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY + offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY + } } - updateOffsetLimits() -} - -fun ScalableState.addDragAmount(diff: Offset) { - offset -= IntOffset((diff.x + 1).toInt(), (diff.y + 1).toInt()) - updateOffsetLimits() -} - -fun ScalableState.updateImageSize(width: Int, height: Int) { - imageSize = IntSize(width, height) - updateOffsetLimits() -} -private fun ScalableState.updateOffsetLimits() { - if (offset.x + visiblePart.width > imageSize.width) { - changeOffset(x = imageSize.width - visiblePart.width) - } - if (offset.y + visiblePart.height > imageSize.height) { - changeOffset(y = imageSize.height - visiblePart.height) + private fun centerLimits(imageSize: Float, areaSize: Float): ClosedFloatingPointRange { + val areaCenter = areaSize / 2 + val imageCenter = imageSize / 2 + val extra = (imageCenter - areaCenter).coerceAtLeast(0f) + return -extra / 2..extra / 2 } - if (offset.x < 0) { - changeOffset(x = 0) + + fun addPan(pan: Offset) { + offset += pan + applyLimits() } - if (offset.y < 0) { - changeOffset(y = 0) + + /** + * @param focus on which point the camera is focused in the area coordinate system. + * After we apply the new scale, the camera should be focused on the same point in + * the target coordinate system. + */ + fun addScale(scaleMultiplier: Float, focus: Offset = Offset.Zero) { + setScale(scale * scaleMultiplier, focus) } -} -private fun ScalableState.changeOffset(x: Int = offset.x, y: Int = offset.y) { - offset = IntOffset(x, y) + fun setScale(scale: Float, focus: Offset = Offset.Zero) { + val newScale = scale.coerceIn(scaleLimits) + val focusInTargetSystem = (focus - offset) / this.scale + // calculate newOffset from this equation: + // focusInTargetSystem = (focus - newOffset) / newScale + offset = focus - focusInTargetSystem * newScale + this.scale = newScale + applyLimits() + } } diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/EventUtils.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/EventUtils.kt new file mode 100644 index 0000000000..a6b3ca2d7f --- /dev/null +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/EventUtils.kt @@ -0,0 +1,26 @@ +package example.imageviewer.utils + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.* + +fun Modifier.onPointerEvent( + eventType: PointerEventType, + pass: PointerEventPass = PointerEventPass.Main, + onEvent: AwaitPointerEventScope.(event: PointerEvent) -> Unit +): Modifier = composed { + val currentEventType by rememberUpdatedState(eventType) + val currentOnEvent by rememberUpdatedState(onEvent) + pointerInput(pass) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(pass) + if (event.type == currentEventType) { + currentOnEvent(event) + } + } + } + } +} \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt index 3e35a0b57c..1e48df9f93 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter @@ -54,46 +55,34 @@ internal fun FullscreenImageScreen( Box(Modifier.fillMaxSize().background(color = ImageviewerColors.fullScreenImageBackground)) { if (imageWithFilter != null) { val scalableState = remember { ScalableState() } - scalableState.updateImageSize(imageWithFilter.width, imageWithFilter.height) - val visiblePartOfImage: IntRect = scalableState.visiblePart - Box( - Modifier.fillMaxSize() - .onGloballyPositioned { coordinates -> - scalableState.changeBoxSize(coordinates.size) - } - .addUserInput(scalableState) + + ScalableImage( + scalableState, + imageWithFilter, + modifier = Modifier.fillMaxSize().clipToBounds(), + ) + + Column( + Modifier + .align(Alignment.BottomCenter) + .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + .background(ImageviewerColors.filterButtonsBackground) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Image( - modifier = Modifier.fillMaxSize(), - painter = BitmapPainter( - imageWithFilter, - srcOffset = visiblePartOfImage.topLeft, - srcSize = visiblePartOfImage.size - ), - contentDescription = null, + FilterButtons( + picture = picture, + filters = availableFilters, + selectedFilters = selectedFilters, + onSelectFilter = { + if (it !in selectedFilters) { + selectedFilters += it + } else { + selectedFilters -= it + } + }, ) - Column( - Modifier - .align(Alignment.BottomCenter) - .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) - .background(ImageviewerColors.filterButtonsBackground) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - FilterButtons( - picture = picture, - filters = availableFilters, - selectedFilters = selectedFilters, - onSelectFilter = { - if (it !in selectedFilters) { - selectedFilters += it - } else { - selectedFilters -= it - } - }, - ) - ZoomControllerView(Modifier, scalableState) - } + ZoomControllerView(Modifier, scalableState) } } else { LoadingScreen() diff --git a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt index 88e2844a03..a988b54866 100644 --- a/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt +++ b/experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt @@ -2,23 +2,91 @@ package example.imageviewer.view import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.withSave +import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import example.imageviewer.model.ScalableState -import example.imageviewer.model.addDragAmount -import example.imageviewer.model.addScale -import example.imageviewer.model.setScale +import example.imageviewer.utils.onPointerEvent +import kotlin.math.min +import kotlin.math.pow -expect fun Modifier.addUserInput(state: ScalableState): Modifier +@Composable +internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, modifier: Modifier = Modifier) { + BoxWithConstraints { + val areaSize = areaSize + val imageSize = image.size + val imageCenter = Offset(image.width / 2f, image.height / 2f) + val areaCenter = Offset(areaSize.width / 2f, areaSize.height / 2f) -fun Modifier.addTouchUserInput(state: ScalableState): Modifier = - pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - state.addDragAmount(pan) - state.addScale(zoom - 1f) + if (areaSize.width > 0 && areaSize.height > 0) { + DisposableEffect(Unit) { + scalableState.setScale( + min(areaSize.width / imageSize.width, areaSize.height / imageSize.height), + Offset.Zero, + ) + onDispose { } + } } - }.pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { state.setScale(1f) } + + Box( + modifier + .drawWithContent { + drawIntoCanvas { + it.withSave { + it.translate(areaCenter.x, areaCenter.y) + it.translate(scalableState.offset.x, scalableState.offset.y) + it.scale(scalableState.scale, scalableState.scale) + it.translate(-imageCenter.x, -imageCenter.y) + drawImage(image) + } + } + } + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + scalableState.addPan(pan) + scalableState.addScale(zoom, centroid - areaCenter) + } + } + .onPointerEvent(PointerEventType.Scroll) { + val centroid = it.changes[0].position + val delta = it.changes[0].scrollDelta + val zoom = 1.2f.pow(-delta.y) + scalableState.addScale(zoom, centroid - areaCenter) + } + .pointerInput(Unit) { + detectTapGestures(onDoubleTap = { position -> + scalableState.setScale( + if (scalableState.scale > 2.0) { + scalableState.scaleLimits.start + } else { + scalableState.scaleLimits.endInclusive + }, + position - areaCenter + ) + }) { } + }, ) + + SideEffect { + scalableState.limitTargetInsideArea(areaSize, imageSize) + } } +} + +private val ImageBitmap.size get() = Size(width.toFloat(), height.toFloat()) + +private val BoxWithConstraintsScope.areaSize + @Composable get() = with(LocalDensity.current) { + Size(maxWidth.toPx(), maxHeight.toPx()) + } \ No newline at end of file diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalableImage.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalableImage.desktop.kt deleted file mode 100644 index b13adf4a34..0000000000 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalableImage.desktop.kt +++ /dev/null @@ -1,28 +0,0 @@ -package example.imageviewer.view - -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput -import example.imageviewer.model.ScalableState -import example.imageviewer.model.addDragAmount -import example.imageviewer.model.addScale - -actual fun Modifier.addUserInput(state: ScalableState): Modifier = - pointerInput(Unit) { - detectDragGestures { change, dragAmount: Offset -> - state.addDragAmount(dragAmount) - change.consume() - } - }.pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - if (event.type == PointerEventType.Scroll) { - val delta = event.changes.getOrNull(0)?.scrollDelta ?: Offset.Zero - state.addScale(delta.y / 100) - } - } - } - } diff --git a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt index 704830cddc..f43a465b0d 100644 --- a/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt +++ b/experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt @@ -8,17 +8,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import example.imageviewer.model.MAX_SCALE -import example.imageviewer.model.MIN_SCALE import example.imageviewer.model.ScalableState -import example.imageviewer.model.setScale @Composable internal actual fun ZoomControllerView(modifier: Modifier, scalableState: ScalableState) { Slider( modifier = modifier.fillMaxWidth(0.5f).padding(12.dp), value = scalableState.scale, - valueRange = MIN_SCALE..MAX_SCALE, + valueRange = scalableState.scaleLimits.start..scalableState.scaleLimits.endInclusive, onValueChange = { scalableState.setScale(it) }, colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White) ) diff --git a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt b/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt deleted file mode 100644 index 221df4bd14..0000000000 --- a/experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt +++ /dev/null @@ -1,7 +0,0 @@ -package example.imageviewer.view - -import androidx.compose.ui.Modifier -import example.imageviewer.model.ScalableState - -actual fun Modifier.addUserInput(state: ScalableState): Modifier = - addTouchUserInput(state)