Skip to content

Commit

Permalink
feat(android): material 3 pickers (#952)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
sidorchukandrew authored Feb 3, 2025
1 parent e5664b7 commit e81c6a1
Show file tree
Hide file tree
Showing 35 changed files with 1,229 additions and 68 deletions.
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ React Native date & time picker component for iOS, Android and Windows (please n
<td><p align="center"><img src="./docs/images/android_date.png" width="200" height="400"/></p></td>
<td><p align="center"><img src="./docs/images/android_time.png" width="200" height="400"/></p></td>
</tr>
<tr>
<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=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 @@ -77,6 +81,10 @@ React Native date & time picker component for iOS, Android and Windows (please n
- [Props / params](#component-props--params-of-the-android-imperative-api)
- [`mode` (`optional`)](#mode-optional)
- [`display` (`optional`)](#display-optional)
- [`design` (`optional`, `Android only`)](#design-optional)
- [`initialInputMode` (`optional`, `Android only`)](#initialinputmode-optional-android-only)
- [`title` (`optional`, `Android only`)](#title-optional-android-only)
- [`fullscreen` (`optional`, `Android only`)](#fullscreen-optional-android-only)
- [`onChange` (`optional`)](#onchange-optional)
- [`value` (`required`)](#value-required)
- [`maximumDate` (`optional`)](#maximumdate-optional)
Expand Down Expand Up @@ -287,6 +295,8 @@ The reason we recommend the imperative API is: on Android, the date/time picker

### Android styling

If you'd like to use the Material pickers, your app theme will need to inherit from `Theme.Material3.DayNight.NoActionBar` in `styles.xml`.

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.

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

#### `design` (`optional`, `Android only`)

Defines if the picker should use Material 3 components or the default picker. The default value is `"default"`.

List of possible values

- `"default"`
- `"material"`

```js
<RNDateTimePicker design="material" />
```

#### `onChange` (`optional`)

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

#### `initialInputMode` (`optional`, `Android only`)

:warning: Has effect only when `design` is "material". Allows setting the initial input mode of the picker.

List of possible values:

- `"default"` - Recommended. Date pickers will show the calendar view by default, and time pickers will show the clock view by default.
- `"keyboard"` - Both pickers will show an input where the user can type the date or time.

```js
<RNDateTimePicker initialInputMode="default" />
```

#### `title` (`optional`, `Android only`)

:warning: Has effect only when `design` is "material". Allows setting the title of the dialog for the pickers.

```js
<RNDateTimePicker title="Choose anniversary" />
```

#### `fullscreen` (`optional`, `Android only`)

:warning: Has effect only when `design` is "material". Allows setting the date picker dialog to be fullscreen.

```js
<RNDateTimePicker fullscreen={true} />
```

#### `positiveButton` (`optional`, `Android only`)

Set the positive button label and text color.
Expand Down
6 changes: 6 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def isNewArchitectureEnabled() {
}

apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.android'
if (isNewArchitectureEnabled()) {
apply plugin: "com.facebook.react"
}
Expand Down Expand Up @@ -53,6 +54,9 @@ android {
}
}
}
kotlinOptions {
jvmTarget = '17'
}
}

repositories {
Expand All @@ -64,4 +68,6 @@ repositories {
dependencies {
//noinspection GradleDynamicVersion
implementation 'com.facebook.react:react-native:+'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.core:core-ktx:1.13.1'
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.util.RNLog;
Expand Down Expand Up @@ -205,6 +206,66 @@ public static Bundle createFragmentArguments(ReadableMap options) {
if (options.hasKey(RNConstants.ARG_TZ_NAME) && !options.isNull(RNConstants.ARG_TZ_NAME)) {
args.putString(RNConstants.ARG_TZ_NAME, options.getString(RNConstants.ARG_TZ_NAME));
}
if (options.hasKey(RNConstants.ARG_TITLE) && !options.isNull(RNConstants.ARG_TITLE)) {
args.putString(RNConstants.ARG_TITLE, options.getString(RNConstants.ARG_TITLE));
}
if (options.hasKey(RNConstants.ARG_INITIAL_INPUT_MODE) && !options.isNull(RNConstants.ARG_INITIAL_INPUT_MODE)) {
args.putString(RNConstants.ARG_INITIAL_INPUT_MODE, options.getString(RNConstants.ARG_INITIAL_INPUT_MODE));
}

return args;
}

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

if (options.hasKey(RNConstants.ARG_MINDATE) && !options.isNull(RNConstants.ARG_MINDATE)) {
args.putLong(RNConstants.ARG_MINDATE, (long) options.getDouble(RNConstants.ARG_MINDATE));
}
if (options.hasKey(RNConstants.ARG_MAXDATE) && !options.isNull(RNConstants.ARG_MAXDATE)) {
args.putLong(RNConstants.ARG_MAXDATE, (long) options.getDouble(RNConstants.ARG_MAXDATE));
}
if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) {
args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY));
}
if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) {
args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS)));
}
if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) {
args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS));
}
if (options.hasKey(RNConstants.ARG_TESTID) && !options.isNull(RNConstants.ARG_TESTID)) {
args.putString(RNConstants.ARG_TESTID, options.getString(RNConstants.ARG_TESTID));
}
if (options.hasKey(RNConstants.ARG_FULLSCREEN) && !options.isNull(RNConstants.ARG_FULLSCREEN)) {
args.putBoolean(RNConstants.ARG_FULLSCREEN, options.getBoolean(RNConstants.ARG_FULLSCREEN));
}
if (options.hasKey(RNConstants.FIRST_DAY_OF_WEEK) && !options.isNull(RNConstants.FIRST_DAY_OF_WEEK)) {
// FIRST_DAY_OF_WEEK is 0-indexed, since it uses the same constants DAY_OF_WEEK used in the Windows implementation
// Android DatePicker uses 1-indexed values, SUNDAY being 1 and SATURDAY being 7, so the +1 is necessary in this case
args.putInt(RNConstants.FIRST_DAY_OF_WEEK, options.getInt(RNConstants.FIRST_DAY_OF_WEEK)+1);
}
return args;
}

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

if (options.hasKey(RNConstants.ARG_IS24HOUR) && !options.isNull(RNConstants.ARG_IS24HOUR)) {
args.putBoolean(RNConstants.ARG_IS24HOUR, options.getBoolean(RNConstants.ARG_IS24HOUR));
}
if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) {
args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY));
}
if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) {
args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS)));
}
if (options.hasKey(RNConstants.ARG_INTERVAL) && !options.isNull(RNConstants.ARG_INTERVAL)) {
args.putInt(RNConstants.ARG_INTERVAL, options.getInt(RNConstants.ARG_INTERVAL));
}
if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) {
args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS));
}

