From 54e09e5f1d6df58439b6081de7eb863e63ec36db Mon Sep 17 00:00:00 2001 From: "Andres Gomez, Thomas (ITDV CC) - AF (ext)" Date: Sun, 16 Jul 2023 15:53:16 +0200 Subject: [PATCH] Add a FAB on the Lexicon --- .../ui/composable/FloatingActionButton.kt | 182 ++++++++++++++++++ .../ui/navigation/ComposableLexicon.kt | 10 +- .../rplexicon/ui/navigation/ScreenNavHost.kt | 5 +- .../ui/screens/lexicon/LexiconScreen.kt | 64 +++++- .../com/pixelized/rplexicon/ui/theme/Theme.kt | 7 +- .../res/drawable/ic_baseline_search_24.xml | 5 + 6 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/pixelized/rplexicon/ui/composable/FloatingActionButton.kt create mode 100644 app/src/main/res/drawable/ic_baseline_search_24.xml diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/FloatingActionButton.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/FloatingActionButton.kt new file mode 100644 index 0000000..b82235d --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/FloatingActionButton.kt @@ -0,0 +1,182 @@ +package com.pixelized.rplexicon.ui.composable + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.pixelized.rplexicon.R +import com.pixelized.rplexicon.ui.theme.LexiconTheme + +@Composable +fun FloatingActionButton( + modifier: Modifier = Modifier, + expended: Boolean, + enabled: Boolean = true, + innerSpacing: Dp = 16.dp, + contentPadding: PaddingValues = FlyingBlueFloatingActionButtonDefault.ContentPadding, + elevation: ButtonElevation? = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + disabledElevation = 0.dp, + ), + shape: Shape = CircleShape, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + colors: ButtonColors = ButtonDefaults.buttonColors(), + onClick: () -> Unit, + icon: @Composable (RowScope.() -> Unit), + text: @Composable (RowScope.() -> Unit), +) { + LocalButton( + modifier = modifier, + onClick = onClick, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + colors = colors, + contentPadding = contentPadding, + content = { + FabContent( + expended = expended, + innerSpacing = innerSpacing, + icon = icon, + text = text, + ) + }, + ) +} + +@Composable +private fun BoxWithConstraintsScope.FabContent( + expended: Boolean, + innerSpacing: Dp, + icon: @Composable (RowScope.() -> Unit), + text: @Composable (RowScope.() -> Unit), +) { + val width by animateDpAsState( + label = "FabContentWidth", + targetValue = if (expended) maxWidth else minWidth, + animationSpec = when (expended) { + true -> tween(durationMillis = 300, easing = FastOutSlowInEasing) + else -> tween(durationMillis = 300, delayMillis = 100, easing = FastOutSlowInEasing) + }, + ) + Row( + modifier = Modifier.size( + height = 56.dp, + width = width + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + icon() + AnimatedVisibility( + visible = expended, + enter = fadeIn( + tween(durationMillis = 300, delayMillis = 100, easing = FastOutSlowInEasing) + ), + exit = fadeOut( + tween(durationMillis = 300, easing = FastOutSlowInEasing) + ) + shrinkHorizontally( + tween(durationMillis = 300, easing = FastOutSlowInEasing) + ), + ) { + Row { + Spacer(modifier = Modifier.width(innerSpacing)) + text() + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun LocalButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = ButtonDefaults.elevation(), + shape: Shape = MaterialTheme.shapes.small, + border: BorderStroke? = null, + colors: ButtonColors = ButtonDefaults.buttonColors(), + contentPadding: PaddingValues = FlyingBlueFloatingActionButtonDefault.ContentPadding, + content: @Composable BoxWithConstraintsScope.() -> Unit +) { + val contentColor by colors.contentColor(enabled) + Surface( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + color = colors.backgroundColor(enabled).value, + contentColor = contentColor.copy(alpha = 1f), + border = border, + elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp, + interactionSource = interactionSource, + ) { + CompositionLocalProvider(LocalContentAlpha provides contentColor.alpha) { + ProvideTextStyle( + value = MaterialTheme.typography.button + ) { + BoxWithConstraints( + Modifier + .defaultMinSize( + minWidth = FlyingBlueFloatingActionButtonDefault.MinWidth, + minHeight = FlyingBlueFloatingActionButtonDefault.MinHeight + ) + .padding(contentPadding), + content = content + ) + } + } + } +} + +object FlyingBlueFloatingActionButtonDefault { + val ContentPadding = PaddingValues(all = 0.dp) + val MinWidth = 56.dp + val MinHeight = 56.dp +} + +@Composable +@Preview +private fun FloatingActionButtonPreview( + @PreviewParameter(FloatingActionButtonPreviewProvider::class) expended: Boolean +) { + LexiconTheme { + FloatingActionButton( + expended = expended, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_search_24), + contentDescription = null, + ) + }, + text = { + Text(text = "Floating Action Button") + }, + onClick = { } + ) + } +} + +private class FloatingActionButtonPreviewProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf(false, true) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ComposableLexicon.kt b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ComposableLexicon.kt index fcc10a2..1550142 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ComposableLexicon.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/navigation/ComposableLexicon.kt @@ -1,5 +1,7 @@ package com.pixelized.rplexicon.ui.navigation +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.NavOptionsBuilder @@ -9,12 +11,16 @@ private const val ROUTE = "lexicon" const val LEXICON_ROUTE = ROUTE -fun NavGraphBuilder.composableLexicon() { +fun NavGraphBuilder.composableLexicon( + lazyListState: LazyListState, +) { animatedComposable( route = LEXICON_ROUTE, animation = NavigationAnimation.Fade, ) { - LexiconScreen() + LexiconScreen( + lazyListState = lazyListState + ) } } 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 ebfc9a8..9b668fc 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 @@ -1,6 +1,7 @@ package com.pixelized.rplexicon.ui.navigation import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf @@ -19,6 +20,8 @@ fun ScreenNavHost( navHostController: NavHostController = rememberAnimatedNavController(), startDestination: String = AUTHENTICATION_ROUTE, ) { + val lexiconListState = rememberLazyListState() + CompositionLocalProvider( LocalScreenNavHost provides navHostController, ) { @@ -27,7 +30,7 @@ fun ScreenNavHost( startDestination = startDestination, ) { composableAuthentication() - composableLexicon() + composableLexicon(lazyListState = lexiconListState) composableCharacterDetail() } } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt index 3566bf7..bcc6439 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/lexicon/LexiconScreen.kt @@ -13,13 +13,17 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshState import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -28,17 +32,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview 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.FloatingActionButton import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost import com.pixelized.rplexicon.ui.navigation.navigateToCharacterDetail import com.pixelized.rplexicon.ui.screens.lexicon.LexiconErrorUio.Default @@ -60,6 +68,7 @@ sealed class LexiconErrorUio { @Composable fun LexiconScreen( viewModel: LexiconViewModel = hiltViewModel(), + lazyListState: LazyListState, ) { val scope = rememberCoroutineScope() val screen = LocalScreenNavHost.current @@ -73,11 +82,22 @@ fun LexiconScreen( }, ) + val isFabExpended = remember { + derivedStateOf { + lazyListState.canScrollForward.not() && viewModel.items.value.isNotEmpty() + } + } + Surface { LexiconScreenContent( items = viewModel.items, - refresh = refresh, + lazyColumnState = lazyListState, + refreshState = refresh, refreshing = viewModel.isLoading, + isFabExpended = isFabExpended, + onSearch = { + + }, onItem = { screen.navigateToCharacterDetail(id = it.id) }, @@ -96,9 +116,12 @@ fun LexiconScreen( @Composable private fun LexiconScreenContent( modifier: Modifier = Modifier, - refresh: PullRefreshState, + lazyColumnState: LazyListState, + refreshState: PullRefreshState, refreshing: State, + isFabExpended: State, items: State>, + onSearch: () -> Unit, onItem: (LexiconItemUio) -> Unit, ) { Scaffold( @@ -109,6 +132,28 @@ private fun LexiconScreenContent( Text(text = stringResource(id = R.string.app_name)) }, ) + }, + floatingActionButton = { + FloatingActionButton( + modifier = Modifier.padding(start = 32.dp), + expended = isFabExpended.value, + onClick = onSearch, + icon = { + Icon( + tint = MaterialTheme.colorScheme.onPrimary, + painter = painterResource(id = R.drawable.ic_baseline_search_24), + contentDescription = null, + ) + }, + text = { + val typography = MaterialTheme.typography + Text( + color = MaterialTheme.colorScheme.onPrimary, + style = remember { typography.bodyLarge.copy(fontWeight = FontWeight.Bold) }, + text = "Rechercher", + ) + }, + ) } ) { Box( @@ -118,8 +163,12 @@ private fun LexiconScreenContent( LazyColumn( modifier = Modifier .fillMaxSize() - .pullRefresh(state = refresh), - contentPadding = PaddingValues(vertical = 8.dp), + .pullRefresh(state = refreshState), + state = lazyColumnState, + contentPadding = PaddingValues( + top = 8.dp, + bottom = 8.dp + 16.dp + 56.dp + 16.dp + ), ) { items(items = items.value) { item -> LexiconItem( @@ -134,7 +183,7 @@ private fun LexiconScreenContent( PullRefreshIndicator( refreshing = refreshing.value, - state = refresh, + state = refreshState, ) } } @@ -177,11 +226,13 @@ private fun LexiconScreenContentPreview() { Surface { LexiconScreenContent( modifier = Modifier.fillMaxSize(), - refresh = rememberPullRefreshState( + lazyColumnState = rememberLazyListState(), + refreshState = rememberPullRefreshState( refreshing = false, onRefresh = {}, ), refreshing = remember { mutableStateOf(false) }, + isFabExpended = remember { mutableStateOf(true) }, items = remember { mutableStateOf( listOf( @@ -195,6 +246,7 @@ private fun LexiconScreenContentPreview() { ) ) }, + onSearch = { }, onItem = { }, ) } diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt b/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt index ce3bba8..e1a1d16 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/theme/Theme.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -14,13 +15,15 @@ import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80, - tertiary = Pink80 + tertiary = Pink80, + onPrimary = Color.White, ) private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, - tertiary = Pink40 + tertiary = Pink40, + onPrimary = Color.White, ) @Composable diff --git a/app/src/main/res/drawable/ic_baseline_search_24.xml b/app/src/main/res/drawable/ic_baseline_search_24.xml new file mode 100644 index 0000000..a5687c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_search_24.xml @@ -0,0 +1,5 @@ + + +