sialcasa / mvvmFX

an Application Framework for implementing the MVVM Pattern with JavaFX
Apache License 2.0
494 stars 105 forks source link

Enhance ItemList and make it usable for ComboBox/ChoiceBox #418

Open manuel-mauky opened 8 years ago

manuel-mauky commented 8 years ago

The package de.saxsys.mvvmfx.itemlist contains utils to bind a list of model elements to a ListView without leaking model details to the View.

However, in my opinion there are some problems at the moment:

I've worked on a new/improved prototype of utilities for this use case. This is what the user code could look like:

public class Person {
    @Id
    private Long id;
    private String firstname;
    private Strign lastname;
    // getter & setter
}

public class MyViewModel implements ViewModel {

    private ItemList<Person, Long> itemList = new ItemList<>(Person::getId);

    public MyViewModel() {
        // define how the item should be made visible in the ListView
        // by providing a Function from Modelelement to String
        itemList.setLabelFunction(person -> person.getFirstname() + " " + person.getLastname());

        List<Person> allPersons = ... // load from Backend

        itemList.getModelList().addAll(allPersons); // modelList works on actual model data

        ObjectProperty<Person> selected = itemList.selectedItemProperty();

        // we can set the selected person from code
        selected.setValue(...);
    }

    public ViewItemList<Long> itemList() {
        return itemList;
    }
}

public class MyView implements FxmlView<MyViewModel> {
    @FXML
    private ListView<Long> listView;
    @InjectViewModel
    private MyViewModel viewModel;

    public void initialize() {
        viewModel.itemList().connect(listView);
    }
}

The class ItemList is new. It has 2 generic type arguments: The type of the model and the type of the ID, in this case Long but it could be String too. The interface ViewItemList is implemented by ItemList. It provides the method connect(ListView<K>) where K is the generic type of the id.

In the View we only call the connect method and everything else is done by the framework. In the ViewModel we can work with a list of model data and get a property for the selected model instance. We don't need to take care for handling selection index.

What is cool about this approach is that we can provide additional connect methods for ComboBox, ChoiceBox and maybe even others. This could act as a general API for visualising "list data". Maybe #385 could be implemented this way too.

Open Points

sideisra commented 8 years ago

:+1: Great idea. In my last projects we had a lot of discussions on how to implement this and about the separation of view and view model. Some colleages say that it is cleaner to have a complete separation which means to work only with strings in the view. In my opionion this does make this very hard and confusing. I prefer towork with model classes as generic types in the view (ListView) but i can understand the concerns. My be this id solution is a good balance.

You should also think about editable ComboBoxes. We often use an editable ComboBox enhanced with autocomplete functionality. Sometimes it is necessary to add new model objects to the modellist because the user entered a new element.

Sometimes we had trouble with changing the list while one element is selected. The SelectionModel of JavaFX is really problematic sometimes. A solution would be to clear the selection before any update and select the same element after the update (if its still there). I think such a behaviour would be achievable with this approach. Therefore ViewItemList could for example have a aroundCallback() method which takes a callback that work like a RequestFilter.

manuel-mauky commented 8 years ago

Hi Denny, thanks for the feedback. We have been using model classes as generic types in our last project too. But it feels wrong which is the reason I was working on this approach.

I will have a look at the editable ComboBox use case.

I don't fully understand your last use case. With the current prototype it's not possible to exchange the whole model list. Instead you can get the ObservableList with getModelList() and add or remove objects. I will try out what happens when the list is cleared and new Items are added. What do you mean with the callback? Can you post a (pseudo-)code example of what you think it should look like?

sideisra commented 8 years ago

Some implementations of the SelectionModel (e.g. SelectionModel of TreeTableView) contains Bugs so that exceptions are thrown when removing and adding elements from and into the list during one event. But there is also a scenario where such an aroundCallback could be useful. Given a scenario where a list of Person objects is shown in a TreeView. The TreeView shows the names only and they are orderd according to the name. When the user selects one person in the TreeView it is shown in a detail view. The user can change the name of the person and save it. Now the list in the TreeView has to be updated because the order is wrong now. When reordering the list, the edited person is removed from the list and added again at the correct position.

Problem: the SelectionModel selects the person right above the removed person but the desired bahaviour is to select the same person at the new position.

This scenario could be solved by an arroundCallback that works just like a ServletFilter (or JerseyFilter). example implementation:

class KeepSelectionCallback implements AroundCallback{
  public void doUpdate(UpdateChain chain){
    Long selectedElementId = treeView.getSelectionModel().getSelectedItem();
    //may be clear selection if necessary
    chain.doUpdate();
    treeView.getSelectionModel().select(selectedElementId);
  }
}

Another approach would be to offer two methods: addPreUpdateCallback and addPostUpdateCallback.

manuel-mauky commented 8 years ago

I've added a method replaceModelItems(Collection<T> items, boolean keepSelection).

It can be used to replace all model items. If the flag is true the old selected element (if any) will be cached. If the old element is present in the new item list it will be reselected afterwards. The implementation looks like this:

public void replaceModelItems(Collection<T> items, boolean keepSelection) {
    if(keepSelection) {
        T oldSelectedElement = selectedItem.get();

        getModelList().clear();
        getModelList().addAll(items);

        if(getModelList().contains(oldSelectedElement)) {
            selectedItem.setValue(oldSelectedElement);
        }
    } else {
        getModelList().clear();
        getModelList().addAll(items);
    }
}

Additionally a list changelistener makes sure that if the model list becomes empty the selected item is set to null.

I've extended the example so that the currently selected model item can be renamed by the user. After that all items are reloaded from the database. The item is still selected afterwards. I think this solves your use case doesn't it?

sideisra commented 8 years ago

Looks good.

When the element class implements equals and hashCode the check getModelList().contains(oldSelectedElement) may returns false because equals checks all data. It would be good when the caller could pass a Comparator to the method that is used to find the old selected element in the new list. This Comparator could for example be implemented to respect the id only.

manuel-mauky commented 8 years ago

Hi denny, instead of a comparator I would use a BiPredicate<T,T>, a function that returns a boolean for two given elements of T.

The code would look like this:

public void replaceModelItems(Collection<T> items, boolean keepSelection, BiPredicate<T,T> equaltyPredicate) {
    if(keepSelection) {
        T oldSelectedElement = selectedItem.get();

        getModelList().clear();
        getModelList().addAll(items);

        boolean contained = getModelList()
            .stream()
            .filter(element -> equaltyPredicate.test(element, oldSelectedElement))
            .anyMatch();
        if(contained) {
            selectedItem.setValue(oldSelectedElement);
        }
    } else {
        getModelList().clear();
        getModelList().addAll(items);
    }
}
manuel-mauky commented 7 years ago

I've started working on this issue again. Here are some updates:

What still needs to be done: