Blog-Archiv

Sonntag, 23. April 2017

Vaadin MVP with Undoable Edits

When implementing user interfaces via MVP or MVC, you will find following classes useful in addition to model, view and presenter (controller):

  • Action - an action can have 0-n visual representations, being button, main menu item, context menu item or just keyboard shortcut. It is the "model" behind these "views", holding the callback, label, tooltip, icon, accelerator key. Disabling the action would disable all its visual representations.

    Actions are part of the presenter, their visual representations are part of the view.

  • Command (Edit) - actions can use commands to track the changes a user performs, feeding an undo-manager to make edit-sessions undo/redo-able step by step. A command records the concrete execution of an action, that means it knows the state of the data before and after, mostly represented by the execution's parameter values.

    Edits (commands) can be part of a two-way data-binding (read/write), or the presenter.

  • Selection - describing selected list-, table- or tree-items which are part of the view, but referring to model-items. Typically this is standard parameter for many commands, e.g. you select some rows and then delete them. Selections should also be usable for selecting items in a view.

    Selections come from the view, but must then refer to the model by means of the presenter.

This Blog is about Commands. In context of UI-programming, Edit is a better name for them, because there is also a command-pattern that refers to client-server communication. I will show a Vaadin application that lets edit a person-form, featuring "Undo" and "Redo" buttons. Please refer to my passed Vaadin Blog for how to get and set-up a Vaadin application that lets launch different examples.

Example Application

Here is a screenshot of the Demo application.

The "Undo"- and "Redo"-buttons will allow to roll UI changes back and forward. Initially they are both disabled, because no change has been done yet. As soon as you change one of the editable fields and it loses the input focus, the "Undo" button should become enabled. As soon as you press "Undo" once, the "Redo" button should become enabled.

To add a little complexity, the "Age" field is a read-only field that always has to be in sync with the "Date of Birth" field. In other words, as soon as you change the day of birth of the person, its age will be calculated automatically.

Package Structure

The files shown here do not include the launching Vaadin application (ExamplesUi).

As all of model, view and presenter became more complex here, I decided to put all of them into their own packages. The Demo application is in the viewimpl package, because it depends on the Vaadin windowing-system. So you will need to enter

http://localhost:8080/ExamplesUi?class=vaadin.examples.mvp.commands.viewimpl.Demo

on your browser's address line to see the application.

The view interfaces that abstract the windowing-system are in the view package.

The presenter is implemented using a read-only and a read/write data-binding in package presenter.

Besides the Person bean, the model package also contains the Age business logic, as it is not so easy to calculate the age of a person. This is needed by the view, implemented reusable in the model, and applied by the binding.

Support for undoable edits is in package command. Because I reused the Swing UndoManager, I needed to override it. For a clear separation of concerns I created two sub-classes of it.

Demo Application

Here is the source of the Demo application. As expected, it builds together the MVP by using a windowing-system specific view, and windowing-system agnostic presenter and model.

package vaadin.examples.mvp.commands.viewimpl;

import java.text.SimpleDateFormat;
import com.vaadin.ui.*;
import vaadin.examples.mvp.commands.model.Person;
import vaadin.examples.mvp.commands.presenter.PersonPresenter;
import vaadin.examples.mvp.commands.view.PersonView;

public class Demo extends VerticalLayout
{
    public Demo()  {
        final PersonView<Component> view = new PersonViewImpl();
        final PersonPresenter<Component> presenter = new PersonPresenter<Component>(view);
        final Person model = new Person(
            "John Doe",
            new SimpleDateFormat("yyyy-MM-dd").parse("1582-10-04"));  // 5-14 do not exist!
        
        presenter.setModel(model);
        addComponent(view.getAddableComponent());
    }
}

Mind that the days from 1582-10-05 until including 1582-10-14 do not exist in Gregorian calendar! That way we can check the reliability of Vaadin date picker :-)

MVP

Model

We will walk now from model to view and finally come to presenter and commands.

The model is a normal Java bean, not having a listener mechanism, as it is common with MVP. It has just two properties, and one read-only property. For better control it outputs itself on every value-change.

package vaadin.examples.mvp.commands.model;

import java.text.DateFormat;
import java.util.Date;

public class Person
{
    private String name;
    private Date dateOfBirth;

    public Person() {
    }
    public Person(String name, Date dateOfBirth) {
        this.name = name;
        this.dateOfBirth = dateOfBirth;
    }

    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
        System.out.println(this);
    }

    public Date getDateOfBirth() {
        return dateOfBirth;
    }
    public void setDateOfBirth(Date dateOfBirth) {
        this.dateOfBirth = dateOfBirth;
        System.out.println(this);
    }
    
    public Integer getAge() {
        return (getDateOfBirth() != null)
                ? new Age().get(getDateOfBirth(), new Date())
                : null;
    }
    
    @Override
    public String toString() {
        final DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM);
        return getClass().getSimpleName()+
            " name="+getName()+
            ", dateOfBirth="+(getDateOfBirth() != null ? dateFormat.format(getDateOfBirth()) : "null");
    }
}

The read-only bean property age (no setter) is implemented by following business-logic class:

package vaadin.examples.mvp.commands.model;

import java.util.*;

/**
 * Calculates the age in years.
 * @see http://stackoverflow.com/questions/1116123/how-do-i-calculate-someones-age-in-java
 */
public class Age
{
    public int get(Date birthDay, Date referenceDay) {
        if (birthDay == null)
            return 0;
        
        if (referenceDay == null)
            referenceDay = new Date();  // today
        
        final Calendar birth = Calendar.getInstance();
        birth.setTime(birthDay);
        
        final Calendar today = Calendar.getInstance();
        today.setTime(referenceDay);

        if (today.getTime().before(birth.getTime()))
            throw new IllegalArgumentException("Given today is before date of birth!");

        final int age = today.get(Calendar.YEAR) - birth.get(Calendar.YEAR);
        final int todayMonth = today.get(Calendar.MONTH);
        final int birthMonth = birth.get(Calendar.MONTH);

        if (todayMonth < birthMonth ||
                (todayMonth == birthMonth &&
                 today.get(Calendar.DAY_OF_MONTH) < birth.get(Calendar.DAY_OF_MONTH)))
            return age - 1; // whole year not finished

        return age;
    }
}

This exists separately from Person because not only persons have an age.

View Interfaces

Don't be confused by a read-only view that has setters. Consider the direction the presenter takes: it will set values into a read-only view, but it will not retrieve values from a read-only view. Thus it has just setters, no getters.

package vaadin.examples.mvp.commands.view;

import java.util.Date;
import vaadin.examples.mvp.ReadOnlyView;

/**
 * A read-only view allows just to render values (set), not to edit them (get).
 * @param <W> component of the windowing-system this view refers to.
 */
public interface PersonReadOnlyView<W> extends ReadOnlyView<W>
{
    void setName(String name);

    void setDateOfBirth(Date dateOfBirth);

    void setAge(Integer ageInYears);
}

The editable view extends the read-only view and adds the getters. Further it listens to field-changes, and the undo- and redo-actions. Last but not least it must provide means to set the undo- and redo-actions disabled when there is nothing to undo or redo, to be called by the presenter (binding).

package vaadin.examples.mvp.commands.view;

import java.util.Date;

/**
 * A read/write view allows to render values (set) and edit them (get).
 * @param <W> component of the windowing-system this view refers to.
 */
public interface PersonView<W> extends PersonReadOnlyView<W>
{
    interface Listener
    {
        void nameChanged();
        void dateOfBirthChanged();
        
        void undo();
        void redo();
    }
    
    void addListener(Listener listener);
    
    String getName();
    Date getDateOfBirth();
    
    void setUndoEnabled(boolean enable);
    void setRedoEnabled(boolean enable);
}

