On the application I am currently working, it is necessary to select a single date or a period from the same JavaFX 8 DatePicker.
The preferred way of doing this would be as follows:
Selecting a single date - same as default behaviour of the DatePicker.
Selecting a period - select start/end date by holding down the mouse button and drag to the desired end/start date. When the mouse button is released you have defined your period. The fact that you cannot select dates other than those displayed is acceptable.
Editing should work for both single date (ex 24.12.2014) and period ( ex: 24.12.2014 - 27.12.2014)
A possible rendering of the selected period (minus the content of the text editor) above would look like this:
Where orange indicates current date, blue indicates selected period. The picture is from a prototype I made, but where the period is selected by using 2 DatePickers rather than one.
I had a look at the sourcecode for
com.sun.javafx.scene.control.skin.DatePickerContent
which has a
protected List<DateCell> dayCells = new ArrayList<DateCell>();
in order to find a way of detecting when the mouse selected a date end when the mouse was released (or maybe detecting a drag).
However I am not quite sure how to go about it. Any suggestions?
I am attaching the simple prototype code I have made so far (that makes use of 2 rather than the desired 1 datepicker).
import java.time.LocalDate;
import javafx.beans.property.SimpleObjectProperty;
public interface PeriodController {
/**
* @return Today.
*/
LocalDate currentDate();
/**
* @return Selected from date.
*/
SimpleObjectProperty<LocalDate> fromDateProperty();
/**
* @return Selected to date.
*/
SimpleObjectProperty<LocalDate> toDateProperty();
}
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import javafx.util.StringConverter;
public class DateConverter extends StringConverter<LocalDate> {
private DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy"); // TODO i18n
@Override
public String toString(LocalDate date) {
if (date != null) {
return dateFormatter.format(date);
} else {
return "";
}
}
@Override
public LocalDate fromString(String string) {
if (string != null && !string.isEmpty()) {
return LocalDate.parse(string, dateFormatter);
} else {
return null;
}
}
}
import static java.lang.System.out;
import java.time.LocalDate;
import java.util.Locale;
import javafx.application.Application;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.geometry.HPos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class PeriodMain extends Application {
private Stage stage;
public static void main(String[] args) {
Locale.setDefault(new Locale("no", "NO"));
launch(args);
}
@Override
public void start(Stage stage) {
this.stage = stage;
stage.setTitle("Period prototype ");
initUI();
stage.getScene().getStylesheets().add(getClass().getResource("/period-picker.css").toExternalForm());
stage.show();
}
private void initUI() {
VBox vbox = new VBox(20);
vbox.setStyle("-fx-padding: 10;");
Scene scene = new Scene(vbox, 400, 200);
stage.setScene(scene);
final PeriodPickerPrototype periodPickerPrototype = new PeriodPickerPrototype(new PeriodController() {
SimpleObjectProperty<LocalDate> fromDate = new SimpleObjectProperty<>();
SimpleObjectProperty<LocalDate> toDate = new SimpleObjectProperty<>();
{
final ChangeListener<LocalDate> dateListener = (observable, oldValue, newValue) -> {
if (fromDate.getValue() != null && toDate.getValue() != null) {
out.println("Selected period " + fromDate.getValue() + " - " + toDate.getValue());
}
};
fromDate.addListener(dateListener);
toDate.addListener(dateListener);
}
@Override public LocalDate currentDate() {
return LocalDate.now();
}
@Override public SimpleObjectProperty<LocalDate> fromDateProperty() {
return fromDate;
}
@Override public SimpleObjectProperty<LocalDate> toDateProperty() {
return toDate;
}
});
GridPane gridPane = new GridPane();
gridPane.setHgap(10);
gridPane.setVgap(10);
Label checkInlabel = new Label("Check-In Date:");
GridPane.setHalignment(checkInlabel, HPos.LEFT);
gridPane.add(periodPickerPrototype, 0, 1);
vbox.getChildren().add(gridPane);
}
}
import java.time.LocalDate;
import javafx.beans.value.ChangeListener;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.control.DateCell;
import javafx.scene.control.DatePicker;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.GridPane;
import javafx.util.Callback;
import javafx.util.StringConverter;
/**
* Selecting a single date or a period - only a prototype.
* As long as you have made an active choice on the {@code toDate}, the {@code fromDate} and {@code toDate} will have the same date.
*/
public class PeriodPickerPrototype extends GridPane {
private static final String CSS_CALENDAR_BEFORE = "calendar-before";
private static final String CSS_CALENDAR_BETWEEN = "calendar-between";
private static final String CSS_CALENDAR_TODAY = "calendar-today";
private static final boolean DISPLAY_WEEK_NUMBER = true;
private Label fromLabel;
private Label toLabel;
private DatePicker fromDate;
private DatePicker toDate;
private StringConverter<LocalDate> converter;
private PeriodController controller;
private ChangeListener<LocalDate> fromDateListener;
private ChangeListener<LocalDate> toDateListener;
private Callback<DatePicker, DateCell> toDateCellFactory;
private Callback<DatePicker, DateCell> fromDateCellFactory;
private Tooltip todayTooltip;
private boolean toDateIsActivlyChosenbyUser;
public PeriodPickerPrototype(final PeriodController periodController)
{
this.controller = periodController;
createComponents();
makeLayout();
createHandlers();
bindAndRegisterHandlers();
i18n();
initComponent();
}
public void createComponents() {
fromLabel = new Label();
toLabel = new Label();
fromDate = new DatePicker();
toDate = new DatePicker();
todayTooltip = new Tooltip();
}
public void createHandlers() {
fromDate.setOnAction(event -> {
if ((!toDateIsActivlyChosenbyUser) || fromDate.getValue().isAfter(toDate.getValue())) {
setDateWithoutFiringEvent(fromDate.getValue(), toDate);
toDateIsActivlyChosenbyUser = false;
}
});
toDate.setOnAction(event -> toDateIsActivlyChosenbyUser = true);
fromDateCellFactory = new Callback<DatePicker, DateCell>() {
@Override public DateCell call(final DatePicker datePicker) {
return new DateCell() {
@Override
public void updateItem(LocalDate item, boolean empty) {
super.updateItem(item, empty);
getStyleClass().removeAll(CSS_CALENDAR_TODAY, CSS_CALENDAR_BEFORE, CSS_CALENDAR_BETWEEN);
if ((item.isBefore(toDate.getValue()) || item.isEqual(toDate.getValue())) && item.isAfter(fromDate.getValue())) {
getStyleClass().add(CSS_CALENDAR_BETWEEN);
}
if (item.isEqual(controller.currentDate())) {
getStyleClass().add(CSS_CALENDAR_TODAY);
setTooltip(todayTooltip);
} else {
setTooltip(null);
}
}
};
}
};
toDateCellFactory =
new Callback<DatePicker, DateCell>() {
@Override
public DateCell call(final DatePicker datePicker) {
return new DateCell() {
@Override
public void updateItem(LocalDate item, boolean empty) {
super.updateItem(item, empty);
setDisable(item.isBefore(fromDate.getValue()));
getStyleClass().removeAll(CSS_CALENDAR_TODAY, CSS_CALENDAR_BEFORE, CSS_CALENDAR_BETWEEN);
if (item.isBefore(fromDate.getValue())) {
getStyleClass().add(CSS_CALENDAR_BEFORE);
} else if (item.isBefore(toDate.getValue()) || item.isEqual(toDate.getValue())) {
getStyleClass().add(CSS_CALENDAR_BETWEEN);
}
if (item.isEqual(controller.currentDate())) {
getStyleClass().add(CSS_CALENDAR_TODAY);
setTooltip(todayTooltip);
} else {
setTooltip(null);
}
}
};
}
};
converter = new DateConverter();
fromDateListener = (observableValue, oldValue, newValue) -> {
if (newValue == null) {
// Restting old value and cancel..
setDateWithoutFiringEvent(oldValue, fromDate);
return;
}
controller.fromDateProperty().set(newValue);
};
toDateListener = (observableValue, oldValue, newValue) -> {
if (newValue == null) {
// Restting old value and cancel..
setDateWithoutFiringEvent(oldValue, toDate);
return;
}
controller.toDateProperty().set(newValue);
};
}
/**
* Changes the date on {@code datePicker} without fire {@code onAction} event.
*/
private void setDateWithoutFiringEvent(LocalDate newDate, DatePicker datePicker) {
final EventHandler<ActionEvent> onAction = datePicker.getOnAction();
datePicker.setOnAction(null);
datePicker.setValue(newDate);
datePicker.setOnAction(onAction);
}
public void bindAndRegisterHandlers() {
toDate.setDayCellFactory(toDateCellFactory);
fromDate.setDayCellFactory(fromDateCellFactory);
fromDate.valueProperty().addListener(fromDateListener);
fromDate.setConverter(converter);
toDate.valueProperty().addListener(toDateListener);
toDate.setConverter(converter);
}
public void makeLayout() {
setHgap(6);
add(fromLabel, 0, 0);
add(fromDate, 1, 0);
add(toLabel, 2, 0);
add(toDate, 3, 0);
fromDate.setPrefWidth(120);
toDate.setPrefWidth(120);
fromLabel.setId("calendar-label");
toLabel.setId("calendar-label");
}
public void i18n() {
// i18n code replaced with
fromDate.setPromptText("dd.mm.yyyy");
toDate.setPromptText("dd.mm.yyyy");
fromLabel.setText("From");
toLabel.setText("To");
todayTooltip.setText("Today");
}
public void initComponent() {
fromDate.setTooltip(null); // Ønsker ikke tooltip
setDateWithoutFiringEvent(controller.currentDate(), fromDate);
fromDate.setShowWeekNumbers(DISPLAY_WEEK_NUMBER);
toDate.setTooltip(null); // Ønsker ikke tooltip
setDateWithoutFiringEvent(controller.currentDate(), toDate);
toDate.setShowWeekNumbers(DISPLAY_WEEK_NUMBER);
}
}
/** period-picker.css goes udner resources (using maven) **/
.date-picker {
/* -fx-font-size: 11pt;*/
}
.calendar-before {
}
.calendar-between {
-fx-background-color: #bce9ff;
}
.calendar-between:hover {
-fx-background-color: rgb(0, 150, 201);
}
.calendar-between:focused {
-fx-background-color: rgb(0, 150, 201);
}
.calendar-today {
-fx-background-color: rgb(255, 218, 111);
}
.calendar-today:hover {
-fx-background-color: rgb(0, 150, 201);
}
.calendar-today:focused {
-fx-background-color: rgb(0, 150, 201);
}
#calendar-label {
-fx-font-style: italic;
-fx-fill: rgb(75, 75, 75);
-fx-font-size: 11;
}
I think you are already in the right track... DateCell
and drag could work, since the popup is not closed if a dragging event is detected or when it ends. That gives you the opportunity to track the cells selected by the user.
This is a quick hack, but it may help you with the range selection.
First it will get the content and a list of all the cells within the displayed month, adding a listener to drag events, marking as the first cell that where the drag starts, and selecting all the cells within this first cell and the cell under the actual mouse position, deselecting the rest.
After the drag event finished, the selected range is shown on the console. And you can start all over again, until the popup is closed.
private DateCell iniCell=null;
private DateCell endCell=null;
@Override
public void start(Stage primaryStage) {
DatePicker datePicker=new DatePicker();
datePicker.setValue(LocalDate.now());
Scene scene = new Scene(new AnchorPane(datePicker), 300, 250);
primaryStage.setScene(scene);
primaryStage.show();
datePicker.showingProperty().addListener((obs,b,b1)->{
if(b1){
DatePickerContent content = (DatePickerContent)((DatePickerSkin)datePicker.getSkin()).getPopupContent();
List<DateCell> cells = content.lookupAll(".day-cell").stream()
.filter(ce->!ce.getStyleClass().contains("next-month"))
.map(n->(DateCell)n)
.collect(Collectors.toList());
content.setOnMouseDragged(e->{
Node n=e.getPickResult().getIntersectedNode();
DateCell c=null;
if(n instanceof DateCell){
c=(DateCell)n;
} else if(n instanceof Text){
c=(DateCell)(n.getParent());
}
if(c!=null && c.getStyleClass().contains("day-cell") &&
!c.getStyleClass().contains("next-month")){
if(iniCell==null){
iniCell=c;
}
endCell=c;
}
if(iniCell!=null && endCell!=null){
int ini=(int)Math.min(Integer.parseInt(iniCell.getText()),
Integer.parseInt(endCell.getText()));
int end=(int)Math.max(Integer.parseInt(iniCell.getText()),
Integer.parseInt(endCell.getText()));
cells.stream()
.forEach(ce->ce.getStyleClass().remove("selected"));
cells.stream()
.filter(ce->Integer.parseInt(ce.getText())>=ini)
.filter(ce->Integer.parseInt(ce.getText())<=end)
.forEach(ce->ce.getStyleClass().add("selected"));
}
});
content.setOnMouseReleased(e->{
if(iniCell!=null && endCell!=null){
System.out.println("Selection from "+iniCell.getText()+" to "+endCell.getText());
}
endCell=null;
iniCell=null;
});
}
});
}
And this is how it looks like:
For now this doesn't update the textfield, as this involves using a custom formatter.
EDIT
I've added a custom string converter to show the range on the textfield, after a selection is done, and also to select a range if a valid one is entered.
This is not bullet proof, but it works as a proof of concept.
private DateCell iniCell=null;
private DateCell endCell=null;
private LocalDate iniDate;
private LocalDate endDate;
final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("d.MM.uuuu", Locale.ENGLISH);
@Override
public void start(Stage primaryStage) {
DatePicker datePicker=new DatePicker();
datePicker.setValue(LocalDate.now());
datePicker.setConverter(new StringConverter<LocalDate>() {
@Override
public String toString(LocalDate object) {
if(iniDate!=null && endDate!=null){
return iniDate.format(formatter)+" - "+endDate.format(formatter);
}
return object.format(formatter);
}
@Override
public LocalDate fromString(String string) {
if(string.contains("-")){
try{
iniDate=LocalDate.parse(string.split("-")[0].trim(), formatter);
endDate=LocalDate.parse(string.split("-")[1].trim(), formatter);
} catch(DateTimeParseException dte){
return LocalDate.parse(string, formatter);
}
return iniDate;
}
return LocalDate.parse(string, formatter);
}
});
Scene scene = new Scene(new AnchorPane(datePicker), 300, 250);
primaryStage.setScene(scene);
primaryStage.show();
datePicker.showingProperty().addListener((obs,b,b1)->{
if(b1){
DatePickerContent content = (DatePickerContent)((DatePickerSkin)datePicker.getSkin()).getPopupContent();
List<DateCell> cells = content.lookupAll(".day-cell").stream()
.filter(ce->!ce.getStyleClass().contains("next-month"))
.map(n->(DateCell)n)
.collect(Collectors.toList());
// select initial range
if(iniDate!=null && endDate!=null){
int ini=iniDate.getDayOfMonth();
int end=endDate.getDayOfMonth();
cells.stream()
.forEach(ce->ce.getStyleClass().remove("selected"));
cells.stream()
.filter(ce->Integer.parseInt(ce.getText())>=ini)
.filter(ce->Integer.parseInt(ce.getText())<=end)
.forEach(ce->ce.getStyleClass().add("selected"));
}
iniCell=null;
endCell=null;
content.setOnMouseDragged(e->{
Node n=e.getPickResult().getIntersectedNode();
DateCell c=null;
if(n instanceof DateCell){
c=(DateCell)n;
} else if(n instanceof Text){
c=(DateCell)(n.getParent());
}
if(c!=null && c.getStyleClass().contains("day-cell") &&
!c.getStyleClass().contains("next-month")){
if(iniCell==null){
iniCell=c;
}
endCell=c;
}
if(iniCell!=null && endCell!=null){
int ini=(int)Math.min(Integer.parseInt(iniCell.getText()),
Integer.parseInt(endCell.getText()));
int end=(int)Math.max(Integer.parseInt(iniCell.getText()),
Integer.parseInt(endCell.getText()));
cells.stream()
.forEach(ce->ce.getStyleClass().remove("selected"));
cells.stream()
.filter(ce->Integer.parseInt(ce.getText())>=ini)
.filter(ce->Integer.parseInt(ce.getText())<=end)
.forEach(ce->ce.getStyleClass().add("selected"));
}
});
content.setOnMouseReleased(e->{
if(iniCell!=null && endCell!=null){
iniDate=LocalDate.of(datePicker.getValue().getYear(),
datePicker.getValue().getMonth(),
Integer.parseInt(iniCell.getText()));
endDate=LocalDate.of(datePicker.getValue().getYear(),
datePicker.getValue().getMonth(),
Integer.parseInt(endCell.getText()));
System.out.println("Selection from "+iniDate+" to "+endDate);
datePicker.setValue(iniDate);
int ini=iniDate.getDayOfMonth();
int end=endDate.getDayOfMonth();
cells.stream()
.forEach(ce->ce.getStyleClass().remove("selected"));
cells.stream()
.filter(ce->Integer.parseInt(ce.getText())>=ini)
.filter(ce->Integer.parseInt(ce.getText())<=end)
.forEach(ce->ce.getStyleClass().add("selected"));
}
endCell=null;
iniCell=null;
});
}
});
}
By using this answer here: https://stackoverflow.com/a/60618476/9278333
I was able to create this date range selector without the use of a private api:
Usage:
MultiDatePicker multiDatePicker = new MultiDatePicker().withRangeSelectionMode();
DatePicker rangePicker = multiDatePicker.getDatePicker();
import javafx.collections.FXCollections;
import javafx.scene.control.*;
import javafx.util.StringConverter;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import static java.time.temporal.ChronoUnit.DAYS;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import javafx.collections.ObservableSet;
import javafx.event.EventHandler;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
public class MultiDatePicker
{
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final ObservableSet<LocalDate> selectedDates;
private final DatePicker datePicker;
public MultiDatePicker()
{
this.selectedDates = FXCollections.observableSet(new TreeSet<>());
this.datePicker = new DatePicker();
setUpDatePicker();
}
public MultiDatePicker withRangeSelectionMode()
{
EventHandler<MouseEvent> mouseClickedEventHandler = (MouseEvent clickEvent) ->
{
if (clickEvent.getButton() == MouseButton.PRIMARY)
{
if (!this.selectedDates.contains(this.datePicker.getValue()))
{
this.selectedDates.add(datePicker.getValue());
this.selectedDates.addAll(getRangeGaps((LocalDate) this.selectedDates.toArray()[0], (LocalDate) this.selectedDates.toArray()[this.selectedDates.size() - 1]));
} else
{
this.selectedDates.remove(this.datePicker.getValue());
this.selectedDates.removeAll(getTailEndDatesToRemove(this.selectedDates, this.datePicker.getValue()));
this.datePicker.setValue(getClosestDateInTree(new TreeSet<>(this.selectedDates), this.datePicker.getValue()));
}
}
this.datePicker.show();
clickEvent.consume();
};
this.datePicker.setDayCellFactory((DatePicker param) -> new DateCell()
{
@Override
public void updateItem(LocalDate item, boolean empty)
{
super.updateItem(item, empty);
//...
if (item != null && !empty)
{
//...
addEventHandler(MouseEvent.MOUSE_CLICKED, mouseClickedEventHandler);
} else
{
//...
removeEventHandler(MouseEvent.MOUSE_CLICKED, mouseClickedEventHandler);
}
if (!selectedDates.isEmpty() && selectedDates.contains(item))
{
if (Objects.equals(item, selectedDates.toArray()[0]) || Objects.equals(item, selectedDates.toArray()[selectedDates.size() - 1]))
{
setStyle("-fx-background-color: rgba(3, 169, 1, 0.7);");
} else
{
setStyle("-fx-background-color: rgba(3, 169, 244, 0.7);");
}
} else
{
setStyle(null);
}
}
});
return this;
}
public ObservableSet<LocalDate> getSelectedDates()
{
return this.selectedDates;
}
public DatePicker getDatePicker()
{
return this.datePicker;
}
private void setUpDatePicker()
{
this.datePicker.setConverter(new StringConverter<LocalDate>()
{
@Override
public String toString(LocalDate date)
{
return (date == null) ? "" : DATE_FORMAT.format(date);
}
@Override
public LocalDate fromString(String string)
{
return ((string == null) || string.isEmpty()) ? null : LocalDate.parse(string, DATE_FORMAT);
}
});
EventHandler<MouseEvent> mouseClickedEventHandler = (MouseEvent clickEvent) ->
{
if (clickEvent.getButton() == MouseButton.PRIMARY)
{
if (!this.selectedDates.contains(this.datePicker.getValue()))
{
this.selectedDates.add(datePicker.getValue());
} else
{
this.selectedDates.remove(this.datePicker.getValue());
this.datePicker.setValue(getClosestDateInTree(new TreeSet<>(this.selectedDates), this.datePicker.getValue()));
}
}
this.datePicker.show();
clickEvent.consume();
};
this.datePicker.setDayCellFactory((DatePicker param) -> new DateCell()
{
@Override
public void updateItem(LocalDate item, boolean empty)
{
super.updateItem(item, empty);
//...
if (item != null && !empty)
{
//...
addEventHandler(MouseEvent.MOUSE_CLICKED, mouseClickedEventHandler);
} else
{
//...
removeEventHandler(MouseEvent.MOUSE_CLICKED, mouseClickedEventHandler);
}
if (selectedDates.contains(item))
{
setStyle("-fx-background-color: rgba(3, 169, 244, 0.7);");
} else
{
setStyle(null);
}
}
});
}
private static Set<LocalDate> getTailEndDatesToRemove(Set<LocalDate> dates, LocalDate date)
{
TreeSet<LocalDate> tempTree = new TreeSet<>(dates);
tempTree.add(date);
int higher = tempTree.tailSet(date).size();
int lower = tempTree.headSet(date).size();
if (lower <= higher)
{
return tempTree.headSet(date);
} else if (lower > higher)
{
return tempTree.tailSet(date);
} else
{
return new TreeSet<>();
}
}
private static LocalDate getClosestDateInTree(TreeSet<LocalDate> dates, LocalDate date)
{
Long lower = null;
Long higher = null;
if (dates.isEmpty())
{
return null;
}
if (dates.size() == 1)
{
return dates.first();
}
if (dates.lower(date) != null)
{
lower = Math.abs(DAYS.between(date, dates.lower(date)));
}
if (dates.higher(date) != null)
{
higher = Math.abs(DAYS.between(date, dates.higher(date)));
}
if (lower == null)
{
return dates.higher(date);
} else if (higher == null)
{
return dates.lower(date);
} else if (lower <= higher)
{
return dates.lower(date);
} else if (lower > higher)
{
return dates.higher(date);
} else
{
return null;
}
}
private static Set<LocalDate> getRangeGaps(LocalDate min, LocalDate max)
{
Set<LocalDate> rangeGaps = new LinkedHashSet<>();
if (min == null || max == null)
{
return rangeGaps;
}
LocalDate lastDate = min.plusDays(1);
while (lastDate.isAfter(min) && lastDate.isBefore(max))
{
rangeGaps.add(lastDate);
lastDate = lastDate.plusDays(1);
}
return rangeGaps;
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With