Loading...
Searching...
No Matches
Tutorial: Using an UndoManager with a ValueTree

Implement undo/redo actions in your applications. Easily restore previous intermediate states with UndoableAction objects and learn how to group undoable actions into transactions.

Level: Intermediate

Platforms: Windows, macOS, Linux

Classes: UndoManager, UndoableAction, ValueTree, TreeView, TreeViewItem

Getting started

This tutorial assumes basic understanding of ValueTree objects as explained in Tutorial: The ValueTree class. If you haven't done so already, you should read that tutorial first.

Download the demo project for this tutorial here: PIP | ZIP. Unzip the project and open the first header file in the Projucer.

If you need help with this step, see Tutorial: Projucer Part 1: Getting started with the Projucer.

The demo project

The demo project illustrates the use of the UndoManager class in conjunction with ValueTree objects to show how easily past history can be restored. It presents the ValueTree data as a tree structure using the TreeView and TreeViewItem classes. If you build and run the project, you should see something like this:

The demo project application window

At the moment, we can drag and drop ValueTree nodes and change the hierarchy of the data structure. We can also expand and collapse children but we cannot undo and redo our changes. Let's try to implement that functionality using the UndoManager class.

Note
The code presented here is broadly similar to the ValueTreesDemo from the JUCE Demo.

Adding Undo/Redo buttons

Let's first add two TextButton objects to our user interface to allow undo and redo functionality. You should be familiar with lambda functions for this section and if you need help with these steps, you can refer to the Tutorial: Listeners and Broadcasters tutorial.

In the MainContentComponent class, declare TextButton variables for each button [1]:

juce::TreeView tree;
juce::TextButton undoButton, redoButton; // [1]
std::unique_ptr<ValueTreeItem> rootItem;

In the constructor member initialisation list, set the text for the TextButton objects [2]:

MainContentComponent()
: undoButton ("Undo"),
redoButton ("Redo") // [2]
{

Finally, make the buttons visible [3] and prepare the lambda functions to be assigned to the Button::onClick helper objects [4]:

//...
addAndMakeVisible (undoButton);
addAndMakeVisible (redoButton); // [3]
undoButton.onClick = [this] { };
redoButton.onClick = [this] { }; // [4]
setSize (600, 400);

We can then set the bounds for the buttons in the resized() method:

void resized() override
{
// This is called when the MainContentComponent is resized.
// If you add any child components, this is where you should
// update their positions.
auto r = getLocalBounds();
auto buttons = r.removeFromBottom (20);
undoButton.setBounds (buttons.removeFromLeft (100));
redoButton.setBounds (buttons.removeFromLeft (100));
tree.setBounds (r);
}

Passing the UndoManager instance as an argument

Since the ValueTree class handles undo/redo behaviour automatically, we need only pass the UndoManager instance as a parameter to register UndoableAction objects. To implement this, first declare an instance of the UndoManager class [1]:

Then assign the functions to be called when the buttons are clicked in order to handle the corresponding undo/redo behaviour. In the lambda functions, respectively call UndoManager::undo() and UndoManager::redo() as follows:

addAndMakeVisible (undoButton);
addAndMakeVisible (redoButton); // [3]
undoButton.onClick = [this] { undoManager.undo(); };
redoButton.onClick = [this] { undoManager.redo(); }; // [4]
setSize (600, 400);

In the ValueTreeItem class, we also keep a reference to the UndoManager instance [2]:

private:
juce::ValueTree tree;
juce::UndoManager& undoManager; // [2]

In the member initialisation list of the class constructor, assign the UndoManager reference [3]:

ValueTreeItem (const juce::ValueTree& v, juce::UndoManager& um)
: tree (v), undoManager (um) // [3]
{

Whenever a sub-item of ValueTreeItem is created recursively, we need to pass the UndoManager instance [4]:

void refreshSubItems()
{
clearSubItems();
for (auto i = 0; i < tree.getNumChildren(); ++i)
addSubItem (new ValueTreeItem (tree.getChild (i), undoManager)); // [4]
}

We can now instantiate the root ValueTreeItem by passing the UndoManager instance [5] in the MainContentComponent class:

tree.setDefaultOpenness (true);
tree.setMultiSelectEnabled (true);
rootItem.reset (new ValueTreeItem (createRootValueTree(), undoManager)); // [5]
tree.setRootItem (rootItem.get());

Now there are three different methods that need to be updated to register the changes we perform on the TreeView.

void itemDropped (const juce::DragAndDropTarget::SourceDetails&, int insertIndex) override
{
juce::OwnedArray<juce::ValueTree> selectedTrees;
getSelectedTreeViewItems (*getOwnerView(), selectedTrees);
moveItems (*getOwnerView(), selectedTrees, tree, insertIndex, undoManager); // [1]
}
static void moveItems (juce::TreeView& treeView, const juce::OwnedArray<juce::ValueTree>& items,
juce::ValueTree newParent, int insertIndex, juce::UndoManager& undoManager)
{
v.getParent().removeChild (v, &undoManager); // [2]
newParent.addChild (v, insertIndex, &undoManager); // [3]
  • [1]: Whenever an item is drag and dropped, pass the undoManager to the static function that handles the move.
  • [2]: We need to remove the child from the previous parent and register that action to the undoManager.
  • [3]: We can then add the child to a new parent and register the new action to the undoManager.

By passing the undoManager reference to the ValueTree functions addChild() and removeChild(), we let the UndoManager perform the UndoableAction for us by calling the perform() function under the hood. We will cover UndoableAction objects in a future tutorial.

Exercise
Display descriptions of stored undo and redo actions in Label components next to their respective TextButton objects using the getUndoDescription() and getRedoDescription() functions respectively.

Handle events as transactions

Another useful feature of the UndoManager is its ability to group several actions together as a single undo/redo transaction. By calling the beginNewTransaction() function on the undoManager instance, all the calls to the perform() function of the UndoManager are grouped together until the next beginNewTransaction() call.

As an example, let's create a Timer to call the beginNewTransaction() function periodically and store groups of actions together as transactions. In the MainContentComponent, inherit from the Timer class to receive timer callbacks [1]:

class MainContentComponent : public juce::Component,
public juce::DragAndDropContainer,
private juce::Timer // [1]
{
public:

Declare the callback function in the corresponding header file [2]:

void timerCallback() override // [2]
{
undoManager.beginNewTransaction(); // [4]
}

Start the timer in the constructor with the desired interval in milliseconds betwen transaction calls [3]:

startTimer (500); // [3]
}

Finally, we can call the beginNewTransaction() function on the UndoManager in the timer callback [4]:

void timerCallback() override // [2]
{
undoManager.beginNewTransaction(); // [4]
}
Exercise
Implement transactions every five undo/redo actions instead of using a timer to separate groups of actions.
Note
The source code for this modified version of the code can be found in the UndoManagerTutorial_02.h file of the demo project.

Summary

By completing this tutorial, you have learnt how to restore previous states of your application. In particular, we have:

See also

linkedin facebook pinterest youtube rss twitter instagram facebook-blank rss-blank linkedin-blank pinterest youtube twitter instagram