Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for date range picker on Android #956

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ React Native date & time picker component for iOS, Android and Windows (please n
<td><p align="center"><img src="./docs/images/android_material_date.jpg" width="200" height="400"/></p></td>
<td><p align="center"><img src="./docs/images/android_material_time.jpg" width="200" height="400"/></p></td>
</tr>
<tr>
<td colspan=2><p align="center"><img src="./docs/images/android_material_range.jpg" width="200" height="400"/></p></td>
</tr>
<tr><td colspan=1><strong>Windows</strong></td></tr>
<tr>
<td><p align="center"><img src="./docs/images/windows_date.png" width="380" height="430"/></p></td>
Expand All @@ -78,6 +81,7 @@ React Native date & time picker component for iOS, Android and Windows (please n
- [Localization note](#localization-note)
- [Android imperative API](#android-imperative-api)
- [Android styling](#android-styling)
- [Date range picker (Android only)](#date-range-picker)
- [Props / params](#component-props--params-of-the-android-imperative-api)
- [`mode` (`optional`)](#mode-optional)
- [`display` (`optional`)](#display-optional)
Expand Down Expand Up @@ -301,6 +305,57 @@ Styling of the dialogs on Android can be easily customized by using the provided

Refer to this documentation for more information: [android-styling.md](/docs/android-styling.md).

### Date range picker (Android only)

Android has an additional component that allows users to select a range of dates (start and end dates). This is only available as a Material picker, meaning your application theme must inherit from `Theme.Material3.DayNight.NoActionBar` in `styles.xml`.

The component is accessible through an imperative API, similar to the Android date and time pickers.

```js
MaterialRangePicker.open({
value: {
start: LAST_SUNDAY,
end: NEXT_SUNDAY,
},
onChange: handleChange,
fullscreen: true,
});
```

The range picker supports many of the same props as the Material date picker with a few modifications:

### `value` (`optional`)

The value is an optional object with two properties: `start` and `end`. Both properties can be `null` or a `Date` object.

```js
MaterialRangePicker.open({
value: {
start: new Date(),
end: new Date(),
},
});
```

This will pre-select the range picker with the provided dates. If no value is provided, the user will be able to select any range.

### `onChange` (`required`)

Range change handler.

This is called when the user changes the range. It receives the event and the new range as parameters. The range will be in the same format as the `value` prop.

```js
const setRange = (event: RangePickerEvent, range: Range) => {
const {
type,
nativeEvent: {startTimestamp, endTimestamp, utcOffset},
} = event;
};
```

The utcOffset field is only available on Android and iOS. It is the offset in minutes between the selected date and UTC time.

## Component props / params of the Android imperative api

> Please note that this library currently exposes functionality from [`UIDatePicker`](https://developer.apple.com/documentation/uikit/uidatepicker?language=objc) on iOS and [DatePickerDialog](https://developer.android.com/reference/android/app/DatePickerDialog) + [TimePickerDialog](https://developer.android.com/reference/android/app/TimePickerDialog) on Android, and [`CalendarDatePicker`](https://docs.microsoft.com/en-us/windows/uwp/design/controls-and-patterns/calendar-date-picker) + [TimePicker](https://docs.microsoft.com/en-us/uwp/api/windows.ui.xaml.controls.timepicker?view=winrt-19041) on Windows.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,20 @@ public static Bundle createDatePickerArguments(ReadableMap options) {
return args;
}

public static Bundle createRangePickerArguments(ReadableMap options) {
final Bundle args = createDatePickerArguments(options);

if (options.hasKey(RNConstants.ARG_START_TIMESTAMP) && !options.isNull(RNConstants.ARG_START_TIMESTAMP)) {
args.putLong(RNConstants.ARG_START_TIMESTAMP, (long) options.getDouble(RNConstants.ARG_START_TIMESTAMP));
}

if (options.hasKey(RNConstants.ARG_END_TIMESTAMP) && !options.isNull(RNConstants.ARG_END_TIMESTAMP)) {
args.putLong(RNConstants.ARG_END_TIMESTAMP, (long) options.getDouble(RNConstants.ARG_END_TIMESTAMP));
}

return args;
}

public static Bundle createTimePickerArguments(ReadableMap options) {
final Bundle args = Common.createFragmentArguments(options);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.reactcommunity.rndatetimepicker

import androidx.fragment.app.FragmentActivity
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.UiThreadUtil
import com.reactcommunity.rndatetimepicker.Common.createDatePickerArguments
import com.reactcommunity.rndatetimepicker.Common.createRangePickerArguments
import com.reactcommunity.rndatetimepicker.Common.dismissDialog

class MaterialRangePickerModule(reactContext: ReactApplicationContext): NativeModuleMaterialRangePickerSpec(reactContext) {
companion object {
const val NAME = "RNCMaterialRangePicker"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, NativeModuleMaterialRangePickerSpec generated by codegen already should contain the name, and so specifying it here should not be needed. also override fun getName should be in the generated code.
if not, it's fine

}

override fun getName(): String {
return NAME
}

override fun dismiss(promise: Promise?) {
val activity = currentActivity as FragmentActivity?
dismissDialog(activity, NAME, promise)
}

override fun open(params: ReadableMap, promise: Promise) {
val activity = currentActivity as FragmentActivity?
if (activity == null) {
promise.reject(
RNConstants.ERROR_NO_ACTIVITY,
"Tried to open a MaterialRangePicker dialog while not attached to an Activity"
)
return
}
Comment on lines +27 to +34
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val activity = currentActivity as FragmentActivity?
if (activity == null) {
promise.reject(
RNConstants.ERROR_NO_ACTIVITY,
"Tried to open a MaterialRangePicker dialog while not attached to an Activity"
)
return
}
val activity = currentActivity as FragmentActivity? ?: run {
return promise.reject(
RNConstants.ERROR_NO_ACTIVITY,
"Tried to open a MaterialRangePicker dialog while not attached to an Activity"
)
}

this is more kotlin-style; just a suggestion. I'm not sure how it'll work with the cast though.


val fragmentManager = activity.supportFragmentManager

UiThreadUtil.runOnUiThread {
val arguments = createRangePickerArguments(params)
val rangePicker =
RNMaterialRangePicker(arguments, promise, fragmentManager, reactApplicationContext)
rangePicker.open()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
public final class RNConstants {
public static final String ERROR_NO_ACTIVITY = "E_NO_ACTIVITY";
public static final String ARG_VALUE = "value";
public static final String ARG_START_TIMESTAMP = "startTimestamp";
public static final String ARG_END_TIMESTAMP = "endTimestamp";
public static final String ARG_MINDATE = "minimumDate";
public static final String ARG_MAXDATE = "maximumDate";
public static final String ARG_INTERVAL = "minuteInterval";
Expand All @@ -18,6 +20,7 @@ public final class RNConstants {
public static final String ARG_INITIAL_INPUT_MODE = "initialInputMode";
public static final String ARG_FULLSCREEN = "fullscreen";
public static final String ACTION_DATE_SET = "dateSetAction";
public static final String ACTION_RANGE_SET = "rangeSetAction";
public static final String ACTION_TIME_SET = "timeSetAction";
public static final String ACTION_DISMISSED = "dismissedAction";
public static final String ACTION_NEUTRAL_BUTTON = "neutralButtonAction";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext)
return new MaterialDatePickerModule(reactContext);
} else if (name.equals(MaterialTimePickerModule.NAME)) {
return new MaterialTimePickerModule(reactContext);
} else if (name.equals(MaterialRangePickerModule.NAME)) {
return new MaterialRangePickerModule(reactContext);
} else {
return null;
}
Expand Down Expand Up @@ -78,6 +80,17 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() {
false, // isCxxModule
isTurboModule // isTurboModule
));
moduleInfos.put(
MaterialRangePickerModule.NAME,
new ReactModuleInfo(
MaterialRangePickerModule.NAME,
MaterialRangePickerModule.NAME,
false, // canOverrideExistingModule
false, // needsEagerInit
false, // hasConstants
false, // isCxxModule
isTurboModule // isTurboModule
));
return moduleInfos;
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package com.reactcommunity.rndatetimepicker

import android.content.DialogInterface
import android.os.Bundle
import androidx.core.util.Pair
import androidx.fragment.app.FragmentManager
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableNativeMap
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.CalendarConstraints.DateValidator
import com.google.android.material.datepicker.CompositeDateValidator
import com.google.android.material.datepicker.DateValidatorPointBackward
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener
import java.util.Calendar

class RNMaterialRangePicker(
private val args: Bundle,
private val promise: Promise,
private val fragmentManager: FragmentManager,
private val reactContext: ReactApplicationContext
) {
private var promiseResolved = false
private var rangePicker: MaterialDatePicker<Pair<Long, Long>>? = null
private var builder = MaterialDatePicker.Builder.dateRangePicker()

fun open() {
createRangePicker()
addListeners()
show()
}

private fun createRangePicker() {
setInitialDates()
setTitle()
setInputMode()
setButtons()
setConstraints()
setFullscreen()

rangePicker = builder.build()
}

private fun setInitialDates() {
var start: Long? = null
var end: Long? = null

if (args.containsKey(RNConstants.ARG_START_TIMESTAMP)) {
// override "value" so we can use the same constructor from RNDate
args.putLong(RNConstants.ARG_VALUE, args.getLong((RNConstants.ARG_START_TIMESTAMP)))
Copy link
Member

@vonovak vonovak Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than mutating args in order to use some constructor of RNDate, I'd add another constructor to RNDate which accepts Long value

start = RNDate(args).timestamp()
}

if (args.containsKey(RNConstants.ARG_END_TIMESTAMP)) {
// override "value" so we can use the same constructor from RNDate
args.putLong(RNConstants.ARG_VALUE, args.getLong((RNConstants.ARG_END_TIMESTAMP)))
end = RNDate(args).timestamp()
}

val selection = Pair(start, end)
builder.setSelection(selection)
}

private fun setTitle() {
val title = args.getString(RNConstants.ARG_TITLE)
if (!title.isNullOrEmpty()) {
builder.setTitleText(args.getString(RNConstants.ARG_TITLE))
}
Comment on lines +67 to +70
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val title = args.getString(RNConstants.ARG_TITLE)
if (!title.isNullOrEmpty()) {
builder.setTitleText(args.getString(RNConstants.ARG_TITLE))
}
args.getString(RNConstants.ARG_TITLE)?.let {
builder.setTitleText(it)
}

more kotlin-y. You can use this pattern more across this file pls, e.g in setButtons.

}

private fun setInputMode() {
if (args.getString(RNConstants.ARG_INITIAL_INPUT_MODE).isNullOrEmpty()) {
builder.setInputMode(MaterialDatePicker.INPUT_MODE_CALENDAR)
return
}

val inputMode =
RNMaterialInputMode.valueOf(
args.getString(RNConstants.ARG_INITIAL_INPUT_MODE)!!.uppercase()
)

if (inputMode == RNMaterialInputMode.KEYBOARD) {
builder.setInputMode(MaterialDatePicker.INPUT_MODE_TEXT)
} else {
builder.setInputMode(MaterialDatePicker.INPUT_MODE_CALENDAR)
}
}

private fun setConstraints() {
val constraintsBuilder = CalendarConstraints.Builder()

if (args.containsKey(RNConstants.FIRST_DAY_OF_WEEK)) {
constraintsBuilder.setFirstDayOfWeek(args.getInt(RNConstants.FIRST_DAY_OF_WEEK))
}

val validators = mutableListOf<DateValidator>()

if (args.containsKey(RNConstants.ARG_MINDATE)) {
val minDate = Common.minDateWithTimeZone(args)
validators.add(DateValidatorPointForward.from(minDate))
}

if (args.containsKey(RNConstants.ARG_MAXDATE)) {
val maxDate = Common.maxDateWithTimeZone(args)
validators.add(DateValidatorPointBackward.before(maxDate))
}

constraintsBuilder.setValidator(CompositeDateValidator.allOf(validators))
builder.setCalendarConstraints(constraintsBuilder.build())
}

private fun setFullscreen() {
val isFullscreen = args.getBoolean(RNConstants.ARG_FULLSCREEN)

if (isFullscreen) {
builder.setTheme(com.google.android.material.R.style.ThemeOverlay_Material3_MaterialCalendar_Fullscreen)
} else {
builder.setTheme(com.google.android.material.R.style.ThemeOverlay_Material3_MaterialCalendar)
}
}

private fun setButtons() {
val buttons = args.getBundle(RNConstants.ARG_DIALOG_BUTTONS) ?: return

val negativeButton = buttons.getBundle(Common.NEGATIVE)
val positiveButton = buttons.getBundle(Common.POSITIVE)

if (negativeButton != null) {
builder.setNegativeButtonText(negativeButton.getString(Common.LABEL))
}

if (positiveButton != null) {
builder.setPositiveButtonText(positiveButton.getString(Common.LABEL))
}
}

private fun addListeners() {
val listeners = Listeners()
rangePicker!!.addOnPositiveButtonClickListener(listeners)
rangePicker!!.addOnDismissListener(listeners)
}

private fun show() {
rangePicker!!.show(fragmentManager, MaterialRangePickerModule.NAME)
}

private inner class Listeners : MaterialPickerOnPositiveButtonClickListener<Pair<Long, Long>>,
DialogInterface.OnDismissListener {
override fun onDismiss(dialog: DialogInterface) {
if (promiseResolved || !reactContext.hasActiveReactInstance()) return

val result = WritableNativeMap()
result.putString("action", RNConstants.ACTION_DISMISSED)
promise.resolve(result)
promiseResolved = true
}

override fun onPositiveButtonClick(selection: Pair<Long, Long>) {
if (promiseResolved || !reactContext.hasActiveReactInstance()) return

val result = WritableNativeMap()

result.putString("action", RNConstants.ACTION_RANGE_SET)
result.putDouble("startTimestamp", getStartTimestamp(selection))
result.putDouble("endTimestamp", getEndTimestamp(selection))
result.putDouble(
"utcOffset",
getStartTimestamp(selection) / 1000 / 60
)
Comment on lines +163 to +171
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val result = WritableNativeMap()
result.putString("action", RNConstants.ACTION_RANGE_SET)
result.putDouble("startTimestamp", getStartTimestamp(selection))
result.putDouble("endTimestamp", getEndTimestamp(selection))
result.putDouble(
"utcOffset",
getStartTimestamp(selection) / 1000 / 60
)
val result = WritableNativeMap().apply {
putString("action", RNConstants.ACTION_RANGE_SET)
putDouble("startTimestamp", getStartTimestamp(selection))
putDouble("endTimestamp", getEndTimestamp(selection))
putDouble(
"utcOffset",
getStartTimestamp(selection) / 1000 / 60
)
}

more koltin-y. You can use apply in many places throughout this file to save keystrokes


promise.resolve(result)
promiseResolved = true
}

private fun getStartTimestamp(selection: Pair<Long, Long>): Double {
val newCalendar = Calendar.getInstance(
Common.getTimeZone(
args
)
)

newCalendar.timeInMillis = selection.first
newCalendar[Calendar.HOUR_OF_DAY] = 0
newCalendar[Calendar.MINUTE] = 0
newCalendar[Calendar.SECOND] = 0

return newCalendar.timeInMillis.toDouble()
}

private fun getEndTimestamp(selection: Pair<Long, Long>): Double {
val newCalendar = Calendar.getInstance(
Common.getTimeZone(
args
)
)

newCalendar.timeInMillis = selection.first
newCalendar[Calendar.HOUR_OF_DAY] = 23
newCalendar[Calendar.MINUTE] = 59
newCalendar[Calendar.SECOND] = 59

return newCalendar.timeInMillis.toDouble()
}
}
}
Loading