Skip to content

Commit 8dc498d

Browse files
Tatsunori-MoritaTatsunoriMoritavonovak
authored
feat(android): date picker startOnYearSelection (#1004)
* add option to show year selection view first. * add showYearPickerFirst option to example. * add readme. * Add a gourd clause. #1004 (comment) * Merge variables. #1004 (comment) * Fix year section display logic. - Remove no force unwrap - Add a guard clause - Add observer cleanup #1004 (comment) * rename showYearPickerFirst to startOnYearSelection #1004 (comment) * review --------- Co-authored-by: TatsunoriMorita <114038079+TatsunoriMorita@users.noreply.github.com> Co-authored-by: Vojtech Novak <vonovak@gmail.com>
1 parent b7d7a56 commit 8dc498d

File tree

13 files changed

+123
-8
lines changed

13 files changed

+123
-8
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ React Native date & time picker component for iOS, Android and Windows (please n
8585
- [`initialInputMode` (`optional`, `Android only`)](#initialinputmode-optional-android-only)
8686
- [`title` (`optional`, `Android only`)](#title-optional-android-only)
8787
- [`fullscreen` (`optional`, `Android only`)](#fullscreen-optional-android-only)
88+
- [`startOnYearSelection` (`optional`, `Android only`)](#startOnYearSelection-optional-android-only)
8889
- [`onChange` (`optional`)](#onchange-optional)
8990
- [`value` (`required`)](#value-required)
9091
- [`maximumDate` (`optional`)](#maximumdate-optional)
@@ -534,6 +535,14 @@ List of possible values:
534535
<RNDateTimePicker fullscreen={true} />
535536
```
536537

538+
#### `startOnYearSelection` (`optional`, `Android only`)
539+
540+
If true, the date picker will open with the year selector first. Not applicable for material picker.
541+
542+
```js
543+
<RNDateTimePicker startOnYearSelection={true} />
544+
```
545+
537546
#### `positiveButton` (`optional`, `Android only`)
538547

539548
Set the positive button label and text color.

android/src/main/java/com/reactcommunity/rndatetimepicker/Common.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package com.reactcommunity.rndatetimepicker;
22

33
import android.app.AlertDialog;
4+
import android.app.DatePickerDialog;
45
import android.content.Context;
56
import android.content.DialogInterface;
67
import android.content.res.Resources;
78
import android.graphics.Color;
89
import android.os.Bundle;
910
import android.util.TypedValue;
11+
import android.view.View;
1012
import android.widget.Button;
13+
import android.widget.DatePicker;
1114

1215
import androidx.annotation.ColorInt;
1316
import androidx.annotation.ColorRes;
@@ -92,6 +95,31 @@ public static DialogInterface.OnShowListener setButtonTextColor(@NonNull final C
9295
};
9396
}
9497

98+
@NonNull
99+
public static DialogInterface.OnShowListener openYearDialog(final AlertDialog dialog, final boolean shouldOpenYearDialog) {
100+
return dialogInterface -> {
101+
if (shouldOpenYearDialog && dialog instanceof DatePickerDialog datePickerDialog) {
102+
DatePicker datePicker = datePickerDialog.getDatePicker();
103+
104+
int yearId = Resources.getSystem().getIdentifier("date_picker_header_year", "id", "android");
105+
if (yearId == 0) return;
106+
View yearView = datePicker.findViewById(yearId);
107+
if (yearView != null) {
108+
yearView.performClick();
109+
}
110+
}
111+
};
112+
}
113+
114+
@NonNull
115+
public static DialogInterface.OnShowListener combine(@NonNull DialogInterface.OnShowListener... listeners) {
116+
return dialogInterface -> {
117+
for (DialogInterface.OnShowListener l : listeners) {
118+
if (l != null) l.onShow(dialogInterface);
119+
}
120+
};
121+
}
122+
95123
private static void setTextColor(Button button, String buttonKey, final Bundle args, final boolean needsColorOverride, int textColorPrimary) {
96124
if (button == null) return;
97125

@@ -245,6 +273,9 @@ public static Bundle createDatePickerArguments(ReadableMap options) {
245273
// Android DatePicker uses 1-indexed values, SUNDAY being 1 and SATURDAY being 7, so the +1 is necessary in this case
246274
args.putInt(RNConstants.FIRST_DAY_OF_WEEK, options.getInt(RNConstants.FIRST_DAY_OF_WEEK)+1);
247275
}
276+
if (options.hasKey(RNConstants.ARG_START_ON_YEAR_SELECTION) && !options.isNull(RNConstants.ARG_START_ON_YEAR_SELECTION)) {
277+
args.putBoolean(RNConstants.ARG_START_ON_YEAR_SELECTION, options.getBoolean(RNConstants.ARG_START_ON_YEAR_SELECTION));
278+
}
248279
return args;
249280
}
250281

android/src/main/java/com/reactcommunity/rndatetimepicker/RNConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public final class RNConstants {
2222
public static final String ACTION_DISMISSED = "dismissedAction";
2323
public static final String ACTION_NEUTRAL_BUTTON = "neutralButtonAction";
2424
public static final String FIRST_DAY_OF_WEEK = "firstDayOfWeek";
25+
public static final String ARG_START_ON_YEAR_SELECTION = "startOnYearSelection";
2526

2627
/**
2728
* Minimum date supported by {@link TimePickerDialog}, 01 Jan 1900

android/src/main/java/com/reactcommunity/rndatetimepicker/RNDatePickerDialogFragment.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
package com.reactcommunity.rndatetimepicker;
99

10+
import static com.reactcommunity.rndatetimepicker.Common.combine;
1011
import static com.reactcommunity.rndatetimepicker.Common.getDisplayDate;
12+
import static com.reactcommunity.rndatetimepicker.Common.openYearDialog;
1113
import static com.reactcommunity.rndatetimepicker.Common.setButtonTextColor;
1214
import static com.reactcommunity.rndatetimepicker.Common.setButtonTitles;
1315

@@ -101,7 +103,13 @@ private DatePickerDialog createDialog(Bundle args) {
101103
if (activityContext != null) {
102104
RNDatePickerDisplay display = getDisplayDate(args);
103105
boolean needsColorOverride = display == RNDatePickerDisplay.SPINNER;
104-
dialog.setOnShowListener(setButtonTextColor(activityContext, dialog, args, needsColorOverride));
106+
boolean shouldOpenYearDialog = display == RNDatePickerDisplay.DEFAULT && args.getBoolean(RNConstants.ARG_START_ON_YEAR_SELECTION);
107+
dialog.setOnShowListener(
108+
combine(
109+
openYearDialog(dialog, shouldOpenYearDialog),
110+
setButtonTextColor(activityContext, dialog, args, needsColorOverride)
111+
)
112+
);
105113
}
106114
}
107115

android/src/main/java/com/reactcommunity/rndatetimepicker/RNMaterialDatePicker.kt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ package com.reactcommunity.rndatetimepicker
33
import android.content.DialogInterface
44
import android.os.Bundle
55
import android.util.TypedValue
6+
import android.view.View
7+
import android.view.ViewGroup
8+
import android.widget.TextView
9+
import androidx.appcompat.app.AppCompatActivity
610
import androidx.fragment.app.FragmentManager
711
import com.facebook.react.bridge.Promise
812
import com.facebook.react.bridge.ReactApplicationContext
@@ -42,6 +46,8 @@ class RNMaterialDatePicker(
4246
setFullscreen()
4347

4448
datePicker = builder.build()
49+
50+
setYearPickerFirst()
4551
}
4652

4753
private fun setInitialDate() {
@@ -108,6 +114,45 @@ class RNMaterialDatePicker(
108114
}
109115
}
110116

117+
private fun setYearPickerFirst() {
118+
val startOnYearSelection = args.getBoolean(RNConstants.ARG_START_ON_YEAR_SELECTION)
119+
if (!startOnYearSelection) return
120+
val initialDate = RNDate(args)
121+
val activity = reactContext.currentActivity as? AppCompatActivity
122+
activity?.let { lifecycleOwner ->
123+
val picker = datePicker ?: return@let
124+
val liveData = picker.viewLifecycleOwnerLiveData
125+
liveData.observe(lifecycleOwner) { owner ->
126+
if (owner == null) return@observe
127+
picker.requireDialog().window?.decorView?.post {
128+
val root = picker.dialog?.window?.decorView ?: return@post
129+
130+
val yearText = initialDate.year().toString()
131+
val hit = findViewBy(root) { v ->
132+
v is TextView && v.isShown && v.isClickable && v.text?.toString()
133+
?.contains(yearText) == true
134+
}
135+
if (hit != null) {
136+
hit.performClick()
137+
return@post
138+
}
139+
liveData.removeObservers(lifecycleOwner)
140+
}
141+
}
142+
}
143+
}
144+
145+
private fun findViewBy(root: View, pred: (View) -> Boolean): View? {
146+
if (pred(root)) return root
147+
148+
if (root is ViewGroup) {
149+
for (i in 0 until root.childCount) {
150+
findViewBy(root.getChildAt(i), pred)?.let { return it }
151+
}
152+
}
153+
return null
154+
}
155+
111156
private fun obtainMaterialThemeOverlayId(resId: Int): Int {
112157
val theme = reactContext.currentActivity?.theme ?: run {
113158
return resId

example/App.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ export const App = () => {
105105
const [display, setDisplay] = useState(DISPLAY_VALUES[0]);
106106
const [interval, setMinInterval] = useState(1);
107107
const [neutralButtonLabel, setNeutralButtonLabel] = useState(undefined);
108-
const [disabled, setDisabled] = useState(false);
109108
const [isFullscreen, setIsFullscreen] = useState(false);
109+
const [startOnYearSelection, setStartOnYearSelection] = useState(false);
110110
const [minimumDate, setMinimumDate] = useState();
111111
const [maximumDate, setMaximumDate] = useState();
112112
const [design, setDesign] = useState(DESIGNS[0]);
@@ -371,20 +371,21 @@ export const App = () => {
371371
placeholder="accentColor"
372372
/>
373373
</View>
374+
374375
<View style={styles.header}>
375376
<ThemedText style={styles.textLabel}>
376-
disabled (iOS only)
377+
fullscreen (android only)
377378
</ThemedText>
378379
<View style={{flex: 1, alignItems: 'flex-start'}}>
379-
<Switch value={disabled} onValueChange={setDisabled} />
380+
<Switch value={isFullscreen} onValueChange={setIsFullscreen} />
380381
</View>
381382
</View>
382383
<View style={styles.header}>
383384
<ThemedText style={styles.textLabel}>
384-
fullscreen (android only)
385+
startOnYearSelection (android only)
385386
</ThemedText>
386387
<View style={{flex: 1, alignItems: 'flex-start'}}>
387-
<Switch value={isFullscreen} onValueChange={setIsFullscreen} />
388+
<Switch value={startOnYearSelection} onValueChange={setStartOnYearSelection} />
388389
</View>
389390
</View>
390391
<View style={styles.header}>
@@ -480,7 +481,7 @@ export const App = () => {
480481
setMinimumDate(undefined);
481482
setMaximumDate(undefined);
482483
setShow(false);
483-
} else {
484+
} else {
484485
setMinimumDate(new Date('2025-09-05'));
485486
setMaximumDate(new Date('2025-09-01'));
486487
setShow(true);
@@ -514,12 +515,12 @@ export const App = () => {
514515
accentColor={accentColor || undefined}
515516
neutralButton={{label: neutralButtonLabel}}
516517
negativeButton={{label: 'Cancel', textColor: 'red'}}
517-
disabled={disabled}
518518
firstDayOfWeek={firstDayOfWeek}
519519
title={isMaterialDesign ? title : undefined}
520520
initialInputMode={isMaterialDesign ? inputMode : undefined}
521521
design={design}
522522
fullscreen={isMaterialDesign ? isFullscreen : undefined}
523+
startOnYearSelection={startOnYearSelection}
523524
/>
524525
)}
525526
</View>

src/DateTimePickerAndroid.android.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function open(props: AndroidNativeProps) {
5151
initialInputMode,
5252
design,
5353
fullscreen,
54+
startOnYearSelection,
5455
} = props;
5556
validateAndroidProps(props);
5657
invariant(originalValue, 'A date or time must be specified as `value` prop.');
@@ -97,6 +98,7 @@ function open(props: AndroidNativeProps) {
9798
title,
9899
initialInputMode,
99100
fullscreen,
101+
startOnYearSelection,
100102
});
101103

102104
switch (action) {

src/androidUtils.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type OpenParams = {
3838
title: AndroidNativeProps['title'],
3939
design: AndroidNativeProps['design'],
4040
fullscreen: AndroidNativeProps['fullscreen'],
41+
startOnYearSelection: AndroidNativeProps['startOnYearSelection'],
4142
};
4243

4344
export type PresentPickerCallback =
@@ -88,6 +89,7 @@ function getOpenPicker(
8889
title,
8990
initialInputMode,
9091
fullscreen,
92+
startOnYearSelection,
9193
}: OpenParams) =>
9294
// $FlowFixMe - `AbstractComponent` [1] is not an instance type.
9395
pickers[ANDROID_MODE.date].open({
@@ -103,6 +105,7 @@ function getOpenPicker(
103105
title,
104106
initialInputMode,
105107
fullscreen,
108+
startOnYearSelection,
106109
});
107110
}
108111
}

src/datetimepicker.android.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default function RNDateTimePickerAndroid(
3737
initialInputMode,
3838
design,
3939
fullscreen,
40+
startOnYearSelection,
4041
} = props;
4142
const valueTimestamp = value.getTime();
4243

@@ -72,6 +73,7 @@ export default function RNDateTimePickerAndroid(
7273
initialInputMode,
7374
design,
7475
fullscreen,
76+
startOnYearSelection,
7577
};
7678
DateTimePickerAndroid.open(params);
7779
},

src/index.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ export type AndroidNativeProps = Readonly<
203203
* Use Material 3 pickers or the default ones
204204
*/
205205
design?: Design;
206+
/**
207+
* Show the year picker first when opening the calendar dialog.
208+
*/
209+
startOnYearSelection?: boolean;
206210
}
207211
>;
208212

0 commit comments

Comments
 (0)