Java SwingのUndo/Redo機能をマスター!javax.swing.undoパッケージ徹底解説

この記事を読むことで、あなたは以下の知識を得られます。

  • javax.swing.undo パッケージの基本的な概念と主要なクラスの役割
  • UndoManager を利用した、テキストコンポーネントへの基本的なUndo/Redo機能の実装方法
  • 複数の編集操作を一つの単位としてグループ化する高度なテクニック
  • StateEditableStateEdit を用いた、グラフィックコンポーネントなど、テキスト以外のカスタムコンポーネントにUndo/Redo機能を実装する方法
  • Undo/Redo機能の実装における注意点と、より洗練されたアプリケーションを作成するためのベストプラクティス

はじめに:なぜUndo/Redo機能が重要なのか

現代の多くのアプリケーションにおいて、「元に戻す(Undo)」と「やり直す(Redo)」機能は、ユーザーにとって不可欠な存在となっています。ユーザーが誤った操作をした際に、簡単にもとの状態に復帰できる安心感は、アプリケーションの使いやすさ(ユーザビリティ)を劇的に向上させます。特に、テキストエディタやグラフィックツールなど、ユーザーが創造的な作業を行うアプリケーションでは、試行錯誤を容易にするUndo/Redo機能は必須と言えるでしょう。

JavaのSwingフレームワークでGUIアプリケーションを開発する際、この強力なUndo/Redo機能を実装するために提供されているのが javax.swing.undo パッケージです。このパッケージを使いこなすことで、開発者は比較的少ない労力で、堅牢かつ柔軟なUndo/Redo機能をアプリケーションに組み込むことができます。

この記事では、javax.swing.undo パッケージの基本的な概念から、テキストコンポーネントへの簡単な実装、さらにはカスタムコンポーネントへの応用まで、詳細な解説と具体的なサンプルコードを通して、あなたがUndo/Redo機能を完全にマスターするためのお手伝いをします。


第1章: `javax.swing.undo`パッケージの主要登場人物

Undo/Redo機能の実装に入る前に、まずは javax.swing.undo パッケージを構成する主要なクラスとインタフェースについて理解を深めましょう。これらの「登場人物」の役割を知ることで、以降の実装がスムーズになります。

クラス / インタフェース役割
UndoableEdit (インタフェース) 元に戻す/やり直すことが可能な「編集」操作そのものを表現します。「文字を入力した」「図形を移動した」といった一つ一つの操作が、このインタフェースを実装したオブジェクトとして扱われます。undo()redo() メソッドを持ちます。
UndoManager (クラス)UndoableEdit のリストを管理する中心的な役割を担います。発生した編集 (UndoableEdit) を受け取り、それらをスタックに積み上げていきます。undo()redo() のリクエストに応じて、適切な編集オブジェクトのメソッドを呼び出します。
UndoableEditListener (インタフェース)UndoableEdit が発生したことを通知するためのリスナーです。コンポーネント(例えばテキストエリア)で編集が行われると、このリスナーの undoableEditHappened() メソッドが呼び出されます。
UndoableEditSupport (クラス)UndoableEditListener の管理を容易にするためのヘルパークラスです。リスナーの追加や削除、イベントの通知などをサポートします。
CompoundEdit (クラス) 複数の UndoableEdit をまとめて一つの編集単位として扱うためのクラスです。「検索して置換」のように、複数のステップからなる操作を一度にUndo/Redoしたい場合に使用します。
StateEditable / StateEdit テキスト以外の、より複雑な状態を持つコンポーネントにUndo/Redoを実装するための仕組みです。StateEditable は状態の保存と復元を行うオブジェクトのインタフェース、StateEdit はその状態変化を記録する UndoableEdit です。

これらの関係性を簡単にまとめると、「コンポーネントで編集が発生」→「UndoableEditListener がそれを検知」→「リスナーが UndoableEdit オブジェクトを UndoManager に渡す」→「UndoManager がそれを履歴として保持する」という流れになります。


第2章: 基本的な使い方 – テキストコンポーネントへの実装

理論を学んだところで、早速最も一般的なユースケースであるテキストコンポーネント (JTextArea) にUndo/Redo機能を実装してみましょう。驚くほど簡単に追加できることがわかるはずです。

手順は以下の通りです。

  1. UndoManagerのインスタンスを作成する。
  2. テキストコンポーネントのDocumentUndoableEditListenerを追加する。
  3. リスナーのundoableEditHappenedメソッド内で、受け取った編集イベントをUndoManagerに登録する。
  4. Undo/Redoを実行するためのボタンやメニュー項目を作成し、クリックされたらUndoManagerundo()またはredo()を呼び出す。
  5. (推奨) Undo/Redoが可能かどうかに応じて、ボタンの有効/無効を切り替える。

サンプルコード: JTextAreaにUndo/Redoを実装する

以下のコードは、JTextArea、Undoボタン、Redoボタンを持つシンプルなウィンドウを作成します。

<?xml version="1.0" encoding="UTF-8"?>
import javax.swing.*;
import javax.swing.event.UndoableEditEvent;
import javax.swing.event.UndoableEditListener;
import javax.swing.undo.UndoManager;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class SimpleUndoExample extends JFrame { private JTextArea textArea; private JButton undoButton; private JButton redoButton; private final UndoManager undoManager = new UndoManager(); public SimpleUndoExample() { setTitle("Simple Undo/Redo Example"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(500, 400); setLocationRelativeTo(null); // 1. コンポーネントの初期化 textArea = new JTextArea(); textArea.setLineWrap(true); textArea.setWrapStyleWord(true); // 2. Documentを取得し、UndoableEditListenerを追加 textArea.getDocument().addUndoableEditListener(new UndoableEditListener() { @Override public void undoableEditHappened(UndoableEditEvent e) { // 編集が発生したらUndoManagerに登録 undoManager.addEdit(e.getEdit()); // ボタンの状態を更新 updateButtonStatus(); } }); // 3. ボタンの初期化とアクションリスナーの設定 undoButton = new JButton("元に戻す (Undo)"); redoButton = new JButton("やり直す (Redo)"); undoButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { if (undoManager.canUndo()) { undoManager.undo(); } updateButtonStatus(); } }); redoButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { if (undoManager.canRedo()) { undoManager.redo(); } updateButtonStatus(); } }); // 初期のボタン状態を設定 updateButtonStatus(); // 4. レイアウトの設定 JPanel buttonPanel = new JPanel(new FlowLayout()); buttonPanel.add(undoButton); buttonPanel.add(redoButton); Container contentPane = getContentPane(); contentPane.setLayout(new BorderLayout()); contentPane.add(new JScrollPane(textArea), BorderLayout.CENTER); contentPane.add(buttonPanel, BorderLayout.SOUTH); } private void updateButtonStatus() { // canUndo()とcanRedo()でUndo/Redoが可能かチェックし、ボタンの有効/無効を切り替える undoButton.setEnabled(undoManager.canUndo()); redoButton.setEnabled(undoManager.canRedo()); } public static void main(String[] args) { SwingUtilities.invokeLater(() -> { new SimpleUndoExample().setVisible(true); }); }
}

コードの解説

  • textArea.getDocument().addUndoableEditListener(...): ここが最も重要な部分です。JTextAreaの内部モデルであるDocumentで発生する全ての編集イベント(文字の挿入、削除など)が、このリスナーによって捕捉されます。
  • undoManager.addEdit(e.getEdit()): 捕捉した編集イベント(UndoableEdit)をUndoManagerに追加しています。これにより、編集履歴が記録されていきます。
  • updateButtonStatus(): このメソッドでは、undoManager.canUndo()undoManager.canRedo()を呼び出しています。これらは、それぞれUndoスタック、Redoスタックに編集履歴が存在するかどうかをboolean値で返します。この結果を用いて、ユーザーがクリックできないようにボタンを無効化しています。これにより、不要な例外(CannotUndoExceptionなど)の発生を防ぎ、UIをより親切なものにしています。

第3章: 編集のグループ化 – CompoundEditと暗黙的なグループ化

基本的な実装では、一文字入力するごと、一文字削除するごとにUndoの履歴が作成されます。しかし、例えば「検索して置換」処理を行った場合、複数の置換操作をまとめて一つの編集単位として扱い、一度のUndo操作で全て元に戻したい、という要求があります。このような場合に活躍するのが編集のグループ化です。

暗黙的なグループ化: `beginUpdate()` と `endUpdate()`

UndoManagerには、一連の編集を暗黙的にグループ化するための便利なメソッドが用意されています。

  • beginUpdate(): これを呼び出すと、UndoManagerはグループ化モードに入ります。以降、endUpdate()が呼ばれるまでの間に追加された全てのUndoableEditは、内部的に一つのCompoundEditにまとめられます。
  • endUpdate(): グループ化を終了し、それまでに蓄積された編集を一つの単位として確定させます。

この方法は、setText()のように、内部的に「全削除」と「全挿入」の二つの編集イベントが発生してしまう操作を、ユーザーから見て一つの操作として扱いたい場合に特に有効です。

サンプルコード: `setText()`を一つのUndo単位にする

<?xml version="1.0" encoding="UTF-8"?>
// ... (前章のコードの続き)
// 例えば、特定のテキストをセットするボタンを追加する場合
JButton setTextButton = new JButton("Set Text");
setTextButton.addActionListener(e -> { try { // 1. グループ化を開始 undoManager.beginUpdate(); // 2. この間の操作がグループ化される textArea.setText("This is a new text set by the button.\n" + "This operation should be undone in one step."); } finally { // 3. 必ずグループ化を終了する undoManager.endUpdate(); updateButtonStatus(); }
});
// このボタンをbuttonPanelに追加する
// buttonPanel.add(setTextButton);

コードの解説

このコードでは、textArea.setText(...)undoManager.beginUpdate()undoManager.endUpdate()で囲んでいます。setTextは内部的にドキュメントの全内容を削除し、新しいテキストを挿入するという2つのUndoableEditを発生させることがありますが、この処理によって、それらが一つのCompoundEditとしてまとめられます。結果として、ユーザーが「元に戻す」ボタンをクリックすると、ボタンを押す前の状態に一度で戻るようになります。

注意点: beginUpdate()を呼び出した後は、必ずfinallyブロックでendUpdate()を呼び出すようにしてください。これにより、処理の途中で例外が発生した場合でも、UndoManagerが不整合な状態になるのを防ぐことができます。

明示的なグループ化: `CompoundEdit` の直接利用

より複雑な制御が必要な場合は、CompoundEditクラスを直接インスタンス化して利用することも可能です。 自分でCompoundEditオブジェクトを作成し、それに個別のUndoableEditを追加していき、最後にCompoundEditを終了(end()メソッドを呼び出す)してUndoManagerに登録します。 この方法は、複数の異なるコンポーネントにまたがる操作を一つにまとめたい場合など、高度なシナリオで役立ちます。


第4章: 高度な応用 – カスタムコンポーネントへの実装

javax.swing.undoパッケージの真の力は、テキストコンポーネント以外にもUndo/Redo機能を適用できる点にあります。例えば、図形を描画するグラフィックエディタ、コンポーネントの色や位置を変更する設定パネルなど、あらゆる「状態」を持つコンポーネントにこの機能を実装できます。

これを実現するのが StateEditable インタフェースと StateEdit クラスのペアです。

  • StateEditable: Undo/Redoの対象となるコンポーネント(またはそのモデル)が実装するインタフェースです。状態を保存するためのstoreState(Hashtable)と、状態を復元するためのrestoreState(Hashtable)という2つの重要なメソッドを定義します。
  • StateEdit: StateEditableオブジェクトの状態変化を記録するUndoableEditです。StateEditは、編集前と編集後の2つの時点でStateEditableオブジェクトに状態を問い合わせ、それぞれの状態をハッシュテーブルに保存します。undo()が呼ばれると編集前の状態を、redo()が呼ばれると編集後の状態を復元させます。

実装の基本的な流れは以下のようになります。

  1. Undo/Redoさせたい状態を持つクラスに StateEditable を実装する。
  2. storeStateメソッドに、現在の状態(例:色、位置など)をキーと値のペアでハッシュテーブルに格納する処理を記述する。
  3. restoreStateメソッドに、ハッシュテーブルから値を取得し、自身の状態を復元する処理を記述する。
  4. 状態が変更される直前に、そのオブジェクトを引数にして StateEdit を生成する。
  5. 状態が変更された直後に、生成した StateEditend() メソッドを呼び出す。
  6. 完成した StateEditUndoManager に登録する。

サンプルコード: パネルの背景色変更にUndo/Redoを実装

この例では、ボタンをクリックするとパネルの背景色がランダムに変わり、その操作をUndo/Redoできるようにします。

<?xml version="1.0" encoding="UTF-8"?>
import javax.swing.*;
import javax.swing.undo.StateEdit;
import javax.swing.undo.StateEditable;
import javax.swing.undo.UndoManager;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.Hashtable;
import java.util.Random;
public class StateEditableExample extends JFrame { private final UndoManager undoManager = new UndoManager(); private final ColorPanel colorPanel = new ColorPanel(); // 1. StateEditableを実装したカスタムコンポーネント(今回はJPanelを継承) private static class ColorPanel extends JPanel implements StateEditable { private static final String KEY_COLOR = "Color"; public ColorPanel() { setBackground(Color.WHITE); } public void changeColor() { Random rand = new Random(); Color newColor = new Color(rand.nextFloat(), rand.nextFloat(), rand.nextFloat()); setBackground(newColor); } @Override public void storeState(Hashtable<Object, Object> state) { // 現在の状態(背景色)をハッシュテーブルに保存 state.put(KEY_COLOR, getBackground()); } @Override public void restoreState(Hashtable<?, ?> state) { // ハッシュテーブルから状態を復元 Color newColor = (Color) state.get(KEY_COLOR); if (newColor != null) { setBackground(newColor); } } } public StateEditableExample() { setTitle("StateEditable Example"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(500, 400); setLocationRelativeTo(null); JButton changeColorButton = new JButton("Change Color"); changeColorButton.addActionListener(e -> { // 2. 状態変更の前にStateEditを作成 StateEdit stateEdit = new StateEdit(colorPanel); // 3. 状態を変更 colorPanel.changeColor(); // 4. 状態変更の後にend()を呼び出し、編集を確定 stateEdit.end(); // 5. UndoManagerに登録 undoManager.addEdit(stateEdit); updateButtonStatus(); }); JButton undoButton = new JButton("Undo"); JButton redoButton = new JButton("Redo"); undoButton.addActionListener(e -> { if (undoManager.canUndo()) { undoManager.undo(); } updateButtonStatus(); }); redoButton.addActionListener(e -> { if (undoManager.canRedo()) { undoManager.redo(); } updateButtonStatus(); }); updateButtonStatus(); JPanel buttonPanel = new JPanel(); buttonPanel.add(changeColorButton); buttonPanel.add(undoButton); buttonPanel.add(redoButton); getContentPane().add(colorPanel, BorderLayout.CENTER); getContentPane().add(buttonPanel, BorderLayout.SOUTH); } // updateButtonStatusメソッドは前章と同じなので省略 private void updateButtonStatus() { /* ... */ } public static void main(String[] args) { SwingUtilities.invokeLater(() -> new StateEditableExample().setVisible(true)); }
}

コードの解説

  • ColorPanelクラス: このクラスはStateEditableを実装しています。storeStateでは現在の背景色を"Color"というキーでハッシュテーブルに保存し、restoreStateではそのキーを使って色を取り出し、背景色として設定し直しています。
  • changeColorButtonのアクションリスナー: 色を変更する一連の処理の流れが重要です。まずnew StateEdit(colorPanel)で編集前の状態がキャプチャされます。次にcolorPanel.changeColor()で実際の状態が変更されます。そしてstateEdit.end()で編集後の状態がキャプチャされ、一つのUndoableEditとして完成します。最後に、完成したstateEditundoManagerに追加しています。

このようにStateEditableStateEditを使うことで、あらゆる種類のオブジェクトの状態変化に対して、柔軟にUndo/Redo機能を実装することが可能になります。


第5章: `UndoManager`のカスタマイズと注意点

UndoManagerは非常に強力ですが、より高度なアプリケーションを開発するためには、いくつかのカスタマイズ方法と注意点を理解しておく必要があります。

Undo履歴数の制限

アプリケーションが長時間使用されたり、非常に多くの編集が行われたりすると、UndoManagerが保持する編集履歴がメモリを圧迫する可能性があります。これを防ぐために、保持するUndo履歴の最大数を設定することができます。

// Undoの履歴を100件までに制限する
undoManager.setLimit(100); 

setLimit()メソッドを使用すると、指定した数を超える古い編集履歴は自動的に破棄されます。これにより、アプリケーションのメモリ使用量を健全に保つことができます。

全ての編集履歴の破棄

ドキュメントを保存して閉じる、新しいファイルを開くなど、特定のタイミングでそれまでの編集履歴をすべてクリアしたい場合があります。その場合はdiscardAllEdits()メソッドを使用します。

// 全てのUndo/Redo履歴を破棄する
undoManager.discardAllEdits();
updateButtonStatus(); // 履歴がなくなったのでボタンを無効化する 

表示名のカスタマイズ

メニューの「元に戻す」項目に、具体的に何が元に戻されるのかを表示したい場合があります(例:「文字の入力を元に戻す」)。これは、UndoableEditgetUndoPresentationName()getRedoPresentationName()をオーバーライドすることで実現できます。UndoManager自身もこれらのメソッドを持っており、現在Undo/Redo可能な編集の表示名を返します。これをメニュー項目のテキストに設定することで、よりユーザーフレンドリーなUIを提供できます。

// ボタンのテキストを動的に変更する例
private void updateButtonStatus() { if (undoManager.canUndo()) { undoButton.setEnabled(true); // "元に戻す: テキストの挿入" のような文字列を取得できる undoButton.setText(undoManager.getUndoPresentationName()); } else { undoButton.setEnabled(false); undoButton.setText("元に戻す"); } if (undoManager.canRedo()) { redoButton.setEnabled(true); redoButton.setText(undoManager.getRedoPresentationName()); } else { redoButton.setEnabled(false); redoButton.setText("やり直す"); }
} 

最も重要な注意点: スレッドセーフティ

SwingのAPIの大部分はスレッドセーフではありません。 これはjavax.swing.undoパッケージにも当てはまります。UndoManagerを含むSwingコンポーネントやそのモデルへのアクセスは、必ずイベントディスパッチスレッド (EDT) から行う必要があります。

バックグラウンドスレッドで重い処理を行い、その結果をGUIに反映させ、かつその操作をUndo可能にしたい場合は、SwingUtilities.invokeLater()SwingWorkerを適切に使用して、GUIの更新やUndoManagerへのアクセスが必ずEDT上で行われるように設計する必要があります。これを怠ると、ConcurrentModificationExceptionのような予期せぬ例外が発生したり、UIがフリーズしたりする原因となります。


まとめ

本記事では、Java SwingにおけるUndo/Redo機能を実現するためのjavax.swing.undoパッケージについて、その基本から応用までを包括的に解説しました。

単純なテキストコンポーネントへの実装から始まり、編集のグループ化、さらにはStateEditableを用いたカスタムコンポーネントへの適用まで、このパッケージが非常に柔軟で強力なツールであることがお分かりいただけたかと思います。

重要なのは、UndoableEditUndoManagerUndoableEditListenerといった中心的なクラスの役割を理解し、それらがどのように連携して動作するかを把握することです。そして、スレッドセーフティのような重要な注意点を常に念頭に置くことです。

ここで得た知識を活用すれば、あなたの開発するSwingアプリケーションに、ユーザーを助ける洗練されたUndo/Redo機能を自信を持って組み込むことができるでしょう。ぜひ、さまざまなコンポーネントでこの機能を試し、より質の高いアプリケーション開発に役立ててください。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です