These are the responsibilities the presenter will work with. It does not import any of the implementations behind these interfaces.

Vaadin View Implementations

Here come the Vaadin implementations of the view interfaces. The PersonReadOnlyViewImpl provides all input fields protected final for sub-classes. Setting the field values is straight forward, just the age-field must be kept read-only always.

package vaadin.examples.mvp.commands.viewimpl;

import java.util.Date;
import com.vaadin.data.util.ObjectProperty;
import com.vaadin.ui.*;
import vaadin.examples.mvp.commands.view.PersonReadOnlyView;

public class PersonReadOnlyViewImpl implements PersonReadOnlyView<Component>
{
    protected final TextField nameField;
    protected final TextField ageField;
    protected final DateField dateOfBirthField;
    protected final AbstractOrderedLayout formLayout;

    public PersonReadOnlyViewImpl() {
        formLayout = new FormLayout();
        formLayout.setMargin(true);

        nameField = new TextField("Name");
        nameField.setNullRepresentation(""); // else "null" will be displayed

        dateOfBirthField = new DateField("Date of Birth");
        dateOfBirthField.setDateFormat("yyyy-MM-dd");

        ageField = new TextField("Age");
        ageField.setReadOnly(true);

        formLayout.addComponent(nameField);
        formLayout.addComponent(dateOfBirthField);
        formLayout.addComponent(ageField);
    }

    @Override
    public Component getAddableComponent() {
        return formLayout;
    }

    @Override
    public void setName(String name) {
        nameField.setPropertyDataSource(new ObjectProperty<String>(name, String.class));
    }
    @Override
    public void setDateOfBirth(Date dateOfBirth) {
        dateOfBirthField.setPropertyDataSource(new ObjectProperty<Date>(dateOfBirth, Date.class));
    }
    @Override
    public void setAge(Integer ageInYears) {
        ageField.setReadOnly(false);
        ageField.setValue(ageInYears != null ? "" + ageInYears : "");
        ageField.setReadOnly(true);
    }
}

The editable sub-class of this adds value-change listeners to all fields, and action-listeners to the buttons. It sets the fields to be unbuffered, for Vaadin this means that they will propagate their values to any value-change listener immediately when they lose the input-focus, i.e. the user clicks the mouse outside of the field, or presses TAB. If they were buffered, a commit() call would be necessary to do that.

package vaadin.examples.mvp.commands.viewimpl;

import java.util.*;
import com.vaadin.ui.*;
import vaadin.examples.mvp.commands.view.PersonView;

public class PersonViewImpl extends PersonReadOnlyViewImpl implements PersonView<Component>
{
    private final AbstractOrderedLayout layout;
    private final Button undo;
    private final Button redo;
    private final List<Listener> listeners = new ArrayList<Listener>();

    public PersonViewImpl() {
        addValueChangeListeners();

        undo = new Button("Undo");
        redo = new Button("Redo");
        final Component toolbar = createToolbar();
        
        layout = new VerticalLayout();
        layout.addComponent(formLayout);
        layout.setExpandRatio(formLayout, 1f); // give the form most space
        layout.addComponent(toolbar);   // give toolbar its natural height
    }

    @Override
    public Component getAddableComponent() {
        return layout;
    }

    @Override
    public void addListener(Listener editorListener) {
        listeners.remove(editorListener);
        listeners.add(editorListener);
    }

    @Override
    public String getName() {
        return nameField.getValue();
    }
    @Override
    public Date getDateOfBirth() {
        return dateOfBirthField.getValue();
    }
    
    @Override
    public void setUndoEnabled(boolean enable) {
        undo.setEnabled(enable);
    }
    @Override
    public void setRedoEnabled(boolean enable) {
        redo.setEnabled(enable);
    }

    
    private void addValueChangeListeners() {
        nameField.setBuffered(false); // deliver value on focus-lost
        nameField.addValueChangeListener(
            (event) -> {
                for (Listener listener : listeners)
                    listener.nameChanged();
            }
        );
        
        dateOfBirthField.setBuffered(false);
        dateOfBirthField.addValueChangeListener(
            (event) -> {
                for (Listener listener : listeners)
                    listener.dateOfBirthChanged();
            }
        );
    }

    private Component createToolbar() {
        undo.addClickListener(
            (event) -> {
                for (Listener listener : listeners)
                    listener.undo();
            }
        );

        redo.addClickListener(
            (event) -> {
                for (Listener listener : listeners)
                    listener.redo();
            }
        );

        final AbstractOrderedLayout toolbar = new HorizontalLayout();
        toolbar.setSpacing(true);
        toolbar.setMargin(true);
        toolbar.addComponent(undo);
        toolbar.addComponent(redo);

        return toolbar;
    }
}

Mind how elegant the lambda-callbacks look. By the way, you won't be able to compile that source with a Java below 1.8 !

Presenter

The most complex always is the presenter. This one has been split across several classes that all have their own responsibility. The presenter itself just allocates a binding and delegates everything to it.

In this case also the undo- and redo-callbacks are implemented by the binding, because they are so tightly connected to the value-change of the edited properties.

package vaadin.examples.mvp.commands.presenter;

import vaadin.examples.mvp.commands.model.Person;
import vaadin.examples.mvp.commands.view.PersonView;

public class PersonPresenter<W>
{
    private Binding<W> binding;
    
    public PersonPresenter(PersonView<W> view) {
        assert view != null;

        binding = new UndoableBinding<W>(view);
        view.addListener(binding);
    }

    public void setModel(Person person) {
        binding.setModel(person);
    }
}

The basic binding provides setting all model values into the view, and receiving a value-change callback per property, where the new value is pushed to the model. In case of dateOfBirth also the age is calculated and pushed to the view.

package vaadin.examples.mvp.commands.presenter;

import vaadin.examples.mvp.commands.model.Person;
import vaadin.examples.mvp.commands.view.PersonView;

abstract class Binding<W> implements PersonView.Listener
{
    final PersonView<W> view;
    private Person model;
    
    public Binding(PersonView<W> view) {
        this.view = view;
    }
    
    final Person getModel() {
        return model;
    }
    
    void setModel(Person person)  {
        this.model = person;
        
        view.setName(person.getName());
        view.setDateOfBirth(person.getDateOfBirth());
    }

    @Override
    public void nameChanged() {
        getModel().setName(view.getName());
    }
    
    @Override
    public void dateOfBirthChanged() {
        getModel().setDateOfBirth(view.getDateOfBirth());
        view.setAge(getModel().getAge());
    }
}

The undoable binding adds all UndoManager functionality by overrides. When receiving a value-change callback, an edit is generated and added to the undo-manager. This leads to recursion when "Undo" is triggered by the user, because then the old value is set into the view, and that leads to a recursive call to the listener which would again add a new edit. Thus we need to distinguish between value-change callbacks that are triggered by the user and such that were triggered by the undo-manager. The undoManager.isExecutingEdit() gives us that.

package vaadin.examples.mvp.commands.presenter;

import java.util.Date;
import vaadin.examples.mvp.commands.command.*;
import vaadin.examples.mvp.commands.model.Person;
import vaadin.examples.mvp.commands.view.PersonView;

class UndoableBinding<W> extends Binding<W>
{
    private StatefulUndoManager undoManager = new NonRecursiveUndoManager();
    
    public UndoableBinding(PersonView<W> view) {
        super(view);
    }
    
    @Override
    void setModel(Person person) {
        super.setModel(person);
        
        undoManager.discardAllEdits();
        adjustUndoRedoButtons();
    }
    
    @Override
    public void nameChanged() {
        if (undoManager.isExecutingEdit() == false)
            undoManager.addEdit(
                new ValueChangeCommand<String>(
                    getModel().getName(),
                    view.getName(),
                    (value) -> view.setName(value)
                )
            );
        adjustUndoRedoButtons();
        super.nameChanged();
    }
    
