Skip to content

Commit 18af10f

Browse files
committed
Added more UI haptic feedback + a material 3 expressive split button
1 parent 4593606 commit 18af10f

1 file changed

Lines changed: 70 additions & 89 deletions

File tree

app/src/main/java/com/better/nothing/music/vizualizer/ui/UIComponents.kt

Lines changed: 70 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import androidx.compose.foundation.Canvas
2929
import androidx.compose.foundation.ExperimentalFoundationApi
3030
import androidx.compose.foundation.background
3131
import androidx.compose.foundation.combinedClickable
32+
import androidx.compose.foundation.gestures.detectTapGestures
3233
import androidx.compose.foundation.interaction.MutableInteractionSource
3334
import androidx.compose.foundation.interaction.PressInteraction
3435
import androidx.compose.foundation.interaction.collectIsDraggedAsState
@@ -80,6 +81,7 @@ import androidx.compose.runtime.LaunchedEffect
8081
import androidx.compose.runtime.getValue
8182
import androidx.compose.runtime.mutableStateOf
8283
import androidx.compose.runtime.remember
84+
import androidx.compose.runtime.rememberCoroutineScope
8385
import androidx.compose.runtime.setValue
8486
import androidx.compose.ui.Alignment
8587
import androidx.compose.ui.Modifier
@@ -99,24 +101,19 @@ import androidx.compose.ui.res.painterResource
99101
import androidx.compose.ui.res.stringResource
100102
import androidx.compose.ui.text.TextStyle
101103
import androidx.compose.ui.text.font.FontWeight
104+
import androidx.compose.ui.text.style.TextOverflow
102105
import androidx.compose.ui.unit.TextUnit
103106
import androidx.compose.ui.unit.dp
104107
import androidx.compose.ui.unit.sp
105108
import androidx.graphics.shapes.CornerRounding
106109
import androidx.graphics.shapes.Morph
107110
import androidx.graphics.shapes.RoundedPolygon
108-
import androidx.compose.foundation.gestures.detectTapGestures
109-
import androidx.compose.material.icons.rounded.Check
110-
import androidx.compose.material3.ButtonDefaults
111-
import androidx.compose.material3.FilledTonalButton
112-
import androidx.compose.runtime.rememberCoroutineScope
113-
import androidx.compose.ui.graphics.Shape
114-
import kotlinx.coroutines.delay
115-
import kotlinx.coroutines.launch
116111
import androidx.graphics.shapes.star
117112
import androidx.graphics.shapes.toPath
118113
import com.better.nothing.music.vizualizer.R
114+
import kotlinx.coroutines.delay
119115
import kotlinx.coroutines.flow.collectLatest
116+
import kotlinx.coroutines.launch
120117
import kotlin.math.exp
121118
import kotlin.math.ln
122119
import kotlin.time.Duration.Companion.milliseconds
@@ -320,6 +317,7 @@ fun FlowRowScope.OptionTile(
320317
when (interaction) {
321318
is PressInteraction.Press -> {
322319
pressStartTime = SystemClock.elapsedRealtime()
320+
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
323321
isWeightExpanded = true
324322
}
325323
is PressInteraction.Release, is PressInteraction.Cancel -> {
@@ -331,6 +329,7 @@ fun FlowRowScope.OptionTile(
331329
delay(remainingFloorDelay.milliseconds)
332330
}
333331
isWeightExpanded = false
332+
haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
334333
}
335334
}
336335
}
@@ -357,7 +356,7 @@ fun FlowRowScope.OptionTile(
357356
val animatedRadius by animateDpAsState(
358357
targetValue = targetRadius,
359358
animationSpec = if (m3eEnabled) {
360-
spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
359+
spring(dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessMediumLow)
361360
} else {
362361
spring(stiffness = Spring.StiffnessMedium)
363362
},
@@ -378,7 +377,6 @@ fun FlowRowScope.OptionTile(
378377
Surface(
379378
onClick = if (enabled) {
380379
{
381-
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
382380
onClick()
383381
}
384382
} else ({}),
@@ -759,8 +757,7 @@ fun <T> ExpressiveSegmentedButtonRow(
759757
selectedItem: T,
760758
onItemSelection: (T) -> Unit,
761759
labelProvider: @Composable (T) -> String,
762-
modifier: Modifier = Modifier,
763-
iconProvider: @Composable ((T) -> Unit)? = null // Optional custom icon overrides
760+
modifier: Modifier = Modifier
764761
) {
765762
val haptics = LocalHapticFeedback.current
766763
val scope = rememberCoroutineScope()
@@ -772,53 +769,66 @@ fun <T> ExpressiveSegmentedButtonRow(
772769
) {
773770
items.forEachIndexed { index, item ->
774771
val isSelected = item == selectedItem
775-
val buttonShape = rememberExpressiveShape(index = index, count = items.size)
776772

777-
// Visual tap bounce tracker
778773
var isPressed by remember { mutableStateOf(false) }
779774

780-
// Spring specs for standard Material 3 expressive/bouncy physics
775+
// Elastic bouncy spring configuration
781776
val bouncySpec = spring<Float>(
782777
dampingRatio = Spring.DampingRatioHighBouncy,
783778
stiffness = Spring.StiffnessMediumLow
784779
)
780+
val dpBouncySpec = spring<androidx.compose.ui.unit.Dp>(
781+
dampingRatio = Spring.DampingRatioLowBouncy,
782+
stiffness = Spring.StiffnessMedium
783+
)
785784

786-
// Animate layout expansion weight (1.2f if selected, 1.0f otherwise)
785+
// 1. Dynamic layout expansion weight (grows wider horizontally)
787786
val animatedWeight by animateFloatAsState(
788-
targetValue = if (isSelected) 1.2f else 1.0f,
787+
targetValue = if (isPressed) 0.89f else if (isSelected) 1.2f else 1.0f,
789788
animationSpec = bouncySpec,
790789
label = "ExpressiveWeightAnimation"
791790
)
792791

793-
// Animate scale factor on click
794-
val animatedScale by animateFloatAsState(
795-
targetValue = if (isPressed) 0.92f else 1.0f,
796-
animationSpec = bouncySpec,
797-
label = "BouncyScaleAnimation"
792+
// 3. Dynamic corner fluid morphing (becomes a pill when selected)
793+
val fullyRounded = 40.dp
794+
val slightlyRounded = 8.dp
795+
796+
val targetTopStart = if (isSelected || index == 0 || items.size == 1) fullyRounded else slightlyRounded
797+
val targetBottomStart = if (isSelected || index == 0 || items.size == 1) fullyRounded else slightlyRounded
798+
val targetTopEnd = if (isSelected || index == items.size - 1 || items.size == 1) fullyRounded else slightlyRounded
799+
val targetBottomEnd = if (isSelected || index == items.size - 1 || items.size == 1) fullyRounded else slightlyRounded
800+
801+
val topStart by animateDpAsState(targetValue = targetTopStart, animationSpec = dpBouncySpec, label = "TopStart")
802+
val bottomStart by animateDpAsState(targetValue = targetBottomStart, animationSpec = dpBouncySpec, label = "BottomStart")
803+
val topEnd by animateDpAsState(targetValue = targetTopEnd, animationSpec = dpBouncySpec, label = "TopEnd")
804+
val bottomEnd by animateDpAsState(targetValue = targetBottomEnd, animationSpec = dpBouncySpec, label = "BottomEnd")
805+
806+
val dynamicButtonShape = RoundedCornerShape(
807+
topStart = topStart.coerceAtLeast(0.dp),
808+
bottomStart = bottomStart.coerceAtLeast(0.dp),
809+
topEnd = topEnd.coerceAtLeast(0.dp),
810+
bottomEnd = bottomEnd.coerceAtLeast(0.dp)
798811
)
799812

800-
FilledTonalButton(
801-
onClick = {}, // Handled manually via gesture listener
802-
shape = buttonShape,
803-
colors = ButtonDefaults.filledTonalButtonColors(
804-
containerColor = if (isSelected) {
805-
MaterialTheme.colorScheme.primaryContainer
806-
} else {
807-
MaterialTheme.colorScheme.surfaceContainerHighest
808-
},
809-
contentColor = if (isSelected) {
810-
MaterialTheme.colorScheme.onPrimaryContainer
811-
} else {
812-
MaterialTheme.colorScheme.onSurfaceVariant
813-
}
814-
),
813+
val containerColor = if (isSelected) {
814+
MaterialTheme.colorScheme.primary
815+
} else {
816+
MaterialTheme.colorScheme.surfaceContainerHighest
817+
}
818+
819+
val contentColor = if (isSelected) {
820+
MaterialTheme.colorScheme.onPrimary
821+
} else {
822+
MaterialTheme.colorScheme.onSurfaceVariant
823+
}
824+
825+
Surface(
826+
color = containerColor,
827+
contentColor = contentColor,
828+
shape = dynamicButtonShape,
815829
modifier = Modifier
816-
.weight(animatedWeight) // Apportion width adaptively based on selection state
817-
.graphicsLayer {
818-
scaleX = animatedScale
819-
scaleY = animatedScale
820-
}
821-
.pointerInput(item) {
830+
.weight(animatedWeight) // Restored horizontal weight expansion
831+
.pointerInput(item, isSelected) {
822832
detectTapGestures(
823833
onPress = {
824834
val startTime = System.currentTimeMillis()
@@ -828,6 +838,7 @@ fun <T> ExpressiveSegmentedButtonRow(
828838
try {
829839
awaitRelease()
830840
} finally {
841+
haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
831842
val elapsedTime = System.currentTimeMillis() - startTime
832843
val remainingTime = 150L - elapsedTime
833844

@@ -836,64 +847,34 @@ fun <T> ExpressiveSegmentedButtonRow(
836847
delay(remainingTime.milliseconds)
837848
}
838849
isPressed = false
839-
onItemSelection(item)
850+
if (!isSelected) {
851+
onItemSelection(item)
852+
}
840853
}
841854
}
842855
}
843856
)
844857
}
845858
) {
846-
// Show icon exclusively when the item is active/selected
847-
if (isSelected) {
848-
if (iconProvider != null) {
849-
iconProvider(item)
850-
} else {
851-
Icon(
852-
imageVector = Icons.Rounded.Check,
853-
contentDescription = "Selected",
854-
modifier = Modifier.graphicsLayer {
855-
// Subtle entry spring synchronization
856-
scaleX = animatedScale
857-
scaleY = animatedScale
858-
}
859-
)
860-
}
861-
Spacer(modifier = Modifier.width(6.dp))
862-
}
859+
Row(
860+
modifier = Modifier.padding(horizontal = 4.dp, vertical = 10.dp),
861+
horizontalArrangement = Arrangement.Center,
862+
verticalAlignment = Alignment.CenterVertically
863+
) {
864+
// 4. Smooth Icon Slide + Fade Entry
863865

864-
Text(
865-
text = labelProvider(item),
866-
style = MaterialTheme.typography.labelLarge,
867-
maxLines = 1
868-
)
866+
Text(
867+
text = labelProvider(item),
868+
style = MaterialTheme.typography.labelLarge,
869+
maxLines = 1,
870+
overflow = TextOverflow.Ellipsis
871+
)
872+
}
869873
}
870874
}
871875
}
872876
}
873877

874-
@Composable
875-
private fun rememberExpressiveShape(index: Int, count: Int): Shape {
876-
val fullyRounded = 100.dp
877-
val slightlyRounded = 8.dp
878-
879-
return when {
880-
count == 1 -> RoundedCornerShape(fullyRounded)
881-
index == 0 -> RoundedCornerShape(
882-
topStart = fullyRounded,
883-
bottomStart = fullyRounded,
884-
topEnd = slightlyRounded,
885-
bottomEnd = slightlyRounded
886-
)
887-
index == count - 1 -> RoundedCornerShape(
888-
topStart = slightlyRounded,
889-
bottomStart = slightlyRounded,
890-
topEnd = fullyRounded,
891-
bottomEnd = fullyRounded
892-
)
893-
else -> RoundedCornerShape(slightlyRounded)
894-
}
895-
}
896-
897878
@OptIn(ExperimentalMaterial3Api::class)
898879
@Composable
899880
fun ExpressiveSlider(

0 commit comments

Comments
 (0)