Skip to content

kez-lab/Compose-DateTimePicker

Repository files navigation

Compose DateTimePicker

Read in Korean

A generic, customizable, and multiplatform date and time picker library for Compose Multiplatform. It provides consistent UI components across Android, iOS, Desktop (JVM), and Web (Wasm).

Features

  • Multiplatform Support: seamless integration for Android, iOS, Desktop (JVM), and Web (Wasm).
  • TimePicker: Supports both 12-hour (AM/PM) and 24-hour formats.
  • DatePicker: A complete date picker for selecting year, month, and day with automatic day validation.
  • YearMonthPicker: A dedicated component for selecting years and months.
  • Customizable: Extensible API with PickerStyle and display options for reusable UI configuration.
  • State Management: simplified state handling with rememberTimePickerState, rememberDatePickerState, and rememberYearMonthPickerState.
  • Accessibility: Built with accessibility in mind, supporting screen readers and navigation.

Sample App

The repository includes a Compose Multiplatform sample app with copyable date, time, and bottom sheet flows.

Sample app home screen DatePicker sample screen TimePicker sample screen Bottom sheet picker sample screen

Installation

Add the dependency to your version catalog or build file.

Version Catalog (libs.versions.toml)

[versions]
composeDateTimePicker = "0.4.0"

[libraries]
compose-date-time-picker = { module = "io.github.kez-lab:compose-date-time-picker", version.ref = "composeDateTimePicker" }

Gradle (build.gradle.kts)

dependencies {
    implementation("io.github.kez-lab:compose-date-time-picker:0.4.0")
}

Release status: 0.4.0 is the latest public Maven Central/GitHub Releases version. This README is maintained from main and documents unreleased 0.6.0 API work, so the Usage and API Reference sections may include APIs that are not available in 0.4.0. For published APIs, use the 0.4.0 release/tag docs. To test main locally, run ./gradlew :datetimepicker:publishToMavenLocal, add mavenLocal() to your consuming build, and depend on 0.6.0.

For release notes and upgrade-impact details, see CHANGELOG.md.

Usage

The examples below target the current main branch API. They may require unreleased 0.6.0 APIs rather than the public 0.4.0 dependency shown above.

TimePicker

Use TimePicker for time selection. It supports both 12-hour and 24-hour formats.

1. 24-Hour Format

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.kez.picker.time.TimePicker
import com.kez.picker.time.rememberTimePickerState
import com.kez.picker.util.TimeFormat
import com.kez.picker.util.currentDateTime

@Composable
fun TimePicker24hExample() {
    val initialTime = remember { currentDateTime().time }
    val state = rememberTimePickerState(
        initialTime = initialTime,
        timeFormat = TimeFormat.HOUR_24
    )

    TimePicker(
        state = state,
        onSelectedTimeChange = { selectedTime ->
            // Update app state, ViewModel, or form data here.
        }
    )

    // Use state.selectedTime when passing the result to app logic.
}

2. 12-Hour Format (AM/PM)

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.kez.picker.time.TimePicker
import com.kez.picker.time.rememberTimePickerState
import com.kez.picker.util.TimeFormat
import com.kez.picker.util.currentDateTime

@Composable
fun TimePicker12hExample() {
    // Handling of 12-hour format conversion is now done internally by the state
    val initialTime = remember { currentDateTime().time }
    val state = rememberTimePickerState(
        initialTime = initialTime,
        timeFormat = TimeFormat.HOUR_12
    )

    TimePicker(
        state = state
    )

    // state.selectedTime is always a kotlinx.datetime.LocalTime.
}

3. Constrained Hours

Use PickerDefaults.timePickerItems(minTime = ..., maxTime = ...) when a form should only allow an inclusive time range. Create state with the same items object so restored or preset values are coerced before the picker renders.

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.kez.picker.PickerDefaults
import com.kez.picker.time.TimePicker
import com.kez.picker.time.rememberTimePickerState
import kotlinx.datetime.LocalTime

