Make SearchableComboBoxSkin accessible to SpreadsheetView's table cells

4 views
Skip to first unread message

Mark Schmieder

unread,
Apr 1, 2023, 1:56:26 AM4/1/23
to ControlsFX
My clients wanted consistent LAF across controls, and especially had a need for searchable combo boxes inside tables, meaning in this case SpreadsheetView.

Although I realize that SpreadsheetView's place in ControlsFX has been tenuous at times (though I recall seeing it resurrected a year or so ago, I haven't had a chance to check current status), there are also other contexts where being able to apply the searchable filter is important, and where something can't be directly skinned and needs an actual SearchableComboBox instance.

Feel free to prove me wrong; I'm a sole developer for many years so I am far from perfect.

The approach I took was to cache the skin as a queryable reference in the main class, altering it like so:

public class SearchableComboBox< T > extends ComboBox< T > {


private static final String DEFAULT_STYLE_CLASS = "searchable-combo-box";


// Cache the Skin so it can be referenced by SpreadsheetView contexts.

protected SearchableComboBoxSkin< T > searchableComboBoxSkin;


public SearchableComboBox() {

this(FXCollections.observableArrayList());

}


public SearchableComboBox( final ObservableList< T > items ) {

super( items );

getStyleClass().add(DEFAULT_STYLE_CLASS);

}


@Override

protected Skin< ? > createDefaultSkin() {

searchableComboBoxSkin = new SearchableComboBoxSkin<>( this );

return searchableComboBoxSkin;

}


public SearchableComboBoxSkin< T > getDefaultSkin() {

return searchableComboBoxSkin;

}


}


I then needed to modify the ListEditor inner class in SpreadsheetCellEditor, but this solution is too specific to a particular application choice and would need to be further abstracted and done through API settings vs. hard-wired into a revised implementation of ListEditor:

