diff --git a/README.md b/README.md index 9a6542cc..5c5bf6c1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ React Native date & time picker component for iOS, Android and Windows (please n

+ +

+ Windows

@@ -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) @@ -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. diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java index ca6b2e06..9874ec1b 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java @@ -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); diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialRangePickerModule.kt b/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialRangePickerModule.kt new file mode 100644 index 00000000..79d2f17c --- /dev/null +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/MaterialRangePickerModule.kt @@ -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" + } + + 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 + } + + val fragmentManager = activity.supportFragmentManager + + UiThreadUtil.runOnUiThread { + val arguments = createRangePickerArguments(params) + val rangePicker = + RNMaterialRangePicker(arguments, promise, fragmentManager, reactApplicationContext) + rangePicker.open() + } + } +} diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java index 07220b79..eddc9412 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java @@ -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"; @@ -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"; diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java index 417183e4..fb03cfd6 100644 --- a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java @@ -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; } @@ -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; }; } diff --git a/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialRangePicker.kt b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialRangePicker.kt new file mode 100644 index 00000000..69bf9fff --- /dev/null +++ b/android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialRangePicker.kt @@ -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>? = 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))) + 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)) + } + } + + 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() + + 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>, + 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) { + 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 + ) + + promise.resolve(result) + promiseResolved = true + } + + private fun getStartTimestamp(selection: Pair): 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): 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() + } + } +} diff --git a/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialRangePickerSpec.java b/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialRangePickerSpec.java new file mode 100644 index 00000000..33ffec46 --- /dev/null +++ b/android/src/paper/java/com/reactcommunity/rndatetimepicker/NativeModuleMaterialRangePickerSpec.java @@ -0,0 +1,36 @@ + +/** + * This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen). + * + * Then it was commited. It is here to support the old architecture. + * If you use the new architecture, this file won't be included and instead will be generated by the codegen. + * + * @generated by codegen project: GenerateModuleJavaSpec.js + * + * @nolint + */ + +package com.reactcommunity.rndatetimepicker; + +import com.facebook.proguard.annotations.DoNotStrip; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReactModuleWithSpec; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.turbomodule.core.interfaces.TurboModule; + +public abstract class NativeModuleMaterialRangePickerSpec extends ReactContextBaseJavaModule implements ReactModuleWithSpec, TurboModule { + public NativeModuleMaterialRangePickerSpec(ReactApplicationContext reactContext) { + super(reactContext); + } + + @ReactMethod + @DoNotStrip + public abstract void dismiss(Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void open(ReadableMap params, Promise promise); +} diff --git a/docs/images/android_material_range.jpg b/docs/images/android_material_range.jpg new file mode 100644 index 00000000..dab4ff0e Binary files /dev/null and b/docs/images/android_material_range.jpg differ diff --git a/example/App.js b/example/App.js index ae542c80..c2d8af5d 100644 --- a/example/App.js +++ b/example/App.js @@ -13,7 +13,9 @@ import { Alert, FlatList, } from 'react-native'; -import DateTimePicker from '@react-native-community/datetimepicker'; +import DateTimePicker, { + MaterialRangePicker, +} from '@react-native-community/datetimepicker'; import SegmentedControl from './SegmentedControl'; import {Colors} from 'react-native/Libraries/NewAppScreen'; import React, {useRef, useState} from 'react'; @@ -216,6 +218,22 @@ export const App = () => { setMaximumDate(minimumDate ? undefined : endOfTomorrowUTC); }; + const handleShowRangePicker = () => { + MaterialRangePicker.open({ + fullscreen: isFullscreen, + initialInputMode: inputMode, + maximumDate, + minimumDate, + title, + timeZoneOffsetInMinutes: tzOffsetInMinutes, + timeZoneName: tzName, + dialogButtons: { + negative: {label: 'Nope'}, + positive: {label: 'Yes'}, + }, + }); + }; + if (Platform.OS !== 'windows') { return ( { title="Show and dismiss picker!" /> + + + [android] show range picker + + + +