@Composable
fun BusinessHoursTimePickerExample() {
    val items = remember {
        PickerDefaults.timePickerItems(
            minuteItems = listOf(0, 15, 30, 45),
            minTime = LocalTime(8, 0),
            maxTime = LocalTime(18, 0)
        )
    }
    val state = rememberTimePickerState(
        items = items,
        initialTime = LocalTime(7, 30)
    )

    TimePicker(
        state = state,
        items = items
    )
}

DatePicker

Use DatePicker for selecting a complete date (year, month, and day). The component automatically adjusts the day when the selected month changes (e.g., Feb 30 → Feb 28).

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.kez.picker.PickerDefaults
import com.kez.picker.date.DatePicker
import com.kez.picker.date.rememberDatePickerState
import com.kez.picker.util.currentDate
import kotlinx.datetime.LocalDate

@Composable
fun DatePickerExample() {
    val initialDate = remember { currentDate() }
    val minDate = remember(initialDate.year) {
        LocalDate(initialDate.year, 1, 1)
    }
    val maxDate = remember(initialDate.year) {
        LocalDate(initialDate.year + 1, 12, 31)
    }
    val selectableYears = remember(minDate.year, maxDate.year) {
        (minDate.year..maxDate.year).toList()
    }
    val selectableDays = remember(initialDate.day) {
        listOf(1, 15, initialDate.day).distinct().sorted()
    }
    val items = remember(selectableYears, selectableDays, minDate, maxDate) {
        PickerDefaults.datePickerItems(
            yearItems = selectableYears,
            dayItems = selectableDays,
            minDate = minDate,
            maxDate = maxDate
        )
    }
    val state = rememberDatePickerState(
        items = items,
        initialDate = initialDate
    )

    DatePicker(
        state = state,
        onSelectedDateChange = { selectedDate ->
            // Update app state, ViewModel, or form data here.
        },
        items = items
    )

    // Use state.selectedDate when passing the result to app logic.
}

When you restrict selectable item lists or date bounds with PickerDefaults.*Items(...), keep the remembered initial or restored state value inside those rules, or create state with rememberDatePickerState(items = items, initialDate = value) to coerce it before first composition. If an external date changes after composition, call state.selectDate(newDate, items) instead of relying on a new initialDate argument.

YearMonthPicker

Use YearMonthPicker for selecting a specific month in a year.

import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.kez.picker.PickerDefaults
import com.kez.picker.date.YearMonth
import com.kez.picker.date.YearMonthPicker
import com.kez.picker.date.rememberYearMonthPickerState
import com.kez.picker.util.currentDate

@Composable
fun YearMonthPickerExample() {
    val initialDate = remember { currentDate() }
    val minYearMonth = remember { YearMonth.from(initialDate) }
    val maxYearMonth = remember {
        YearMonth(year = initialDate.year + 1, month = initialDate.month.number)
    }
    val items = remember {
        PickerDefaults.yearMonthPickerItems(
            minYearMonth = minYearMonth,
            maxYearMonth = maxYearMonth
        )
    }
    val state = rememberYearMonthPickerState(
        items = items,
        initialDate = initialDate
    )

    YearMonthPicker(
        state = state,
        items = items,
        onSelectedYearMonthChange = { selectedYearMonth: YearMonth ->
            // Update app state, ViewModel, or form data here.
        }
    )

    // state.selectedYearMonth is YearMonth(year, month).
    // state.selectedMonthDate is still available for LocalDate interoperability.
}

BottomSheet Integration

The pickers work seamlessly within a ModalBottomSheet or other dialog components. Keep the committed value separate from the temporary sheet state so dismissing the sheet does not accidentally change app state.

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.kez.picker.time.rememberTimePickerState
import com.kez.picker.time.TimePicker
import kotlinx.datetime.LocalTime

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomSheetPickerExample() {
    var committedHour by rememberSaveable { mutableIntStateOf(9) }
    var committedMinute by rememberSaveable { mutableIntStateOf(30) }
    var showBottomSheet by remember { mutableStateOf(false) }
    val sheetState = rememberModalBottomSheetState()
    val committedTime = LocalTime(committedHour, committedMinute)

    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Text("Selected time: $committedTime")

        Button(onClick = { showBottomSheet = true }) {
            Text("Select Time")
        }
    }

    if (showBottomSheet) {
        val draftState = rememberTimePickerState(initialTime = committedTime)

        ModalBottomSheet(
            onDismissRequest = { showBottomSheet = false },
            sheetState = sheetState
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(24.dp),
                verticalArrangement = Arrangement.spacedBy(16.dp)
            ) {
                TimePicker(state = draftState)

                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    OutlinedButton(
                        onClick = { showBottomSheet = false },
                        modifier = Modifier.weight(1f)
                    ) {
                        Text("Cancel")
                    }

                    Button(
                        onClick = {
                            val selected = draftState.selectedTime
                            committedHour = selected.hour
                            committedMinute = selected.minute
                            showBottomSheet = false
                        },
                        modifier = Modifier.weight(1f)
                    ) {
                        Text("Apply")
                    }
                }
            }
        }
    }
}

