diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3245687..4d83c94 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -142,8 +142,7 @@ dependencies { kapt("com.google.dagger:hilt-compiler:2.48") // Image - implementation("com.github.skydoves:landscapist-glide:2.1.11") - ksp("com.github.bumptech.glide:ksp:4.14.2") // this have to be align with landscapist-glide + implementation("io.coil-kt:coil-compose:2.4.0") } java { diff --git a/app/src/main/java/com/pixelized/rplexicon/MainApplication.kt b/app/src/main/java/com/pixelized/rplexicon/MainApplication.kt index d62a343..78e0fdf 100644 --- a/app/src/main/java/com/pixelized/rplexicon/MainApplication.kt +++ b/app/src/main/java/com/pixelized/rplexicon/MainApplication.kt @@ -1,7 +1,31 @@ package com.pixelized.rplexicon import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache +import coil.memory.MemoryCache import dagger.hilt.android.HiltAndroidApp +import java.io.File @HiltAndroidApp -class MainApplication : Application() \ No newline at end of file +class MainApplication : Application(), ImageLoaderFactory { + + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(this) + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(applicationContext.cacheDir.resolve("image_cache")) + .maxSizeBytes(size = 150.Mo) + .build() + } + .build() + } +} + +val Int.Mo: Long get() = (this * 1024 * 1024).toLong() \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/AsyncImage.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/AsyncImage.kt index 68d934d..6f2d1e8 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/composable/AsyncImage.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/AsyncImage.kt @@ -1,57 +1,38 @@ package com.pixelized.rplexicon.ui.composable -import androidx.annotation.DrawableRes -import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.painter.Painter -import com.bumptech.glide.request.RequestListener -import com.pixelized.rplexicon.utilitary.rememberLoadingTransition -import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.InternalLandscapistApi -import com.skydoves.landscapist.components.ImageComponent -import com.skydoves.landscapist.components.rememberImageComponent -import com.skydoves.landscapist.glide.GlideImage -import com.skydoves.landscapist.glide.GlideImageState -import com.skydoves.landscapist.glide.GlideRequestType +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImagePainter @Composable fun AsyncImage( - imageModel: () -> Any?, modifier: Modifier = Modifier, - glideRequestType: GlideRequestType = GlideRequestType.DRAWABLE, - requestListener: (() -> RequestListener)? = null, - component: ImageComponent = rememberImageComponent {}, - imageOptions: ImageOptions = ImageOptions(), - onImageStateChanged: (GlideImageState) -> Unit = {}, - @DrawableRes previewPlaceholder: Int = 0, - loading: @Composable (BoxScope.(imageState: GlideImageState.Loading) -> Unit)? = null, - success: @Composable (BoxScope.(imageState: GlideImageState.Success, painter: Painter) -> Unit)? = null, - failure: @Composable (BoxScope.(imageState: GlideImageState.Failure) -> Unit)? = null, + model: Any?, + contentDescription: String? = null, + transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, + onState: ((AsyncImagePainter.State) -> Unit)? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, ) { - val transition = rememberLoadingTransition(imageModel) - - GlideImage( - imageModel = imageModel, - modifier = modifier.alpha(alpha = transition.alpha), - glideRequestType = glideRequestType, - requestListener = requestListener, - component = component, - imageOptions = imageOptions, - onImageStateChanged = { - when (it) { - is GlideImageState.Success -> transition.target = 1f - is GlideImageState.Failure -> transition.target = 1f - is GlideImageState.Loading -> transition.target = 0f - is GlideImageState.None -> transition.target = 0f - } - onImageStateChanged(it) - }, - previewPlaceholder = previewPlaceholder, - loading = loading, - success = success, - failure = failure, + coil.compose.AsyncImage( + modifier = modifier, + model = model, + contentDescription = contentDescription, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + transform = transform, + onState = onState, + filterQuality = filterQuality, ) } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/BackgroundImage.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/BackgroundImage.kt index 40ad20c..1349460 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/composable/BackgroundImage.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/BackgroundImage.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import com.pixelized.rplexicon.R -import com.skydoves.landscapist.ImageOptions @Composable @@ -52,20 +51,17 @@ fun BackgroundImage( ColorMatrix().also { it.setToSaturation(0f) } ) }, - model: () -> Any?, + model: Any?, ) { Box( modifier = modifier ) { AsyncImage( modifier = Modifier.matchParentSize(), - imageOptions = ImageOptions( - alignment = alignment, - contentScale = contentScale, - colorFilter = colorFilter, - ), - imageModel = model, - previewPlaceholder = R.drawable.im_brulkhai, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + model = model, ) Box( modifier = Modifier diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/ImageDialog.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/ImageDialog.kt new file mode 100644 index 0000000..f9348dd --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/ImageDialog.kt @@ -0,0 +1,108 @@ +package com.pixelized.rplexicon.ui.composable + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.net.Uri +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import com.pixelized.rplexicon.R +import com.pixelized.rplexicon.ui.theme.LexiconTheme +import com.pixelized.rplexicon.utilitary.extentions.uri +import com.pixelized.rplexicon.utilitary.extentions.zoomable +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class FullScreenImageViewModel @Inject constructor() : ViewModel() { + private val _url = mutableStateOf(null) + val url: State get() = _url + + fun showDetail(url: Uri) { + _url.value = url + } + + fun hideDetail() { + _url.value = null + } +} + +@Composable +fun FullScreenImageHandler( + viewModel: FullScreenImageViewModel = hiltViewModel(), +) { + ImageDialog( + url = viewModel.url, + onDismissRequest = viewModel::hideDetail, + ) +} + +@Composable +fun ImageDialog( + url: State, + onDismissRequest: () -> Unit, +) { + val uri by url + if (uri != null) { + Dialog( + properties = remember { + DialogProperties( + decorFitsSystemWindows = false, + usePlatformDefaultWidth = false, + ) + }, + onDismissRequest = onDismissRequest, + ) { + Box { + AsyncImage( + modifier = Modifier + .fillMaxSize() + .zoomable() + .padding(all = 16.dp), + contentScale = ContentScale.Fit, + model = uri, + ) + + IconButton( + modifier = Modifier.align(alignment = Alignment.TopEnd), + onClick = onDismissRequest, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + ) + } + } + } + } +} + +@Composable +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +private fun ImageDialogPreview() { + LexiconTheme { + ImageDialog( + url = remember { mutableStateOf(Uri.parse(R.drawable.im_brulkhai.uri)) }, + onDismissRequest = { }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ScreenNavHost.kt b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ScreenNavHost.kt index f51eb2c..8537923 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ScreenNavHost.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ScreenNavHost.kt @@ -10,7 +10,6 @@ import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import com.pixelized.rplexicon.ui.navigation.screens.AUTHENTICATION_ROUTE -import com.pixelized.rplexicon.ui.navigation.screens.HOME_ROUTE import com.pixelized.rplexicon.ui.navigation.screens.composableAuthentication import com.pixelized.rplexicon.ui.navigation.screens.composableCharacterSheet import com.pixelized.rplexicon.ui.navigation.screens.composableHome diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/detail/LexiconDetailScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/detail/LexiconDetailScreen.kt index 3a8d3cb..6741085 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/detail/LexiconDetailScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/detail/LexiconDetailScreen.kt @@ -5,6 +5,7 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -18,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -58,6 +58,8 @@ import com.pixelized.rplexicon.R import com.pixelized.rplexicon.model.Lexicon import com.pixelized.rplexicon.ui.composable.AsyncImage import com.pixelized.rplexicon.ui.composable.BackgroundImage +import com.pixelized.rplexicon.ui.composable.FullScreenImageHandler +import com.pixelized.rplexicon.ui.composable.FullScreenImageViewModel import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost import com.pixelized.rplexicon.ui.navigation.screens.navigateToCharacterSheet import com.pixelized.rplexicon.ui.theme.LexiconTheme @@ -68,7 +70,6 @@ import com.pixelized.rplexicon.utilitary.extentions.highlightRegex import com.pixelized.rplexicon.utilitary.extentions.lexicon import com.pixelized.rplexicon.utilitary.extentions.scrollOffset import com.pixelized.rplexicon.utilitary.extentions.searchCriterion -import com.skydoves.landscapist.ImageOptions @Stable data class LexiconDetailUio( @@ -160,6 +161,7 @@ fun LexiconDetailUio.annotate(): AnnotatedLexiconDetailUio { @Composable fun LexiconDetailScreen( viewModel: LexiconDetailViewModel = hiltViewModel(), + imageViewModel: FullScreenImageViewModel = hiltViewModel(), ) { val screen = LocalScreenNavHost.current @@ -170,6 +172,11 @@ fun LexiconDetailScreen( haveCharacterSheet = viewModel.haveCharacterSheet, onBack = { screen.popBackStack() }, onCharacterSheet = { screen.navigateToCharacterSheet(name = it) }, + onImage = { imageViewModel.showDetail(it) } + ) + + FullScreenImageHandler( + viewModel = imageViewModel, ) } } @@ -183,6 +190,7 @@ private fun LexiconDetailContent( haveCharacterSheet: State, onBack: () -> Unit, onCharacterSheet: (String) -> Unit, + onImage: (Uri) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val typography = MaterialTheme.typography @@ -230,7 +238,7 @@ private fun LexiconDetailContent( .fillMaxWidth() .aspectRatio(ratio = 1f) .scrollOffset(scrollState = state) { -it / 2 }, - model = { uri.toString() }, + model = uri, ) } Column( @@ -384,17 +392,16 @@ private fun LexiconDetailContent( ) { items(items = annotatedItem.portrait) { AsyncImage( - modifier = Modifier.sizeIn( - minWidth = maxSize / 2, - maxWidth = maxSize, - minHeight = maxSize, - maxHeight = maxSize, - ), - imageOptions = ImageOptions( - contentScale = ContentScale.FillHeight - ), - imageModel = { it }, - previewPlaceholder = R.drawable.im_brulkhai, + modifier = Modifier + .clickable { onImage(it) } + .sizeIn( + minWidth = maxSize / 2, + maxWidth = maxSize, + minHeight = maxSize, + maxHeight = maxSize, + ), + contentScale = ContentScale.FillHeight, + model = it, ) } } @@ -459,6 +466,7 @@ private fun LexiconDetailPreview() { haveCharacterSheet = remember { mutableStateOf(true) }, onBack = { }, onCharacterSheet = { }, + onImage = { }, ) } } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/FantasyMap.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/FantasyMap.kt index 3e4438c..7cccb7a 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/FantasyMap.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/FantasyMap.kt @@ -1,6 +1,5 @@ package com.pixelized.rplexicon.ui.screens.location.detail -import androidx.annotation.DrawableRes import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateOffsetAsState import androidx.compose.foundation.gestures.detectTapGestures @@ -26,21 +25,20 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.pixelized.rplexicon.ui.composable.AsyncImage import com.pixelized.rplexicon.ui.theme.LexiconTheme import com.pixelized.rplexicon.utilitary.extentions.lexicon -import com.skydoves.landscapist.ImageOptions @Composable fun FantasyMap( modifier: Modifier = Modifier, state: FantasyMapState, - model: () -> Any?, - imageOptions: ImageOptions = ImageOptions(), - @DrawableRes previewPlaceholder: Int, + model: Any?, + contentScale: ContentScale = ContentScale.Fit, items: State>, highlight: State, selectedItem: State, @@ -163,9 +161,8 @@ fun FantasyMap( } ) }, - imageModel = model, - imageOptions = imageOptions, - previewPlaceholder = previewPlaceholder, + model = model, + contentScale = contentScale, ) } } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/LocationDetail.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/LocationDetail.kt index 2ee30dd..50cb690 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/LocationDetail.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/LocationDetail.kt @@ -74,7 +74,6 @@ import com.pixelized.rplexicon.ui.composable.Handle import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost import com.pixelized.rplexicon.ui.theme.LexiconTheme import com.pixelized.rplexicon.utilitary.extentions.lexicon -import com.skydoves.landscapist.ImageOptions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -292,9 +291,8 @@ private fun LocationContent( ) .offset(scrollState = scrollState), state = fantasyMapState, - model = { item.value.map }, - previewPlaceholder = R.drawable.im_brulkhai, - imageOptions = ImageOptions(contentScale = ContentScale.Fit), + model = item.value.map, + contentScale = ContentScale.Fit, items = remember { derivedStateOf { item.value.marquees } }, selectedItem = selectedIndex, highlight = mapHighlight, diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/quest/detail/QuestDetailScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/quest/detail/QuestDetailScreen.kt index b2295af..1782b6c 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/quest/detail/QuestDetailScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/quest/detail/QuestDetailScreen.kt @@ -203,7 +203,7 @@ private fun QuestDetailContent( ) { BackgroundImage( modifier = Modifier.matchParentSize(), - model = { annotatedQuest.background }, + model = annotatedQuest.background, ) if (annotatedQuest.completed) { Text( diff --git a/app/src/main/java/com/pixelized/rplexicon/utilitary/LexiconGlideModule.kt b/app/src/main/java/com/pixelized/rplexicon/utilitary/LexiconGlideModule.kt deleted file mode 100644 index 966747e..0000000 --- a/app/src/main/java/com/pixelized/rplexicon/utilitary/LexiconGlideModule.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.pixelized.rplexicon.utilitary - -import android.content.Context -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalView -import com.bumptech.glide.GlideBuilder -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory -import com.bumptech.glide.module.AppGlideModule - - -@GlideModule -class LexiconGlideModule : AppGlideModule() { - override fun applyOptions(context: Context, builder: GlideBuilder) { - val diskCacheSizeBytes = 1024 * 1024 * 100 // 100 MB - builder.setDiskCache(InternalCacheDiskCacheFactory(context, diskCacheSizeBytes.toLong())) - } -} - -@Stable -class GlideLoadingTransition( - target: MutableState, - alpha: State -) { - var target by target - val alpha by alpha -} - -@Composable -fun rememberLoadingTransition(model: () -> Any?): GlideLoadingTransition { - val isInEditMode = LocalView.current.isInEditMode - - return if (isInEditMode) { - remember { - GlideLoadingTransition( - target = mutableFloatStateOf(1f), - alpha = mutableFloatStateOf(1f), - ) - } - } else { - val key = model() - val target = remember(key) { - mutableFloatStateOf(0f) - } - val alpha = animateFloatAsState( - targetValue = target.value, - label = "LoadingAlphaTransition" - ) - remember(key) { - GlideLoadingTransition( - target = target, - alpha = alpha, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/ModifierEx.kt b/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/ModifierEx.kt index 96849dd..7a9e637 100644 --- a/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/ModifierEx.kt +++ b/app/src/main/java/com/pixelized/rplexicon/utilitary/extentions/ModifierEx.kt @@ -2,11 +2,15 @@ package com.pixelized.rplexicon.utilitary.extentions import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateOffsetAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth @@ -19,12 +23,20 @@ import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +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 androidx.compose.ui.unit.dp @@ -49,6 +61,33 @@ fun Modifier.placeholder( ) } +fun Modifier.zoomable() = composed { + var offset by remember { mutableStateOf(Offset.Zero) } + var zoom by remember { mutableFloatStateOf(1f) } + + return@composed this + .pointerInput(Unit) { + detectTransformGestures( + onGesture = { centroid, pan, gestureZoom, _ -> + val newScale = maxOf(1f, zoom * gestureZoom) + val newOffset = (offset + centroid / zoom) - (centroid / newScale + pan / zoom) + offset = Offset( + newOffset.x.coerceIn(0f, (size.width / zoom) * (zoom - 1f)), + newOffset.y.coerceIn(0f, (size.height / zoom) * (zoom - 1f)) + ) + zoom = maxOf(1f, zoom * gestureZoom) + } + ) + } + .graphicsLayer { + translationX = -offset.x * zoom + translationY = -offset.y * zoom + scaleX = zoom + scaleY = zoom + transformOrigin = TransformOrigin(0f, 0f) + } +} + @Stable fun Modifier.cell() = composed { Modifier