Skip to content

Commit e81c6a1

Browse files
feat(android): material 3 pickers (#952)
* Support Kotlin files Android Studio recommended these settings for supporting Kotlin in the project. * Add Material dependency * Extract re-usable picker argument utilities The new Material pickers will utilize these utilities as well. * Set up new specs/modules * Accept new arguments for Material pickers The pickers will allow the user to set a title for the dialog and specify if the initial input mode should be a text input or calendar/clock. * Implement MaterialDatePicker This also adds a utility function to RNDate to get the date in milliseconds. * Implement MaterialTimePicker * Create JS connectors that call the native modules * Choose native module based on `design` prop If the `design` is "material", then use the Material modules. Otherwise, use the default modules. * Validate Material 3 props not used with default pickers * Update components to pass new props to modules * Update types * Update example app * Fix typo in existing docs * Add dummy files to prevent tests from failing The tests were trying to access the native modules. These dummy files will make sure the tests don't try accessing the real implementations that end with `.android.js`. * Update README * Update E2E specs Since there are multiple "default" options on the screen now, I've specified to tap the first one. I also had to make sure to scroll the screen before tapping the show picker button. * Make example app inherit from Material3 theme * Move post-install into `start` script
1 parent e5664b7 commit e81c6a1

35 files changed

+1229
-68
lines changed

Diff for: README.md

+52
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ React Native date & time picker component for iOS, Android and Windows (please n
5151
<td><p align="center"><img src="./docs/images/android_date.png" width="200" height="400"/></p></td>
5252
<td><p align="center"><img src="./docs/images/android_time.png" width="200" height="400"/></p></td>
5353
</tr>
54+
<tr>
55+
<td><p align="center"><img src="./docs/images/android_material_date.jpg" width="200" height="400"/></p></td>
56+
<td><p align="center"><img src="./docs/images/android_material_time.jpg" width="200" height="400"/></p></td>
57+
</tr>
5458
<tr><td colspan=1><strong>Windows</strong></td></tr>
5559
<tr>
5660
<td><p align="center"><img src="./docs/images/windows_date.png" width="380" height="430"/></p></td>
@@ -77,6 +81,10 @@ React Native date & time picker component for iOS, Android and Windows (please n
7781
- [Props / params](#component-props--params-of-the-android-imperative-api)
7882
- [`mode` (`optional`)](#mode-optional)
7983
- [`display` (`optional`)](#display-optional)
84+
- [`design` (`optional`, `Android only`)](#design-optional)
85+
- [`initialInputMode` (`optional`, `Android only`)](#initialinputmode-optional-android-only)
86+
- [`title` (`optional`, `Android only`)](#title-optional-android-only)
87+
- [`fullscreen` (`optional`, `Android only`)](#fullscreen-optional-android-only)
8088
- [`onChange` (`optional`)](#onchange-optional)
8189
- [`value` (`required`)](#value-required)
8290
- [`maximumDate` (`optional`)](#maximumdate-optional)
@@ -287,6 +295,8 @@ The reason we recommend the imperative API is: on Android, the date/time picker
287295

288296
### Android styling
289297

298+
If you'd like to use the Material pickers, your app theme will need to inherit from `Theme.Material3.DayNight.NoActionBar` in `styles.xml`.
299+
290300
Styling of the dialogs on Android can be easily customized by using the provided config plugin, provided that you use a [Expo development build](https://docs.expo.dev/develop/development-builds/introduction/). The plugin allows you to configure color properties that cannot be set at runtime and requires building a new app binary to take effect.
291301

292302
Refer to this documentation for more information: [android-styling.md](/docs/android-styling.md).
@@ -334,6 +344,19 @@ List of possible values for iOS (maps to [preferredDatePickerStyle](https://deve
334344
<RNDateTimePicker display="spinner" />
335345
```
336346

347+
#### `design` (`optional`, `Android only`)
348+
349+
Defines if the picker should use Material 3 components or the default picker. The default value is `"default"`.
350+
351+
List of possible values
352+
353+
- `"default"`
354+
- `"material"`
355+
356+
```js
357+
<RNDateTimePicker design="material" />
358+
```
359+
337360
#### `onChange` (`optional`)
338361

339362
Date change handler.
@@ -482,6 +505,35 @@ Allows changing of the time picker to a 24-hour format. By default, this value i
482505
<RNDateTimePicker is24Hour={true} />
483506
```
484507

508+
#### `initialInputMode` (`optional`, `Android only`)
509+
510+
:warning: Has effect only when `design` is "material". Allows setting the initial input mode of the picker.
511+
512+
List of possible values:
513+
514+
- `"default"` - Recommended. Date pickers will show the calendar view by default, and time pickers will show the clock view by default.
515+
- `"keyboard"` - Both pickers will show an input where the user can type the date or time.
516+
517+
```js
518+
<RNDateTimePicker initialInputMode="default" />
519+
```
520+
521+
#### `title` (`optional`, `Android only`)
522+
523+
:warning: Has effect only when `design` is "material". Allows setting the title of the dialog for the pickers.
524+
525+
```js
526+
<RNDateTimePicker title="Choose anniversary" />
527+
```
528+
529+
#### `fullscreen` (`optional`, `Android only`)
530+
531+
:warning: Has effect only when `design` is "material". Allows setting the date picker dialog to be fullscreen.
532+
533+
```js
534+
<RNDateTimePicker fullscreen={true} />
535+
```
536+
485537
#### `positiveButton` (`optional`, `Android only`)
486538

487539
Set the positive button label and text color.

Diff for: android/build.gradle

+6
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def isNewArchitectureEnabled() {
1818
}
1919

2020
apply plugin: 'com.android.library'
21+
apply plugin: 'org.jetbrains.kotlin.android'
2122
if (isNewArchitectureEnabled()) {
2223
apply plugin: "com.facebook.react"
2324
}
@@ -53,6 +54,9 @@ android {
5354
}
5455
}
5556
}
57+
kotlinOptions {
58+
jvmTarget = '17'
59+
}
5660
}
5761

5862
repositories {
@@ -64,4 +68,6 @@ repositories {
6468
dependencies {
6569
//noinspection GradleDynamicVersion
6670
implementation 'com.facebook.react:react-native:+'
71+
implementation 'com.google.android.material:material:1.12.0'
72+
implementation 'androidx.core:core-ktx:1.13.1'
6773
}

Diff for: android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java

+61
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import androidx.fragment.app.FragmentActivity;
1818
import androidx.fragment.app.FragmentManager;
1919

20+
import com.facebook.react.bridge.Arguments;
2021
import com.facebook.react.bridge.Promise;
2122
import com.facebook.react.bridge.ReadableMap;
2223
import com.facebook.react.util.RNLog;
@@ -205,6 +206,66 @@ public static Bundle createFragmentArguments(ReadableMap options) {
205206
if (options.hasKey(RNConstants.ARG_TZ_NAME) && !options.isNull(RNConstants.ARG_TZ_NAME)) {
206207
args.putString(RNConstants.ARG_TZ_NAME, options.getString(RNConstants.ARG_TZ_NAME));
207208
}
209+
if (options.hasKey(RNConstants.ARG_TITLE) && !options.isNull(RNConstants.ARG_TITLE)) {
210+
args.putString(RNConstants.ARG_TITLE, options.getString(RNConstants.ARG_TITLE));
211+
}
212+
if (options.hasKey(RNConstants.ARG_INITIAL_INPUT_MODE) && !options.isNull(RNConstants.ARG_INITIAL_INPUT_MODE)) {
213+
args.putString(RNConstants.ARG_INITIAL_INPUT_MODE, options.getString(RNConstants.ARG_INITIAL_INPUT_MODE));
214+
}
215+
216+
return args;
217+
}
218+
219+
public static Bundle createDatePickerArguments(ReadableMap options) {
220+
final Bundle args = Common.createFragmentArguments(options);
221+
222+
if (options.hasKey(RNConstants.ARG_MINDATE) && !options.isNull(RNConstants.ARG_MINDATE)) {
223+
args.putLong(RNConstants.ARG_MINDATE, (long) options.getDouble(RNConstants.ARG_MINDATE));
224+
}
225+
if (options.hasKey(RNConstants.ARG_MAXDATE) && !options.isNull(RNConstants.ARG_MAXDATE)) {
226+
args.putLong(RNConstants.ARG_MAXDATE, (long) options.getDouble(RNConstants.ARG_MAXDATE));
227+
}
228+
if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) {
229+
args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY));
230+
}
231+
if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) {
232+
args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS)));
233+
}
234+
if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) {
235+
args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS));
236+
}
237+
if (options.hasKey(RNConstants.ARG_TESTID) && !options.isNull(RNConstants.ARG_TESTID)) {
238+
args.putString(RNConstants.ARG_TESTID, options.getString(RNConstants.ARG_TESTID));
239+
}
240+
if (options.hasKey(RNConstants.ARG_FULLSCREEN) && !options.isNull(RNConstants.ARG_FULLSCREEN)) {
241+
args.putBoolean(RNConstants.ARG_FULLSCREEN, options.getBoolean(RNConstants.ARG_FULLSCREEN));
242+
}
243+
if (options.hasKey(RNConstants.FIRST_DAY_OF_WEEK) && !options.isNull(RNConstants.FIRST_DAY_OF_WEEK)) {
244+
// FIRST_DAY_OF_WEEK is 0-indexed, since it uses the same constants DAY_OF_WEEK used in the Windows implementation
245+
// Android DatePicker uses 1-indexed values, SUNDAY being 1 and SATURDAY being 7, so the +1 is necessary in this case
246+
args.putInt(RNConstants.FIRST_DAY_OF_WEEK, options.getInt(RNConstants.FIRST_DAY_OF_WEEK)+1);
247+
}
248+
return args;
249+
}
250+
251+
public static Bundle createTimePickerArguments(ReadableMap options) {
252+
final Bundle args = Common.createFragmentArguments(options);
253+
254+
if (options.hasKey(RNConstants.ARG_IS24HOUR) && !options.isNull(RNConstants.ARG_IS24HOUR)) {
255+
args.putBoolean(RNConstants.ARG_IS24HOUR, options.getBoolean(RNConstants.ARG_IS24HOUR));
256+
}
257+
if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) {
258+
args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY));
259+
}
260+
if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) {
261+
args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS)));
262+
}
263+
if (options.hasKey(RNConstants.ARG_INTERVAL) && !options.isNull(RNConstants.ARG_INTERVAL)) {
264+
args.putInt(RNConstants.ARG_INTERVAL, options.getInt(RNConstants.ARG_INTERVAL));
265+
}
266+
if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) {
267+
args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS));
268+
}
208269