The example stores hour and minute separately because primitive values work with rememberSaveable; LocalTime is recreated as a derived value before creating the draft picker state.

API Reference

This reference describes the current main branch API. Check CHANGELOG.md and the 0.4.0 release/tag docs before copying API examples into a project that depends on the public 0.4.0 artifact.

Public state APIs live beside their components: TimePicker, TimePickerState, and rememberTimePickerState are in com.kez.picker.time; DatePicker, DatePickerState, YearMonthPicker, YearMonthPickerState, and their remember*State functions are in com.kez.picker.date.

Accessibility options customize the picker-column prefix, accessibility value text, and previous/next accessibility action labels without changing the visual item text. Selection is exposed through Compose selected semantics rather than appended as a hardcoded English phrase. Use PickerDefaults.accessibility(...), timePickerAccessibility(...), datePickerAccessibility(...), or yearMonthPickerAccessibility(...) to create reusable localized accessibility objects.

Display options customize the visible item text without changing accessibility output. Use PickerDefaults.itemText(...) on a generic Picker<T>, or PickerDefaults.timePickerDisplay(...), datePickerDisplay(...), and yearMonthPickerDisplay(...) for composite pickers when visible labels need padding, suffixes, or localized month/period names.

TimePicker(
    state = state,
    display = PickerDefaults.timePickerDisplay(
        hourItemText = { it.toString().padStart(2, '0') },
        minuteItemText = { it.toString().padStart(2, '0') }
    ),
    accessibility = PickerDefaults.timePickerAccessibility(
        hourPickerLabel = "Hour",
        minutePickerLabel = "Minute",
        hourItemContentDescription = { "$it hour" },
        minuteItemContentDescription = { "$it minute" },
        previousItemActionLabel = "Select previous value",
        nextItemActionLabel = "Select next value"
    )
)

Generic Picker

Use Picker<T> when you need a single custom picker column.

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberSaveable
import androidx.compose.runtime.setValue
import com.kez.picker.Picker
import com.kez.picker.PickerDefaults

@Composable
fun SizePickerExample() {
    val items = listOf("Small", "Medium", "Large")
    var selectedSize by rememberSaveable { mutableStateOf("Medium") }

    Picker(
        items = items,
        selectedItem = selectedSize,
        onSelectedItemChange = { selectedSize = it },
        enabled = true,
        isInfinity = false,
        display = PickerDefaults.itemText(
            itemText = { size -> size.uppercase() }
        ),
        accessibility = PickerDefaults.accessibility(
            pickerLabel = "Size",
            itemContentDescription = { it }
        )
    )
}

Picker<T> is a controlled component. Keep the selected value in app state, pass it through selectedItem, and update that state from onSelectedItemChange. items must be non-empty and distinct, and selectedItem must exist in items. If T is not saveable, store a saveable key in your app state and map that key back to an item before rendering the picker. Pass enabled = false to prevent user scroll, click, and accessibility selection actions while still showing the current value. Disabled pickers use the disabled slots from PickerDefaults.colors(...) for default text, dividers, and selected-item backgrounds.

Custom content receives PickerItemScope<T> so custom rows can reuse the default formatted text, selected/enabled state, distance fraction, text style, and content color:

Picker(
    items = items,
    selectedItem = selectedSize,
    onSelectedItemChange = { selectedSize = it },
    content = { item ->
        Text(
            text = if (item.isSelected) "[${item.text}]" else item.text,
            style = item.textStyle,
            color = item.contentColor
        )
    }
)

