Add reusable slider component (#3974)

This commit is contained in:
David Perez 2024-09-26 16:00:23 -05:00 committed by GitHub
parent f349a72f72
commit 80c1c26f5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 167 additions and 134 deletions

View file

@ -0,0 +1,160 @@
package com.x8bit.bitwarden.ui.platform.components.slider
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.toDp
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A custom Bitwarden-themed slider.
*
* @param value The currently set value.
* @param range The range of values allowed.
* @param onValueChange Lambda callback for when the value changes and whether the change was from
* user interaction or not.
* @param modifier The [Modifier] to be applied to this radio button.
* @param sliderTag The option test tag for the slider component.
* @param valueTag The option test tag for the value field component.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun BitwardenSlider(
value: Int,
range: ClosedRange<Int>,
onValueChange: (value: Int, isUserInteracting: Boolean) -> Unit,
modifier: Modifier = Modifier,
sliderTag: String? = null,
valueTag: String? = null,
) {
val sliderValue by rememberUpdatedState(newValue = value.coerceIn(range = range))
var labelTextWidth by remember { mutableStateOf(value = Dp.Unspecified) }
val density = LocalDensity.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.semantics(mergeDescendants = true) {},
) {
OutlinedTextField(
value = sliderValue.toString(),
readOnly = true,
onValueChange = { },
label = {
Text(
text = stringResource(id = R.string.length),
modifier = Modifier.onGloballyPositioned { layoutCoordinates ->
if (labelTextWidth == Dp.Unspecified) {
labelTextWidth = layoutCoordinates.size.width.toDp(density = density)
}
},
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.onPreviewKeyEvent { keyEvent ->
when (keyEvent.key) {
Key.DirectionUp -> {
onValueChange(sliderValue + 1, true)
true
}
Key.DirectionDown -> {
onValueChange(sliderValue - 1, true)
true
}
else -> false
}
}
.semantics { valueTag?.let { testTag = it } }
.wrapContentWidth()
// We want the width to be no wider than the label + 16dp on either side
.width(width = 16.dp + labelTextWidth + 16.dp),
)
val colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
disabledActiveTickColor = Color.Transparent,
disabledInactiveTickColor = Color.Transparent,
)
Slider(
value = sliderValue.toFloat(),
onValueChange = { newValue -> onValueChange(newValue.toInt(), true) },
onValueChangeFinished = { onValueChange(sliderValue, false) },
valueRange = range.start.toFloat()..range.endInclusive.toFloat(),
steps = range.endInclusive - 1,
colors = colors,
thumb = {
SliderDefaults.Thumb(
interactionSource = remember { MutableInteractionSource() },
colors = colors,
thumbSize = DpSize(width = 20.dp, height = 20.dp),
)
},
track = { sliderState ->
SliderDefaults.Track(
modifier = Modifier.height(height = 4.dp),
drawStopIndicator = null,
colors = colors,
sliderState = sliderState,
thumbTrackGapSize = 0.dp,
)
},
modifier = Modifier
.focusProperties { canFocus = false }
.semantics { sliderTag?.let { testTag = it } }
.weight(weight = 1f),
)
}
}
@Preview(showBackground = true)
@Composable
private fun BitwardenSlider_preview() {
BitwardenTheme {
BitwardenSlider(
value = 6,
range = 0..10,
onValueChange = { _, _ -> },
)
}
}

View file

@ -2,28 +2,19 @@
package com.x8bit.bitwarden.ui.tools.feature.generator package com.x8bit.bitwarden.ui.tools.feature.generator
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
@ -31,26 +22,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -59,7 +37,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
import com.x8bit.bitwarden.ui.platform.base.util.toDp
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenMediumTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem
@ -73,6 +50,7 @@ import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextFieldWithAc
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.model.TooltipData import com.x8bit.bitwarden.ui.platform.components.model.TooltipData
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.slider.BitwardenSlider
import com.x8bit.bitwarden.ui.platform.components.stepper.BitwardenStepper import com.x8bit.bitwarden.ui.platform.components.stepper.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch
@ -526,11 +504,12 @@ private fun ColumnScope.PasswordTypeContent(
) { ) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
PasswordLengthSliderItem( BitwardenSlider(
length = passwordTypeState.length, value = passwordTypeState.length,
onPasswordSliderLengthChange = passwordHandlers.onPasswordSliderLengthChange, onValueChange = passwordHandlers.onPasswordSliderLengthChange,
minValue = passwordTypeState.minLength, range = passwordTypeState.minLength..passwordTypeState.maxLength,
maxValue = passwordTypeState.maxLength, sliderTag = "PasswordLengthSlider",
valueTag = "PasswordLengthLabel",
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -595,112 +574,6 @@ private fun ColumnScope.PasswordTypeContent(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
private fun PasswordLengthSliderItem(
length: Int,
onPasswordSliderLengthChange: (value: Int, isUserInteracting: Boolean) -> Unit,
minValue: Int,
maxValue: Int,
) {
val sliderValue by rememberUpdatedState(newValue = length.coerceIn(minValue, maxValue))
var labelTextWidth by remember { mutableStateOf(Dp.Unspecified) }
val density = LocalDensity.current
val sliderRange = minValue.toFloat()..maxValue.toFloat()
val lengthLabel: @Composable () -> Unit = remember {
{
Text(
text = stringResource(id = R.string.length),
modifier = Modifier
.onGloballyPositioned { layoutCoordinates ->
if (labelTextWidth == Dp.Unspecified) {
labelTextWidth = layoutCoordinates.size.width.toDp(density)
}
},
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.semantics(mergeDescendants = true) {},
) {
OutlinedTextField(
value = sliderValue.toString(),
readOnly = true,
onValueChange = { },
label = lengthLabel,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.onPreviewKeyEvent { keyEvent ->
when (keyEvent.key) {
Key.DirectionUp -> {
onPasswordSliderLengthChange(sliderValue + 1, true)
true
}
Key.DirectionDown -> {
onPasswordSliderLengthChange(sliderValue - 1, true)
true
}
else -> false
}
}
.testTag("PasswordLengthLabel")
.wrapContentWidth()
// We want the width to be no wider than the label + 16dp on either side
.width(16.dp + labelTextWidth + 16.dp),
)
val colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
disabledActiveTickColor = Color.Transparent,
disabledInactiveTickColor = Color.Transparent,
)
Slider(
value = sliderValue.toFloat(),
onValueChange = { newValue ->
onPasswordSliderLengthChange(newValue.toInt(), true)
},
onValueChangeFinished = {
onPasswordSliderLengthChange(sliderValue, false)
},
valueRange = sliderRange,
steps = maxValue - 1,
colors = colors,
thumb = {
SliderDefaults.Thumb(
interactionSource = remember { MutableInteractionSource() },
colors = colors,
thumbSize = DpSize(width = 20.dp, height = 20.dp),
)
},
track = { sliderState ->
SliderDefaults.Track(
modifier = Modifier.height(height = 4.dp),
drawStopIndicator = null,
colors = colors,
sliderState = sliderState,
thumbTrackGapSize = 0.dp,
)
},
modifier = Modifier
.focusProperties { canFocus = false }
.testTag(tag = "PasswordLengthSlider")
.weight(weight = 1f),
)
}
}
@Composable @Composable
private fun PasswordCapitalLettersToggleItem( private fun PasswordCapitalLettersToggleItem(
useCapitals: Boolean, useCapitals: Boolean,