return args;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import com.facebook.react.common.annotations.VisibleForTesting;
import com.facebook.react.module.annotations.ReactModule;

import static com.reactcommunity.rndatetimepicker.Common.createDatePickerArguments;
import static com.reactcommunity.rndatetimepicker.Common.dismissDialog;

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

Bundle arguments = createFragmentArguments(options);
Bundle arguments = createDatePickerArguments(options);

if (oldFragment != null) {
oldFragment.update(arguments);
Expand All @@ -162,33 +163,4 @@ public void open(final ReadableMap options, final Promise promise) {
fragment.show(fragmentManager, NAME);
});
}

private Bundle createFragmentArguments(ReadableMap options) {
final Bundle args = Common.createFragmentArguments(options);

if (options.hasKey(RNConstants.ARG_MINDATE) && !options.isNull(RNConstants.ARG_MINDATE)) {
args.putLong(RNConstants.ARG_MINDATE, (long) options.getDouble(RNConstants.ARG_MINDATE));
}
if (options.hasKey(RNConstants.ARG_MAXDATE) && !options.isNull(RNConstants.ARG_MAXDATE)) {
args.putLong(RNConstants.ARG_MAXDATE, (long) options.getDouble(RNConstants.ARG_MAXDATE));
}
if (options.hasKey(RNConstants.ARG_DISPLAY) && !options.isNull(RNConstants.ARG_DISPLAY)) {
args.putString(RNConstants.ARG_DISPLAY, options.getString(RNConstants.ARG_DISPLAY));
}
if (options.hasKey(RNConstants.ARG_DIALOG_BUTTONS) && !options.isNull(RNConstants.ARG_DIALOG_BUTTONS)) {
args.putBundle(RNConstants.ARG_DIALOG_BUTTONS, Arguments.toBundle(options.getMap(RNConstants.ARG_DIALOG_BUTTONS)));
}
if (options.hasKey(RNConstants.ARG_TZOFFSET_MINS) && !options.isNull(RNConstants.ARG_TZOFFSET_MINS)) {
args.putLong(RNConstants.ARG_TZOFFSET_MINS, (long) options.getDouble(RNConstants.ARG_TZOFFSET_MINS));
}
if (options.hasKey(RNConstants.ARG_TESTID) && !options.isNull(RNConstants.ARG_TESTID)) {
args.putString(RNConstants.ARG_TESTID, options.getString(RNConstants.ARG_TESTID));
}
if (options.hasKey(RNConstants.FIRST_DAY_OF_WEEK) && !options.isNull(RNConstants.FIRST_DAY_OF_WEEK)) {
// FIRST_DAY_OF_WEEK is 0-indexed, since it uses the same constants DAY_OF_WEEK used in the Windows implementation
// Android DatePicker uses 1-indexed values, SUNDAY being 1 and SATURDAY being 7, so the +1 is necessary in this case
args.putInt(RNConstants.FIRST_DAY_OF_WEEK, options.getInt(RNConstants.FIRST_DAY_OF_WEEK)+1);
}
return args;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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.dismissDialog

class MaterialDatePickerModule(reactContext: ReactApplicationContext): NativeModuleMaterialDatePickerSpec(reactContext) {
companion object {
const val NAME = "RNCMaterialDatePicker"
}

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 MaterialDatePicker dialog while not attached to an Activity"
)
return
}