Use style = PickerDefaults.style(...) to customize visible item count, colors, text styles, dividers, item padding, selected item background, and fading edge behavior with one reusable object. Use display.itemText for visible text and accessibility.itemContentDescription for screen-reader text when those two strings should differ.

Programmatic Selection

Create picker state with remember*State, pass it to the picker, then call the public selection method from an event handler or a LaunchedEffect(externalValue). Do not recreate the state just to reset the selection.

State Method
Generic Picker<T> Update the app-owned selectedItem value
time.TimePickerState selectTime(LocalTime(...)) or selectTime(LocalTime(...), items)
date.DatePickerState selectDate(LocalDate(...)) or selectDate(LocalDate(...), items)
date.YearMonthPickerState selectYearMonth(YearMonth(...)), selectYearMonth(year, month), selectDate(LocalDate(...)), or the matching items overloads
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.kez.picker.time.rememberTimePickerState
import com.kez.picker.time.TimePicker
import kotlinx.datetime.LocalTime

@Composable
fun ProgrammaticTimePickerExample() {
    val state = rememberTimePickerState(initialTime = LocalTime(8, 0))

    Column {
        Button(onClick = { state.selectTime(LocalTime(9, 30)) }) {
            Text("Set 09:30")
        }

        TimePicker(state = state)
    }
}

The picker scroll position is synchronized when the current item lists contain the requested values. Custom item lists are strict: they must be non-empty, distinct, within the supported value ranges, and contain the current selected value. TimePicker filters hour, minute, and AM/PM columns through optional minTime/maxTime bounds. DatePicker filters dayItems by the selected year/month maximum day and optional minDate/maxDate bounds. If an app can restore or request values outside a custom list or configured bounds, call the state.select*(value, items) overload or items.coerce* helper to move to the closest selectable value before rendering the picker. For first composition, use remember*State(items = items, initial... = value) to apply the same coercion before the picker is rendered.

onSelectedTimeChange, onSelectedDateChange, and onSelectedYearMonthChange are called for user-driven picker changes. Programmatic state.select* calls update the state directly; update your app-owned value in the same event handler when you trigger programmatic changes.

Use PickerDefaults.timePickerLayout(...), datePickerLayout(...), or yearMonthPickerLayout(...) when a composite picker needs different column proportions. Pass null for a column weight to let pickerModifier provide an explicit width for that column.

TimePicker

Parameter Description Default
state The state object to control the picker. rememberTimePickerState()
onSelectedTimeChange Called after user interaction changes the selected LocalTime. {}
enabled Whether user scroll, click, and accessibility selection actions are enabled. true
items Selectable minute, 24-hour hour, 12-hour display-hour, and AM/PM item lists plus optional inclusive minTime/maxTime bounds. PickerDefaults.timePickerItems()
display Visible item text formatters for each picker column. PickerDefaults.timePickerDisplay()
style Visual and layout styling for each picker column. PickerDefaults.style()
layout Column weights for period, hour, and minute picker columns. Use null weights for explicit-width columns. PickerDefaults.timePickerLayout()
spacingBetweenPickers Horizontal spacing between picker columns. 0.dp
accessibility Accessibility labels, item descriptions, and custom action labels for each picker column. PickerDefaults.timePickerAccessibility()

TimePickerState Properties:

  • selectedHour: The selected hour shown by the picker.
  • selectedMinute: The selected minute (0-59).
  • selectedPeriod: The selected AM/PM period when using 12-hour format.
  • selectedHourOfDay: The selected hour converted to 24-hour clock time (0-23).
  • selectedTime: The selected value as kotlinx.datetime.LocalTime.

rememberTimePickerState uses saveable state. On Android, selected values can be restored across Activity recreation when the platform saveable registry is available.

For initial values, use either rememberTimePickerState(initialTime = LocalTime(...)) or the explicit initialHour/initialMinute parameters.

To change the selection after state creation, call state.selectTime(LocalTime(...)).

Invalid custom item values, duplicate items, empty required lists, or current selections missing from custom lists or time bounds throw IllegalArgumentException during composition. In 12-hour mode, PickerDefaults.timePickerItems(hour12Items = ...) uses display-hour values (1..12): initialHour = 13 becomes state.selectedHour == 1 with PM.

