diff --git a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/FloatingActionButtonPreview.kt b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/FloatingActionButtonPreview.kt new file mode 100644 index 0000000..3929dea --- /dev/null +++ b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/FloatingActionButtonPreview.kt @@ -0,0 +1,722 @@ +package com.developerstring.jetco_library + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.AccountBox +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.MailOutline +import androidx.compose.material.icons.outlined.Place +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.developerstring.jetco.ui.components.button.fab.MorphFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.RadialFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.StackFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.model.FabItem +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig.Orientation +import com.developerstring.jetco.ui.components.button.fab.model.MorphFabItem +import com.developerstring.jetco.ui.components.button.fab.model.StackDirection +import com.developerstring.jetco.ui.components.button.fab.model.StackExpandOffset +import com.developerstring.jetco.ui.components.button.fab.model.StackFabItem +import com.developerstring.jetco.ui.components.button.fab.transition.FabButtonTransition +import com.developerstring.jetco.ui.components.button.fab.transition.FabItemTransition + +private val Orange = Color(0xFFE46212) +private val Teal = Color(0xFF3DBFE2) +private val Green = Color(0xFF4AA651) +private val Red = Color(0xFFDE3B3D) +private val Purple = Color(0xFF7C4DFF) +private val Pink = Color(0xFFE91E8C) +private val Navy = Color(0xFF1A237E) +private val Gold = Color(0xFFFFC107) + +private enum class FabVariant(val label: String) { + RADIAL_CLASSIC("Radial"), + RADIAL_FULL_ARC("Radial 2"), + STACK_VERTICAL("Stack 1"), + STACK_MIXED("Stack 2"), + STACK_HORIZONTAL("H-Stack"), + STACK_PUSH("Push"), + MORPH_GRID("Morph"), + MORPH_DETAIL("M-Detail") +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FloatingActionButtonPreview() { + var selected by remember { mutableStateOf(FabVariant.STACK_PUSH) } + var expandOffset by remember { mutableStateOf(StackExpandOffset()) } + + val screenOffsetY by animateDpAsState( + targetValue = -expandOffset.offsetY, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "screenPushY" + ) + val screenOffsetX by animateDpAsState( + targetValue = -expandOffset.offsetX, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "screenPushX" + ) + + Scaffold( + topBar = { + FlowRow( + maxItemsInEachRow = 4, + modifier = Modifier + .statusBarsPadding() + .fillMaxWidth() + .background(Color.White), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally) + ) { + FabVariant.entries.forEach { variant -> + FilterChip( + selected = selected == variant, + onClick = { selected = variant }, + label = { + Text( + text = variant.label, + fontSize = 11.sp, + fontWeight = if (selected == variant) FontWeight.Bold else FontWeight.Normal + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Orange, + selectedLabelColor = Color.White + ) + ) + } + } + }, + floatingActionButton = { + AnimatedContent( + targetState = selected, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "fab_switch" + ) { variant -> + when (variant) { + FabVariant.RADIAL_CLASSIC -> RadialClassicFab() + FabVariant.RADIAL_FULL_ARC -> RadialFullArcFab() + FabVariant.STACK_VERTICAL -> StackVerticalFab() + FabVariant.STACK_MIXED -> StackMixedFab() + FabVariant.STACK_HORIZONTAL -> StackHorizontalFab() + FabVariant.STACK_PUSH -> StackPushFab(onExpandChange = { expandOffset = it }) + FabVariant.MORPH_GRID -> MorphGridFab() + FabVariant.MORPH_DETAIL -> MorphDetailedFab() + } + } + } + ) { paddingValues -> + Column(modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .offset(y = screenOffsetY, x = screenOffsetX) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items((1..12).toList()) { i -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(if (i == 0) 72.dp else 56.dp) + .background( + color = if (i % 3 == 0) Color(0xFFF0F0F0) + else if (i % 3 == 1) Color(0xFFE8E8E8) + else Color(0xFFDDDDDD), + shape = RoundedCornerShape(16.dp) + ) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color(0xFFCCCCCC), CircleShape) + ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .width(120.dp) + .height(10.dp) + .background(Color(0xFFBBBBBB), RoundedCornerShape(4.dp)) + ) + Box( + modifier = Modifier + .width(80.dp) + .height(8.dp) + .background(Color(0xFFCCCCCC), RoundedCornerShape(4.dp)) + ) + } + } + } + } + } + } + } +} + +@Composable +private fun MorphDetailedFab() { + var expanded by remember { mutableStateOf(false) } + + MorphFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = emptyList(), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Purple, size = 72.dp), + itemArrangement = FabMainConfig.ItemArrangement( + morph = Orientation.Morph( + columns = 1, + width = 260.dp, + cardShape = RoundedCornerShape(28.dp) + ) + ) + ), + card = { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .background( + Purple.copy(alpha = 0.1f), RoundedCornerShape(16.dp) + ).clickable(onClick = { expanded = !expanded }), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Outlined.AccountBox, + null, + modifier = Modifier.size(32.dp), + tint = Purple + ) + } + Text( + "User Profile", + Modifier.padding(top = 12.dp), + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Text( + "System Administrator", + color = Color.Gray, + fontSize = 12.sp + ) + + Row( + Modifier + .padding(top = 20.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + listOf(Icons.Outlined.Edit, Icons.Outlined.Share, Icons.Outlined.Delete).forEach { icon -> + Box( + Modifier.size(44.dp) + .background(Color(0xFFF0F0F0), RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + icon, + null, + tint = Color.DarkGray, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + }, + modifier = Modifier.offset(x = if (expanded) 20.dp else 0.dp) + ) +} + +@Composable +private fun StackHorizontalFab() { + var expanded by remember { mutableStateOf(false) } + + StackFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + FabItem(icon = Icons.Outlined.Share, onClick = { expanded = false }), + FabItem(icon = Icons.Outlined.Favorite, onClick = { expanded = false }), + FabItem(icon = Icons.Outlined.Email, onClick = { expanded = false }) + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Navy, size = 64.dp), + itemArrangement = FabMainConfig.ItemArrangement(stack = Orientation.Stack(spacedBy = 12.dp)), + animation = FabMainConfig.Animation( + enterTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + exitTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + buttonEnterTransition = FabButtonTransition.Rotate(180f) + FabButtonTransition.Scale(0.7f), + buttonExitTransition = FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale(1f) + ) + ) + ) +} + +@Composable +private fun RadialClassicFab() { + var expanded by remember { mutableStateOf(false) } + + RadialFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Home, + buttonStyle = FabItem.ButtonStyle(color = Orange, size = 56.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.MailOutline, + buttonStyle = FabItem.ButtonStyle(color = Teal, size = 56.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Place, + buttonStyle = FabItem.ButtonStyle(color = Green, size = 56.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Delete, + buttonStyle = FabItem.ButtonStyle(color = Red, size = 56.dp) + ) + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Orange, size = 72.dp), + itemArrangement = FabMainConfig.ItemArrangement( + radial = Orientation.Radial( + arc = Orientation.Radial.Arc.END, + radius = 100.dp + ) + ), + animation = FabMainConfig.Animation( + enterTransition = FabItemTransition.Spring() + FabItemTransition.Fade(), + exitTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + enterOrder = FabMainConfig.StaggerOrder.FIFO, + exitOrder = FabMainConfig.StaggerOrder.FILO, + buttonEnterTransition = FabButtonTransition.Rotate(45f) + FabButtonTransition.Scale(0.85f), + buttonExitTransition = FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale(1f) + ) + ) + ) +} + +@Composable +private fun RadialFullArcFab() { + var expanded by remember { mutableStateOf(false) } + + RadialFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Edit, + buttonStyle = FabItem.ButtonStyle(color = Purple, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Share, + buttonStyle = FabItem.ButtonStyle(color = Pink, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Favorite, + buttonStyle = FabItem.ButtonStyle(color = Red, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.AccountBox, + buttonStyle = FabItem.ButtonStyle(color = Teal, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Delete, + buttonStyle = FabItem.ButtonStyle(color = Gold, size = 52.dp) + ) + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle( + color = Navy, + size = 68.dp, + shape = RoundedCornerShape(20.dp) + ), + itemArrangement = FabMainConfig.ItemArrangement( + radial = Orientation.Radial( + arc = Orientation.Radial.Arc.CENTER, + radius = 110.dp + ) + ), + animation = FabMainConfig.Animation( + enterTransition = FabItemTransition.Scale() + FabItemTransition.Fade() + FabItemTransition.Rotate(-360f), + exitTransition = FabItemTransition.Scale() + FabItemTransition.Fade() + FabItemTransition.Rotate(0f), + enterOrder = FabMainConfig.StaggerOrder.ALL, + exitOrder = FabMainConfig.StaggerOrder.ALL, + buttonEnterTransition = FabButtonTransition.Rotate(135f) + FabButtonTransition.Scale(0.9f) + FabButtonTransition.SlideTo(y = (-8).dp), + buttonExitTransition = FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale(1f) + FabButtonTransition.SlideTo(y = 0.dp) + ) + ) + ) +} + +@Composable +private fun StackVerticalFab() { + var expanded by remember { mutableStateOf(false) } + + StackFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.AccountBox, + buttonStyle = FabItem.ButtonStyle(color = Teal, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Edit, + buttonStyle = FabItem.ButtonStyle(color = Purple, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Share, + buttonStyle = FabItem.ButtonStyle(color = Green, size = 52.dp) + ), + FabItem( + onClick = { expanded = false }, + icon = Icons.Outlined.Delete, + buttonStyle = FabItem.ButtonStyle(color = Red, size = 52.dp) + ) + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Orange, size = 72.dp), + itemArrangement = FabMainConfig.ItemArrangement( + stack = Orientation.Stack(spacedBy = 16.dp) + ), + animation = FabMainConfig.Animation( + enterTransition = FabItemTransition.SlideAndFade() + FabItemTransition.Scale(), + exitTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + enterOrder = FabMainConfig.StaggerOrder.FIFO, + exitOrder = FabMainConfig.StaggerOrder.FILO, + buttonEnterTransition = FabButtonTransition.Rotate(45f), + buttonExitTransition = FabButtonTransition.Rotate(0f) + ) + ) + ) +} + +@Composable +private fun StackMixedFab() { + var expanded by remember { mutableStateOf(false) } + + StackFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Purple, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑1", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Pink, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑2", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.START) { + Box( + modifier = Modifier + .size(56.dp) + .background(Teal, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("←1", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.START) { + Box( + modifier = Modifier + .size(56.dp) + .background(Green, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("←2", color = Color.White, fontWeight = FontWeight.Bold) + } + } + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle( + color = Navy, + size = 68.dp, + shape = RoundedCornerShape(20.dp) + ), + itemArrangement = FabMainConfig.ItemArrangement( + stack = Orientation.Stack(spacedBy = 14.dp) + ), + animation = FabMainConfig.Animation( + enterTransition = FabItemTransition.Spring() + FabItemTransition.Scale() + FabItemTransition.Fade(), + exitTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + enterOrder = FabMainConfig.StaggerOrder.FIFO, + exitOrder = FabMainConfig.StaggerOrder.FILO, + buttonEnterTransition = FabButtonTransition.Rotate(90f) + FabButtonTransition.Scale(0.8f), + buttonExitTransition = FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale(1f) + ) + ) + ) +} + +@Composable +private fun StackPushFab(onExpandChange: (StackExpandOffset) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + StackFloatingActionButton( + expanded = expanded, + onClick = { + expanded = !expanded + if (!expanded) { + focusManager.clearFocus() + } + }, + onExpandChange = onExpandChange, + items = listOf( + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Purple, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑1", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Purple, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑2", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.TOP) { + Box( + modifier = Modifier + .size(56.dp) + .background(Purple, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("↑3", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + StackFabItem(direction = StackDirection.START) { + var text by remember { mutableStateOf("") } + + Box( + modifier = Modifier.width(330.dp) + ) { + TextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + placeholder = { + Text(text = "Hinted search text", color = Color.Gray) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = Color.Black + ) + }, + shape = RoundedCornerShape(28.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color(0xFFF3F3F9), + unfocusedContainerColor = Color(0xFFF3F3F9), + disabledContainerColor = Color(0xFFF3F3F9), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + singleLine = true + ) + } + } + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle( + color = Orange, + size = 64.dp + ), + itemArrangement = FabMainConfig.ItemArrangement( + stack = Orientation.Stack(spacedBy = 14.dp) + ), + animation = FabMainConfig.Animation( + enterTransition = FabItemTransition.Spring() + FabItemTransition.Scale() + FabItemTransition.Fade(), + exitTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + enterOrder = FabMainConfig.StaggerOrder.FIFO, + exitOrder = FabMainConfig.StaggerOrder.FILO, + buttonEnterTransition = FabButtonTransition.Rotate(45f) + FabButtonTransition.Scale(0.9f), + buttonExitTransition = FabButtonTransition.Rotate(0f) + FabButtonTransition.Scale(1f) + ) + ) + ) +} + +@Composable +private fun MorphGridFab() { + var expanded by remember { mutableStateOf(false) } + + MorphFloatingActionButton( + expanded = expanded, + onClick = { expanded = !expanded }, + items = listOf( + MorphFabItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = Modifier + .size(96.dp) + .background(Orange, RoundedCornerShape(16.dp)) + ) { + Text("🏠", fontSize = 24.sp) + Text("Home", color = Color.White, fontSize = 11.sp) + } + }, + MorphFabItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = Modifier + .size(96.dp) + .background(Teal, RoundedCornerShape(16.dp)) + ) { + Text("📷", fontSize = 24.sp) + Text("Camera", color = Color.White, fontSize = 11.sp) + } + }, + MorphFabItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = Modifier + .size(96.dp) + .background(Purple, RoundedCornerShape(16.dp)) + ) { + Text("✏️", fontSize = 24.sp) + Text("Edit", color = Color.White, fontSize = 11.sp) + } + }, + MorphFabItem { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = Modifier + .size(96.dp) + .background(Green, RoundedCornerShape(16.dp)) + ) { + Text("🗺️", fontSize = 24.sp) + Text("Maps", color = Color.White, fontSize = 11.sp) + } + } + ), + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle(color = Orange, size = 72.dp), + itemArrangement = FabMainConfig.ItemArrangement( + morph = Orientation.Morph( + columns = 2, + spacedBy = 12.dp, + width = 240.dp, + cardShape = RoundedCornerShape(24.dp) + ) + ), + animation = FabMainConfig.Animation( + enterTransition = FabItemTransition.Scale() + FabItemTransition.Fade(), + enterOrder = FabMainConfig.StaggerOrder.FIFO + ) + ) + ) +} diff --git a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt index b9194c3..23b232d 100644 --- a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt +++ b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt @@ -17,7 +17,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { JetCoLibraryTheme { - LineGraphScreen() + FloatingActionButtonPreview() } } } diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/Helper.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/Helper.kt new file mode 100644 index 0000000..5d2d0f6 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/Helper.kt @@ -0,0 +1,19 @@ +package com.developerstring.jetco.ui.components.button.fab + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector + +internal suspend fun Animatable.animateOrSnap( + targetValue: T?, + spec: AnimationSpec?, + predicate: () -> Boolean = { true } +) { + if (targetValue == null) return + + if (spec != null) { + animateTo(targetValue, spec) + } else if (predicate.invoke()) { + snapTo(targetValue) + } +} \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/MorphFloatingActionButton.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/MorphFloatingActionButton.kt new file mode 100644 index 0000000..9688d96 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/MorphFloatingActionButton.kt @@ -0,0 +1,222 @@ +package com.developerstring.jetco.ui.components.button.fab + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFabItem +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.base.DefaultMorphCard +import com.developerstring.jetco.ui.components.button.fab.model.FabItem +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.model.MorphFabItem +import com.developerstring.jetco.ui.components.button.fab.scope.MorphCardScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A floating action button that morphs into a card containing a grid of actions. + * + * This component provides a circular FAB that, when expanded, smoothly transitions + * into a rectangular card layout. The card contains a grid of items arranged + * in a [FlowRow], with customizable header space and item arrangement. + * + * @param expanded Whether the FAB is currently morphed into a card. + * @param items List of [FabItem] to be displayed in the card grid. + * @param modifier Modifier applied to the root container. + * @param onClick Callback triggered when the FAB is clicked or the card's close action is invoked. + * @param config Comprehensive configuration for styling and layout. See [FabMainConfig]. + * @param content Optional custom composable for the collapsed FAB button. + * @param card Optional custom composable for the expanded card shell. Use [MorphCardScope.MorphItems] to place the grid. + * + * @see FabMainConfig for detailed arrangement and animation options. + * @see MorphCardScope for customizing the expanded card layout. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MorphFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + }, + card: @Composable MorphCardScope.() -> Unit = { + DefaultMorphCard(config = config, onClose = onClick, scope = this) + } +) { + val items = items.map { item -> + MorphFabItem { + DefaultFabItem(item = item, onClick = { item.onClick() }) + } + } + + MorphFloatingActionButtonBase( + expanded = expanded, + items = items, + modifier = modifier, + onClick = onClick, + config = config, + content = content, + card = card + ) +} + +/** + * A floating action button that morphs into a card with custom [MorphFabItem]s. + * + * Use this overload when you need to provide fully custom composables for each item + * in the grid instead of the standard icon/button pair. + * + * @param expanded Whether the FAB is currently morphed into a card. + * @param items List of [MorphFabItem] containing custom composables. + * @param modifier Modifier applied to the root container. + * @param onClick Callback triggered when the FAB is clicked. + * @param config Configuration for styling and layout. + * @param content Optional custom composable for the collapsed FAB button. + * @param card Optional custom composable for the expanded card shell. + */ +@JvmName("MorphFloatingActionButtonCustom") +@Composable +fun MorphFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + }, + card: @Composable MorphCardScope.() -> Unit = { + DefaultMorphCard(config = config, onClose = onClick, scope = this) + } +) { + MorphFloatingActionButtonBase( + expanded = expanded, + items = items, + modifier = modifier, + onClick = onClick, + config = config, + content = content, + card = card + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun MorphFloatingActionButtonBase( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + }, + card: @Composable MorphCardScope.() -> Unit = { + DefaultMorphCard(config = config, onClose = onClick, scope = this) + } +) { + val morph = config.itemArrangement.morph + val itemsContent: @Composable () -> Unit = { + Spacer(modifier = Modifier.size(morph.headerSpace)) + + FlowRow( + maxItemsInEachRow = morph.columns, + horizontalArrangement = Arrangement.spacedBy(morph.spacedBy, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(morph.spacedBy), + modifier = Modifier.fillMaxWidth() + ) { + items.forEachIndexed { index, morphItem -> + // key = index ensures remember is stable per item slot + val alpha = remember(index) { Animatable(0f) } + val scale = remember(index) { Animatable(0f) } + val rotation = remember { Animatable(0f) } + + LaunchedEffect(index) { + val transition = config.animation.enterTransition + val stepMs = 300 / (items.size - 1).coerceAtLeast(1) + val staggerDelay = config.animation.enterOrder.delayFor( + index = index, + total = items.size - 1, + stepMs = stepMs + ) + + delay(staggerDelay) + + coroutineScope { + launch { + alpha.animateOrSnap( + targetValue = if (expanded) 1f else 0f, + spec = transition.alphaSpec, + predicate = { expanded } + ) + } + launch { + scale.animateOrSnap( + targetValue = if (expanded) 1f else 0f, + spec = transition.scaleSpec, + predicate = { expanded } + ) + } + launch { + rotation.animateOrSnap( + targetValue = transition.rotate?.target, + spec = transition.rotate?.spec, + predicate = { expanded } + ) + } + } + + if (!expanded) { // Reset to initial position + alpha.snapTo(0f) + scale.snapTo(0f) + rotation.snapTo(0f) + } + } + + Box( + modifier = Modifier.graphicsLayer { + this.alpha = alpha.value + this.scaleX = scale.value + this.scaleY = scale.value + this.rotationZ = rotation.value + } + ) { + morphItem.content() + } + } + } + } + val scope = MorphCardScope( + itemsContent = itemsContent + ) + + AnimatedContent( + targetState = expanded, + label = "MorphFloatingActionButton", + modifier = modifier + ) { + if (it) { + scope.card() + } else { + Box { + content() + } + } + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/RadialFloatingActionButton.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/RadialFloatingActionButton.kt new file mode 100644 index 0000000..28e898c --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/RadialFloatingActionButton.kt @@ -0,0 +1,249 @@ +package com.developerstring.jetco.ui.components.button.fab + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFabItem +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.model.FabItem +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.model.RadialFabItem +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.cos +import kotlin.math.sin + +/** + * A floating action button that expands items in a radial (arc) pattern. + * + * This component provides a main FAB that, when expanded, reveals a set of items + * arranged along a configurable arc. It supports both simple [FabItem]s (icons with actions) + * and custom [RadialFabItem]s for full composable control. + * + * @param expanded Whether the FAB is currently showing its items. + * @param items List of [FabItem] to be displayed as buttons. + * @param modifier Modifier applied to the root container of the FAB. + * @param onClick Callback triggered when the main FAB button is clicked. + * @param config Configuration for styling and animations. See [FabMainConfig]. + * @param content Optional custom composable for the main FAB button. Defaults to [DefaultFloatingActionButton]. + * + * @see FabMainConfig for detailed arrangement and animation options. + * @see FabItem for item data model. + */ +@Composable +fun RadialFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + val items = items.map { item -> + RadialFabItem { + DefaultFabItem(item = item, onClick = { item.onClick() }) + } + } + + RadialFloatingActionButtonBase( + expanded = expanded, + items = items, + modifier = modifier, + onClick = onClick, + config = config, + content = content + ) +} + +/** + * A floating action button that expands custom [RadialFabItem]s in a radial (arc) pattern. + * + * Use this overload when you need to provide fully custom composables for each item + * instead of the standard icon/button pair. + * + * @param expanded Whether the FAB is currently showing its items. + * @param items List of [RadialFabItem] containing custom composables. + * @param modifier Modifier applied to the root container of the FAB. + * @param onClick Callback triggered when the main FAB button is clicked. + * @param config Configuration for styling and animations. + * @param content Optional custom composable for the main FAB button. + */ +@JvmName("RadialFloatingActionButtonCustom") +@Composable +fun RadialFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + RadialFloatingActionButtonBase( + expanded = expanded, + items = items, + modifier = modifier, + onClick = onClick, + config = config, + content = content + ) +} + +@Composable +internal fun RadialFloatingActionButtonBase( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + Box( + modifier = modifier, + contentAlignment = Alignment.BottomEnd + ) { + val fabOffsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val fabOffsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + + // Sub-items — laid out behind the main FAB + items.forEachIndexed { index, radialItem -> + val startAngle = config.itemArrangement.radial.arc.start + val endAngle = config.itemArrangement.radial.arc.end + + val angleDeg = if (items.size == 1) { + (startAngle + endAngle) / 2.0 // single item lands at the midpoint of the arc + } else { + startAngle + (endAngle - startAngle) * (index.toDouble() / (items.size - 1)) + } + val angleRad = Math.toRadians(angleDeg) + + val targetOffsetX = (config.itemArrangement.radial.radius.value * cos(angleRad)).dp + val targetOffsetY = (config.itemArrangement.radial.radius.value * sin(angleRad)).dp + + val rotation = remember { Animatable(0f) } + val scale = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val offsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val offsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + + LaunchedEffect(expanded) { + val stepMs = 300 / (items.size - 1).coerceAtLeast(1) + val order = if (expanded) config.animation.enterOrder else config.animation.exitOrder + val transition = if (expanded) config.animation.enterTransition else config.animation.exitTransition + val staggerDelay = order.delayFor(index = index, total = (items.size - 1), stepMs = stepMs) + + delay(staggerDelay) + + coroutineScope { + launch { + offsetX.animateOrSnap( + targetValue = if (expanded) targetOffsetX else fabOffsetX.value, + spec = transition.offsetSpec, + predicate = { expanded } + ) + } + launch { + offsetY.animateOrSnap( + targetValue = if (expanded) targetOffsetY else -fabOffsetY.value, + spec = transition.offsetSpec, + predicate = { expanded } + ) + } + launch { + alpha.animateOrSnap( + targetValue = if (expanded) 1f else 0f, + spec = transition.alphaSpec, + predicate = { expanded } + ) + } + launch { + scale.animateOrSnap( + targetValue = if (expanded) 1f else 0f, + spec = transition.scaleSpec, + predicate = { expanded } + ) + } + launch { + rotation.animateOrSnap( + targetValue = transition.rotate?.target, + spec = transition.rotate?.spec, + predicate = { expanded } + ) + } + } + + if (!expanded) { // Reset to initial position + offsetX.snapTo(0.dp) + offsetY.snapTo(0.dp) + alpha.snapTo(0f) + scale.snapTo(0f) + rotation.snapTo(0f) + } + } + + Box( + modifier = Modifier + .offset(x = offsetX.value + fabOffsetX.value, y = -offsetY.value + fabOffsetY.value) + .graphicsLayer { + this.alpha = alpha.value + this.scaleX = scale.value + this.scaleY = scale.value + this.rotationZ = rotation.value + } + ) { + radialItem.content() + } + } + + val fabScale = remember { Animatable(1f) } + val fabRotation = remember { Animatable(0f) } + + LaunchedEffect(expanded) { + val btnTransition = if (expanded) config.animation.buttonEnterTransition + else config.animation.buttonExitTransition + + coroutineScope { + launch { + fabOffsetX.animateOrSnap(btnTransition.offset?.offsetX, btnTransition.offset?.spec) + } + launch { + fabOffsetY.animateOrSnap(btnTransition.offset?.offsetY, btnTransition.offset?.spec) + } + launch { + fabScale.animateOrSnap(btnTransition.scale?.target, btnTransition.scale?.spec) + } + launch { + fabRotation.animateOrSnap(btnTransition.rotation?.target, btnTransition.rotation?.spec) + } + } + } + + // Main FAB button + Box( + modifier = Modifier + .offset(x = fabOffsetX.value, y = fabOffsetY.value) + .graphicsLayer { + scaleX = fabScale.value + scaleY = fabScale.value + rotationZ = fabRotation.value + } + ) { + content() + } + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/StackFloatingActionButton.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/StackFloatingActionButton.kt new file mode 100644 index 0000000..16f3e3b --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/StackFloatingActionButton.kt @@ -0,0 +1,329 @@ +package com.developerstring.jetco.ui.components.button.fab + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFabItem +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.model.FabItem +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.model.StackDirection +import com.developerstring.jetco.ui.components.button.fab.model.StackExpandOffset +import com.developerstring.jetco.ui.components.button.fab.model.StackFabItem +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A floating action button that expands items in a linear stack (Vertical or Horizontal). + * + * This component allows for expanding items in multiple directions (TOP, START, END) + * simultaneously. It also provides an [onExpandChange] callback that reports the total + * displacement of the main button, which can be used to "push" other UI elements + * (like screen content) out of the way. + * + * @param expanded Whether the FAB is currently showing its items. + * @param items List of [FabItem] to be displayed as buttons. Defaults to [StackDirection.TOP]. + * @param modifier Modifier applied to the root container. + * @param onClick Callback triggered when the main FAB button is clicked. + * @param config Configuration for styling and animations. See [FabMainConfig]. + * @param onExpandChange Callback that provides [StackExpandOffset] + * containing the total width/height of expanded main FAB + [FabMainConfig.Orientation.Stack.spacingPadding]. + * @param content Optional custom composable for the main FAB button. + * + * @see StackExpandOffset for handling screen content displacement. + * @see StackFabItem for custom composable items with specific directions. + */ +@Composable +fun StackFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + onExpandChange: (StackExpandOffset) -> Unit = {}, + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + val stackItems = items.map { item -> + StackFabItem { + DefaultFabItem(item = item, onClick = { item.onClick() }) + } + } + + StackFloatingActionButtonBase( + expanded = expanded, + items = stackItems, + modifier = modifier, + onClick = onClick, + config = config, + onExpandChange = onExpandChange, + content = content + ) +} + +/** + * A floating action button that expands custom [StackFabItem]s in a linear stack. + * + * Use this overload to specify different directions for each item or to use custom composables. + * + * @param expanded Whether the FAB is currently showing its items. + * @param items List of [StackFabItem] with direction and custom content. + * @param modifier Modifier applied to the root container. + * @param onClick Callback triggered when the main FAB button is clicked. + * @param config Configuration for styling and animations. + * @param onExpandChange Callback that provides [StackExpandOffset] data. + * @param content Optional custom composable for the main FAB button. + */ +@JvmName("StackFloatingActionButtonCustom") +@Composable +fun StackFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + onExpandChange: (StackExpandOffset) -> Unit = {}, + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + StackFloatingActionButtonBase( + expanded = expanded, + items = items, + modifier = modifier, + onClick = onClick, + config = config, + onExpandChange = onExpandChange, + content = content + ) +} + +@Composable +internal fun StackFloatingActionButtonBase( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + onExpandChange: (StackExpandOffset) -> Unit = {}, + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton(onClick = onClick, config = config) + } +) { + val density = LocalDensity.current + var fabWidthDp by remember { mutableStateOf(config.buttonStyle.size) } + var fabHeightDp by remember { mutableStateOf(config.buttonStyle.size) } + val spacedBy = config.itemArrangement.stack.spacedBy + val spacingPadding = config.itemArrangement.stack.spacingPadding + + val itemWidths = remember(items.size) { + mutableStateListOf().also { list -> + repeat(items.size) { list.add(config.buttonStyle.size) } + } + } + val itemHeights = remember(items.size) { + mutableStateListOf().also { list -> + repeat(items.size) { list.add(config.buttonStyle.size) } + } + } + + val topIndices = remember(items) { items.indices.filter { items[it].direction == StackDirection.TOP } } + val startIndices = remember(items) { items.indices.filter { items[it].direction == StackDirection.START } } + val endIndices = remember(items) { items.indices.filter { items[it].direction == StackDirection.END } } + + Box( + modifier = modifier, + contentAlignment = Alignment.BottomEnd + ) { + val fabOffsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val fabOffsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + + items.forEachIndexed { index, stackItem -> + val direction = stackItem.direction + + val groupIndices = when (direction) { + StackDirection.TOP -> topIndices + StackDirection.START -> startIndices + StackDirection.END -> endIndices + } + val positionInGroup = groupIndices.indexOf(index) + + val spacing = when (direction) { + StackDirection.TOP -> { + val heightBefore = (0 until positionInGroup).fold(0.dp) { acc, pos -> + acc + itemHeights[groupIndices[pos]] + spacedBy + } + fabHeightDp + spacedBy + heightBefore + } + StackDirection.START, + StackDirection.END -> { + val widthBefore = (0 until positionInGroup).fold(0.dp) { acc, pos -> + acc + itemWidths[groupIndices[pos]] + spacedBy + } + fabWidthDp + spacedBy + widthBefore + } + } + + val targetOffsetX = when (direction) { + StackDirection.START -> -spacing + StackDirection.END -> spacing + StackDirection.TOP -> -(fabWidthDp - itemWidths[index]) / 2 + } + + val targetOffsetY = when (direction) { + StackDirection.TOP -> -spacing + StackDirection.START, + StackDirection.END -> -(fabHeightDp - itemHeights[index]) / 2 + } + + val rotation = remember { Animatable(0f) } + val scale = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val offsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val offsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + + val groupSize = groupIndices.size + + LaunchedEffect(expanded, targetOffsetX, targetOffsetY) { + val stepMs = 300 / groupSize.coerceAtLeast(1) + val order = if (expanded) config.animation.enterOrder else config.animation.exitOrder + val transition = if (expanded) config.animation.enterTransition else config.animation.exitTransition + val staggerDelay = order.delayFor( + index = positionInGroup, + total = (groupSize - 1).coerceAtLeast(0), + stepMs = stepMs + ) + + delay(staggerDelay) + + coroutineScope { + launch { + offsetX.animateOrSnap( + targetValue = if (expanded) targetOffsetX else 0.dp, + spec = transition.offsetSpec, + predicate = { expanded } + ) + } + launch { + offsetY.animateOrSnap( + targetValue = if (expanded) targetOffsetY else 0.dp, + spec = transition.offsetSpec, + predicate = { expanded } + ) + } + launch { + alpha.animateOrSnap( + targetValue = if (expanded) 1f else 0f, + spec = transition.alphaSpec, + predicate = { expanded } + ) + } + launch { + scale.animateOrSnap( + targetValue = if (expanded) 1f else 0f, + spec = transition.scaleSpec, + predicate = { expanded } + ) + } + launch { + rotation.animateOrSnap( + targetValue = transition.rotate?.target, + spec = transition.rotate?.spec, + predicate = { expanded } + ) + } + } + + if (!expanded) { // Reset to initial position + offsetX.snapTo(0.dp) + offsetY.snapTo(0.dp) + alpha.snapTo(0f) + scale.snapTo(0f) + } + } + + Box( + modifier = Modifier + .offset(x = offsetX.value + fabOffsetX.value, y = offsetY.value + fabOffsetY.value) + .onSizeChanged { size -> + val width = with(density) { size.width.toDp() } + val height = with(density) { size.height.toDp() } + if (itemWidths[index] != width) itemWidths[index] = width + if (itemHeights[index] != height) itemHeights[index] = height + }.graphicsLayer { + this.alpha = alpha.value + this.scaleX = scale.value + this.scaleY = scale.value + this.rotationZ = rotation.value + } + ) { + stackItem.content() + } + } + + val fabScale = remember { Animatable(1f) } + val fabRotation = remember { Animatable(0f) } + + LaunchedEffect(expanded) { + val btnTransition = if (expanded) config.animation.buttonEnterTransition + else config.animation.buttonExitTransition + + if (expanded) { + onExpandChange(StackExpandOffset( + offsetY = fabHeightDp + spacingPadding, + offsetX = fabWidthDp + spacingPadding + )) + } else { + onExpandChange(StackExpandOffset()) + } + + coroutineScope { + launch { + fabOffsetX.animateOrSnap(btnTransition.offset?.offsetX, btnTransition.offset?.spec) + } + launch { + fabOffsetY.animateOrSnap(btnTransition.offset?.offsetY, btnTransition.offset?.spec) + } + launch { + fabScale.animateOrSnap(btnTransition.scale?.target, btnTransition.scale?.spec) + } + launch { + fabRotation.animateOrSnap(btnTransition.rotation?.target, btnTransition.rotation?.spec) + } + } + } + + // Main FAB + Box( + modifier = Modifier + .offset(x = fabOffsetX.value, y = fabOffsetY.value) + .graphicsLayer { + scaleX = fabScale.value + scaleY = fabScale.value + rotationZ = fabRotation.value + }.onSizeChanged { size -> + fabWidthDp = with(density) { size.width.toDp() } + fabHeightDp = with(density) { size.height.toDp() } + } + ) { + content() + } + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFabItem.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFabItem.kt new file mode 100644 index 0000000..1052a4f --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFabItem.kt @@ -0,0 +1,48 @@ +package com.developerstring.jetco.ui.components.button.fab.base + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.model.FabItem + +@Composable +internal fun DefaultFabItem( + item: FabItem, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = modifier + .size(item.buttonStyle.size) + .clip(item.buttonStyle.shape) + .background(item.buttonStyle.color) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + onClick = onClick + ) + ) { + item.icon?.let { icon -> + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(item.buttonStyle.size * 0.55f) + ) + } + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFloatingActionButton.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFloatingActionButton.kt new file mode 100644 index 0000000..b1b9081 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFloatingActionButton.kt @@ -0,0 +1,51 @@ +package com.developerstring.jetco.ui.components.button.fab.base + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig + +@Composable +internal fun DefaultFloatingActionButton( + modifier: Modifier = Modifier, + onClick: (() -> Unit) = {}, + config: FabMainConfig = FabMainConfig() +) { + Box( + modifier = modifier + .defaultMinSize(minWidth = config.buttonStyle.size) + .height(config.buttonStyle.size) + .clip(config.buttonStyle.shape) + .background(config.buttonStyle.color) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current + ) { onClick.invoke() }, + contentAlignment = Alignment.Center + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "Base FAB icon", + tint = Color.White, + modifier = Modifier.size(config.buttonStyle.size * 0.55f) + ) + } + } +} \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultMorphCard.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultMorphCard.kt new file mode 100644 index 0000000..5e691ce --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultMorphCard.kt @@ -0,0 +1,72 @@ +package com.developerstring.jetco.ui.components.button.fab.base + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.scope.MorphCardScope + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun DefaultMorphCard( + config: FabMainConfig, + onClose: () -> Unit, + scope: MorphCardScope +) { + val morph = config.itemArrangement.morph + + Column( + modifier = Modifier + .width(morph.width) + .background( + color = config.buttonStyle.color, + shape = morph.cardShape + ).padding(16.dp) + ) { + // header + Box(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.align(Alignment.CenterStart)) { + Text( + text = "Quick Actions", + color = Color.White + ) + } + Box( + modifier = Modifier + .size(32.dp) + .align(Alignment.CenterEnd) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClose + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + } + scope.MorphItems() + } +} \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabItem.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabItem.kt new file mode 100644 index 0000000..1be2852 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabItem.kt @@ -0,0 +1,44 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.StackFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.MorphFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.RadialFloatingActionButton + +/** + * Data model representing a standard item in a Floating Action Button (FAB) menu. + * + * This is the default item type used by [RadialFloatingActionButton], [StackFloatingActionButton], + * and [MorphFloatingActionButton]. It consists of an icon, a click action, and visual styling. + * + * @property onClick Callback triggered when this specific item is clicked. + * @property icon to be displayed inside the item button. + * @property buttonStyle Visual configuration for this item, including color, shape, and size. + */ +@Stable +data class FabItem( + val onClick: () -> Unit, + val icon: ImageVector? = null, + val buttonStyle: ButtonStyle = ButtonStyle() +) { + + /** + * Visual style configuration for an individual [FabItem]. + * + * @property color Background color of the item button. + * @property shape Shape of the item button. Default is [CircleShape]. + * @property size Diameter/Height of the item button. Default is 52.dp. + */ + @Stable + data class ButtonStyle( + val color: Color = Color(0xFF1976D2), + val shape: Shape = CircleShape, + val size: Dp = 52.dp + ) +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabMainConfig.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabMainConfig.kt new file mode 100644 index 0000000..daf0b78 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabMainConfig.kt @@ -0,0 +1,151 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.transition.FabButtonTransition +import com.developerstring.jetco.ui.components.button.fab.transition.FabItemTransition + +/** + * Main configuration class for the Floating Action Button (FAB) family. + * + * This class centralizes all visual and behavioral settings, including button styling, + * item arrangement patterns (Radial, Stack, Morph), and animation specifications + * for both the main button and its items. + * + * @property buttonStyle Visual styling for the main FAB (color, shape, size, etc.). + * @property itemArrangement Configuration for how items are positioned when expanded. + * @property animation Animation specs for entry/exit transitions and stagger orders. + */ +@Stable +data class FabMainConfig( + val buttonStyle: ButtonStyle = ButtonStyle(), + val itemArrangement: ItemArrangement = ItemArrangement(), + val animation: Animation = Animation() +) { + + /** + * Defines the layout orientation and specific parameters for FAB expansion. + */ + sealed interface Orientation { + /** + * Radial orientation that expands items in an arc. + * + * @property arc The direction and span of the arc (END, START, CENTER). + * @property radius The distance from the main FAB to the sub-items. + */ + data class Radial( + val arc: Arc = Arc.END, + val radius: Dp = 80.dp + ) : Orientation { + enum class Arc(val start: Double, val end: Double) { + /** Spreads items from 90° to 180° (upward and to the left). */ + END(90.0, 180.0), + /** Spreads items from 90° to 0° (upward and to the right). */ + START(90.0, 0.0), + /** Spreads items from 0° to 180° (full upper arc). */ + CENTER(0.0, 180.0) + } + } + + /** + * Stack orientation that expands items linearly. + * + * @property spacedBy Gap between consecutive items in the stack. + * @property spacingPadding Extra padding used to report total expansion size in [StackExpandOffset]. + */ + data class Stack( + val spacedBy: Dp = 40.dp, + val spacingPadding: Dp = 24.dp + ) : Orientation + + /** + * Morph orientation that expands the main FAB into a card grid. + * + * @property columns Number of item columns in the card grid. + * @property spacedBy Gap between items inside the grid. + * @property headerSpace Space between the card header and the item grid. + * @property width Total width of the expanded card. + * @property cardShape Shape of the expanded card. + */ + data class Morph( + val columns: Int = 2, + val spacedBy: Dp = 12.dp, + val headerSpace: Dp = 20.dp, + val width: Dp = 250.dp, + val cardShape: Shape = RoundedCornerShape(24.dp) + ) : Orientation + } + + /** + * Animation configuration for the FAB family. + * + * @property enterOrder The order in which items appear (FIFO, FILO, ALL). + * @property exitOrder The order in which items disappear. + * @property enterTransition Combined transitions for sub-item entry. + * @property exitTransition Combined transitions for sub-item exit. + * @property buttonEnterTransition Transitions applied to the main FAB on expansion. + * @property buttonExitTransition Transitions applied to the main FAB on collapse. + */ + @Stable + open class Animation( + val enterOrder: StaggerOrder = StaggerOrder.FIFO, + val exitOrder: StaggerOrder = StaggerOrder.FILO, + val enterTransition: FabItemTransition = FabItemTransition.Spring() + FabItemTransition.Fade(), + val exitTransition: FabItemTransition = FabItemTransition.Slide() + FabItemTransition.Fade(), + val buttonEnterTransition: FabButtonTransition = FabButtonTransition.Rotate(45f), + val buttonExitTransition: FabButtonTransition = FabButtonTransition.Rotate(0f) + ) + + /** + * Controls the sequence of staggered animations for sub-items. + */ + @Stable + enum class StaggerOrder { + /** First In, First Out: items animate in the order they appear in the list. */ + FIFO, + /** First In, Last Out: items animate in reverse order. */ + FILO, + /** All items animate simultaneously. */ + ALL; + + internal fun delayFor(index: Int, total: Int, stepMs: Int): Long = when (this) { + FIFO -> (index * stepMs).toLong() + FILO -> ((total - 1 - index) * stepMs).toLong() + ALL -> 0L + } + } + + /** + * Visual style configuration for the main FAB button. + * + * @property color Background color of the main FAB. + * @property shape Shape of the main FAB button. + * @property size Diameter/Height of the main FAB. + * @property iconRotation Target rotation of the icon when expanded (legacy support). + * @property padding Internal padding for the button content. + */ + @Stable + data class ButtonStyle( + val color: Color = Color(0xFF1976D2), + val shape: Shape = CircleShape, + val size: Dp = 72.dp, + val iconRotation: Float = 45f, + val padding: PaddingValues = PaddingValues() + ) + + /** + * Container for all arrangement-specific settings. + */ + @Stable + data class ItemArrangement( + val radial: Orientation.Radial = Orientation.Radial(), + val stack: Orientation.Stack = Orientation.Stack(), + val morph: Orientation.Morph = Orientation.Morph() + ) +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/MorphFabItem.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/MorphFabItem.kt new file mode 100644 index 0000000..ba75ca7 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/MorphFabItem.kt @@ -0,0 +1,18 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.developerstring.jetco.ui.components.button.fab.MorphFloatingActionButton + +/** + * A wrapper for a custom composable to be used as an item in [MorphFloatingActionButton]. + * + * Use this class when you want to provide a fully custom UI for an item inside + * the morphed card grid instead of the default [FabItem]. + * + * @property content The composable content to be rendered for this item in the grid. + */ +@Stable +class MorphFabItem( + val content: @Composable () -> Unit +) diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/RadialFabItem.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/RadialFabItem.kt new file mode 100644 index 0000000..c3abb33 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/RadialFabItem.kt @@ -0,0 +1,18 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.developerstring.jetco.ui.components.button.fab.RadialFloatingActionButton + +/** + * A wrapper for a custom composable to be used as an item in [RadialFloatingActionButton]. + * + * Use this class when you want to provide a fully custom UI for a radial menu item + * instead of the default icon-based [FabItem]. + * + * @property content The composable content to be rendered for this radial item. + */ +@Stable +class RadialFabItem( + val content: @Composable () -> Unit +) diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackDirection.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackDirection.kt new file mode 100644 index 0000000..7ef9f82 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackDirection.kt @@ -0,0 +1,10 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +enum class StackDirection { + /** Spreads items upward above the main FAB. */ + TOP, + /** Spreads items to the left of the main FAB. */ + START, + /** Spreads items to the right of the main FAB. */ + END +} \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackExpandOffset.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackExpandOffset.kt new file mode 100644 index 0000000..d68c571 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackExpandOffset.kt @@ -0,0 +1,9 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class StackExpandOffset( + val offsetY: Dp = 0.dp, + val offsetX: Dp = 0.dp +) diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackFabItem.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackFabItem.kt new file mode 100644 index 0000000..3fcd057 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/StackFabItem.kt @@ -0,0 +1,20 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.developerstring.jetco.ui.components.button.fab.StackFloatingActionButton + +/** + * A wrapper for a custom composable with a specific direction to be used as an item in [StackFloatingActionButton]. + * + * Use this class when you need to specify a different expansion direction for each item + * or when you want to provide a fully custom UI instead of the default [FabItem]. + * + * @property direction The direction in which this item will expand from the main FAB (TOP, START, END). + * @property content The composable content to be rendered for this item. + */ +@Stable +class StackFabItem( + val direction: StackDirection = StackDirection.TOP, + val content: @Composable () -> Unit +) diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/scope/MorphCardScope.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/scope/MorphCardScope.kt new file mode 100644 index 0000000..6689d53 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/scope/MorphCardScope.kt @@ -0,0 +1,10 @@ +package com.developerstring.jetco.ui.components.button.fab.scope + +import androidx.compose.runtime.Composable + +class MorphCardScope( + internal val itemsContent: @Composable () -> Unit +) { + @Composable + fun MorphItems() = itemsContent() +} \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/FabButtonTransition.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/FabButtonTransition.kt new file mode 100644 index 0000000..16a2bd1 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/FabButtonTransition.kt @@ -0,0 +1,100 @@ +package com.developerstring.jetco.ui.components.button.fab.transition + +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Describes how the main FAB button animates when the component expands or collapses. + * + * Transitions can be combined using the `+` operator. + * + * ## Example: + * ```kotlin + * val buttonTransition = FabButtonTransition.Rotate(45f) + FabButtonTransition.Scale(0.8f) + * ``` + * + * @property offset Translation animation for the main button. + * @property scale Scale animation for the main button. + * @property rotation Rotation animation for the main button. + * @property spring Optional global spring override for all properties. + */ +class FabButtonTransition internal constructor( + val offset: OffsetTransition? = null, + val scale: ScaleTransition? = null, + val rotation: RotateTransition? = null, + val spring: SpringTransition? = null +) { + /** + * Combines two button transitions. Properties in [other] take precedence. + */ + operator fun plus(other: FabButtonTransition): FabButtonTransition = FabButtonTransition( + offset = other.offset ?: this.offset, + scale = other.scale ?: this.scale, + rotation = other.rotation ?: this.rotation, + spring = other.spring ?: this.spring + ) + + companion object { + + /** + * Animates the main button position to a specific (x, y) offset. + */ + fun SlideTo( + x: Dp = 0.dp, + y: Dp = 0.dp, + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabButtonTransition = FabButtonTransition( + offset = OffsetTransition( + offsetX = x, + offsetY = y, + spec = tween(durationMillis, easing = easing) + ) + ) + + /** + * Scales the main button to a specific target value. + */ + fun Scale( + scale: Float, + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabButtonTransition = FabButtonTransition( + scale = ScaleTransition( + target = scale, + spec = tween(durationMillis, easing = easing) + ) + ) + + /** + * Rotates the main button to a specific target angle. + */ + fun Rotate( + rotation: Float, + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabButtonTransition = FabButtonTransition( + rotation = RotateTransition( + target = rotation, + spec = tween(durationMillis, easing = easing) + ) + ) + + /** + * Overrides the animation spec with physics-based spring behavior. + */ + fun Spring( + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + stiffness: Float = Spring.StiffnessMedium + ): FabButtonTransition = FabButtonTransition( + spring = SpringTransition( + spec = spring(dampingRatio, stiffness) + ) + ) + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/FabItemTransition.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/FabItemTransition.kt new file mode 100644 index 0000000..4764517 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/FabItemTransition.kt @@ -0,0 +1,89 @@ +package com.developerstring.jetco.ui.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.ui.unit.Dp + +/** + * Describes how an item (FAB) animates when it enters or exits the screen. + * + * Transitions can be combined using the `+` operator. + * Each factory function returns a new [FabItemTransition] that only defines the specific + * animation spec for its property. + * + * ## Example: + * ```kotlin + * val myItemTransition = FabItemTransition.Spring() + FabItemTransition.Fade() + * ``` + * + * @property offsetSpec Animation for the item's movement (displacement). + * @property alphaSpec Animation for the item's transparency. + * @property scaleSpec Animation for the item's size (X and Y). + * @property rotate Configuration for the item's rotation. + */ +class FabItemTransition( + val offsetSpec: AnimationSpec? = null, + val alphaSpec: AnimationSpec? = null, + val scaleSpec: AnimationSpec? = null, + val rotate: RotateTransition? = null +) { + operator fun plus(other: FabItemTransition): FabItemTransition = FabItemTransition( + offsetSpec = other.offsetSpec ?: this.offsetSpec, + alphaSpec = other.alphaSpec ?: this.alphaSpec, + scaleSpec = other.scaleSpec ?: this.scaleSpec, + rotate = other.rotate ?: this.rotate + ) + + companion object { + fun Spring( + dampingRatio: Float = Spring.DampingRatioMediumBouncy, + stiffness: Float = Spring.StiffnessMedium + ): FabItemTransition = FabItemTransition( + offsetSpec = spring(dampingRatio, stiffness) + ) + + fun Slide( + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabItemTransition = FabItemTransition( + offsetSpec = tween(durationMillis, easing = easing) + ) + + fun Fade( + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabItemTransition = FabItemTransition( + alphaSpec = tween(durationMillis, easing = easing), + ) + + fun SlideAndFade( + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabItemTransition = FabItemTransition( + offsetSpec = tween(durationMillis, easing = easing), + alphaSpec = tween(durationMillis, easing = easing), + ) + + fun Scale( + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabItemTransition = FabItemTransition( + scaleSpec = tween(durationMillis, easing = easing) + ) + + fun Rotate( + target: Float, + durationMillis: Int = 300, + easing: Easing = FastOutSlowInEasing + ): FabItemTransition = FabItemTransition( + rotate = RotateTransition( + target = target, + spec = tween(durationMillis, easing = easing) + ) + ) + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/OffsetTransition.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/OffsetTransition.kt new file mode 100644 index 0000000..f840c97 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/OffsetTransition.kt @@ -0,0 +1,10 @@ +package com.developerstring.jetco.ui.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.ui.unit.Dp + +data class OffsetTransition( + val offsetX: Dp, + val offsetY: Dp, + val spec: AnimationSpec +) \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/RotateTransition.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/RotateTransition.kt new file mode 100644 index 0000000..18c5d16 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/RotateTransition.kt @@ -0,0 +1,8 @@ +package com.developerstring.jetco.ui.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec + +data class RotateTransition( + val target: Float, + val spec: AnimationSpec +) \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/ScaleTransition.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/ScaleTransition.kt new file mode 100644 index 0000000..d926040 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/ScaleTransition.kt @@ -0,0 +1,8 @@ +package com.developerstring.jetco.ui.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec + +data class ScaleTransition( + val target: Float, + val spec: AnimationSpec +) \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/SpringTransition.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/SpringTransition.kt new file mode 100644 index 0000000..0340a0c --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/transition/SpringTransition.kt @@ -0,0 +1,7 @@ +package com.developerstring.jetco.ui.components.button.fab.transition + +import androidx.compose.animation.core.AnimationSpec + +data class SpringTransition( + val spec: AnimationSpec +) \ No newline at end of file