From fe6088b324e2646be4482a0fff875b770021bb22 Mon Sep 17 00:00:00 2001 From: cgspine Date: Mon, 24 Jan 2022 22:25:06 +0800 Subject: [PATCH] feat: photo scale --- .../photo/activity/QMUIPhotoViewerActivity.kt | 193 +++--------- .../qmuiteam/photo/compose/GesturePhoto.kt | 294 ++++++++++++++++++ .../com/qmuiteam/photo/compose/Thumbnail.kt | 2 +- .../photo/data/QMUIPhotoTransition.kt | 1 + 4 files changed, 334 insertions(+), 156 deletions(-) create mode 100644 photo/src/main/java/com/qmuiteam/photo/compose/GesturePhoto.kt diff --git a/photo/src/main/java/com/qmuiteam/photo/activity/QMUIPhotoViewerActivity.kt b/photo/src/main/java/com/qmuiteam/photo/activity/QMUIPhotoViewerActivity.kt index 9681d6c1..afc78cd0 100644 --- a/photo/src/main/java/com/qmuiteam/photo/activity/QMUIPhotoViewerActivity.kt +++ b/photo/src/main/java/com/qmuiteam/photo/activity/QMUIPhotoViewerActivity.kt @@ -22,27 +22,18 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.* -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.transformable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Text import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.toSize import androidx.core.graphics.drawable.toBitmap import androidx.core.view.WindowCompat @@ -52,8 +43,8 @@ import androidx.lifecycle.ViewModel import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.rememberPagerState +import com.qmuiteam.photo.compose.GesturePhoto import com.qmuiteam.photo.data.* -import com.qmuiteam.photo.data.PhotoTransitionDelivery import com.qmuiteam.photo.util.asBitmap private const val PHOTO_CURRENT_INDEX = "qmui_photo_current_index" @@ -67,8 +58,6 @@ class PhotoViewerViewModel(val state: SavedStateHandle) : ViewModel() { val enterIndex = state.get(PHOTO_CURRENT_INDEX) ?: 0 val data: PhotoViewerData? - var isEnterTransitionFinished: Boolean = false - private val transitionDeliverKey = state.get(PHOTO_TRANSITION_DELIVERY_KEY) ?: -1 init { @@ -187,147 +176,31 @@ open class QMUIPhotoViewerActivity : AppCompatActivity() { count = list.size, state = pagerState ) { page -> - PhotoPager(list[page], page == index && !viewModel.isEnterTransitionFinished) - } - } - - @Composable - protected open fun PhotoPager( - photoTransition: QMUIPhotoTransition, - animateEnterTarget: Boolean, - ) { - var transitionStartOffset = photoTransition.offsetInWindow - var transitionStartSize = photoTransition.size - val transitionStartPhoto = photoTransition.photo - val radio = photoTransition.photoProvider.ratio() - val transitionDurationMs = 1000 - PhotoBackgroundWithTransition(animateEnterTarget, transitionDurationMs) - if(transitionStartOffset == null || - transitionStartSize == null || - transitionStartPhoto == null || - radio <= 0f - ){ - PhotoContentWithAlphaTransition(photoTransition, animateEnterTarget, transitionDurationMs) - }else{ + val item = list[page] + val transitionStartOffset = item.offsetInWindow + val transitionStartSize = item.size?.toSize() + val initRect = if (transitionStartOffset != null && transitionStartSize != null) { + Rect(transitionStartOffset, transitionStartSize) + } else null BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val targetViewRatio = constraints.maxWidth * 1f / constraints.maxHeight - val targetWidth = if(radio >= targetViewRatio){ - constraints.maxWidth.toFloat() - } else { - constraints.maxHeight * radio + GesturePhoto( + containerWidth = maxWidth, + containerHeight = maxHeight, + imageRatio = item.photoProvider.ratio(), + isLongImage = item.photoProvider.isLongImage(), + initRect = initRect, + transitionEnter = page == index + ) { + PhotoContent(photoTransition = item) } - val targetHeight = if(radio >= targetViewRatio){ - constraints.maxWidth / radio - }else{ - constraints.maxHeight.toFloat() - } - val targetLeft = (constraints.maxWidth - targetWidth) / 2f - val targetTop = (constraints.maxHeight - targetHeight) / 2f - PhotoContentWithRectTransition( - photoTransition = photoTransition, - initRect = Rect(transitionStartOffset, transitionStartSize.toSize()), - targetRect = Rect( - targetLeft, - targetTop, - targetLeft + targetWidth, - targetTop + targetHeight - ), - transitionDurationMs = transitionDurationMs - ) } } } - - @Composable - protected open fun PhotoBackgroundWithTransition( - animateEnterTarget: Boolean, - transitionDurationMs: Int - ){ - val alphaTransitionState = remember { - MutableTransitionState(if(animateEnterTarget) 0f else 1f) - } - alphaTransitionState.targetState = 1f - val transition = updateTransition(transitionState = alphaTransitionState, label = "") - val alpha = transition.animateFloat( - transitionSpec = { tween(durationMillis = transitionDurationMs) }, - label = "" - ) { - it - } - Box(modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .alpha(alpha.value) - ) - } - - @Composable - protected open fun PhotoContentWithRectTransition( - photoTransition: QMUIPhotoTransition, - initRect: Rect, - targetRect: Rect, - transitionDurationMs: Int - ){ - val alphaTransitionState = remember { - MutableTransitionState(initRect) - } - alphaTransitionState.targetState = targetRect - val transition = updateTransition(transitionState = alphaTransitionState, label = "") - val rect = transition.animateRect( - transitionSpec = { tween(durationMillis = transitionDurationMs) }, - label = "" - ) { - it - } - val left = with(LocalDensity.current){ - rect.value.left.toDp() - } - val top = with(LocalDensity.current){ - rect.value.top.toDp() - } - val width = with(LocalDensity.current){ - rect.value.width.toDp() - } - val height = with(LocalDensity.current){ - rect.value.height.toDp() - } - Box( - modifier = Modifier.offset(left, top).width(width).height(height) - ){ - PhotoContent(photoTransition) - } - } - - @Composable - protected open fun PhotoContentWithAlphaTransition( - photoTransition: QMUIPhotoTransition, - animateEnterTarget: Boolean, - transitionDurationMs: Int - ){ - val alphaTransitionState = remember { - MutableTransitionState(if(animateEnterTarget) 0f else 1f) - } - alphaTransitionState.targetState = 1f - val transition = updateTransition(transitionState = alphaTransitionState, label = "") - val alpha = transition.animateFloat( - transitionSpec = { tween(durationMillis = transitionDurationMs) }, - label = "" - ) { - it - } - Box(modifier = Modifier - .fillMaxSize() - .alpha(alpha.value) - ){ - PhotoContent(photoTransition) - } - } - @Composable protected open fun PhotoContent( photoTransition: QMUIPhotoTransition - ){ + ) { Box(modifier = Modifier.fillMaxSize()) { var isPhotoLoadSuccess by remember { mutableStateOf(false) @@ -339,14 +212,24 @@ open class QMUIPhotoViewerActivity : AppCompatActivity() { onSuccess = { isPhotoLoadSuccess = true }, onError = null ) - if(!isPhotoLoadSuccess){ - photoTransition.photoProvider.thumbnail()?.Compose( - contentScale = ContentScale.Fit, - isContainerFixed = true, - isLongImage = false, - onSuccess = null, - onError = null - ) + if (!isPhotoLoadSuccess) { + val transitionPhoto = photoTransition.photo + if (transitionPhoto != null) { + Image( + painter = BitmapPainter(transitionPhoto.toBitmap().asImageBitmap()), + contentDescription = "", + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } else { + photoTransition.photoProvider.thumbnail()?.Compose( + contentScale = ContentScale.Fit, + isContainerFixed = true, + isLongImage = false, + onSuccess = null, + onError = null + ) + } } } } diff --git a/photo/src/main/java/com/qmuiteam/photo/compose/GesturePhoto.kt b/photo/src/main/java/com/qmuiteam/photo/compose/GesturePhoto.kt new file mode 100644 index 00000000..fac57dde --- /dev/null +++ b/photo/src/main/java/com/qmuiteam/photo/compose/GesturePhoto.kt @@ -0,0 +1,294 @@ +package com.qmuiteam.photo.compose + +import android.util.Log +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + + +@Composable +fun GesturePhoto( + containerWidth: Dp, + containerHeight: Dp, + imageRatio: Float, + isLongImage: Boolean, + initRect: Rect? = null, + transitionEnter: Boolean = false, + transitionDurationMs: Int = 1000, + onPress: (() -> Unit)? = null, + content: @Composable () -> Unit +) { + val layoutRatio = containerWidth / containerHeight + val imageWidth: Dp + val imageHeight: Dp + when { + imageRatio >= layoutRatio -> { + imageWidth = containerWidth + imageHeight = containerWidth / imageRatio + } + isLongImage -> { + imageWidth = containerWidth + imageHeight = containerHeight + } + else -> { + imageWidth = containerHeight * imageRatio + imageHeight = containerHeight + } + } + + var backgroundTargetAlpha by remember { + mutableStateOf(1f) + } + + val photoTargetNormalTranslateX = with(LocalDensity.current) { + ((containerWidth - imageWidth) / 2f).toPx() + } + + val photoTargetNormalTranslateY = with(LocalDensity.current) { + ((containerHeight - imageHeight) / 2f).toPx() + } + + var photoTargetScale by remember(containerWidth, containerHeight) { mutableStateOf(1f) } + var photoTargetTranslateX by remember(containerWidth, containerHeight) { mutableStateOf(photoTargetNormalTranslateX) } + var photoTargetTranslateY by remember(containerWidth, containerHeight) { mutableStateOf(photoTargetNormalTranslateY) } + + val containerWidthPx = with(LocalDensity.current) { containerWidth.toPx() } + val containerHeightPx = with(LocalDensity.current) { containerHeight.toPx() } + val imageWidthPx = with(LocalDensity.current) { imageWidth.toPx() } + val imageHeightPx = with(LocalDensity.current) { imageHeight.toPx() } + + val transitionState = remember(containerWidth, containerHeight) { + val canTransitionEnter = initRect != null && transitionEnter + MutableTransitionState(!canTransitionEnter) + } + + val scaleHandler: (Offset, Float) -> Unit = remember(containerWidth, containerHeight) { + { center, scale -> + var targetLeft = center.x + ((photoTargetTranslateX - center.x) * scale) + var targetTop = center.y + ((photoTargetTranslateY - center.y) * scale) + val targetWidth = imageWidthPx * photoTargetScale * scale + val targetHeight = imageHeightPx * photoTargetScale * scale + when { + containerWidthPx > targetWidth -> { + targetLeft = (containerWidthPx - targetWidth) / 2 + } + targetLeft > 0 -> { + targetLeft = 0f + } + targetLeft + targetWidth < containerWidthPx -> { + targetLeft = containerWidthPx - targetWidth + } + } + + when { + containerHeightPx > targetHeight -> { + targetTop = (containerHeightPx - targetHeight) / 2 + } + targetTop > 0 -> { + targetTop = 0f + } + targetTop + targetHeight < containerHeightPx -> { + targetTop = containerHeightPx - targetHeight + } + } + photoTargetTranslateX = targetLeft + photoTargetTranslateY = targetTop + photoTargetScale *= scale + } + } + + transitionState.targetState = true + val transition = updateTransition(transitionState = transitionState, label = "PhotoPager") + Box( + modifier = Modifier + .width(containerWidth) + .height(containerHeight) + ) { + PhotoBackgroundWithTransition(backgroundTargetAlpha, transition, transitionDurationMs){ + PhotoBackground(alpha = it) + } + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput("${containerWidth}_${containerHeight}") { + coroutineScope { + launch { + detectTapGestures( + onPress = { + onPress?.invoke() + }, + onDoubleTap = { + if (photoTargetScale == 1f) { + var scale = 2f + val alignScale = (containerWidth / imageWidth).coerceAtLeast((containerHeight / imageHeight)) + if (alignScale > 1.25 && alignScale < scale) { + scale = alignScale + } + scaleHandler.invoke(it, scale) + } else { + photoTargetScale = 1f + photoTargetTranslateX = photoTargetNormalTranslateX + photoTargetTranslateY = photoTargetNormalTranslateY + } + } + ) + } + +// launch { +// detectTransformGestures(true) { centroid: Offset, pan: Offset, zoom: Float, _: Float -> +// scaleHandler.invoke(centroid, zoom) +// } +// } + } + } + ) { + + if (initRect == null || initRect == Rect.Zero) { + PhotoContentWithAlphaTransition( + scale = photoTargetScale, + translateX = photoTargetTranslateX, + translateY = photoTargetTranslateY, + transition = transition, + transitionDurationMs = transitionDurationMs + ) { alpha, scale, translateX, translateY -> + PhotoTransformContent(imageWidth, imageHeight, alpha, scale, translateX, translateY, content) + } + } else { + PhotoContentWithRectTransition( + imageWidth = imageWidthPx, + imageHeight = imageHeightPx, + initRect = initRect, + scale = photoTargetScale, + translateX = photoTargetTranslateX, + translateY = photoTargetTranslateY, + transition = transition, + transitionDurationMs = transitionDurationMs + ) { scale, translateX, translateY -> + PhotoTransformContent(imageWidth, imageHeight, 1f, scale, translateX, translateY, content) + } + } + } + } +} + +@Composable +fun PhotoBackgroundWithTransition( + backgroundTargetAlpha: Float, + transition: Transition, + transitionDurationMs: Int, + content: @Composable (alpha: Float) -> Unit +) { + if (transition.currentState) { + Box( + modifier = Modifier + .fillMaxSize() + .alpha(backgroundTargetAlpha) + .background(Color.Black) + ) + } else { + val alpha = transition.animateFloat( + transitionSpec = { tween(durationMillis = transitionDurationMs) }, + label = "PhotoBackgroundWithTransition" + ) { + if (it) backgroundTargetAlpha else 0f + } + content(alpha.value) + } +} + +@Composable +fun PhotoContentWithAlphaTransition( + scale: Float, + translateX: Float, + translateY: Float, + transition: Transition, + transitionDurationMs: Int, + content: @Composable (alpha: Float, scale: Float, translateX: Float, translateY: Float) -> Unit +) { + val alpha = transition.animateFloat( + transitionSpec = { tween(durationMillis = transitionDurationMs) }, + label = "PhotoContentWithAlphaTransition" + ) { + if (it) 1f else 0f + } + content(alpha.value, scale, translateX, translateY) +} + +@Composable +fun PhotoContentWithRectTransition( + imageWidth: Float, + imageHeight: Float, + initRect: Rect, + scale: Float, + translateX: Float, + translateY: Float, + transition: Transition, + transitionDurationMs: Int, + content: @Composable (scale: Float, translateX: Float, translateY: Float) -> Unit +) { + val rect = transition.animateRect( + transitionSpec = { tween(durationMillis = transitionDurationMs) }, + label = "PhotoContentWithRectTransition" + ) { + if (it) Rect(translateX, translateY, translateX + imageWidth * scale, translateY + imageHeight * scale) else initRect + } + content(rect.value.width / imageWidth, rect.value.left, rect.value.top) +} + +@Composable +fun PhotoBackground( + alpha: Float +) { + Box( + modifier = Modifier + .fillMaxSize() + .alpha(alpha) + .background(Color.Black) + ) +} + +@Composable +fun PhotoTransformContent( + imageWidth: Dp, + imageHeight: Dp, + alpha: Float, + scale: Float, + translateX: Float, + translateY: Float, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .width(imageWidth) + .height(imageHeight) + .graphicsLayer { + this.transformOrigin = TransformOrigin(0f, 0f) + this.scaleX = scale + this.scaleY = scale + this.translationX = translateX + this.translationY = translateY + this.alpha = alpha + } + ) { + content() + } +} \ No newline at end of file diff --git a/photo/src/main/java/com/qmuiteam/photo/compose/Thumbnail.kt b/photo/src/main/java/com/qmuiteam/photo/compose/Thumbnail.kt index 3c7e0b6e..84a80c84 100644 --- a/photo/src/main/java/com/qmuiteam/photo/compose/Thumbnail.kt +++ b/photo/src/main/java/com/qmuiteam/photo/compose/Thumbnail.kt @@ -35,7 +35,7 @@ class QMUIPhotoThumbnailConfig( val averageIfTwoImage: Boolean = true, val horGap: Dp = 5.dp, val verGap: Dp = 5.dp, - val alphaWhenPressed: Float = 0.5f + val alphaWhenPressed: Float = 1f ) val qmuiDefaultPhotoThumbnailConfig = QMUIPhotoThumbnailConfig() diff --git a/photo/src/main/java/com/qmuiteam/photo/data/QMUIPhotoTransition.kt b/photo/src/main/java/com/qmuiteam/photo/data/QMUIPhotoTransition.kt index 142b2a5c..f8648389 100644 --- a/photo/src/main/java/com/qmuiteam/photo/data/QMUIPhotoTransition.kt +++ b/photo/src/main/java/com/qmuiteam/photo/data/QMUIPhotoTransition.kt @@ -45,6 +45,7 @@ interface QMUIPhotoProvider { fun thumbnail(): QMUIPhoto? fun photo(): QMUIPhoto? fun ratio(): Float = -1f + fun isLongImage(): Boolean = false fun meta(): Bundle? fun recoverCls(): Class?