Android Jetpack Compose NumberPicker Widget 等效项

问题描述 投票:0回答:5

在 Jetpack Compose 中创建 NumberPicker 小部件的推荐解决方案是什么?类似于下图。我可以在可组合项中使用 AndroidView 创建 NumberPicker,但该视图似乎不允许快速滑动或捕捉到位置。顺便说一句,下面的 UI 显示了连续放置的三个 NumberPicker。它不应该代表 DatePicker

kotlin android-jetpack android-jetpack-compose numberpicker android-number-picker
5个回答
18
投票

我在 Jetpack Compose 中实现了 NumberPicker(不使用 AndroidView):https://gist.github.com/nhcodes/dc68c65ee586628fda5700911e44543f

选择器.kt

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Picker(
    items: List<String>,
    state: PickerState = rememberPickerState(),
    modifier: Modifier = Modifier,
    startIndex: Int = 0,
    visibleItemsCount: Int = 3,
    textModifier: Modifier = Modifier,
    textStyle: TextStyle = LocalTextStyle.current,
    dividerColor: Color = LocalContentColor.current,
) {

    val visibleItemsMiddle = visibleItemsCount / 2
    val listScrollCount = Integer.MAX_VALUE
    val listScrollMiddle = listScrollCount / 2
    val listStartIndex = listScrollMiddle - listScrollMiddle % items.size - visibleItemsMiddle + startIndex

    fun getItem(index: Int) = items[index % items.size]

    val listState = rememberLazyListState(initialFirstVisibleItemIndex = listStartIndex)
    val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState)

    val itemHeightPixels = remember { mutableStateOf(0) }
    val itemHeightDp = pixelsToDp(itemHeightPixels.value)

    val fadingEdgeGradient = remember {
        Brush.verticalGradient(
            0f to Color.Transparent,
            0.5f to Color.Black,
            1f to Color.Transparent
        )
    }

    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .map { index -> getItem(index + visibleItemsMiddle) }
            .distinctUntilChanged()
            .collect { item -> state.selectedItem = item }
    }

    Box(modifier = modifier) {

        LazyColumn(
            state = listState,
            flingBehavior = flingBehavior,
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .fillMaxWidth()
                .height(itemHeightDp * visibleItemsCount)
                .fadingEdge(fadingEdgeGradient)
        ) {
            items(listScrollCount) { index ->
                Text(
                    text = getItem(index),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                    style = textStyle,
                    modifier = Modifier
                        .onSizeChanged { size -> itemHeightPixels.value = size.height }
                        .then(textModifier)
                )
            }
        }

        Divider(
            color = dividerColor,
            modifier = Modifier.offset(y = itemHeightDp * visibleItemsMiddle)
        )

        Divider(
            color = dividerColor,
            modifier = Modifier.offset(y = itemHeightDp * (visibleItemsMiddle + 1))
        )

    }

}

private fun Modifier.fadingEdge(brush: Brush) = this
    .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
    .drawWithContent {
        drawContent()
        drawRect(brush = brush, blendMode = BlendMode.DstIn)
    }

@Composable
private fun pixelsToDp(pixels: Int) = with(LocalDensity.current) { pixels.toDp() }

PickerState.kt

@Composable
fun rememberPickerState() = remember { PickerState() }

class PickerState {
    var selectedItem by mutableStateOf("")
}

PickerExample.kt

@Composable
fun PickerExample() {
    Surface(modifier = Modifier.fillMaxSize()) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
            modifier = Modifier.fillMaxSize()
        ) {

            val values = remember { (1..99).map { it.toString() } }
            val valuesPickerState = rememberPickerState()
            val units = remember { listOf("seconds", "minutes", "hours") }
            val unitsPickerState = rememberPickerState()

            Text(text = "Example Picker", modifier = Modifier.padding(top = 16.dp))
            Row(modifier = Modifier.fillMaxWidth()) {
                Picker(
                    state = valuesPickerState,
                    items = values,
                    visibleItemsCount = 3,
                    modifier = Modifier.weight(0.3f),
                    textModifier = Modifier.padding(8.dp),
                    textStyle = TextStyle(fontSize = 32.sp)
                )
                Picker(
                    state = unitsPickerState,
                    items = units,
                    visibleItemsCount = 3,
                    modifier = Modifier.weight(0.7f),
                    textModifier = Modifier.padding(8.dp),
                    textStyle = TextStyle(fontSize = 32.sp)
                )
            }

            Text(
                text = "Interval: ${valuesPickerState.selectedItem} ${unitsPickerState.selectedItem}",
                modifier = Modifier.padding(vertical = 16.dp)
            )

        }
    }
}

