Add location tap feature

This commit is contained in:
Thomas Andres Gomez 2023-08-10 18:27:14 +02:00
parent 6e56313639
commit 81259cabc9
5 changed files with 199 additions and 50 deletions

View file

@ -41,6 +41,7 @@ fun FantasyMap(
item: State<LocationDetailUio>, item: State<LocationDetailUio>,
selectedItem: State<Int>, selectedItem: State<Int>,
onMarquee: (MarqueeUio) -> Unit, onMarquee: (MarqueeUio) -> Unit,
onTap: (Offset) -> Unit,
) { ) {
val colorScheme = MaterialTheme.lexicon.colorScheme val colorScheme = MaterialTheme.lexicon.colorScheme
val animatedScale = animateFloatAsState(targetValue = state.scale, label = "ScaleAnimation") val animatedScale = animateFloatAsState(targetValue = state.scale, label = "ScaleAnimation")
@ -84,47 +85,63 @@ fun FantasyMap(
.onSizeChanged { state.imageSize = it } .onSizeChanged { state.imageSize = it }
.drawWithContent { .drawWithContent {
drawContent() drawContent()
item.value.marquees.forEachIndexed { index, item ->
if (item.position != Offset.Unspecified) { if (state.freeHand.not()) {
drawCircle( item.value.marquees.forEachIndexed { index, item ->
color = colorScheme.shadow, if (item.position != Offset.Unspecified) {
radius = 12.dp.toPx() / animatedScale.value, drawCircle(
style = Stroke( color = colorScheme.shadow,
width = 2.dp.toPx() / animatedScale.value, radius = 12.dp.toPx() / animatedScale.value,
), style = Stroke(
center = Offset( width = 2.dp.toPx() / animatedScale.value,
x = size.width * item.position.x, ),
y = size.height * item.position.y + 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(
drawCircle( color = when (selectedItem.value) {
color = when (selectedItem.value) { index -> colorScheme.base.primary
index -> colorScheme.base.primary else -> Color.White
else -> Color.White },
}, radius = 12.dp.toPx() / animatedScale.value,
radius = 12.dp.toPx() / animatedScale.value, style = Stroke(
style = Stroke( width = 2.dp.toPx() / animatedScale.value,
width = 2.dp.toPx() / animatedScale.value, ),
), center = Offset(
center = Offset( x = size.width * item.position.x,
x = size.width * item.position.x, y = size.height * item.position.y,
y = size.height * item.position.y, )
) )
) }
} }
} }
} }
.pointerInput("DetectTapGestures") { .pointerInput("DetectTapGestures") {
detectTapGestures( detectTapGestures(
onTap = { tap -> onTap = { tap ->
val marquee = item.value.marquees if (state.freeHand) {
.asReversed() onTap(
.firstOrNull { item -> Offset(
val radius = 24.dp.toPx() / animatedScale.value x = tap.x / size.width,
(size.width * item.position.x).let { tap.x in (it - radius)..(it + radius) } && y = tap.y / size.height,
(size.height * item.position.y).let { tap.y in (it - radius)..(it + radius) } )
} )
marquee?.let(onMarquee) } 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<IntSize> = mutableStateOf(IntSize.Zero) private val _imageSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
var imageSize: IntSize by _imageSize var imageSize: IntSize by _imageSize
private val _freeHand: MutableState<Boolean> = mutableStateOf(false)
val freeHand: Boolean by _freeHand
@Stable @Stable
fun scale( fun scale(
scale: Float, scale: Float,
@ -193,6 +213,10 @@ class FantasyMapState(
} }
} }
fun toggleFreeHand(toggle: Boolean) {
_freeHand.value = toggle
}
@Stable @Stable
fun computeMarqueeOffset( fun computeMarqueeOffset(
origin: Offset, origin: Offset,

View file

@ -22,11 +22,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar 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.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.rplexicon.LocalSnack
import com.pixelized.rplexicon.R import com.pixelized.rplexicon.R
import com.pixelized.rplexicon.ui.composable.Handle import com.pixelized.rplexicon.ui.composable.Handle
import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost
import com.pixelized.rplexicon.ui.theme.LexiconTheme import com.pixelized.rplexicon.ui.theme.LexiconTheme
import com.pixelized.rplexicon.utilitary.rememberTextSize
import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.ImageOptions
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.math.max import kotlin.math.max
@ -73,11 +80,16 @@ data class LocationDetailUio(
fun LocationDetail( fun LocationDetail(
viewModel: LocationDetailViewModel = hiltViewModel() viewModel: LocationDetailViewModel = hiltViewModel()
) { ) {
val screen = LocalScreenNavHost.current
val snack = LocalSnack.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scroll = rememberScrollState() val scroll = rememberScrollState()
val pager = rememberPagerState() val pager = rememberPagerState()
val fantasy = rememberFantasyMapState() val fantasy = rememberFantasyMapState()
val screen = LocalScreenNavHost.current
val ok = stringResource(id = android.R.string.ok)
val job = remember { mutableStateOf<Job?>(null) }
val connection = remember { val connection = remember {
object : NestedScrollConnection { object : NestedScrollConnection {
@ -117,6 +129,19 @@ fun LocationDetail(
pager.animateScrollToPage(page = index) 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 = { onCenter = {
fantasy.scale(scale = 1f) fantasy.scale(scale = 1f)
fantasy.pan(offset = Offset.Zero) fantasy.pan(offset = Offset.Zero)
@ -176,10 +201,26 @@ private fun LocationContent(
selectedIndex: State<Int>, selectedIndex: State<Int>,
onBack: () -> Unit, onBack: () -> Unit,
onMarquee: (MarqueeUio) -> Unit, onMarquee: (MarqueeUio) -> Unit,
onMapTap: (Offset) -> Unit,
onTouch: (Boolean) -> Unit,
onCenter: () -> Unit, onCenter: () -> Unit,
onZoomIn: () -> Unit, onZoomIn: () -> Unit,
onZoomOut: () -> 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( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@ -226,19 +267,31 @@ private fun LocationContent(
item = item, item = item,
selectedItem = selectedIndex, selectedItem = selectedIndex,
onMarquee = onMarquee, 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( Column(
modifier = Modifier modifier = Modifier
.align(alignment = Alignment.BottomEnd) .align(alignment = Alignment.BottomEnd)
.padding(all = 16.dp), .padding(all = 16.dp),
) { ) {
val colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
)
FilledIconButton( FilledIconButton(
onClick = onZoomOut, onClick = onZoomOut,
colors = colors, colors = filledIconButtonColors,
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_baseline_remove_24), painter = painterResource(id = R.drawable.ic_baseline_remove_24),
@ -247,7 +300,7 @@ private fun LocationContent(
} }
FilledIconButton( FilledIconButton(
onClick = onZoomIn, onClick = onZoomIn,
colors = colors, colors = filledIconButtonColors,
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_baseline_add_24), painter = painterResource(id = R.drawable.ic_baseline_add_24),
@ -256,7 +309,7 @@ private fun LocationContent(
} }
FilledIconButton( FilledIconButton(
onClick = onCenter, onClick = onCenter,
colors = colors, colors = filledIconButtonColors,
) { ) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_baseline_zoom_in_map_24), painter = painterResource(id = R.drawable.ic_baseline_zoom_in_map_24),
@ -273,6 +326,16 @@ private fun LocationContent(
.padding(top = 16.dp) .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( HorizontalPager(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -287,7 +350,7 @@ private fun LocationContent(
MarqueeItem( MarqueeItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(this@constraint.maxHeight - 32.dp), .height(this@constraint.maxHeight - 32.dp - itemNameSize.height),
marquee = marquee, marquee = marquee,
) )
} }
@ -351,6 +414,8 @@ private fun LocationPreview() {
selectedIndex = remember { mutableStateOf(0) }, selectedIndex = remember { mutableStateOf(0) },
onBack = { }, onBack = { },
onMarquee = { }, onMarquee = { },
onMapTap = { },
onTouch = { },
onCenter = { }, onCenter = { },
onZoomIn = { }, onZoomIn = { },
onZoomOut = { }, onZoomOut = { },

View file

@ -4,9 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@ -39,7 +41,7 @@ fun MarqueeItem(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.titleMedium,
text = marquee.name, text = marquee.name,
) )
marquee.description?.let { marquee.description?.let {
@ -56,13 +58,15 @@ fun MarqueeItem(
@Preview @Preview
private fun MarqueeItemPreview() { private fun MarqueeItemPreview() {
LexiconTheme { LexiconTheme {
MarqueeItem( Surface {
modifier = Modifier.fillMaxSize(), MarqueeItem(
marquee = MarqueeUio( modifier = Modifier.padding(all = 16.dp),
name = "Name", marquee = MarqueeUio(
position = Offset.Zero, name = "Name",
description = "description", position = Offset.Zero,
description = "description",
)
) )
) }
} }
} }

View file

@ -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,
)
}
}

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M9,11.24V7.5C9,6.12 10.12,5 11.5,5S14,6.12 14,7.5v3.74c1.21,-0.81 2,-2.18 2,-3.74C16,5.01 13.99,3 11.5,3S7,5.01 7,7.5C7,9.06 7.79,10.43 9,11.24zM18.84,15.87l-4.54,-2.26c-0.17,-0.07 -0.35,-0.11 -0.54,-0.11H13v-6C13,6.67 12.33,6 11.5,6S10,6.67 10,7.5v10.74c-3.6,-0.76 -3.54,-0.75 -3.67,-0.75c-0.31,0 -0.59,0.13 -0.79,0.33l-0.79,0.8l4.94,4.94C9.96,23.83 10.34,24 10.75,24h6.79c0.75,0 1.33,-0.55 1.44,-1.28l0.75,-5.27c0.01,-0.07 0.02,-0.14 0.02,-0.2C19.75,16.63 19.37,16.09 18.84,15.87z"/>
</vector>