DatePicker

Parameter Description Default
state The state object to control the picker. rememberDatePickerState()
onSelectedDateChange Called after user interaction changes the selected LocalDate. {}
enabled Whether user scroll, click, and accessibility selection actions are enabled. true
items Selectable year/month/day item lists plus optional inclusive minDate/maxDate bounds. Values must be in 1000..9999, 1..12, and 1..31. PickerDefaults.datePickerItems()
display Visible item text formatters for each picker column. PickerDefaults.datePickerDisplay()
style Visual and layout styling for each picker column. PickerDefaults.style()
layout Column weights for year, month, and day picker columns. Use null weights for explicit-width columns. PickerDefaults.datePickerLayout()
spacingBetweenPickers Horizontal spacing between picker columns. 0.dp
accessibility Accessibility labels, item descriptions, and custom action labels for each picker column. PickerDefaults.datePickerAccessibility()

DatePickerState Properties:

  • selectedYear: The currently selected year.
  • selectedMonth: The currently selected month (1-12).
  • selectedDay: The currently selected day (1-31, auto-adjusted based on month).
  • selectedDate: The selected value as kotlinx.datetime.LocalDate.
  • maxDay: The maximum valid day for the selected year and month.

rememberDatePickerState uses saveable state. On Android, selected values can be restored across Activity recreation when the platform saveable registry is available.

For initial values, use either rememberDatePickerState(initialDate = LocalDate(...)) or the explicit initialYear/initialMonth/initialDay parameters. Initial years must be in 1000..9999, months in 1..12, and days must be at least 1. If initialDay is greater than the maximum valid day for the initial year/month, it is clamped to that maximum.

To change the selection after state creation, call state.selectDate(LocalDate(...)).

Invalid custom item values, duplicate items, empty lists, or current selected year/month/day values missing from custom lists or date bounds throw IllegalArgumentException during composition. If a year/month change makes the selected month or day unavailable, the picker selects the closest available value for the configured constraints.

YearMonthPicker

Parameter Description Default
state The state object to control the picker. rememberYearMonthPickerState()
onSelectedYearMonthChange Called after user interaction changes the selected YearMonth. {}
enabled Whether user scroll, click, and accessibility selection actions are enabled. true
items Selectable year/month item lists plus optional inclusive minYearMonth/maxYearMonth bounds. Values must be in 1000..9999 and 1..12. PickerDefaults.yearMonthPickerItems()
display Visible item text formatters for each picker column. PickerDefaults.yearMonthPickerDisplay()
style Visual and layout styling for each picker column. PickerDefaults.style()
layout Column weights for year and month picker columns. Use null weights for explicit-width columns. PickerDefaults.yearMonthPickerLayout()
spacingBetweenPickers Horizontal spacing between picker columns. 0.dp
accessibility Accessibility labels, item descriptions, and custom action labels for each picker column. PickerDefaults.yearMonthPickerAccessibility()

YearMonthPickerState Properties:

  • selectedYear: The currently selected year.
  • selectedMonth: The currently selected month (1-12).
  • selectedYearMonth: The selected value as date.YearMonth.
  • selectedMonthDate: The selected year/month represented as the first day of that month.

rememberYearMonthPickerState uses saveable state. On Android, selected values can be restored across Activity recreation when the platform saveable registry is available.

For initial values, use either rememberYearMonthPickerState(initialDate = LocalDate(...)) or the explicit initialYear/initialMonth parameters. Initial years must be in 1000..9999.

To change the selection after state creation, call state.selectYearMonth(YearMonth(...)), state.selectYearMonth(year, month), or state.selectDate(LocalDate(...)).

Invalid custom item values, duplicate items, empty lists, or current selected year/month values missing from custom lists or year/month bounds throw IllegalArgumentException during composition. If a year change makes the current month unavailable, the picker moves to the closest available YearMonth.

License

Copyright 2024 KEZ Lab

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

About

A Compose Multiplatform date and time picker library that provides a consistent UI across Android, iOS, Desktop, and Web platforms.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors