JTableについて
JTableはちょっとした表を作るには非常に便利だ。これを自分で作るのはかなりの負担がかかる。
ただ、高機能に設計されている分、複雑になっていて実装するにもいくつか方法があり、使い方が難しい。
ここでは自分流の使い方を説明する。
サンプルコードだけを見るのではなく、説明まで全部読んで欲しい。
理屈がわかればもっと複雑なものも作れるようになるはずだ。
●TableModelの構造
Swingコンポーネントは全てMVCで設計されているがJTableも例外ではない。
モデルに相当するのはTableModelオブジェクトだ。
これは、次のことを要求される。
・指定セルの値の取得
・指定セルの値の設定
・カラム数の取得
・行数の取得
・指定セルが編集可能か
・リスナーの追加
まあ、表形のデータを操作するにはこのぐらいの機能は必要だ。
最後の2つだけ補足しておく。
「指定セルが編集可能か」
指定セルの値の設定をするのならこの機能は必要になる。もし、読み出し用モデル書き込み用モデルとに分けたら
書き込み用モデルの機能になる。
「リスナーの追加」
モデルの内容に変化があった場合、モデルを参照しているオブジェクトに対し変化したことを報告しなければならない。
これをしないと、モデルの内容と表示内容に食い違いが起こる。
この作業を自動的にできないのかというと、それは無理だ。
どうしてもやるとなれば、表示側は常にモデルをスレッドを使って監視するような仕組みが必要だ。
それは非常に効率が悪い。
さて、TableModelというのはインターフェースだ。インスタンスを作るには実装しなければならないが、
ある程度まで実装してあるクラスが用意されている。
AbstractTableModelだ。
なお、細かいことは気にせず、全てデフォルトでよいのならDefaultTableModelというのもある。
こちらは、すぐにインスタンス化できる。
●レンダラーとエディター
TableModelというのがデータを表しているのはわかったと思う。
次にレンダラーとエディターを説明する。
JTableの表示のおおまかな仕組みは、
1.TableModelからデータのサイズ(縦と横)を取得
2.サイズだけ次を繰り返す
・その中のセルの内容を取得
・セルの内容を表示
となっている。
このときの、「セルの内容を表示」とうことを行うのが、TableCellReandererだ。
つまり、
TableModelに対し「5行目3列目のデータを下さい」といってデータをもらい、
TableCellReandererに対し「5行目3列目にこれを表示してください」という。
モデルとビューが分離したMVCならではの構造だ。
次にエディターだがこちらは、セルの内容を編集する際に起動される。
レンダラーとエディターが別になっているところがポイントで、これによって表示と編集を別のタイプで
行うことができる。
細かい話はサンプルで説明するので、ここでは概略だけしっておけばよい。
●方針
さて、JTableの使い方の方針だが、
・JTableはカスタマイズしない
・TableModelはデザインパターンでいうアダプターとして使う。
・JTableの設定や制御を行うためのメソッド類はTableModelに一緒に詰め込む。
という方針で行く。
3番目に異論があるかもしれないが、自分の経験上これがいいと思う。
というのは、2番目で言っているように、TableModelをアダプターとして使うので、
フィールドに関する情報などをこのオブジェクトで一元管理できるからだ。
アダプターとして使うというのがどういうことなのかを説明しておく。
その前に、そもそもJTableでどんなものを表示するのか。
最初から表型のデータなんてそうそうあるものではない。
JTableで表示するのはレコード群だろう。
つまり、横方向にレコードの項目、縦方向にレコードという形だ。
JTable自体もそういうものを表示することを前提に設計されているような節がある。
レコード群の表示だとすると、もとのデータはレコードのオブジェクトだ。
そのままでは表示できないので、セル位置をレコード・フィールドに変換する。
例えば、5行目2列目と言われたら、5レコード目の「名前」を返すといった具合だ。
ちなみにアダプターでないやり方というと、レコード群をすべて表形のデータに移し替えて表示するということになる。
更新があった場合は、最後に表型のデータをレコード群へ移し替える。
これよりはアダプターの方が美しいしすっきりする。
アダプターとして使うと言うことは、ここで座標とフィールドを結びつけるわけで、
それらの情報が存在している。それならば他の情報、例えば列名や列の幅などもここで管理した方が
わかりやすい。
というわけで、TableModelオブジェクトで一元管理するのをお勧めする。
●前提条件
今回のサンプルとしては、同好会メンバーの一覧を表示するということにする。
つまり、他のコードで作られたClubMemberオブジェクトがjava.util.ArrayListに格納されている。
ClubMemberオブジェクトは次の通り。
/** 同好会メンバークラス
*/
class ClubMember
{
public static final int TYPE_NORMAL = 1;
public static final int TYPE_SPECIAL = 2;
/**会員番号
*/
public int no;
/**会員氏名
*/
public String name;
/**会員種別
*/
public int type;
/**会員住所
*/
public String address;
}
●コード
まずは、レコード群とアダプターを結びつけるコードを作る。
作るときは、AbstractTableModelをベースにする。
ほとんどの場合レコード群はコンテナに格納されているはずだが、このコンテナの種類によって少し替える必要がある。
java.util.Listのようにランダムアクセスのできるものであれば、コンテナをそのままアダプターにセットするようにすればいい。
しかし、Iteratorのようなものだと、Listオブジェクトに格納し直す必要がある。
java.util.Listの場合
public void setContainer(List list){
this.list = list;
fireTableDataChanged();
}
Iteratorの場合
public void setContainer(Iterator it){
this.list = new ArrayList();
while(it.hasNext()){
list.add(it.next());
}
fireTableDataChanged();
}
今回はjava.util.Listに格納されているという前提なので前者でよい。
●TableModelの実装
さてメインの部分だ。
・指定セルの値の取得
・指定セルの値の設定
・カラム数の取得
・行数の取得
は絶対に実装する必要がある。
「行数の取得」はList#size()をそのまま返せばよい。
public int getRowCount(){
return list.size();
}
「カラム数の取得」だが、クラス内に定数を定義して返すようにすること。
カラム数が増えたときなどにバグを防げる。
public static finalint COLUMN_COUNT = 4;
public int getColumnCount(){
return COLUMN_COUNT;
}
「指定セルの値の取得」、「指定セルの値の設定」だがこれもカラム位置を定数定義しておく。
public static final int COL_NO = 0;
public static final int COL_NAME = 1;
public static final int COL_TYPE = 2;
public static final int COL_ADDRESS = 3;
public Object getValueAt(int row, int col){
ClubMember mem = (ClubMember)list.get(row);
switch(col){
case COL_NO: return new Integer(mem.no);
case COL_NAME: return mem.name;
case COL_TYPE: return new Integer(mem.type);
case COL_ADDRESS: return mem.address;
}
return "";
}
ここでポイントは会員種別だ。もしこの項目がJTableから変更されることがないのなら、
typeに応じた文字列を返してもよい。
この要領で、設定も作ってしまおう。
public void setValueAt(Object value, int row, int col){
ClubMember mem = (ClubMember)list.get(row);
System.out.println(value.getClass().getName());
switch(col){
case COL_NO:
int wkno = 0;
try{
wkno = Integer.parseInt((String)value);
mem.no = wkno;
}catch(Exception e){
}
return;
case COL_NAME:
mem.name = (String)value;
return;
case COL_TYPE:
int wk = 0;
try{
wk = Integer.parseInt((String)value);
if(ClubMember.IsMemberTypeCode(wk)){
mem.type = wk;
}
}catch(Exception e){
}
return;
case COL_ADDRESS:
mem.address = (String)value;
return;
}
}
注意点は、文字列入力なので、数値項目(番号、会員区分)はそのままではダメで
文字列を解析する必要がある。
さて、これだけでも一応は表示される。しかし、会員種別が数字で表示されるのはイマイチだ。
会員種別を数字ではなく、文字で表示されるようにしよう。
数値のデータを文字列に変換する必要がある。
こういうのは、いろいろと使う可能性があるので、ClubMemberクラスのクラスメソッドとして定義しておく。
<ClubMember.javaの一部>
-------------------------------
static public String GetMemberTypeString(int type){
switch(type){
case TYPE_NORMAL:
return "通常会員";
case TYPE_SPECIAL:
return "特別会員";
}
return "";
}
-------------------------------
これを、レンダラーで使う。レンダラーはTableModelオブジェクトに実装してしまう。
class CMemTableModel
extends AbstractTableModel
implements TableCellRenderer
{
....
....
public Component getTableCellRendererComponent(JTable tbl, Object value, boolean isSelected, boolean hasFocus, int row, int col){
}
}
さてここでTableCellRendererについて説明しなければならない。
TableCellRendererはインターフェースだ。何を要求されるかというと、
「指定されたセルの内容を適切な形で表示できるような状態にあるComponentオブジェクトを返す」
ということだ。
もう少しわかりやすく説明すると、
「5行目3列目のデータがtrue(Boolean)ですが表示してください」
と言われるので、それに合ったComponentオブジェクトを返す。
このとき文字列で'true'とするのか、チェックボックスをオンにして返すのか
具体的な表示方法はこのレンダラーが決める。
逆にいえばどんな表示方法も可能だということだ。
文字列で表示するのならJLabelに文字をセットして返せばいいし、チェックボックスなら
JCheckBoxをセレクト状態にして返せばいい。
ここで返したComponentオブジェクトをJTableはpaintメソッドを使って内容を表示する。
つまり、ゴム印のような使い方をするだけだ。
そのため、同じインスタンスを使い回しても全く問題ない。
引数について説明しておこう。
JTableは呼び出し元のJTableオブジェクトだ。
色の情報などはこれを使って取得できる。
valueは表示するセルの内容。
rowとcolは表示するセルの位置だ。
isSelectedはこのセルが選択されているかを示す。
hasFocusはこのセルがフォーカス状態にあるかを示す。
さて今回の場合は、「会員種別」が特別なだけで他の項目はデフォルトにしてしまう。
デフォルトで表示するために、DefaultTableCellRendererというのを使おう。これはJLabelをカスタマイズしたものだ。
つまり、表示対象カラムが会員種別でなければDefaultTableCellRendererにすべて任せてしまう。
if(col != COL_TYPE){
Component comp = defCellRenderer.getTableCellRendererComponent(tbl, value, isSelected, hasFocus, row, col);
return comp;
}
としておけばよい。
defCellRendererというのは、DefaultTableCellRendererのインスタンスだ。
毎回インスタンスを生成するのは効率が悪いのでインスタンス変数として持たせている。
さて会員種別のときだが、コードを文字列に変換し、その文字列をdefCellRendererに渡してしまおう。
int type = ((Integer)value).intValue();
String typeString = ClubMember.GetMemberTypeString(type);
Component comp = defCellRenderer.getTableCellRendererComponent(tbl, typeString, isSelected, hasFocus, row, col);
return comp;
これでTableCellRendererの実装はできた。
それから、AbstractTableModelではisCellEditable()がfalseを返すのでこれもオーバーライドしておく。
public boolean isCellEditable(int rowIndex, int columnIndex){
return true;
}
整理を兼ねてここまでのコードを全部示しておく。
/**
* @author ugnag
*
*/
public class CMemTableModel
extends AbstractTableModel
implements TableCellRenderer
{
public static final int COLUMN_COUNT = 4;
public static final int COL_NO = 0;
public static final int COL_NAME = 1;
public static final int COL_TYPE = 2;
public static final int COL_ADDRESS = 3;
List list = new ArrayList();
DefaultTableCellRenderer defCellRenderer = new DefaultTableCellRenderer();
/** コンストラクタです
*
*/
public CMemTableModel() {
super();
}
public void setContainer(List list){
this.list = list;
fireTableDataChanged();
}
public int getRowCount(){
return list.size();
}
public int getColumnCount(){
return COLUMN_COUNT;
}
public Object getValueAt(int row, int col){
ClubMember mem = (ClubMember)list.get(row);
switch(col){
case COL_NO: return new Integer(mem.no);
case COL_NAME: return mem.name;
case COL_TYPE: return new Integer(mem.type);
case COL_ADDRESS: return mem.address;
}
return "";
}
public void setValueAt(Object value, int row, int col){
ClubMember mem = (ClubMember)list.get(row);
System.out.println(value.getClass().getName());
switch(col){
case COL_NO:
int wkno = 0;
try{
wkno = Integer.parseInt((String)value);
mem.no = wkno;
}catch(Exception e){
}
return;
case COL_NAME:
mem.name = (String)value;
return;
case COL_TYPE:
int wk = 0;
try{
wk = Integer.parseInt((String)value);
if(ClubMember.IsMemberTypeCode(wk)){
mem.type = wk;
}
}catch(Exception e){
}
return;
case COL_ADDRESS:
mem.address = (String)value;
return;
}
}
/* (非 Javadoc)
* @see javax.swing.table.TableCellRenderer#getTableCellRendererComponent(javax.swing.JTable, java.lang.Object, boolean, boolean, int, int)
*/
public Component getTableCellRendererComponent(JTable tbl, Object value, boolean isSelected, boolean hasFocus, int row, int col){
if(col != COL_TYPE){
Component comp = defCellRenderer.getTableCellRendererComponent(tbl, value, isSelected, hasFocus, row, col);
return comp;
}
int type = ((Integer)value).intValue();
String typeString = ClubMember.GetMemberTypeString(type);
Component comp = defCellRenderer.getTableCellRendererComponent(tbl, typeString, isSelected, hasFocus, row, col);
return comp;
}
public boolean isCellEditable(int rowIndex, int columnIndex){
return true;
}
}
これだけではまだ完成とは言えない。
カラム名の設定やセル幅の初期設定をしなければならない。
同時に、レンダラーの設定もしなければならない。
/** 指定されたJTableオブジェクトを、このオブジェクトで初期化する
*/
public void initJTable(JTable tbl){
tbl.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
tbl.setModel(this);
TableColumnModel tcm = tbl.getColumnModel();
initTableColumn(tcm, COL_NO , 50);
initTableColumn(tcm, COL_NAME , 200);
initTableColumn(tcm, COL_TYPE , 80);
initTableColumn(tcm, COL_ADDRESS, 300);
}
/** 指定されたカラムのカラム幅の設定をする
* @param tcm TableColumnModel
* @param col カラムインデックス
* @param size カラム幅
*/
protected void initTableColumn(TableColumnModel tcm, int col, int size){
TableColumn tc = tcm.getColumn(col);
tc.setResizable(true);
tc.setPreferredWidth(size);
tc.setCellRenderer(this);
return;
}
public String getColumnName(int col){
switch(col){
case COL_NO: return "番号";
case COL_NAME: return "氏名";
case COL_TYPE: return "会員区分";
case COL_ADDRESS: return "住所";
}
return "";
}
さて、ここまで出来たらほとんど完成だ。
これでも一応動作はするが、保留にしておいた会員区分の問題がある。
今のままでは、会員区分は番号を入力する必要がある。
それでは使い難いので、他の方法で入力できるようにしよう。
考えられるのは、
・リストボックス
・コンボボックス
・チェックボックス
という3つだ。
今のところ会員区分は2種類しかないが、将来種類が増えるとチェックボックスは使えない。
リストボックスは場所をとるので、やはりコンボボックスがいいだろう。
ということで、会員区分の項目だけカスタムエディターを使おう。
しかし、TableCellEditorインターフェースを実装するのは必須メソッドが多いので面倒だ。
そこで、デフォルトのエディタを用意しておこう。
public class CMemTableModel
extends AbstractTableModel
implements TableCellRenderer
{
public static final int COLUMN_COUNT = 4;
public static final int COL_NO = 0;
public static final int COL_NAME = 1;
public static final int COL_TYPE = 2;
public static final int COL_ADDRESS = 3;
List list = new ArrayList();
DefaultTableCellRenderer defCellRenderer = new DefaultTableCellRenderer();
JComboBox cb = new JComboBox();
DefaultCellEditor defEditor;
/** コンストラクタです
*
*/
public CMemTableModel() {
super();
cb.addItem( new TextIntValue(ClubMember.GetMemberTypeString(ClubMember.TYPE_NORMAL ), ClubMember.TYPE_NORMAL ));
cb.addItem( new TextIntValue(ClubMember.GetMemberTypeString(ClubMember.TYPE_SPECIAL), ClubMember.TYPE_SPECIAL));
defEditor = new DefaultCellEditor(cb);
}
public void setValueAt(Object value, int row, int col){
ClubMember mem = (ClubMember)list.get(row);
System.out.println(value.getClass().getName());
switch(col){
case COL_NO:
int wkno = 0;
try{
wkno = Integer.parseInt((String)value);
mem.no = wkno;
}catch(Exception e){
}
return;
case COL_NAME:
mem.name = (String)value;
return;
case COL_TYPE:
int wk = 0;
try{
TextIntValue tiv = (TextIntValue)value;
mem.type = tiv.value;
}catch(Exception e){
}
return;
case COL_ADDRESS:
mem.address = (String)value;
return;
}
}
/** 指定されたJTableオブジェクトを、このオブジェクトで初期化する
*/
public void initJTable(JTable tbl){
tbl.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
tbl.setModel(this);
TableColumnModel tcm = tbl.getColumnModel();
initTableColumn(tcm, COL_NO , 50, null);
initTableColumn(tcm, COL_NAME , 200, null);
initTableColumn(tcm, COL_TYPE , 80, defEditor);
initTableColumn(tcm, COL_ADDRESS, 300, null);
}
/** 指定されたカラムのカラム幅の設定をする
* @param tcm TableColumnModel
* @param col カラムインデックス
* @param size カラム幅
*/
protected void initTableColumn(TableColumnModel tcm, int col, int size, TableCellEditor ed){
TableColumn tc = tcm.getColumn(col);
tc.setResizable(true);
tc.setPreferredWidth(size);
tc.setCellRenderer(this);
if(ed != null){
tc.setCellEditor(ed);
}
return;
}
/** コンボボックスのアイテムに値を結びつける。
*/
class TextIntValue
{
public String text = "";
public int value = 0;
public TextIntValue(String text, int value){
this.text = text;
this.value = value;
}
public TextIntValue(){
}
public String toString(){
return text;
}
}
これで、目的は達成できる。
しかし、会員区分変更のコンボボックスの状態は、そのレコードの状態ではなく、
前回変更した状態になっている。
これがかっこ悪いと思った場合、修正するのは多少面倒になる。
まず、どのように修正するかを説明する。
コンボボックスが前回の状態になっているのは、DefaultCellEditorで使っているコンボボックスの状態が
変更されないからだ。
変更するには、TableCellEditorのgetTableCellEditorComponentメソッドで返すコンポーネントが、
適切な状態になっている必要がある。
最適なタイミングは、TableCellEditorのgetTableCellEditorComponentが呼び出されたタイミングだろう。
そのためには、DefaultCellEditorをカスタマイズする必要がある。
getTableCellEditorComponentのvalue引数はTableModelのgetValueAtメソッドの戻り値である。
このプログラムでは会員区分はIntegerオブジェクトが返される。
getTableCellEditorComponentが呼び出されたときにコンボボックスをvalueのものに設定しリターンする。
public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column){
if(value instanceof Integer){
int val = ((Integer)value).intValue();
cb.setSelectedIndex(-1);
int n = cb.getItemCount();
for(int i = 0 ; i < n ; i++){
TextIntValue v = (TextIntValue)cb.getItemAt(i);
if(v.value == val){
cb.setSelectedIndex(i);
break;
}
}
}
return cb;
}