From 81259cabc998324f49cbbd5ea78327ce1966cbcc Mon Sep 17 00:00:00 2001 From: Thomas Andres Gomez Date: Thu, 10 Aug 2023 18:27:14 +0200 Subject: [PATCH] Add location tap feature --- .../ui/screens/location/detail/FantasyMap.kt | 90 ++++++++++++------- .../screens/location/detail/LocationDetail.kt | 83 +++++++++++++++-- .../ui/screens/location/detail/MarqueeItem.kt | 20 +++-- .../rplexicon/utilitary/TextMeasurement.kt | 51 +++++++++++ .../res/drawable/ic_baseline_touch_app_24.xml | 5 ++ 5 files changed, 199 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/pixelized/rplexicon/utilitary/TextMeasurement.kt create mode 100644 app/src/main/res/drawable/ic_baseline_touch_app_24.xml 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 5e2eac7..f0d5322 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 @@ -41,6 +41,7 @@ fun FantasyMap( item: State, selectedItem: State, onMarquee: (MarqueeUio) -> Unit, + onTap: (Offset) -> Unit, ) { val colorScheme = MaterialTheme.lexicon.colorScheme val animatedScale = animateFloatAsState(targetValue = state.scale, label = "ScaleAnimation") @@ -84,47 +85,63 @@ fun FantasyMap( .onSizeChanged { state.imageSize = it } .drawWithContent { drawContent() - item.value.marquees.forEachIndexed { index, item -> - if (item.position != Offset.Unspecified) { - drawCircle( - color = colorScheme.shadow, - radius = 12.dp.toPx() / animatedScale.value, - style = Stroke( - width = 2.dp.toPx() / animatedScale.value, - ), - center = Offset( - x = size.width * item.position.x, - y = size.height * item.position.y + 2.dp.toPx() / animatedScale.value, + + if (state.freeHand.not()) { + item.value.marquees.forEachIndexed { index, item -> + if (item.position != Offset.Unspecified) { + drawCircle( + color = colorScheme.shadow, + radius = 12.dp.toPx() / animatedScale.value, + style = Stroke( + width = 2.dp.toPx() / animatedScale.value, + ), + center = Offset( + x = size.width * item.position.x, + y = size.height * item.position.y + 2.dp.toPx() / animatedScale.value, + ) ) - ) - drawCircle( - color = when (selectedItem.value) { - index -> colorScheme.base.primary - else -> Color.White - }, - radius = 12.dp.toPx() / animatedScale.value, - style = Stroke( - width = 2.dp.toPx() / animatedScale.value, - ), - center = Offset( - x = size.width * item.position.x, - y = size.height * item.position.y, + drawCircle( + color = when (selectedItem.value) { + index -> colorScheme.base.primary + else -> Color.White + }, + radius = 12.dp.toPx() / animatedScale.value, + style = Stroke( + width = 2.dp.toPx() / animatedScale.value, + ), + center = Offset( + x = size.width * item.position.x, + y = size.height * item.position.y, + ) ) - ) + } } } } .pointerInput("DetectTapGestures") { detectTapGestures( onTap = { tap -> - val marquee = item.value.marquees - .asReversed() - .firstOrNull { item -> - val radius = 24.dp.toPx() / animatedScale.value - (size.width * item.position.x).let { tap.x in (it - radius)..(it + radius) } && - (size.height * item.position.y).let { tap.y in (it - radius)..(it + radius) } - } - marquee?.let(onMarquee) + if (state.freeHand) { + onTap( + Offset( + x = tap.x / size.width, + y = tap.y / size.height, + ) + ) + } else { + val marquee = item.value.marquees + .asReversed() + .firstOrNull { item -> + if (item.position != Offset.Unspecified) { + val radius = 24.dp.toPx() / animatedScale.value + (size.width * item.position.x).let { tap.x in (it - radius)..(it + radius) } && + (size.height * item.position.y).let { tap.y in (it - radius)..(it + radius) } + } else { + false + } + } + marquee?.let(onMarquee) + } } ) }, @@ -174,6 +191,9 @@ class FantasyMapState( private val _imageSize: MutableState = mutableStateOf(IntSize.Zero) var imageSize: IntSize by _imageSize + private val _freeHand: MutableState = mutableStateOf(false) + val freeHand: Boolean by _freeHand + @Stable fun scale( scale: Float, @@ -193,6 +213,10 @@ class FantasyMapState( } } + fun toggleFreeHand(toggle: Boolean) { + _freeHand.value = toggle + } + @Stable fun computeMarqueeOffset( origin: Offset, 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 74c34b1..2ffdd27 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 @@ -22,11 +22,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -48,15 +51,19 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.pixelized.rplexicon.LocalSnack import com.pixelized.rplexicon.R 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.rememberTextSize import com.skydoves.landscapist.ImageOptions +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlin.math.max @@ -73,11 +80,16 @@ data class LocationDetailUio( fun LocationDetail( viewModel: LocationDetailViewModel = hiltViewModel() ) { + val screen = LocalScreenNavHost.current + val snack = LocalSnack.current + val scope = rememberCoroutineScope() val scroll = rememberScrollState() val pager = rememberPagerState() val fantasy = rememberFantasyMapState() - val screen = LocalScreenNavHost.current + + val ok = stringResource(id = android.R.string.ok) + val job = remember { mutableStateOf(null) } val connection = remember { object : NestedScrollConnection { @@ -117,6 +129,19 @@ fun LocationDetail( pager.animateScrollToPage(page = index) } }, + onMapTap = { + job.value?.cancel() + job.value = scope.launch { + snack.showSnackbar( + message = "x:${it.x}, y:${it.y}", + actionLabel = ok, + duration = SnackbarDuration.Indefinite, + ) + } + }, + onTouch = { + fantasy.toggleFreeHand(it) + }, onCenter = { fantasy.scale(scale = 1f) fantasy.pan(offset = Offset.Zero) @@ -176,10 +201,26 @@ private fun LocationContent( selectedIndex: State, onBack: () -> Unit, onMarquee: (MarqueeUio) -> Unit, + onMapTap: (Offset) -> Unit, + onTouch: (Boolean) -> Unit, onCenter: () -> Unit, onZoomIn: () -> Unit, onZoomOut: () -> Unit, ) { + val itemNameSize = rememberTextSize(style = MaterialTheme.typography.headlineSmall) + + val filledIconButtonColors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) + + val filledIconToggleButtonColors = IconButtonDefaults.filledIconToggleButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + checkedContainerColor = MaterialTheme.colorScheme.primary, + checkedContentColor = MaterialTheme.colorScheme.onPrimary, + ) + Scaffold( modifier = modifier, topBar = { @@ -226,19 +267,31 @@ private fun LocationContent( item = item, selectedItem = selectedIndex, onMarquee = onMarquee, + onTap = onMapTap, ) + + FilledIconToggleButton( + modifier = Modifier + .align(alignment = Alignment.TopEnd) + .padding(all = 16.dp), + checked = fantasyMapState.freeHand, + onCheckedChange = onTouch, + colors = filledIconToggleButtonColors, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_touch_app_24), + contentDescription = null + ) + } + Column( modifier = Modifier .align(alignment = Alignment.BottomEnd) .padding(all = 16.dp), ) { - val colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) FilledIconButton( onClick = onZoomOut, - colors = colors, + colors = filledIconButtonColors, ) { Icon( painter = painterResource(id = R.drawable.ic_baseline_remove_24), @@ -247,7 +300,7 @@ private fun LocationContent( } FilledIconButton( onClick = onZoomIn, - colors = colors, + colors = filledIconButtonColors, ) { Icon( painter = painterResource(id = R.drawable.ic_baseline_add_24), @@ -256,7 +309,7 @@ private fun LocationContent( } FilledIconButton( onClick = onCenter, - colors = colors, + colors = filledIconButtonColors, ) { Icon( painter = painterResource(id = R.drawable.ic_baseline_zoom_in_map_24), @@ -273,6 +326,16 @@ private fun LocationContent( .padding(top = 16.dp) ) + Text( + modifier = Modifier + .align(alignment = Alignment.CenterHorizontally) + .padding(horizontal = 16.dp) + .padding(top = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + text = item.value.name, + ) + HorizontalPager( modifier = Modifier .fillMaxWidth() @@ -287,7 +350,7 @@ private fun LocationContent( MarqueeItem( modifier = Modifier .fillMaxWidth() - .height(this@constraint.maxHeight - 32.dp), + .height(this@constraint.maxHeight - 32.dp - itemNameSize.height), marquee = marquee, ) } @@ -351,6 +414,8 @@ private fun LocationPreview() { selectedIndex = remember { mutableStateOf(0) }, onBack = { }, onMarquee = { }, + onMapTap = { }, + onTouch = { }, onCenter = { }, onZoomIn = { }, onZoomOut = { }, diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/MarqueeItem.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/MarqueeItem.kt index 9f2a6d3..6e3b243 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/MarqueeItem.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/location/detail/MarqueeItem.kt @@ -4,9 +4,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -39,7 +41,7 @@ fun MarqueeItem( textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis, maxLines = 1, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleMedium, text = marquee.name, ) marquee.description?.let { @@ -56,13 +58,15 @@ fun MarqueeItem( @Preview private fun MarqueeItemPreview() { LexiconTheme { - MarqueeItem( - modifier = Modifier.fillMaxSize(), - marquee = MarqueeUio( - name = "Name", - position = Offset.Zero, - description = "description", + Surface { + MarqueeItem( + modifier = Modifier.padding(all = 16.dp), + marquee = MarqueeUio( + name = "Name", + position = Offset.Zero, + description = "description", + ) ) - ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/utilitary/TextMeasurement.kt b/app/src/main/java/com/pixelized/rplexicon/utilitary/TextMeasurement.kt new file mode 100644 index 0000000..4d303f9 --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/utilitary/TextMeasurement.kt @@ -0,0 +1,51 @@ +package com.pixelized.rplexicon.utilitary + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit + +private const val PT_TO_PX_RATIO = 4f / 3f // pt to px ratio + +/** + * TextMeasurement class + * + * Currently it is impossible to set a *minLine* value on a Text in Compose. + * This class is a work around that compute the require Height for a [TextStyle]. + * + * google issue tracker : + * https://issuetracker.google.com/issues/122476634 + * + * based on : + * https://stackoverflow.com/questions/66394624/specify-minimal-lines-for-text-in-jetpack-compose + */ +@Stable +@Immutable +data class TextMeasurement( + val height: Dp, + val line: TextUnit, +) + +@Composable +fun rememberTextSize( + style: TextStyle, +): TextMeasurement { + val density = LocalDensity.current + return remember(density, style) { + val line = if (style.lineHeight != TextUnit.Unspecified) { + style.lineHeight + } else { + style.fontSize * PT_TO_PX_RATIO + } + val height = with(density) { line.toDp() } + + TextMeasurement( + height = height, + line = line, + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_touch_app_24.xml b/app/src/main/res/drawable/ic_baseline_touch_app_24.xml new file mode 100644 index 0000000..ff6ff93 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_touch_app_24.xml @@ -0,0 +1,5 @@ + + +