Change the map detail design

This commit is contained in:
Thomas Andres Gomez 2023-08-09 12:29:19 +02:00
parent e74bd7c097
commit d09b16ee33
9 changed files with 279 additions and 119 deletions

View file

@ -0,0 +1,29 @@
package com.pixelized.rplexicon.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import com.pixelized.rplexicon.utilitary.extentions.lexicon
@Composable
fun Handle(
modifier: Modifier = Modifier,
width: Dp = MaterialTheme.lexicon.dimens.handle.width,
thickness: Dp = MaterialTheme.lexicon.dimens.handle.thickness,
color: Color = MaterialTheme.lexicon.colorScheme.handle,
shape: Shape = CircleShape
) = Box(
modifier = modifier
.size(width = width, height = thickness)
.background(
color = color,
shape = shape,
)
)

View file

@ -38,7 +38,7 @@ fun FantasyMap(
@DrawableRes previewPlaceholder: Int, @DrawableRes previewPlaceholder: Int,
item: State<LocationDetailUio>, item: State<LocationDetailUio>,
selectedItem: State<Int>, selectedItem: State<Int>,
onMarquee: (LocationDetailUio.MarqueeUio) -> Unit, onMarquee: (MarqueeUio) -> Unit,
) { ) {
val animatedScale = animateFloatAsState(targetValue = state.scale, label = "ScaleAnimation") val animatedScale = animateFloatAsState(targetValue = state.scale, label = "ScaleAnimation")
val animatedOffset = animateOffsetAsState(targetValue = state.offset, label = "OffsetAnimation") val animatedOffset = animateOffsetAsState(targetValue = state.offset, label = "OffsetAnimation")
@ -52,14 +52,19 @@ fun FantasyMap(
modifier = modifier modifier = modifier
.pointerInput("DetectTransformGestures") { .pointerInput("DetectTransformGestures") {
detectTransformGestures(panZoomLock = true) { _, pan, zoom, _ -> detectTransformGestures(panZoomLock = true) { _, pan, zoom, _ ->
val newScale = state.scale * zoom
val oldScale = state.scale
val oldOffset = state.offset
state.scale( state.scale(
scale = state.scale * zoom, scale = newScale
) )
state.pan( state.pan(
offset = Offset( offset = Offset(
x = state.offset.x + pan.x, x = oldOffset.x * newScale / oldScale + pan.x,
y = state.offset.y + pan.y, y = oldOffset.y * newScale / oldScale + pan.y,
), )
) )
} }
} }
@ -99,7 +104,7 @@ fun FantasyMap(
val marquee = item.value.marquees val marquee = item.value.marquees
.asReversed() .asReversed()
.firstOrNull { item -> .firstOrNull { item ->
val radius = 24.dp.toPx() * animatedScale.value val radius = 24.dp.toPx() / animatedScale.value
(size.width * item.position.x).let { tap.x in (it - radius)..(it + radius) } && (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) } (size.height * item.position.y).let { tap.y in (it - radius)..(it + radius) }
} }

View file

@ -3,16 +3,23 @@ package com.pixelized.rplexicon.ui.screens.location.detail
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
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.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
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.Icon import androidx.compose.material3.Icon
@ -34,21 +41,24 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
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.text.style.TextOverflow
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.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.rplexicon.R import com.pixelized.rplexicon.R
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.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.ImageOptions
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.math.max import kotlin.math.max
@Stable @Stable
@ -56,14 +66,7 @@ data class LocationDetailUio(
val name: String, val name: String,
val map: Uri, val map: Uri,
val marquees: List<MarqueeUio>, val marquees: List<MarqueeUio>,
) { )
@Stable
data class MarqueeUio(
val name: String,
val position: Offset,
val description: String?,
)
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@ -71,15 +74,35 @@ fun LocationDetail(
viewModel: LocationDetailViewModel = hiltViewModel() viewModel: LocationDetailViewModel = hiltViewModel()
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scroll = rememberScrollState()
val pager = rememberPagerState() val pager = rememberPagerState()
val fantasy = rememberFantasyMapState() val fantasy = rememberFantasyMapState()
val screen = LocalScreenNavHost.current val screen = LocalScreenNavHost.current
val connection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = runBlocking {
Offset(
x = 0f,
y = when (scroll.canScrollForward) {
true -> -scroll.scrollBy(-available.y)
else -> 0f
},
)
}
}
}
val selectedIndex = remember { mutableStateOf(0) } val selectedIndex = remember { mutableStateOf(0) }
Surface { Surface {
LocationContent( LocationContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
connection = connection,
scrollState = scroll,
pagerState = pager, pagerState = pager,
fantasyMapState = fantasy, fantasyMapState = fantasy,
item = viewModel.location, item = viewModel.location,
@ -99,12 +122,32 @@ fun LocationDetail(
fantasy.pan(offset = Offset.Zero) fantasy.pan(offset = Offset.Zero)
}, },
onZoomIn = { onZoomIn = {
fantasy.scale(fantasy.scale + 1) val newScale = fantasy.scale + 1
fantasy.pan(offset = fantasy.offset) if (newScale <= fantasy.maxScale) {
val oldScale = fantasy.scale
val oldOffset = fantasy.offset
fantasy.scale(newScale)
fantasy.pan(
offset = Offset(
x = oldOffset.x * newScale / oldScale,
y = oldOffset.y * newScale / oldScale,
)
)
}
}, },
onZoomOut = { onZoomOut = {
fantasy.scale(fantasy.scale - 1) val newScale = fantasy.scale - 1
fantasy.pan(offset = fantasy.offset) if (newScale >= fantasy.minScale) {
val oldScale = fantasy.scale
val oldOffset = fantasy.offset
fantasy.scale(newScale)
fantasy.pan(
offset = Offset(
x = oldOffset.x * newScale / oldScale,
y = oldOffset.y * newScale / oldScale,
)
)
}
}, },
) )
@ -125,19 +168,20 @@ fun LocationDetail(
@Composable @Composable
private fun LocationContent( private fun LocationContent(
modifier: Modifier, modifier: Modifier,
connection: NestedScrollConnection,
scrollState: ScrollState,
pagerState: PagerState, pagerState: PagerState,
fantasyMapState: FantasyMapState, fantasyMapState: FantasyMapState,
item: State<LocationDetailUio>, item: State<LocationDetailUio>,
selectedIndex: State<Int>, selectedIndex: State<Int>,
onBack: () -> Unit, onBack: () -> Unit,
onMarquee: (LocationDetailUio.MarqueeUio) -> Unit, onMarquee: (MarqueeUio) -> Unit,
onCenter: () -> Unit, onCenter: () -> Unit,
onZoomIn: () -> Unit, onZoomIn: () -> Unit,
onZoomOut: () -> Unit, onZoomOut: () -> Unit,
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
containerColor = Color.Transparent,
topBar = { topBar = {
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
@ -149,117 +193,110 @@ private fun LocationContent(
} }
}, },
title = { title = {
Text(text = stringResource(id = R.string.detail_title)) Text(text = stringResource(id = R.string.map_title))
}, },
) )
}, },
) { paddingValues -> ) { paddingValues ->
Column( BoxWithConstraints(
modifier = Modifier.padding(paddingValues = paddingValues), modifier = Modifier.padding(paddingValues = paddingValues),
) { ) constraint@{
Surface( Column(
modifier = Modifier modifier = Modifier.verticalScroll(state = scrollState),
.fillMaxWidth()
.weight(weight = 2f),
tonalElevation = 2.dp,
) { ) {
Box( Surface(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.clip(shape = RectangleShape), .heightIn(
min = this@constraint.maxHeight / 2,
max = this@constraint.maxHeight * 2 / 3,
),
tonalElevation = 2.dp,
) { ) {
FantasyMap( Box(
modifier = Modifier.align(alignment = Alignment.Center), modifier = Modifier.clip(shape = RectangleShape),
state = fantasyMapState,
previewPlaceholder = R.drawable.im_brulkhai,
imageOptions = ImageOptions(contentScale = ContentScale.Fit),
item = item,
selectedItem = selectedIndex,
onMarquee = onMarquee,
)
Column(
modifier = Modifier
.align(alignment = Alignment.BottomEnd)
.padding(all = 16.dp),
) { ) {
val colors = IconButtonDefaults.filledIconButtonColors( FantasyMap(
containerColor = MaterialTheme.colorScheme.surface, modifier = Modifier
contentColor = MaterialTheme.colorScheme.onSurface, .align(alignment = Alignment.Center)
.offset { IntOffset(x = 0, y = scrollState.value / 2) },
state = fantasyMapState,
previewPlaceholder = R.drawable.im_brulkhai,
imageOptions = ImageOptions(contentScale = ContentScale.Fit),
item = item,
selectedItem = selectedIndex,
onMarquee = onMarquee,
) )
FilledIconButton( Column(
onClick = onZoomOut, modifier = Modifier
colors = colors, .align(alignment = Alignment.BottomEnd)
.padding(all = 16.dp),
) { ) {
Icon( val colors = IconButtonDefaults.filledIconButtonColors(
painter = painterResource(id = R.drawable.ic_baseline_remove_24), containerColor = MaterialTheme.colorScheme.surface,
contentDescription = null contentColor = MaterialTheme.colorScheme.onSurface,
)
}
FilledIconButton(
onClick = onZoomIn,
colors = colors,
) {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_add_24),
contentDescription = null
)
}
FilledIconButton(
onClick = onCenter,
colors = colors,
) {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_zoom_in_map_24),
contentDescription = null
) )
FilledIconButton(
onClick = onZoomOut,
colors = colors,
) {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_remove_24),
contentDescription = null
)
}
FilledIconButton(
onClick = onZoomIn,
colors = colors,
) {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_add_24),
contentDescription = null
)
}
FilledIconButton(
onClick = onCenter,
colors = colors,
) {
Icon(
painter = painterResource(id = R.drawable.ic_baseline_zoom_in_map_24),
contentDescription = null
)
}
} }
} }
} }
}
HorizontalPager( Handle(
modifier = Modifier.weight(weight = 1f), modifier = Modifier
state = pagerState, .align(alignment = Alignment.CenterHorizontally)
pageCount = item.value.marquees.size, .padding(top = 16.dp)
contentPadding = PaddingValues(all = 16.dp), )
pageSpacing = 8.dp,
) { HorizontalPager(
item.value.marquees.getOrNull(it)?.let { marquee -> modifier = Modifier
Marquee( .fillMaxWidth()
modifier = Modifier.fillMaxSize(), .nestedScroll(connection),
marquee = marquee, state = pagerState,
) verticalAlignment = Alignment.Top,
pageCount = item.value.marquees.size,
contentPadding = PaddingValues(all = 16.dp),
pageSpacing = 16.dp,
) {
item.value.marquees.getOrNull(it)?.let { marquee ->
MarqueeItem(
modifier = Modifier
.fillMaxWidth()
.height(this@constraint.maxHeight - 32.dp),
marquee = marquee,
)
}
} }
} }
} }
} }
} }
@Composable
private fun Marquee(
modifier: Modifier = Modifier,
marquee: LocationDetailUio.MarqueeUio,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(space = 8.dp)
) {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.headlineSmall,
text = marquee.name,
)
marquee.description?.let {
Text(
style = MaterialTheme.typography.bodyMedium,
text = it,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun HandlePagerScroll( private fun HandlePagerScroll(
@ -287,6 +324,8 @@ private fun LocationPreview() {
Surface { Surface {
LocationContent( LocationContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
connection = remember { object : NestedScrollConnection {} },
scrollState = rememberScrollState(),
pagerState = rememberPagerState(), pagerState = rememberPagerState(),
fantasyMapState = rememberFantasyMapState(), fantasyMapState = rememberFantasyMapState(),
item = remember { item = remember {
@ -295,12 +334,12 @@ private fun LocationPreview() {
name = "Daggerfall", name = "Daggerfall",
map = Uri.parse("https://i.pinimg.com/originals/6d/56/cd/6d56cd9358cc94a7077157ea3c1b5842.jpg"), map = Uri.parse("https://i.pinimg.com/originals/6d/56/cd/6d56cd9358cc94a7077157ea3c1b5842.jpg"),
marquees = listOf( marquees = listOf(
LocationDetailUio.MarqueeUio( MarqueeUio(
name = "start", name = "start",
position = Offset.Zero, position = Offset.Zero,
description = "Marquee en haut à gauche." description = "Marquee en haut à gauche."
), ),
LocationDetailUio.MarqueeUio( MarqueeUio(
name = "end", name = "end",
position = Offset(1f, 1f), position = Offset(1f, 1f),
description = "Marquee en bas à droite." description = "Marquee en bas à droite."

View file

@ -26,7 +26,7 @@ class LocationDetailViewModel @Inject constructor(
name = source.name, name = source.name,
map = source.uri, map = source.uri,
marquees = source.marquees.map { marquee -> marquees = source.marquees.map { marquee ->
LocationDetailUio.MarqueeUio( MarqueeUio(
name = marquee.name, name = marquee.name,
position = Offset(x = marquee.x, y = marquee.y), position = Offset(x = marquee.x, y = marquee.y),
description = marquee.description, description = marquee.description,

View file

@ -0,0 +1,68 @@
package com.pixelized.rplexicon.ui.screens.location.detail
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.pixelized.rplexicon.ui.theme.LexiconTheme
@Stable
data class MarqueeUio(
val name: String,
val position: Offset,
val description: String?,
)
@Composable
fun MarqueeItem(
modifier: Modifier = Modifier,
marquee: MarqueeUio,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(space = 8.dp)
) {
Text(
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.headlineSmall,
text = marquee.name,
)
marquee.description?.let {
Text(
modifier = Modifier.verticalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodyMedium,
text = it,
)
}
}
}
@Composable
@Preview
private fun MarqueeItemPreview() {
LexiconTheme {
MarqueeItem(
modifier = Modifier.fillMaxSize(),
marquee = MarqueeUio(
name = "Name",
position = Offset.Zero,
description = "description",
)
)
}
}

View file

@ -14,6 +14,7 @@ class LexiconColors(
val status: Color, val status: Color,
val navigation: Color, val navigation: Color,
val placeholder: Color, val placeholder: Color,
val handle: Color,
) )
@Stable @Stable
@ -32,6 +33,7 @@ fun darkColorScheme(
status = status, status = status,
navigation = navigation, navigation = navigation,
placeholder = placeholder, placeholder = placeholder,
handle = base.onSurface.copy(alpha = 0.5f),
) )
@Stable @Stable
@ -50,4 +52,5 @@ fun lightColorScheme(
status = status, status = status,
navigation = navigation, navigation = navigation,
placeholder = placeholder, placeholder = placeholder,
handle = base.onSurface.copy(alpha = 0.5f),
) )

View file

@ -12,7 +12,15 @@ data class LexiconDimens(
val item: Dp, val item: Dp,
val detailPadding: Dp, val detailPadding: Dp,
val itemListPadding: PaddingValues, val itemListPadding: PaddingValues,
) val handle: Handle
) {
@Stable
@Immutable
data class Handle(
val width: Dp,
val thickness: Dp,
)
}
fun lexiconDimen( fun lexiconDimen(
itemHeight: Dp = 52.dp, itemHeight: Dp = 52.dp,
@ -21,8 +29,13 @@ fun lexiconDimen(
top = 8.dp, top = 8.dp,
bottom = 8.dp + 16.dp + 56.dp + 16.dp, bottom = 8.dp + 16.dp + 56.dp + 16.dp,
), ),
handle: LexiconDimens.Handle = LexiconDimens.Handle(
width = 32.dp,
thickness = 4.dp,
),
) = LexiconDimens( ) = LexiconDimens(
item = itemHeight, item = itemHeight,
detailPadding = detailPadding, detailPadding = detailPadding,
itemListPadding = itemListPadding itemListPadding = itemListPadding,
handle = handle,
) )

View file

@ -52,4 +52,6 @@
<string name="quest_detail_area">Lieu :</string> <string name="quest_detail_area">Lieu :</string>
<string name="quest_detail_individual_reward">Récompense individuelle :</string> <string name="quest_detail_individual_reward">Récompense individuelle :</string>
<string name="quest_detail_group_rewars">Récompense de groupe :</string> <string name="quest_detail_group_rewars">Récompense de groupe :</string>
<string name="map_title">Carte</string>
</resources> </resources>

View file

@ -53,4 +53,5 @@
<string name="quest_detail_individual_reward">Individual reward:</string> <string name="quest_detail_individual_reward">Individual reward:</string>
<string name="quest_detail_group_rewars">Group reward:</string> <string name="quest_detail_group_rewars">Group reward:</string>
<string name="map_title">Map</string>
</resources> </resources>