209270
return args;
210271
}

Diff for: android/src/main/java/com/reactcommunity/rndatetimepicker/DatePickerModule.java

+2-30
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.facebook.react.common.annotations.VisibleForTesting;
2222
import com.facebook.react.module.annotations.ReactModule;
2323

24+
import static com.reactcommunity.rndatetimepicker.Common.createDatePickerArguments;
2425
import static com.reactcommunity.rndatetimepicker.Common.dismissDialog;
2526

2627
import java.util.Calendar;
@@ -144,7 +145,7 @@ public void open(final ReadableMap options, final Promise promise) {
144145
RNDatePickerDialogFragment oldFragment =
145146
(RNDatePickerDialogFragment) fragmentManager.findFragmentByTag(NAME);
146147

147-
Bundle arguments = createFragmentArguments(options);
148+
Bundle arguments = createDatePickerArguments(options);
148149

149150
if (oldFragment != null) {
150151
oldFragment.update(arguments);
@@ -162,33 +163,4 @@ public void open(final ReadableMap options, final Promise promise) {
162163
fragment.show(fragmentManager, NAME);
163164
});
164165
}
165-
166-
private Bundle createFragmentArguments(ReadableMap options) {
167-
final Bundle args = Common.createFragmentArguments(options);
168-
169-
if (options.hasKey(RNConstants.ARG_MINDATE) && !options.isNull(RNConstants.ARG_MINDATE)) {
170-
args.putLong(RNConstants.ARG_MINDATE, (long) options.getDouble(RNConstants.ARG_MINDATE));
171-
}
172-
if (options.hasKey(RNConstants.ARG_MAXDATE) && !options.isNull(RNConstants.ARG_MAXDATE)) {
173-
args.putLong(RNConstants.ARG_MAXDATE, (long) options.getDouble(RNConstants.ARG_MAXDATE));
174-
}
175-
if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) {
176-
args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY));
177-
}
178-
if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) {
179-
args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS)));
180-
}
181-
if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) {
182-
args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS));
183-
}
184-
if (options.hasKey(RNConstants.ARG_TESTID) && !options.isNull(RNConstants.ARG_TESTID)) {
185-
args.putString(RNConstants.ARG_TESTID, options.getString(RNConstants.ARG_TESTID));
186-
}
187-
if (options.hasKey(RNConstants.FIRST_DAY_OF_WEEK) && !options.isNull(RNConstants.FIRST_DAY_OF_WEEK)) {
188-
// FIRST_DAY_OF_WEEK is 0-indexed, since it uses the same constants DAY_OF_WEEK used in the Windows implementation
189-
// Android DatePicker uses 1-indexed values, SUNDAY being 1 and SATURDAY being 7, so the +1 is necessary in this case
190-
args.putInt(RNConstants.FIRST_DAY_OF_WEEK, options.getInt(RNConstants.FIRST_DAY_OF_WEEK)+1);
191-
}
192-
return args;
193-
}
194166
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.reactcommunity.rndatetimepicker
2+
3+
import androidx.fragment.app.FragmentActivity
4+
import com.facebook.react.bridge.Promise
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.bridge.ReadableMap
7+
import com.facebook.react.bridge.UiThreadUtil
8+
import com.reactcommunity.rndatetimepicker.Common.createDatePickerArguments
9+
import com.reactcommunity.rndatetimepicker.Common.dismissDialog
10+
11+
class MaterialDatePickerModule(reactContext: ReactApplicationContext): NativeModuleMaterialDatePickerSpec(reactContext) {
12+
companion object {
13+
const val NAME = "RNCMaterialDatePicker"
14+
}
15+
16+
override fun getName(): String {
17+
return NAME
18+
}
19+
20+
override fun dismiss(promise: Promise?) {
21+
val activity = currentActivity as FragmentActivity?
22+
dismissDialog(activity, NAME, promise)
23+
}
24+
25+
override fun open(params: ReadableMap, promise: Promise) {
26+
val activity = currentActivity as FragmentActivity?
27+
if (activity == null) {
28+
promise.reject(
29+
RNConstants.ERROR_NO_ACTIVITY,
30+
"Tried to open a MaterialDatePicker dialog while not attached to an Activity"
31+
)
32+
return
33+
}
34+
35+
val fragmentManager = activity.supportFragmentManager
36+
37+
UiThreadUtil.runOnUiThread {
38+
val arguments = createDatePickerArguments(params)
39+
val datePicker =
40+
RNMaterialDatePicker(arguments, promise, fragmentManager, reactApplicationContext)
41+
datePicker.open()
42+
}
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.reactcommunity.rndatetimepicker
2+
3+
import androidx.fragment.app.FragmentActivity
4+
import com.facebook.react.bridge.Promise
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.bridge.ReadableMap
7+
import com.facebook.react.bridge.UiThreadUtil
8+
import com.reactcommunity.rndatetimepicker.Common.createTimePickerArguments
9+
import com.reactcommunity.rndatetimepicker.Common.dismissDialog
10+
11+
class MaterialTimePickerModule(reactContext: ReactApplicationContext) :
12+
NativeModuleMaterialTimePickerSpec(reactContext) {
13+
companion object {
14+
const val NAME = "RNCMaterialTimePicker"
15+
}
16+
17+
override fun getName(): String {
18+
return NAME
19+
}
20+
21+
override fun dismiss(promise: Promise?) {
22+
val activity = currentActivity as FragmentActivity?
23+
dismissDialog(activity, NAME, promise)
24+
}
25+
26+
override fun open(params: ReadableMap, promise: Promise) {
27+
val activity = currentActivity as FragmentActivity?
28+
if (activity == null) {
29+
promise.reject(
30+
RNConstants.ERROR_NO_ACTIVITY,
31+
"Tried to open a MaterialTimePicker dialog while not attached to an Activity"
32+
)
33+
}
34+
35+
val fragmentManager = activity!!.supportFragmentManager
36+
37+
UiThreadUtil.runOnUiThread {
38+
val arguments =
39+
createTimePickerArguments(params)
40+
val materialPicker = RNMaterialTimePicker(
41+
arguments,
42+
promise,
43+
fragmentManager,
44+
reactApplicationContext
45+
)
46+
materialPicker.open()
47+
}
48+
}
49+
}

Diff for: android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public final class RNConstants {
1414
public static final String ARG_TZOFFSET_MINS = "timeZoneOffsetInMinutes";
1515
public static final String ARG_TZ_NAME = "timeZoneName";
1616
public static final String ARG_TESTID = "testID";
17+
public static final String ARG_TITLE = "title";
18+
public static final String ARG_INITIAL_INPUT_MODE = "initialInputMode";
19+
public static final String ARG_FULLSCREEN = "fullscreen";
1720
public static final String ACTION_DATE_SET = "dateSetAction";
1821
public static final String ACTION_TIME_SET = "timeSetAction";
1922
public static final String ACTION_DISMISSED = "dismissedAction";

Diff for: android/src/main/java/com/reactcommunity/rndatetimepicker/RNDate.java

+1
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ public RNDate(Bundle args) {
2323
public int day() { return now.get(Calendar.DAY_OF_MONTH); }
2424
public int hour() { return now.get(Calendar.HOUR_OF_DAY); }
2525
public int minute() { return now.get(Calendar.MINUTE); }
26+
public Long timestamp() { return now.getTimeInMillis(); }
2627
}

Diff for: android/src/main/java/com/reactcommunity/rndatetimepicker/RNDateTimePickerPackage.java

+26
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext)
2020
return new DatePickerModule(reactContext);
2121
} else if (name.equals(TimePickerModule.NAME)) {
2222
return new TimePickerModule(reactContext);
23+
} else if (name.equals(MaterialDatePickerModule.NAME)) {
24+
return new MaterialDatePickerModule(reactContext);
25+
} else if (name.equals(MaterialTimePickerModule.NAME)) {
26+
return new MaterialTimePickerModule(reactContext);
2327
} else {
2428
return null;
2529
}
@@ -52,6 +56,28 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() {
5256
false, // isCxxModule
5357
isTurboModule // isTurboModule
5458
));
59+
moduleInfos.put(
60+
MaterialDatePickerModule.NAME,
61+
new ReactModuleInfo(
62+
MaterialDatePickerModule.NAME,
63+
MaterialDatePickerModule.NAME,
64+
false, // canOverrideExistingModule
65+
false, // needsEagerInit
66+
false, // hasConstants
67+
false, // isCxxModule
68+
isTurboModule // isTurboModule
69+
));
70+
moduleInfos.put(
71+
MaterialTimePickerModule.NAME,
72+
new ReactModuleInfo(
73+
MaterialTimePickerModule.NAME,
74+
MaterialTimePickerModule.NAME,
75+
false, // canOverrideExistingModule
76+
false, // needsEagerInit
77+
false, // hasConstants
78+
false, // isCxxModule
79+
isTurboModule // isTurboModule
80+
));
5581
return moduleInfos;
5682
};
5783
}

0 commit comments

Comments
 (0)