diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt index 0ce7c09ea5..38377ea05e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt @@ -17,16 +17,28 @@ package io.element.android.features.messages.impl.media.viewer import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.messages.impl.media.viewer.model.MediaContentUiModel +import io.element.android.libraries.designsystem.components.ZoomableBox import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight @@ -54,14 +66,14 @@ private fun MediaImageViewer( image: MediaContentUiModel.Image, modifier: Modifier = Modifier, ) { - Box( + ZoomableBox( modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center ) { BlurHashAsyncImage( blurHash = image.blurhash, + modifier = Modifier.fillMaxSize().zoomable(), model = image.mediaRequestData, - contentScale = ContentScale.Crop, + contentScale = ContentScale.Fit, ) } } @@ -79,6 +91,7 @@ private fun MediaVideoViewer( } } + @Preview @Composable fun MediaViewerViewLightPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ZoomableBox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ZoomableBox.kt new file mode 100644 index 0000000000..d9159da3c8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ZoomableBox.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.LayoutScopeMarker +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize + +@Composable +fun ZoomableBox( + modifier: Modifier = Modifier, + minScale: Float = 1f, + maxScale: Float = 5f, + content: @Composable ZoomableBoxScope.() -> Unit +) { + var scale by remember { mutableStateOf(1f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + var size by remember { mutableStateOf(IntSize.Zero) } + Box( + modifier = modifier + .clip(RectangleShape) + .onSizeChanged { size = it } + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = maxOf(minScale, minOf(scale * zoom, maxScale)) + val maxX = (size.width * (scale - 1)) / 2 + val minX = -maxX + offsetX = maxOf(minX, minOf(maxX, offsetX + pan.x)) + val maxY = (size.height * (scale - 1)) / 2 + val minY = -maxY + offsetY = maxOf(minY, minOf(maxY, offsetY + pan.y)) + } + } + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + offsetX = 0f + offsetY = 0f + scale = if (scale > minScale) { + minScale + } else { + maxScale / 2f + } + + } + ) + } + ) { + DefaultZoomableBoxScope(this, scale, offsetX, offsetY).content() + } +} + +@LayoutScopeMarker +@Immutable +interface ZoomableBoxScope : BoxScope { + @Stable + fun Modifier.zoomable(): Modifier +} + +private class DefaultZoomableBoxScope( + private val parentScope: BoxScope, + private val scale: Float, + private val offsetX: Float, + private val offsetY: Float +) : ZoomableBoxScope, BoxScope by parentScope { + + override fun Modifier.zoomable(): Modifier { + return graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ) + } +}