val fragmentManager = activity.supportFragmentManager

UiThreadUtil.runOnUiThread {
val arguments = createDatePickerArguments(params)
val datePicker =
RNMaterialDatePicker(arguments, promise, fragmentManager, reactApplicationContext)
datePicker.open()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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.createTimePickerArguments
import com.reactcommunity.rndatetimepicker.Common.dismissDialog

class MaterialTimePickerModule(reactContext: ReactApplicationContext) :
NativeModuleMaterialTimePickerSpec(reactContext) {
companion object {
const val NAME = "RNCMaterialTimePicker"
}

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 MaterialTimePicker dialog while not attached to an Activity"
)
}

val fragmentManager = activity!!.supportFragmentManager

UiThreadUtil.runOnUiThread {
val arguments =
createTimePickerArguments(params)
val materialPicker = RNMaterialTimePicker(
arguments,
promise,
fragmentManager,
reactApplicationContext
)
materialPicker.open()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public final class RNConstants {
public static final String ARG_TZOFFSET_MINS = "timeZoneOffsetInMinutes";
public static final String ARG_TZ_NAME = "timeZoneName";
public static final String ARG_TESTID = "testID";
public static final String ARG_TITLE = "title";
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_TIME_SET = "timeSetAction";
public static final String ACTION_DISMISSED = "dismissedAction";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ public RNDate(Bundle args) {
public int day() { return now.get(Calendar.DAY_OF_MONTH); }
public int hour() { return now.get(Calendar.HOUR_OF_DAY); }
public int minute() { return now.get(Calendar.MINUTE); }
public Long timestamp() { return now.getTimeInMillis(); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext)
return new DatePickerModule(reactContext);
} else if (name.equals(TimePickerModule.NAME)) {
return new TimePickerModule(reactContext);
} else if (name.equals(MaterialDatePickerModule.NAME)) {
return new MaterialDatePickerModule(reactContext);
} else if (name.equals(MaterialTimePickerModule.NAME)) {
return new MaterialTimePickerModule(reactContext);
} else {
return null;
}
Expand Down Expand Up @@ -52,6 +56,28 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() {
false, // isCxxModule
isTurboModule // isTurboModule
));
moduleInfos.put(
MaterialDatePickerModule.NAME,
new ReactModuleInfo(
MaterialDatePickerModule.NAME,
MaterialDatePickerModule.NAME,
false, // canOverrideExistingModule
false, // needsEagerInit
false, // hasConstants
false, // isCxxModule
isTurboModule // isTurboModule
));
moduleInfos.put(
MaterialTimePickerModule.NAME,
new ReactModuleInfo(
MaterialTimePickerModule.NAME,
MaterialTimePickerModule.NAME,
false, // canOverrideExistingModule
false, // needsEagerInit
false, // hasConstants
false, // isCxxModule
isTurboModule // isTurboModule
));
return moduleInfos;
};
}
Expand Down
Loading

0 comments on commit e81c6a1

Please sign in to comment.