Add snaping beavior to the LocationDetail

This commit is contained in:
Thomas Andres Gomez 2023-08-11 13:55:48 +02:00
parent 85cdd69570
commit e3cd0bdd4b
4 changed files with 133 additions and 72 deletions

View file

@ -30,14 +30,10 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pixelized.rplexicon.ui.composable.AsyncImage import com.pixelized.rplexicon.ui.composable.AsyncImage
import com.pixelized.rplexicon.ui.theme.LexiconTheme
import com.pixelized.rplexicon.ui.theme.colors.LexiconColors import com.pixelized.rplexicon.ui.theme.colors.LexiconColors
import com.pixelized.rplexicon.utilitary.extentions.lexicon import com.pixelized.rplexicon.utilitary.extentions.lexicon
import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.ImageOptions
import kotlin.math.sqrt
private val RADIUS = 12.dp
private val SQUARE = (RADIUS / sqrt(2f))
@Composable @Composable
fun FantasyMap( fun FantasyMap(
@ -51,7 +47,7 @@ fun FantasyMap(
onMarquee: (MarqueeUio) -> Unit, onMarquee: (MarqueeUio) -> Unit,
onTap: (Offset) -> Unit, onTap: (Offset) -> Unit,
) { ) {
val colorScheme = MaterialTheme.lexicon.colorScheme val lexiconTheme = MaterialTheme.lexicon
val animatedScale = animateFloatAsState( val animatedScale = animateFloatAsState(
targetValue = state.scale, targetValue = state.scale,
@ -118,12 +114,11 @@ fun FantasyMap(
.onSizeChanged { state.imageSize = it } .onSizeChanged { state.imageSize = it }
.drawWithContent { .drawWithContent {
drawContent() drawContent()
if (animatedMarqueeAlpha.value > 0f) { if (animatedMarqueeAlpha.value > 0f) {
item.value.marquees.forEachIndexed { index, item -> item.value.marquees.forEachIndexed { index, item ->
if (item.position != Offset.Unspecified) { if (item.position != Offset.Unspecified) {
drawMarque( drawMarque(
colorScheme = colorScheme, theme = lexiconTheme,
alpha = animatedMarqueeAlpha, alpha = animatedMarqueeAlpha,
scale = animatedScale, scale = animatedScale,
position = item.position, position = item.position,
@ -134,7 +129,7 @@ fun FantasyMap(
} }
if (highlight.value != Offset.Unspecified && animatedCrossAlpha.value > 0f) { if (highlight.value != Offset.Unspecified && animatedCrossAlpha.value > 0f) {
drawCross( drawCross(
colorScheme = colorScheme, theme = lexiconTheme,
alpha = animatedCrossAlpha, alpha = animatedCrossAlpha,
scale = animatedScale, scale = animatedScale,
position = highlight.value, position = highlight.value,
@ -257,34 +252,34 @@ class FantasyMapState(
} }
private fun DrawScope.drawMarque( private fun DrawScope.drawMarque(
colorScheme: LexiconColors, theme: LexiconTheme,
alpha: State<Float>, alpha: State<Float>,
scale: State<Float>, scale: State<Float>,
position: Offset, position: Offset,
selected: Boolean, selected: Boolean,
) { ) {
val scaledRadius = theme.dimens.map.marqueeRadiusPx / scale.value
val scaledStroke = theme.dimens.map.marqueeStrokePx / scale.value
val scaledShadowDrop = theme.dimens.map.shadowDropPx / scale.value
drawCircle( drawCircle(
color = colorScheme.shadow, color = theme.colorScheme.shadow,
alpha = alpha.value, alpha = alpha.value,
radius = 12.dp.toPx() / scale.value, radius = scaledRadius,
style = Stroke( style = Stroke(width = scaledStroke),
width = 2.dp.toPx() / scale.value,
),
center = Offset( center = Offset(
x = size.width * position.x, x = size.width * position.x,
y = size.height * position.y + 2.dp.toPx() / scale.value, y = size.height * position.y + scaledShadowDrop,
) )
) )
drawCircle( drawCircle(
color = when (selected) { color = when (selected) {
true -> colorScheme.base.primary true -> theme.colorScheme.base.primary
else -> Color.White else -> Color.White
}, },
alpha = alpha.value, alpha = alpha.value,
radius = 12.dp.toPx() / scale.value, radius = scaledRadius,
style = Stroke( style = Stroke(width = scaledStroke),
width = 2.dp.toPx() / scale.value,
),
center = Offset( center = Offset(
x = size.width * position.x, x = size.width * position.x,
y = size.height * position.y, y = size.height * position.y,
@ -293,61 +288,65 @@ private fun DrawScope.drawMarque(
} }
private fun DrawScope.drawCross( private fun DrawScope.drawCross(
colorScheme: LexiconColors, theme: LexiconTheme,
alpha: State<Float>, alpha: State<Float>,
scale: State<Float>, scale: State<Float>,
position: Offset, position: Offset,
) { ) {
val scaledRadius = theme.dimens.map.crossRadiusPx / scale.value
val scaledStroke = theme.dimens.map.crossStrokePx / scale.value
val scaledShadowDrop = theme.dimens.map.shadowDropPx / scale.value
drawLine( drawLine(
color = colorScheme.shadow, color = theme.colorScheme.shadow,
alpha = alpha.value, alpha = alpha.value,
strokeWidth = 2.dp.toPx() / scale.value, strokeWidth = scaledStroke,
start = Offset( start = Offset(
x = size.width * position.x - SQUARE.toPx() / scale.value, x = size.width * position.x - scaledRadius,
y = size.height * position.y - SQUARE.toPx() / scale.value + 2.dp.toPx() / scale.value, y = size.height * position.y - scaledRadius + scaledShadowDrop,
), ),
end = Offset( end = Offset(
x = size.width * position.x + SQUARE.toPx() / scale.value, x = size.width * position.x + scaledRadius,
y = size.height * position.y + SQUARE.toPx() / scale.value + 2.dp.toPx() / scale.value, y = size.height * position.y + scaledRadius + scaledShadowDrop,
) )
) )
drawLine( drawLine(
color = colorScheme.shadow, color = theme.colorScheme.shadow,
alpha = alpha.value, alpha = alpha.value,
strokeWidth = 2.dp.toPx() / scale.value, strokeWidth = scaledStroke,
start = Offset( start = Offset(
x = size.width * position.x + SQUARE.toPx() / scale.value, x = size.width * position.x + scaledRadius,
y = size.height * position.y - SQUARE.toPx() / scale.value + 2.dp.toPx() / scale.value, y = size.height * position.y - scaledRadius + scaledShadowDrop,
), ),
end = Offset( end = Offset(
x = size.width * position.x - SQUARE.toPx() / scale.value, x = size.width * position.x - scaledRadius,
y = size.height * position.y + SQUARE.toPx() / scale.value + 2.dp.toPx() / scale.value, y = size.height * position.y + scaledRadius + scaledShadowDrop,
) )
) )
drawLine( drawLine(
color = colorScheme.base.primary, color = theme.colorScheme.base.primary,
alpha = alpha.value, alpha = alpha.value,
strokeWidth = 2.dp.toPx() / scale.value, strokeWidth = scaledStroke,
start = Offset( start = Offset(
x = size.width * position.x - SQUARE.toPx() / scale.value, x = size.width * position.x - scaledRadius,
y = size.height * position.y - SQUARE.toPx() / scale.value, y = size.height * position.y - scaledRadius,
), ),
end = Offset( end = Offset(
x = size.width * position.x + SQUARE.toPx() / scale.value, x = size.width * position.x + scaledRadius,
y = size.height * position.y + SQUARE.toPx() / scale.value, y = size.height * position.y + scaledRadius,
) )
) )
drawLine( drawLine(
color = colorScheme.base.primary, color = theme.colorScheme.base.primary,
alpha = alpha.value, alpha = alpha.value,
strokeWidth = 2.dp.toPx() / scale.value, strokeWidth = scaledStroke,
start = Offset( start = Offset(
x = size.width * position.x + SQUARE.toPx() / scale.value, x = size.width * position.x + scaledRadius,
y = size.height * position.y - SQUARE.toPx() / scale.value, y = size.height * position.y - scaledRadius,
), ),
end = Offset( end = Offset(
x = size.width * position.x - SQUARE.toPx() / scale.value, x = size.width * position.x - scaledRadius,
y = size.height * position.y + SQUARE.toPx() / scale.value, y = size.height * position.y + scaledRadius,
) )
) )
} }

View file

@ -53,6 +53,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.Velocity
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.LocalSnack
@ -60,8 +61,10 @@ 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.extentions.lexicon
import com.pixelized.rplexicon.utilitary.rememberTextSize import com.pixelized.rplexicon.utilitary.rememberTextSize
import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.ImageOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -86,34 +89,21 @@ fun LocationDetail(
val scroll = rememberScrollState() val scroll = rememberScrollState()
val pager = rememberPagerState() val pager = rememberPagerState()
val fantasy = rememberFantasyMapState() val fantasy = rememberFantasyMapState()
val snapBehavior = rememberSnapConnection(scrollState = scroll)
val scrollBehavior = rememberScrollConnection(scrollState = scroll)
val ok = stringResource(id = android.R.string.ok) val ok = stringResource(id = android.R.string.ok)
val job = remember { mutableStateOf<Job?>(null) }
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 snackJob = remember { mutableStateOf<Job?>(null) }
val mapHighlight = remember { mutableStateOf(Offset.Unspecified) } val mapHighlight = remember { mutableStateOf(Offset.Unspecified) }
val selectedIndex = remember { mutableStateOf(0) } val selectedIndex = remember { mutableStateOf(0) }
Surface { Surface {
LocationContent( LocationContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier
connection = connection, .fillMaxSize()
.nestedScroll(connection = snapBehavior),
connection = scrollBehavior,
scrollState = scroll, scrollState = scroll,
pagerState = pager, pagerState = pager,
fantasyMapState = fantasy, fantasyMapState = fantasy,
@ -131,8 +121,8 @@ fun LocationDetail(
} }
}, },
onMapTap = { onMapTap = {
job.value?.cancel() snackJob.value?.cancel()
job.value = scope.launch { snackJob.value = scope.launch {
snack.showSnackbar( snack.showSnackbar(
message = "x:${it.x}, y:${it.y}", message = "x:${it.x}, y:${it.y}",
actionLabel = ok, actionLabel = ok,
@ -146,7 +136,7 @@ fun LocationDetail(
if (it) { if (it) {
mapHighlight.value = Offset.Unspecified mapHighlight.value = Offset.Unspecified
} else { } else {
job.value?.cancel() snackJob.value?.cancel()
} }
fantasy.toggleFreeHand(it) fantasy.toggleFreeHand(it)
}, },
@ -388,6 +378,52 @@ private fun HandlePagerScroll(
} }
} }
@Composable
@Stable
private fun rememberSnapConnection(
scope: CoroutineScope = rememberCoroutineScope(),
scrollState: ScrollState,
): NestedScrollConnection {
val dimens = MaterialTheme.lexicon.dimens
return remember(scope, scrollState) {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
if (scrollState.value < dimens.map.mapSnapPx) {
scope.launch { scrollState.animateScrollTo(0) }
}
if ((scrollState.maxValue - scrollState.value) < dimens.map.mapSnapPx) {
scope.launch { scrollState.animateScrollTo(scrollState.maxValue) }
}
return super.onPostFling(consumed, available)
}
}
}
}
@Composable
@Stable
private fun rememberScrollConnection(
scrollState: ScrollState,
): NestedScrollConnection {
return remember(scrollState) {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = runBlocking {
Offset(
x = 0f,
y = when (scrollState.canScrollForward) {
true -> -scrollState.scrollBy(-available.y)
else -> 0f
},
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.pixelized.rplexicon.ui.theme.animation.LexiconAnimation import com.pixelized.rplexicon.ui.theme.animation.LexiconAnimation
@ -42,7 +43,8 @@ fun LexiconTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val lexiconTheme = remember { val density = LocalDensity.current
val lexiconTheme = remember(density) {
LexiconTheme( LexiconTheme(
animation = lexiconAnimation(), animation = lexiconAnimation(),
colorScheme = when (darkTheme) { colorScheme = when (darkTheme) {
@ -50,7 +52,7 @@ fun LexiconTheme(
else -> lightColorScheme() else -> lightColorScheme()
}, },
shapes = lexiconShapes(), shapes = lexiconShapes(),
dimens = lexiconDimen(), dimens = lexiconDimen(density = density),
typography = lexiconTypography(), typography = lexiconTypography(),
) )
} }

View file

@ -3,8 +3,10 @@ package com.pixelized.rplexicon.ui.theme.dimen
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.sqrt
@Stable @Stable
@Immutable @Immutable
@ -12,7 +14,8 @@ data class LexiconDimens(
val item: Dp, val item: Dp,
val detailPadding: Dp, val detailPadding: Dp,
val itemListPadding: PaddingValues, val itemListPadding: PaddingValues,
val handle: Handle val handle: Handle,
val map: Map,
) { ) {
@Stable @Stable
@Immutable @Immutable
@ -20,9 +23,21 @@ data class LexiconDimens(
val width: Dp, val width: Dp,
val thickness: Dp, val thickness: Dp,
) )
@Stable
@Immutable
data class Map(
val mapSnapPx: Int,
val marqueeRadiusPx: Int,
val marqueeStrokePx: Int,
val crossRadiusPx: Int,
val crossStrokePx: Int,
val shadowDropPx: Int,
)
} }
fun lexiconDimen( fun lexiconDimen(
density: Density,
itemHeight: Dp = 52.dp, itemHeight: Dp = 52.dp,
detailPadding: Dp = 248.dp, detailPadding: Dp = 248.dp,
itemListPadding: PaddingValues = PaddingValues( itemListPadding: PaddingValues = PaddingValues(
@ -33,9 +48,18 @@ fun lexiconDimen(
width = 32.dp, width = 32.dp,
thickness = 4.dp, thickness = 4.dp,
), ),
map: LexiconDimens.Map = LexiconDimens.Map(
mapSnapPx = with(density) { 64.dp.roundToPx() },
marqueeRadiusPx = with(density) { 12.dp.roundToPx() },
marqueeStrokePx = with(density) { 2.dp.roundToPx() },
crossRadiusPx = with(density) { 12.dp.roundToPx() / sqrt(2f) }.toInt(),
crossStrokePx = with(density) { 2.dp.roundToPx() },
shadowDropPx = with(density) { 2.dp.roundToPx() },
),
) = LexiconDimens( ) = LexiconDimens(
item = itemHeight, item = itemHeight,
detailPadding = detailPadding, detailPadding = detailPadding,
itemListPadding = itemListPadding, itemListPadding = itemListPadding,
handle = handle, handle = handle,
map = map,
) )