预览:


13
投票

巧合的是,我上周实现了这样的屏幕。 我无法在这里分享整个代码,但基本上我所做的是:

  1. 使用
    DatePicker
    (res/layout/date_picker.xml) 创建布局。
<DatePicker xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/datePicker"
    android:theme="@style/DatePickerStyle"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:calendarViewShown="false"
    android:datePickerMode="spinner" />
  1. 然后,在您的可组合函数中使用它。
@Composable
fun DatePicker(
    onDateSelected: (Date) -> Unit
) {
    AndroidView(
        modifier = Modifier.fillMaxWidth(),
        factory = { context ->
            val view = LayoutInflater.from(context).inflate(R.layout.date_picker, null)
            val datePicker = view.findViewById<DatePicker>(R.id.datePicker)
            val calendar = Calendar.getInstance() // show today by default
            datePicker.init(
                calendar.get(Calendar.YEAR),
                calendar.get(Calendar.MONTH),
                calendar.get(Calendar.DAY_OF_MONTH)
            ) { _, year, monthOfYear, dayOfMonth ->
                val date = Calendar.getInstance().apply {
                    set(year, monthOfYear, dayOfMonth)
                }.time
                onSelectedDateUpdate(date)
            }
            datePicker
        }
    )
}
  1. 最后,在
    ModalBottomSheetLayout
  2. 中使用它

编辑我的答案...使用

NumberPicker
也有效...

AndroidView(
    modifier = Modifier.fillMaxWidth(),
    factory = { context ->
        NumberPicker(context).apply {
            setOnValueChangedListener { numberPicker, i, i2 ->  }
            minValue = 0
            maxValue = 50
        }
    }
)

这是结果。


4
投票

我知道也许您并不是在寻找这样的东西。但由于 compose 中还没有这样的小部件,而 compose 的目的就是让您更轻松地构建自己的组件。因此,除了 android.widget NumberPicker 之外,您还可以制作类似这样的东西。您可以像 NumberPicker 小部件一样更改可视化效果,并添加回调和其他内容。

你在github上检查过这个吗? ComposeNumberPicker.Kt


4
投票

我们在我们的数字选择器小部件的撰写项目中使用这个库。 https://github.com/ChargeMap/Compose-NumberPicker


0
投票

列表选择器:

package com.inidamleader.ovtracker.util.compose

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.inidamleader.ovtracker.layer.ui.theme.OvTrackerTheme
import com.inidamleader.ovtracker.util.compose.geometry.toDp
import kotlinx.coroutines.flow.collectLatest
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <E> ListPicker(
    initialValue: E,
    list: ImmutableWrapper<List<E>>,
    modifier: Modifier = Modifier,
    wrapSelectorWheel: Boolean = false,
    format: E.() -> String = { this.toString() },
    onValueChange: (E) -> Unit,
    beyondBoundsPageCount: Int = 1,
    textStyle: TextStyle = LocalTextStyle.current,
    verticalPadding: Dp = 16.dp,
    dividerColor: Color = MaterialTheme.colorScheme.outline,
    dividerThickness: Dp = 1.dp
) {
    val listSize = list.value.size
    val coercedBeyondBoundsPageCount = beyondBoundsPageCount.coerceIn(0..listSize / 2)
    val indexOfInitialValue = remember(list) {
        list.value.indexOf(initialValue).takeIf { it != -1 } ?: 0
    }
    val visibleItemsCount = 1 + coercedBeyondBoundsPageCount * 2

    val iteration =
        if (wrapSelectorWheel)
            remember(coercedBeyondBoundsPageCount, listSize) {
                (Int.MAX_VALUE - 2 * coercedBeyondBoundsPageCount) / listSize
            }
        else 1

    val intervals = remember(coercedBeyondBoundsPageCount, iteration, listSize) {
        listOf(
            0,
            coercedBeyondBoundsPageCount,
            coercedBeyondBoundsPageCount + iteration * listSize,
            coercedBeyondBoundsPageCount + iteration * listSize + coercedBeyondBoundsPageCount,
        )
    }

    val initialFirstVisibleItemIndex = remember(indexOfInitialValue, listSize, iteration) {
        indexOfInitialValue + (listSize * (iteration / 2))
    }

    val state = rememberLazyListState(initialFirstVisibleItemIndex = initialFirstVisibleItemIndex)
    LaunchedEffect(state) {
        snapshotFlow { state.firstVisibleItemIndex }
            .collectLatest {
                onValueChange(list.value[it % listSize])
            }
    }

    var itemHeight by remember { mutableStateOf(0.dp) }
    val density = LocalDensity.current
    val onSizeChanged = { intSize: IntSize ->
        itemHeight = density.toDp(intSize.height)
    }
    Box(modifier = modifier) {
        LazyColumn(
            state = state,
            flingBehavior = rememberSnapFlingBehavior(lazyListState = state),
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .fillMaxWidth()
                .height(itemHeight * visibleItemsCount)
                .fadingEdge(
                    brush = remember {
                        Brush.verticalGradient(
                            0f to Color.Transparent,
                            0.5f to Color.Black,
                            1f to Color.Transparent
                        )
                    },
                )
        ) {
            items(
                count = intervals.last(),
                key = { it },
            ) { index ->
                when (index) {
                    in intervals[0]..<intervals[1] -> Text(
                        text = if (wrapSelectorWheel) list.value[(index - coercedBeyondBoundsPageCount + listSize) % listSize].format() else "",
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        style = textStyle,
                        modifier = Modifier
                            .onSizeChanged(onSizeChanged)
                            .padding(vertical = verticalPadding)
                    )

                    in intervals[1]..<intervals[2] -> Text(
                        text = list.value[(index - coercedBeyondBoundsPageCount) % listSize].format(),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        style = textStyle,
                        modifier = Modifier
                            .onSizeChanged(onSizeChanged)
                            .padding(vertical = verticalPadding)
                    )

                    in intervals[2]..<intervals[3] -> Text(
                        text = if (wrapSelectorWheel) list.value[(index - coercedBeyondBoundsPageCount) % listSize].format() else "",
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        style = textStyle,
                        modifier = Modifier
                            .onSizeChanged(onSizeChanged)
                            .padding(vertical = verticalPadding),
                    )
                }
            }
        }

        HorizontalDivider(
            modifier = Modifier.offset(y = itemHeight * coercedBeyondBoundsPageCount - dividerThickness / 2),
            thickness = dividerThickness,
            color = dividerColor,
        )

        HorizontalDivider(
            modifier = Modifier.offset(y = itemHeight * (coercedBeyondBoundsPageCount + 1) - dividerThickness / 2),
            thickness = dividerThickness,
            color = dividerColor,
        )
    }
}

@Preview(widthDp = 100)
@Composable
fun PreviewListPicker1() {
    OvTrackerTheme {
        Surface {
            var value by remember { mutableStateOf("5") }
            val values = remember { (1..10).map { it.toString() } }
            ListPicker(
                initialValue = value,
                list = values.toImmutableWrapper(),
                modifier = Modifier,
                onValueChange = {
                    value = it
                },
                textStyle = TextStyle(fontSize = 32.sp),
                verticalPadding = 8.dp,
            )
        }
    }
}

@Preview(widthDp = 300)
@Composable
fun PreviewListPicker2() {
    OvTrackerTheme {
        Surface(color = MaterialTheme.colorScheme.primary) {
            var value by remember { mutableStateOf(LocalDate.now()) }
            val list = remember {
                buildList {
                    repeat(10) {
                        add(LocalDate.now().minusDays((it - 5).toLong()))
                    }
                }
            }
            ListPicker(
                initialValue = value,
                list = list.toImmutableWrapper(),
                modifier = Modifier,
                format = {
                    format(
                        DateTimeFormatter
                            .ofLocalizedDate(FormatStyle.MEDIUM)
                            .withLocale(Locale.getDefault()),
                    )
                },
                wrapSelectorWheel = true,
                onValueChange = {
                    value = it
                },
                textStyle = TextStyle(fontSize = 32.sp),
                verticalPadding = 8.dp,
            )
        }
    }
}

@Preview(widthDp = 100)
@Composable
fun PreviewListPicker3() {
    OvTrackerTheme {
        Surface(color = MaterialTheme.colorScheme.tertiary) {
            var value by remember { mutableStateOf("5") }
            val list = remember { (1..10).map { it.toString() } }
            ListPicker(
                initialValue = value,
                list = list.toImmutableWrapper(),
                modifier = Modifier,
                onValueChange = {
                    value = it
                },
                beyondBoundsPageCount = 2,
                textStyle = TextStyle(fontSize = 32.sp),
                verticalPadding = 8.dp,
            )
        }
    }
}

DensityExt.kt:

package com.inidamleader.ovtracker.util.compose.geometry

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isSpecified

// DP
fun Density.toSp(dp: Dp): TextUnit = dp.toSp()
fun Density.toPx(dp: Dp): Float = dp.toPx()
fun Density.roundToPx(dp: Dp): Int = dp.roundToPx()

// TEXT UNIT
fun Density.toDp(sp: TextUnit): Dp = sp.toDp()
fun Density.toPx(sp: TextUnit): Float = sp.toPx()
fun Density.roundToPx(sp: TextUnit): Int = sp.roundToPx()

// FLOAT
fun Density.toDp(px: Float): Dp = px.toDp()
fun Density.toSp(px: Float): TextUnit = px.toSp()

// INT
fun Density.toDp(px: Int): Dp = px.toDp()
fun Density.toSp(px: Int): TextUnit = px.toSp()

// SIZE
fun Density.toIntSize(dpSize: DpSize): IntSize =
    IntSize(dpSize.width.roundToPx(), dpSize.height.roundToPx())

fun Density.toSize(dpSize: DpSize): Size =
    if (dpSize.isSpecified) Size(dpSize.width.toPx(), dpSize.height.toPx())
    else Size.Unspecified

fun Density.toDpSize(size: Size): DpSize =
    if (size.isSpecified) DpSize(size.width.toDp(), size.height.toDp())
    else DpSize.Unspecified

fun Density.toDpSize(intSize: IntSize): DpSize =
    DpSize(intSize.width.toDp(), intSize.height.toDp())

// OFFSET
fun Density.toIntOffset(dpOffset: DpOffset): IntOffset =
    IntOffset(dpOffset.x.roundToPx(), dpOffset.y.roundToPx())

fun Density.toOffset(dpOffset: DpOffset): Offset =
    if (dpOffset.isSpecified) Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
    else Offset.Unspecified

fun Density.toDpOffset(offset: Offset): DpOffset =
    if (offset.isSpecified) DpOffset(offset.x.toDp(), offset.y.toDp())
    else DpOffset.Unspecified

fun Density.toDpOffset(intOffset: IntOffset): DpOffset =
    DpOffset(intOffset.x.toDp(), intOffset.y.toDp())

ImmutableWrapper.kt:

package com.inidamleader.ovtracker.util.compose

import androidx.compose.runtime.Immutable
import kotlin.reflect.KProperty

@Immutable
data class ImmutableWrapper<T>(val value: T)

fun <T> T.toImmutableWrapper() = ImmutableWrapper(this)

operator fun <T> ImmutableWrapper<T>.getValue(thisRef: Any?, property: KProperty<*>) = value
© www.soinside.com 2019 - 2024. All rights reserved.