public static class ListEditor< R > extends SpreadsheetCellEditor {


protected static final boolean isCategoryLabel( final String item ) {

// NOTE: We must maintain specific formatting for lines that are

// markers for categories vs. actual supported items for selection,

// as a simple strategy for now vs. an explicit exclusion list that

// is pre-generated.

final boolean isCategoryLabel = item.startsWith( "--" ) || item.endsWith( ":" );

return isCategoryLabel;

}


private final List< String > itemList;


// We need to know at all times whether we are marked as Searchable.

protected boolean searchable;


protected final ComboBox< String > cb;


protected String originalValue;


private ChangeListener< String > changeListener;


/**

* This is needed because "endEdit" will call our "end" method too late

* when pressing ENTER, so several "endEdit" calls will occur. This flag

* is designed to prevent that from happening.

*/

protected boolean ending;


/**

* Constructor for the ListEditor.

*

* @param view

* The SpreadsheetView host for this ListEditor

* @param pItemList

* The items to display in the editor

* @param pSearchable

* Flag for whether to use a SearchableComboBox or a

* regular ComboBox as the selector/editor

*/

public ListEditor( final SpreadsheetView view,

final List< String > pItemList,

final boolean pSearchable ) {

super( view );


ending = false;


itemList = pItemList;

searchable = pSearchable;


final ObservableList< String > items = FXCollections.observableList( itemList );

cb = searchable ? new SearchableComboBox<>( items ) : new ComboBox<>( items );

cb.setVisibleRowCount( 16 );


// The items list is static as we regenerate the ComboBox every

// time the list changes, so we immediately disable the custom

// syntax based category labels.

cb.setCellFactory( param -> {

final ListCell< String > listCell = new ListCell< String >() {

@Override

public void updateItem( final String item, final boolean empty ) {

super.updateItem( item, empty );

if ( item != null ) {

setText( item );


// Selectively enable or disable each item based on

// whether it is a Category Label or a real item.

final boolean disable = isCategoryLabel( item );

setDisable( disable );

if ( disable ) {

setTextFill( Color.DARKGRAY );

}

else {

setTextFill( Color.BLACK );

}

}

else {

setText( null );

}

}

};


return listCell;

} );

}


/***************************************************************************

* * Public Methods * *

**************************************************************************/


/** {@inheritDoc} */

@Override

public void startEdit( final Object value, final String format, final Object... options ) {

if ( value instanceof String ) {

originalValue = value.toString();

}

else {

originalValue = null;

}


final ObservableList< String > items = FXCollections.observableList( itemList );

cb.setItems( items );

cb.setValue( originalValue );


attachEnterEscapeEventHandler();


cb.show();


// Do not request focus if we are using a SearchableComboBox as

// that prevents the specialized custom control from showing on the

// screen due to some quirks in the implementation.

if ( !searchable ) {

cb.requestFocus();

}

}

/** {@inheritDoc} */

@Override

public void end() {

cb.setOnKeyPressed( null );

if ( changeListener != null ) {

cb.valueProperty().removeListener( changeListener );

}

}


/** {@inheritDoc} */

@Override

public ComboBox< String > getEditor() {

return cb;

}

/** {@inheritDoc} */

@Override

public String getControlValue() {

return cb.getSelectionModel().getSelectedItem();

}


/** {@inheritDoc} */

@Override

public int getSelectedIndex() {

return cb.getSelectionModel().getSelectedIndex();

}


/***************************************************************************

* * Private Methods * *

**************************************************************************/


private void attachEnterEscapeEventHandler() {

// To avoid deadlock, we first release any listeners already

// attached.

if ( changeListener != null ) {

cb.valueProperty().removeListener( changeListener );

}


// We need to set an "ending" flag because otherwise the change

// listener may auto-save when "ENTER" or "ESCAPE" is pressed,

// before we get a chance to add our special handling rules.

cb.setOnKeyPressed( keyEvent -> {

final KeyCode keyCode = keyEvent.getCode();

switch ( keyCode ) {

case ENTER:

ending = true;

endEdit( true );

ending = false;


break;

case ESCAPE:

cb.setValue( originalValue );

endEdit( false );


break;

// $CASES-OMITTED$

default:

break;

}

} );


// Unless the ComboBox is processing an ENTER or ESCAPE key event,

/ we should immediately commit edits from list selection changes.

// NOTE: An exception is made when we are Searchable, as that is a

// multi-level control just like other complex controls such as the

// Color Picker, with several levels of focus and commit handling.

f ( !searchable ) {

changeListener = ( observableValue, oldValue, newValue ) -> {

if ( !ending ) {

endEdit( true );

}

};


cb.valueProperty().addListener( changeListener );

}

else {

// If we are using a SearchableComboBox, we have to instead wait

// for focus to be lost from the search field, as that is what

// indicates that the user has either made a new selection or

// canceled changes to restore the old choice.

final SearchableComboBoxSkin< String > skin =

( ( SearchableComboBox< String > ) cb )

.getDefaultSkin();

final CustomTextField searchField = skin.getSearchField();

searchField.focusedProperty()

.addListener( ( observableValue, oldValue, newValue ) -> {

// If we just lost focus on the search field, the

// user has either made their new choice or canceled

// changes to restore the old choice.

if ( !newValue ) {

// As with regular ComboBox user interaction, we

// have to be careful to avoid double-processing

// on ENTER key handling, but probably this is

// unreachable code due to order of handling.

if ( !ending ) {

endEdit( true );

}

}

} );

}

}

}


As is probably evident, the ListEditor modifications also include a slew of much earlier bugfixes that I did, that I was stymied in trying to contribute due to how often things changed between releases (and how I would frequently have to go back a version and cherry-pick updates).

I have a LOT of SpreadsheetView fixes and a few enhancements (but mostly fixes for edge cases, greater stability, consistency, interoperability with other ControlsFX features, and performance). It is unlikely I will have time to find how to retrofit them into current code streams, but the snippet above gives some idea of the nature of some of the changes I had to make. Some classes had to be modified just to force integration with custom changes and fixes, but mostly that was not the case.

Mark Schmieder

unread,
Apr 1, 2023, 2:15:13 AM4/1/23
to ControlsFX
I also needed the additional public getter method added to SearchableComboBoxSkin:

public CustomTextField getSearchField() {

return searchField;

}


Reply all
Reply to author
Forward
0 new messages