    @Override
    public void dateOfBirthChanged() {
        if (undoManager.isExecutingEdit() == false)
            undoManager.addEdit(
                new ValueChangeCommand<Date>(
                    getModel().getDateOfBirth(),
                    view.getDateOfBirth(),
                    (value) -> view.setDateOfBirth(value)
                )
            );
        adjustUndoRedoButtons();
        super.dateOfBirthChanged();
    }
    
    @Override
    public void undo() {
        undoManager.undo();
    }
    
    @Override
    public void redo() {
        undoManager.redo();
    }
    
    private void adjustUndoRedoButtons() {
        view.setUndoEnabled(undoManager.canUndo());
        view.setRedoEnabled(undoManager.canRedo());
    }
}

Controlling the enabled-state of the undo-buttons and forwarding their callback to the undo-manager complete this presentation logic.

Undoable Edits

As simple as all other classes are the ones in command package. First there is a generic edit that is used to record value-changes. The embedded interface ValueSetter enables us to write the elegant lambdas that set values in UndoableBinding.

package vaadin.examples.mvp.commands.command;

import javax.swing.undo.*;

public class ValueChangeCommand<T> extends AbstractUndoableEdit
{
    public interface ValueSetter<T>
    {
        void setValue(T oldValue);
    }
    
    private final T oldValue, newValue;
    private final ValueSetter<T> valueSetter;
    
    public ValueChangeCommand(T oldValue, T newValue, ValueSetter<T> valueSetter) {
        assert valueSetter != null;
        
        this.oldValue = oldValue;
        this.newValue = newValue;
        this.valueSetter = valueSetter;
    }
    
    @Override
    public void undo() throws CannotUndoException {
        super.undo();
        valueSetter.setValue(oldValue);
    }
    
    @Override
    public void redo() throws CannotRedoException {
        super.redo();
        valueSetter.setValue(newValue);
    }
}

Next there are two sub-classes of Swing UndoManager. The StatefulUndoManager sets a flag when it calls some undoable edit. This flag is exposed and enables the binding to find out whether a new edit should be created on value-change.

The Swing UndoManager is not a super-hero implementation, but it works, also without graphics environment. You can test this by setting -Djava.awt.headless=true as command-line argument to your JVM.

package vaadin.examples.mvp.commands.command;

import javax.swing.undo.*;

public class StatefulUndoManager extends UndoManager
{
    private boolean executingEdit;  // = false by default
    
    public synchronized boolean isExecutingEdit() {
        return executingEdit;
    }
    
    @Override
    public synchronized void undo() throws CannotUndoException {
        executingEdit = true;
        try {
            super.undo();
        }
        finally {
            executingEdit = false;
        }
    }
    
    @Override
    public synchronized void redo() throws CannotRedoException {
        executingEdit = true;
        try {
            super.redo();
        }
        finally {
            executingEdit = false;
        }
    }
}

Even more simple is NonRecursiveUndoManager. It uses the state of its super-class to avoid adding edits when being in execution-state.

package vaadin.examples.mvp.commands.command;

import javax.swing.undo.UndoableEdit;

public class NonRecursiveUndoManager extends StatefulUndoManager
{
    /** Overridden to not add any edit while an edit is executing. */
    @Override
    public synchronized boolean addEdit(UndoableEdit edit) {
        return isExecutingEdit() ? false : super.addEdit(edit);
    }
}

This functionality is not really needed here, but could be useful when callers don't want to care about the state actively.

Summary

These classes are small and do not aggregate responsibilities. Together they refine MVP functionality. Commands can be constructed for any purpose, e.g. deleting list items, moving or copying nodes, etc. They are mementos, i.e. state snapshots. In relation with an undo-manager they can serve for rolling back and forward user actions.




Keine Kommentare: