Раскрывающееся раскрывающееся меню для создания реактивного ранца

Мне было интересно, есть ли решение для раскрывающегося меню Exposed для создания реактивного ранца? Я не смог найти подходящего решения для этого компонента внутри Jetpack Compose. Любая помощь?

7 ответов

В настоящее время 1.0.0-beta04нет встроенного компонента.
Вы можете использовать OutlinedTextField + DropdownMenu.

Это просто базовая (очень простая) реализация:

      var expanded by remember { mutableStateOf(false) }
val suggestions = listOf("Item1","Item2","Item3")
var selectedText by remember { mutableStateOf("") }

val icon = if (expanded)
    Icons.Filled.....
else
    Icons.Filled.ArrowDropDown


Column() {
    OutlinedTextField(
        value = selectedText,
        onValueChange = { selectedText = it },
        modifier = Modifier.fillMaxWidth(),
        label = {Text("Label")},
        trailingIcon = {
            Icon(icon,"contentDescription", Modifier.clickable { expanded = !expanded })
        }
    )
    DropdownMenu(
        expanded = expanded,
        onDismissRequest = { expanded = false },
        modifier = Modifier.fillMaxWidth()
    ) {
        suggestions.forEach { label ->
            DropdownMenuItem(onClick = {
                selectedText = label
            }) {
                Text(text = label)
            }
        }
    }
}

Вот что я сделал, чтобы ширина была такой же, как у текстового поля: Копирование и изменение ответа Габриэле.

      var expanded by remember { mutableStateOf(false) }
val suggestions = listOf("Item1","Item2","Item3")
var selectedText by remember { mutableStateOf("") }

var dropDownWidth by remember { mutableStateOf(0) }

val icon = if (expanded)
    Icons.Filled.....
else
    Icons.Filled.ArrowDropDown


Column() {
    OutlinedTextField(
        value = selectedText,
        onValueChange = { selectedText = it },
        modifier = Modifier.fillMaxWidth()
            .onSizeChanged {
                dropDownWidth = it.width
            },
        label = {Text("Label")},
        trailingIcon = {
            Icon(icon,"contentDescription", Modifier.clickable { expanded = !expanded })
        }
    )
    DropdownMenu(
        expanded = expanded,
        onDismissRequest = { expanded = false },
        modifier = Modifier
                .width(with(LocalDensity.current){dropDownWidth.toDp()})
    ) {
        suggestions.forEach { label ->
            DropdownMenuItem(onClick = {
                selectedText = label
            }) {
                Text(text = label)
            }
        }
    }
}

Вот моя версия. Я добился этого без использования TextField(так что без клавиатуры). Есть «обычная» и «обрисованная» версия.

      @Composable
fun SimpleExposedDropDownMenu(
    value: String,
    onChange: (String) -> Unit,
    label: @Composable () -> Unit,
    possibleValues: List<String>,
    modifier: Modifier,
    backgroundColor: Color = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity),
    shape: Shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
) {
    SimpleExposedDropDownMenuImpl(
        value = value,
        onChange = onChange,
        label = label,
        possibleValues = possibleValues,
        modifier = modifier,
        backgroundColor = backgroundColor,
        shape = shape,
        decorator = { color, width, content ->
            Box(
                Modifier
                    .drawBehind {
                        val strokeWidth = width.value * density
                        val y = size.height - strokeWidth / 2
                        drawLine(
                            color,
                            Offset(0f, y),
                            Offset(size.width, y),
                            strokeWidth
                        )
                    }
            ) {
                content()
            }
        }
    )
}

@Composable
fun SimpleOutlinedExposedDropDownMenu(
    value: String,
    onChange: (String) -> Unit,
    label: @Composable () -> Unit,
    possibleValues: List<String>,
    modifier: Modifier,
    backgroundColor: Color = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity),
    shape: Shape = MaterialTheme.shapes.small
) {
    SimpleExposedDropDownMenuImpl(
        value = value,
        onChange = onChange,
        label = label,
        possibleValues = possibleValues,
        modifier = modifier,
        backgroundColor = backgroundColor,
        shape = shape,
        decorator = { color, width, content ->
            Box(
                Modifier
                    .border(width, color, shape)
            ) {
                content()
            }
        }
    )
}

@Composable
private fun SimpleExposedDropDownMenuImpl(
    value: String,
    onChange: (String) -> Unit,
    label: @Composable () -> Unit,
    possibleValues: List<String>,
    modifier: Modifier,
    backgroundColor: Color,
    shape: Shape,
    decorator: @Composable (Color, Dp, @Composable () -> Unit) -> Unit
) {
    var expanded by remember { mutableStateOf(false) }
    var textfieldSize by remember { mutableStateOf(Size.Zero)}

    val indicatorColor =
        if (expanded) MaterialTheme.colors.primary.copy(alpha = ContentAlpha.high)
        else MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.UnfocusedIndicatorLineOpacity)
    val indicatorWidth = (if (expanded) 2 else 1).dp
    val labelColor =
        if (expanded) MaterialTheme.colors.primary.copy(alpha = ContentAlpha.high)
        else MaterialTheme.colors.onSurface.copy(ContentAlpha.medium)
    val trailingIconColor = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.IconOpacity)

    val rotation: Float by animateFloatAsState(if (expanded) 180f else 0f)

    Column(modifier = modifier.width(IntrinsicSize.Min)) {
        decorator(indicatorColor, indicatorWidth) {
            Box(
                Modifier
                    .fillMaxWidth()
                    .background(color = backgroundColor, shape = shape)
                    .onGloballyPositioned { textfieldSize = it.size.toSize() }
                    .clip(shape)
                    .clickable { expanded = !expanded }
                    .padding(start = 16.dp, end = 12.dp, top = 7.dp, bottom = 10.dp)
            ) {
                Column(Modifier.padding(end = 32.dp)) {
                    ProvideTextStyle(value = MaterialTheme.typography.caption.copy(color = labelColor)) {
                        label()
                    }
                    Text(
                        text = value,
                        modifier = Modifier.padding(top = 1.dp)
                    )
                }
                Icon(
                    imageVector = Icons.Filled.ExpandMore,
                    contentDescription = "Change",
                    tint = trailingIconColor,
                    modifier = Modifier
                        .align(Alignment.CenterEnd)
                        .padding(top = 4.dp)
                        .rotate(rotation)
                )

            }
        }

        DropdownMenu(
            expanded = expanded,
            onDismissRequest = { expanded = false },
            modifier = Modifier
                .width(with(LocalDensity.current) { textfieldSize.width.toDp() })
        ) {
            possibleValues.forEach {
                val scope = rememberCoroutineScope()
                DropdownMenuItem(
                    onClick = {
                        onChange(it)
                        scope.launch {
                            delay(150)
                            expanded = false
                        }
                    }
                ) {
                    Text(it)
                }
            }
        }
    }
}

Если вы используетеmaterial3и более новая версия compose (это работает дляv1.3.1),DropdownMenuItemнемного изменился. Текст теперь должен быть свойством (а не@Composable).

Вам все равно нужно будет подписаться на экспериментальный API,@OptIn(ExperimentalMaterial3Api::class).

Этот пример находится в документации androidx.compose.material3 .

      import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember

val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5")
var expanded by remember { mutableStateOf(false) }
var selectedOptionText by remember { mutableStateOf(options[0]) }
// We want to react on tap/press on TextField to show menu
ExposedDropdownMenuBox(
    expanded = expanded,
    onExpandedChange = { expanded = !expanded },
) {
    TextField(
        // The `menuAnchor` modifier must be passed to the text field for correctness.
        modifier = Modifier.menuAnchor(),
        readOnly = true,
        value = selectedOptionText,
        onValueChange = {},
        label = { Text("Label") },
        trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
        colors = ExposedDropdownMenuDefaults.textFieldColors(),
    )
    ExposedDropdownMenu(
        expanded = expanded,
        onDismissRequest = { expanded = false },
    ) {
        options.forEach { selectionOption ->
            DropdownMenuItem(
                text = { Text(selectionOption) },
                onClick = {
                    selectedOptionText = selectionOption
                    expanded = false
                },
                contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
            )
        }
    }
}

Делая это «по-старому», у меня были следующие ошибки наText(text = selectionOption)линия:

  • No value passed for parameter 'text'
  • Type mismatch: inferred type is () -> Unit but MutableInteractionSource was expected
  • @Composable invocations can only happen from the context of a @Composable function

Несколько изменений в ответе @Gabriele Mariotti. Пользователь может выбрать текстовое поле схемы и выбрать один из вариантов. Опция исчезнет, ​​как только пользователь выберет любую опцию.

          @Composable
fun DropDownMenu(optionList: List<String>,label:String,) {
    var expanded by remember { mutableStateOf(false) }

    var selectedText by remember { mutableStateOf("") }

    var textfieldSize by remember { mutableStateOf(Size.Zero) }

    val icon = if (expanded)
        Icons.Filled.KeyboardArrowUp
    else
        Icons.Filled.KeyboardArrowDown


    Column() {
        OutlinedTextField(
            value = selectedText,
            onValueChange = { selectedText = it },
            enabled = false,
            modifier = Modifier
                .fillMaxWidth()
                .onGloballyPositioned { coordinates ->
                    //This value is used to assign to the DropDown the same width
                    textfieldSize = coordinates.size.toSize()
                }
                .clickable { expanded = !expanded },
            label = { Text(label) },
            trailingIcon = {
                Icon(icon, "Drop Down Icon",
                    Modifier.clickable { expanded = !expanded })
            }
        )
        DropdownMenu(
            expanded = expanded,
            onDismissRequest = { expanded = false },
            modifier = Modifier
                .width(with(LocalDensity.current) { textfieldSize.width.toDp() })
        ) {
            optionList.forEach { label ->
                DropdownMenuItem(onClick = {
                    selectedText = label
                    expanded = !expanded
                }) {
                    Text(text = label)
                }
            }
        }
    }
}

Другие ответы основаны на выборе строк. Однако мне нужен был раскрывающийся список, чтобы выбрать модель из списка и разрешить выбор только модели. Я также хотел, чтобы раскрывающийся список сразу отображался со всеми опциями.

Вот решение для выбора модели, позволяющее автоматически заполнять поиск по моделям. Решение также работает для простых строк.

Применение

      data class PreviewOption(val text: String, val id: Int)

val options = remember {
    listOf(
        PreviewOption("Option 1", 1),
        PreviewOption("Option 2", 2),
        PreviewOption("Option 3", 3),
        PreviewOption("Option 4", 4),
        PreviewOption("Option 5", 5),
    )
}

var selectedOption by remember { mutableStateOf<PreviewOption?>(null) }

TextFieldMenu(
    label = "Options",
    options = options,
    selectedOption = selectedOption,
    onOptionSelected = { selectedOption = it },
    optionToString = { it.text },
    filteredOptions = { searchInput ->
        options.filter { it.text.contains(searchInput, ignoreCase = true) }
    },
)

Полная реализация M3: TextFieldMenu.kt

      import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

/** A text field that allows the user to type in to filter down options. */
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class,
    ExperimentalMaterial3Api::class
)
@Composable
fun <T> TextFieldMenu(
    modifier: Modifier = Modifier,
    /** The label for the text field */
    label: String,
    /** All the available options. */
    options: List<T>,
    /** The selected option. */
    selectedOption: T?,
    /** When the option is selected via tapping on the dropdown option or typing in the option. */
    onOptionSelected: (T?) -> Unit,
    /** Converts [T] to a string for populating the initial text field value. */
    optionToString: (T) -> String,
    /** Returns the filtered options based on the input. This where you need to implement your search. */
    filteredOptions: (searchInput: String) -> List<T>,
    /** Creates the row for the filtered down option in the menu. */
    optionToDropdownRow: @Composable (T) -> Unit = { option ->
        Text(optionToString(option))
    },
    /** Creates the view when [filteredOptions] returns a empty list. */
    noResultsRow: @Composable () -> Unit = {
        // By default, wrap in a menu item to get the same style
        DropdownMenuItem(
            onClick = {},
            text = {
                Text(
                    "No Matches Found",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.secondary,
                    fontStyle = FontStyle.Italic,
                )
            },
        )
    },
    focusRequester: FocusRequester = remember { FocusRequester() },
    keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
    trailingIcon: @Composable (expanded: Boolean) -> Unit = { expanded ->
        ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
    },
    textFieldColors: TextFieldColors = ExposedDropdownMenuDefaults.textFieldColors(
        containerColor = Color.Transparent,
    ),
    bringIntoViewRequester: BringIntoViewRequester = remember { BringIntoViewRequester() },
    coroutineScope: CoroutineScope = rememberCoroutineScope(),
) {
    // Get our text for the selected option
    val selectedOptionText = remember(selectedOption) {
        selectedOption?.let { optionToString(it) }.orEmpty()
    }

    // Default our text input to the selected option
    var textInput by remember(selectedOptionText) {
        mutableStateOf(selectedOptionText)
    }

    var dropDownExpanded by remember { mutableStateOf(false) }

    // Update our filtered options everytime our text input changes
    val filteredOptions = remember(textInput, dropDownExpanded) {
        when (dropDownExpanded) {
            true -> filteredOptions(textInput)
            // Skip filtering when we don't need to
            false -> emptyList()
        }
    }

    val keyboardController = LocalSoftwareKeyboardController.current
    val focusManager = LocalFocusManager.current

    ExposedDropdownMenuBox(
        expanded = dropDownExpanded,
        onExpandedChange = { dropDownExpanded = !dropDownExpanded },
        modifier = modifier,
    ) {
        // Text Input
        OutlinedTextField(
            value = textInput,
            onValueChange = {
                // Dropdown may auto hide for scrolling but it's important it always shows when a user
                // does a search
                dropDownExpanded = true
                textInput = it
            },
            modifier = Modifier
                // Match the parent width
                .fillMaxWidth()
                .bringIntoViewRequester(bringIntoViewRequester)
                .menuAnchor()
                .focusRequester(focusRequester)
                .onFocusChanged { focusState ->
                    // When only 1 option left when we lose focus, selected it.
                    if (!focusState.isFocused) {
                        // Whenever we lose focus, always hide the dropdown
                        dropDownExpanded = false

                        when (filteredOptions.size) {
                            // Auto select the single option
                            1 -> if (filteredOptions.first() != selectedOption) {
                                onOptionSelected(filteredOptions.first())
                            }
                            // Nothing to we can auto select - reset our text input to the selected value
                            else -> textInput = selectedOptionText
                        }
                    } else {
                        // When focused:
                        // Ensure field is visible by scrolling to it
                        coroutineScope.launch {
                            bringIntoViewRequester.bringIntoView()
                        }
                        // Show the dropdown right away
                        dropDownExpanded = true
                    }
                },
            label = { Text(label) },
            trailingIcon = { trailingIcon(dropDownExpanded) },
            colors = textFieldColors,
            keyboardOptions = keyboardOptions.copy(
                imeAction = when (filteredOptions.size) {
                    // We will either reset input or auto select the single option
                    0, 1 -> ImeAction.Done
                    // Keyboard will hide to make room for search results
                    else -> ImeAction.Search
                }
            ),
            keyboardActions = KeyboardActions(
                onAny = {
                    when (filteredOptions.size) {
                        // Remove focus to execute our onFocusChanged effect
                        0, 1 -> focusManager.clearFocus(force = true)
                        // Can't auto select option since we have a list, so hide keyboard to give more room for dropdown
                        else -> keyboardController?.hide()
                    }
                }
            )
        )

        // Dropdown
        if (dropDownExpanded) {
            val dropdownOptions = remember(textInput) {
                if (textInput.isEmpty()) {
                    // Show all options if nothing to filter yet
                    options
                } else {
                    filteredOptions(textInput)
                }
            }

            ExposedDropdownMenu(
                expanded = dropDownExpanded,
                onDismissRequest = { dropDownExpanded = false },
            ) {
                if (dropdownOptions.isEmpty()) {
                    noResultsRow()
                } else {
                    dropdownOptions.forEach { option ->
                        DropdownMenuItem(
                            onClick = {
                                dropDownExpanded = false
                                onOptionSelected(option)
                                focusManager.clearFocus(force = true)
                            },
                            text = {
                                optionToDropdownRow(option)
                            }
                        )
                    }
                }
            }
        }
    }
}

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Preview(showSystemUi = true)
@Composable
private fun PreviewTextFieldMenu() {
    data class PreviewOption(val text: String, val id: Int)

    var selectedOption by remember { mutableStateOf<PreviewOption?>(null) }
    val options = remember {
        listOf(
            PreviewOption("Option 1", 1),
            PreviewOption("Option 2", 2),
            PreviewOption("Option 3", 3),
            PreviewOption("Option 4", 4),
            PreviewOption("Option 5", 5),
        )
    }

    Column(
        modifier = Modifier
            // Reduce column height when keyboard is shown
            // Note: This needs to be set _before_ verticalScroll so that BringIntoViewRequester APIs work
            .imePadding()
            .verticalScroll(rememberScrollState())
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {

        val nameFocusRequester = remember { FocusRequester() }
        val optionsFocusRequester = remember { FocusRequester() }

        var nameInput by remember { mutableStateOf("") }

        // Free Style Input
        OutlinedTextField(
            modifier = Modifier
                .focusRequester(nameFocusRequester)
                .fillMaxWidth(),
            label = {
                Text(
                    text = "Name",
                    overflow = TextOverflow.Ellipsis,
                    maxLines = 1,
                )
            },
            value = nameInput,
            onValueChange = { nameInput = it },
            singleLine = true,
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
            keyboardActions = KeyboardActions(
                onNext = { optionsFocusRequester.requestFocus() },
            ),
        )

        TextFieldMenu(
            modifier = Modifier.fillMaxWidth(),
            label = "Options",
            options = options,
            selectedOption = selectedOption,
            onOptionSelected = { selectedOption = it },
            optionToString = { it.text },
            filteredOptions = { searchInput ->
                options.filter { it.text.contains(searchInput, ignoreCase = true) }
            },
            focusRequester = optionsFocusRequester,
        )
    }
}

В дополнение к тому, что было написано здесь, мой случай может быть полезен кому-то, и для моей личной заметки для следующего использования я реализовал этот компонент функции раскрывающегося меню, используя BasicTextField без украшения и без заполнения по умолчанию, без значка стрелки , с выделенным элементом текстом, выровненным по правому краю (.End), с заполнением максимальной ширины текста (.fillMaxWidth()) одной строкой в ​​списке.

      data class DropDownMenuParameter(
        var options: List<String>,
        var expanded: Boolean,
        var selectedOptionText: String,
        var backgroundColor: Color
    )




@ExperimentalMaterialApi
@Composable
fun DropDownMenuComponent(params: DropDownMenuParameter) {
    var expanded by remember { mutableStateOf(params.expanded) }
    var selectedOptionText by remember { mutableStateOf(params.selectedOptionText) }

    ExposedDropdownMenuBox(
        expanded = expanded,
        onExpandedChange = {
            expanded = !expanded
        }
    ) {
        BasicTextField(
            modifier = Modifier
                .background(params.backgroundColor)
                .fillMaxWidth(),
            readOnly = true,
            value = selectedOptionText,
            onValueChange = { },
            textStyle = TextStyle(
                color = Color.White,
                textAlign = TextAlign.End,
                fontSize = 16.sp,
            ),
            singleLine = true

        )
        ExposedDropdownMenu(
            modifier = Modifier
                .background(params.backgroundColor),
            expanded = expanded,
            onDismissRequest = {
                expanded = false
            }
        ) {
            params.options.forEach { selectionOption ->
                DropdownMenuItem(
                    modifier = Modifier
                        .background(params.backgroundColor),
                    onClick = {
                        selectedOptionText = selectionOption
                        expanded = false
                    },

                    ) {
                    Text(
                        text = selectionOption,
                        color = Color.White,
                    )

                }
            }
        }
    }

}

Мое использование:

      @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class)
@Composable
fun SubscribeSubscriptionDetails(selectedSubscription : Subscription){

    
    
    val categoryOptions = listOf("Entertainment", "Gaming", "Business", "Utility", "Music", "Food & Drink", "Health & Fitness", "Bank", "Transport", "Education", "Insurance", "News")
    val expanded by remember { mutableStateOf(false) }
    val selectedOptionText by remember { mutableStateOf(selectedSubscription.category) }

    // ....


    Row { // categoria

            Text(
                modifier = Modifier
                    .padding(textMargin_24, 0.dp, 0.dp, 0.dp)
                    .weight(0.5f),
                text = "Categoria",
                fontWeight = FontWeight.Bold,
                color = Color.White,
                textAlign = TextAlign.Left,
                fontSize = 16.sp,
                )


            Row(
                modifier = Modifier
                    .padding(0.dp, 0.dp, 24.dp, 0.dp)
                    .weight(0.5f),
                horizontalArrangement = Arrangement.End
            ){
                DropDownMenuComponent(
                    DropDownMenuParameter(
                        options = categoryOptions,
                        expanded = expanded,
                        selectedOptionText = selectedOptionText,
                        backgroundColor = Color.Red
                    )
                )
            }


        }


    // .....


}