0 こんなことをしたいと思います。
「TListBox(VCL)で面白いこと、やってみましょう」の第2回目になります。
前回は、TListBoxの基本的な部分のご紹介と、簡単なオーナードローの実演でした。
今回は、Delphi(VCLアプリケーション)で、もう少し、凝ったことをしたいな、と思います。
まず、準備をお願いします。
・OS :Windows10が動くパソコン
・プログラム開発環境 :Delphi Community Edition
1 データ構造を決めます。
今回は、TListBox.Items[n]の中身にする文字列データを、次のように決めたいと思います。
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8~末尾 |
---|---|---|---|---|---|---|---|
予約 | 開閉 | インデント | 高さ | ライン色 | チェック | セパレータ | 文字列 |
0 | +,- | 0~9 | 0~9 | 0~9 | x,_ | @ | S... |
※テスト用のデータを用意しました。これをコピーして、テキストファイルで保存してください。折り畳みは、クリックで開きます。
テスト用データ
0+011x@植物の分類 0+121x@胞子でふえる植物 0+232_@・シダ植物\ 根・茎・葉の区別がある植物。 0+233_@・コケ植物\ 根・茎・葉の区別がない。湿った場所に多い。雄株と雌株にわかれる。 0+124_@・種子でふえる植物 0+225_@・裸子植物\ 子房がなく種子がむき出し。松、杉など 0+226_@・被子植物\ 子房(果実)がある植物。イネ、イチゴ、サクラなど2 画面を作ります。
さっそく画面を作りましょう。
Delphiにはたくさんのコンポーネントがあります。今回は...
>Standardのグループの中にあります。
・ListBox ×1個
・Memo ×1個
・Button ×3個
>Dialogsのグループの中にあります。
・OpenDialog ×1個
これらを使います。右下にあるパレットから選んで、画面中央にいるFormに貼り付けてください。
2-1.テスト用データを読み込む準備
前回もご紹介しましたが、DelphiはIDEという統合開発環境を持っています。次の絵のように、プロパティを設定したいときや、イベントを書きたいときは、画面上でオブジェクトを選択します。直感的に操作できるので、理解しやすいと思います。
オブジェクトインスペクタで上の絵のようにダブルクリックすると、自動的で、
interface部にprocedure Button1Click(Sender: TObject);
と、宣言が追加されます。
また、implementationには、次のようにイベントハンドラのコードを書く場所が用意されます。あとは、begin end; の間に必要なコードを書くだけです。
procedure TForm1.Button1Click(Sender: TObject);
begin
//ここへコードを書き込みます。
end;
では、次のようにコードを書き込んでください。begin end;の間をコピーして貼り付けても構いません。
このコードの意味は、もしも、ファイルを開くのダイアログの結果がTrueだったら、取得したファイルをListBox1に読み込んでくださいな。 という意味です。
procedure TForm1.Button1Click(Sender: TObject);
begin
if OpenDialog1.Execute = true then //ファイルを開く画面を表示します。
ListBox1.Items.LoadFromFile(OpenDialog1.FileName); //ListBox1へファイルを読み込みます。
end;
Delphiの良い所は、すぐに実行して試して確認できることです。
[F9]キーで実行します。
いま、コードを書いたButton1をクリックして、テストデータを読み込んでください。
ちゃんと動くかな? 試してみましょうね。
button2にもClickイベントを追加します。
ListBox1の表示スタイルを標準に戻すボタンを作ります。
procedure TForm1.Button2Click(Sender: TObject);
begin
ListBox1.Style:=lbStandard;
end;
さらに、button3にClickイベントをを追加します。
これが、ListBox1の表示スタイルを今回のお題、オーナードローにするボタンになります。
procedure TForm1.Button3Click(Sender: TObject);
begin
ListBox1.Style:=lbOwnerDrawVariable;
end;
ここまでが、準備です。
次から、実際にコードをガリガリ書いていきます。
3 インデント表示をしたい。
インデントは案外、簡単にできます。
OnDrawItemイベントでは、描画領域はRectで与えられます。なので、表示位置を右へ少しずらすだけです。
インデントのレベルは、先ほど決めたデータ構造では先頭から数えて3文字目にあります。なので、Copy(S,3,1)で取り出せます。それから、抜き出した数字を数値に変換するには、**StrToIntDef(文字列,規定値の数値)**を使っています。
3-1.数字を数値に変換するのは、なぜ?
プログラムでは、数字と数値は別のものとして考えます。
ListBox.Items[n]に保存できるのは、文字列です。
これに対して、描画位置の計算を行うRect.Leftの値は整数型の数値です。
このため、StrToIntDef(文字列,規定値の数値)を使って、文字を数値に変換しています。既定の数値を設定したのは、何かの手違いで数値に変換できない文字列が混入した際は、エラーにならないように、無難そうな数値を返すための工夫です。
それから... データ型はとても複雑なシステムです。とても全部を覚えきれるものじゃありません。最初は、Longint、 String、 Booleanの3つだけは覚えましょう。
前置き長くなりましたが、プログラムは下のようになります。
procedure TForm1.ListBox1DrawItem(Control: TWinControl; Index: Integer;
Rect: TRect; State: TOwnerDrawState);
var
Lv:longunt;
S:string;
begin
S:=ListBox1.items[index]; //項目を見出し
Lv:=StrToIntDef(Copy(S,3,1),0); //3文字目を読み出して数値に変換
Rect.Left:=Rect.Left+Lv*20; //インデントレベル×20pixを右へ下げます
《中略》
end;
4 テキストを折り返し表示したい。
TListBoxの項目内で、テキストを折り返し表示するには、次のふたつのことを考える必要があります。
4-1. 描画領域の中で文字列を折りたたみます。
OnDrawItemイベントで与えられるRectの範囲中に納まるように、文字列を調節して改行表示するには、どんな数値が必要になるのでしょうか?
求める部分のサイズ | 算出方法 |
---|---|
描画領域の横幅 A | Rect.Right - Rect.Left |
文字列の大きさ B | Canvas.TextWidth(文字列) |
文字の高さ C | Canvas.TextHeight(文字列) |
したがい、A < B のとき、Cだけ下へ表示位置をずらすと改行表示になります。 |
参考までに、文字列を改行表示する方法は、もうひとつあります。
**Canvas.TextRect(Rect,文字列)**でも、Rectの四角内に収まるように文字を描画してくれます。
4-2 改行コードは代替文字に変換します。
もうひとつ考えなければいけないことがあります。
・TListBoxで項目を保持しているItemsは、文字列リストです。**改行コードは項目を区切るためのセパレータとして使われています。**改行コードを含む文字列は、そのままでは文字列リスト内に保持できません。
具体的には、ListBox1.Items[index]:=Memo1.text; と代入することはできるのですが、改行コードで項目が区切られて、ひとつの項目には収まらなくなります。
そこで、次のように、改行コードを代替文字に置き換えることになります。
ListBox1.Items[index]:=ReturnToAlt(Memo1.text);
今回は、「 \ 」(日本語環境のパソコンでは、半角¥マーク)に置き換えています。どんな文字に置き換えるのかは、アプリの使い道に合わせて考えるとよいでしょう。
function TForm1.ReturnToAlt(Mes:String) :string; //改行コードを\記号へ代替
var
i:longint;
S:string;
begin
Mes:=AdjustLineBreaks(Mes); //改行コードを直すおまじない
S:='';
i:=1;
while i <= length(Mes) do
if Copy(Mes,i,2)=chr(13)+chr(10) then
begin
S:=S+'\';
i:=i+2;
end
else
begin
S:=S+Copy(Mes,i,1);
i:=i+1;
end;
result:=S;
end;
コードの中に、Mes:=AdjustLineBreaks(Mes); とあるのは、改行コードを直すおまじないです。入れてなくっても、たぶん、問題なく動きます。
おまじないの紹介を兼ねて入れてます。AdjustLineBreaks 関数は、改行コードを調整してくれます。というのも、この世には、Windowsとは違うシステムで作られたアプリがあり、そこには、chr(13)+chr(10)の組み合わせ とは違う改行コードを含む文字列があるからです。いつか、そういう文字列を貼り付ける時が来ても大丈夫なようにAdjustLineBreaks 関数を混ぜました。
さて、OnDrawItemイベントで与えられるRectの範囲中に、文字列を折りたたむコードはこんな感じになります。
procedure TForm1.ListBox1DrawItem(Control: TWinControl; Index: Integer;
Rect: TRect; State: TOwnerDrawState);
var
i,TpPos,Lv:longint; //i ループカウンタ TpPos文字表示の改行縦位置 Lvインデントレベル横位置
S,Mes:string; //Mes 文字列加工元 S 加工後の文字列
begin
Mes:=ListBox1.Items[index]; //ListBox から項目を読み出し
//テキスト描画ループ
TpPos:=4; //textout の Top 位置
i:=1;
S:='';
while i<=length(Mes) do // i を1つずつ増やしながら文字列を頭から尻尾まで1文字ずつ確認します。
begin
//\が改行記号置き換え
if Copy(Mes,i,1)='\' then
begin
textOut(Rect.left+8,Rect.Top+TpPos,S); //文字を表示してから
TpPos:=TpPos+textheight('漢')+2; //改行します
font.Style:=[]; //タイトル行の太文字指定を解除します。★(#6でタイトル行を解除するために使います)
S:='';
end
else
S:=S+Copy(Mes,i,1); //改行がないときは文字列を伸ばします
if Rect.Right-Rect.Left-textwidth('漢')-10 < textwidth(S) then //漢1文字分引いてはみ出し1つ手前確認
begin
if TpPos+textheight('漢') > Rect.Bottom-Rect.Top then
exit; //縦位置がはみ出す場合は終了
textOut(Rect.left+8,Rect.Top+TpPos,S);
TpPos:=TpPos+textheight('漢')+2; //改行
S:='';
end;
if TpPos+textheight('漢') > Rect.Bottom-Rect.Top-2 then //縦位置がはみ出す場合は終了
exit;
i:=i+1;
end;
textOut(Rect.left+8,Rect.Top+TpPos,S); //改行なしand 右端超えもないときの文字列表示
end;
いまのコードで使った関数はこんな感じです。
関数 | 書式 | 得られる値、結果 |
---|---|---|
Lenght | Length(文字列) | 文字列の長さ |
TextWidth | TextWidth(文字列) | 文字列が表示しきれる横幅 |
TextHeight | TextHeight(文字) | 文字を表示できる高さ |
TextOut | TextOut(x,y,文字列) | 文字列を x, y の位置へ表示 |
Copy | Copy(文字列,インデックス,読み出す文字数 | 部分文字列(文字列をインデックスの位置から指定された文字数分読み出す) |
5 項目の高さ(ItemHeight)を変更したい。
項目ごとに高さを変更することもできます。
方法は、ListBoxのStyleをlbOwnerDrawVariableに設定して、OnMeasureItemイベントの中で、Heightの値を変えるだけです。Height:=0; とすれば、項目を隠すこともできます。非表示をきれいに処理するには、少し手間がかかりますけど(後述)
プログラムの例を示します。今回のお題では、最初にデータ構造を決めました。4文字目を項目の高さに割り振っています。
0~9までをListBox.ItemHeightにかけて、1行だけの高さ(ListBox1.ItemHeight)×倍数で高さを表すことにしました。
procedure TForm1.ListBox1MeasureItem(Control: TWinControl; Index: Integer;
var Height: Integer);
begin
Height:=ListBox1.ItemHeight*StrToIntDef(Copy(ListBox1.Items[index],4,1),1);
end;
関数は入れ子にすることもできます。上の例では、StrToIndDef関数の中に、Copy関数が入っています。
6 項目を開閉表示したい。
前節で、項目ごとに高さを変えられることをお話ししました。
Height:=0; とやっちゃえば、項目を非表示にできるはず... と言いたいのですが、実際はもう少しコードが必要です。
6-1.非表示の場合は、OnDrawItemeで描画しないようにします。
実は、OnDrawItemeイベントは、たとえRectの高さがゼロであっても、がんばって描画しようとします。試してみると画面表示が微妙に乱れます。
なので、非表示の時は描画を止めるように判定を増やします。
具体的には、最初に決めたデータ構造では、2文字目を開閉フラグに割り振っています。項目の高さがゼロの時に閉じるでも、同じことができますが、開閉と項目の高さは別にした方が便利です。項目の高さを覚えたまま開閉できるため、使い勝手が良いのです。なので開閉フラグも用意しました。
//OnDrawItemeイベントの先頭へ追加
if Copy(S,2,1)='-' then //閉じている項目は描画しない。
exit;
6-2.[↑][↓]キーで移動する場合も、非表示の項目を飛ばすようにします。
つまり、ListBox1.ItemIndexも、項目の高さがゼロの項目は通り過ぎるようにします。
[↑]キーの時は、項目を上に遡りながら、開閉フラグが開(2文字目が'+')になっている項目を探します。
[↓]キーの時は、項目を下へ降りながら、開閉フラグが開(2文字目が'+')になっている項目を探します。
※先頭と最後尾は閉じていない想定です。
プログラムの中では、どのキーが押されているのか? を判定するために、仮想キーコードを使います。KeyDownイベントでは、Keyに仮想キーコードが格納されています。
よく使いそうな仮想キーコードはこんな感じです。
キー | 仮想キーコード |
---|---|
[↑]キー | VK_Up |
[↓]キー | VK_Down |
[←]キー | VK_Left |
[→]キー | VK_Right |
[Enter]キー | VK_Return |
KeyDownは後で、もう一度使います。
procedure TForm1.ListBox1KeyDown(Sender: TObject; var Key: Word;
Shift: TShiftState);
var
i:longint;
begin
//エラーチェック
if ListBox1.ItemIndex<0 then
exit;
//[↑]キーのとき
if (key=VK_Up)and(ListBox1.ItemIndex>0) then
begin
for i:=ListBox1.ItemIndex downto 0 do
if Copy(ListBox1.Items[i-1],2,1)='+' then //開いている項目を見つけたらItemIndexを移動
begin
ListBox1.ItemIndex:=i;
exit;
end;
ListBox1.ItemIndex:=0; //該当なしのばあい 上端
end;
//[↓]キーのとき
if (key=VK_Down)and(ListBox1.ItemIndex<ListBox1.Items.Count-2) then
begin
for i:=ListBox1.ItemIndex to ListBox1.Items.Count-2 do
if Copy(ListBox1.Items[i+1],2,1)='+' then //開いている項目を見つけたらItemIndexを移動
begin
ListBox1.ItemIndex:=i;
exit;
end;
ListBox1.ItemIndex:=ListBox1.Items.Count-1; //該当なしのばあい 下端
end;
7 見栄えをカッコよくしたい。
項目ごとに色を付けて分類できたら見栄えが良くて使いやすいかもしれません。カラーバーをどの色で塗るのか? は、データ構造の5文字目です。
**Rectange(X1, Y1, X2, Y2)**は、左上と右下を指定すると四角形を描いてくれます。輪郭の色はPen.Colorで、塗りつぶしはBrush.Colorです。
//OnDrawItemeイベントへ追加
//カラーバー塗り
brush.Color:=AskColor(StrToIntDef(Copy(S,5,1),0));
Pen.Color:=ListBox1.Color;
Rectangle(Rect.Left,Rect.bottom,Rect.Left+16,Rect.Top);
Rect.Left:=Rect.Left+16;
塗り色については、次のように簡単な関数をつくりました。
Font,Pen,BrushのColorは、longint型の整数値です。
Color は 青、緑、赤の輝度を1バイトずつで表しています。
function TForm1.AskColor(Clr:longint):longint;
begin
case clr of
0:
Result:=$00E6E6E6;
1:
Result:=$00EACDF3;
2:
Result:=$00F4CCD3;
3:
Result:=$006A6AFB;
4:
Result:=$00E0F4CC;
5:
Result:=$00CDF3DC;
6:
Result:=$0079DE74;
7:
Result:=$00CED7F2;
8:
Result:=$00D8773D;
9:
Result:=$00F7F7F7;
else
Result:=$00C6C6C6; //elseは、それ以外のばあい
end;
それから、いま、選択されている項目を目立つように塗ります。OnDrawItemeイベントのStateを確認すると、いま描画している項目が選択されているか? が、わかります。
stateはTOwnerDrawStateというオーナードローに便利な情報のセットです。複数の値が詰め合わせになっているため、if odSelected in state then という書き方で、**Stateの中にodSelectedが含まれていますか?**を評価しています。
//OnDrawItemeイベントへ追加
//項目塗り 塗色決め
if odSelected in state then //state odSelectedが含まれている?
Brush.Color:= clActiveCaption //選択状態の色
else
brush.Color:=clBtnFace; //それ以外の色
FillRect(Rect); //項目塗り
さらに、カッコよくしてみましょう。
項目の初めの1文字目が「・」全角中点の時は、中点~改行までがタイトル行にしましょう。太文字にすると同時に下線をつけてカッコよくします。太文字属性の解除は改行処理の中で**Font.Style:=[]**で元に戻してます。
タイトル行の下へ線を引きます。
2点間を結ぶ直線は、Canvas.MoveTo(x,y)で始点を指定します。次に、 Canvas.LineTo(x,y)で終点を指定するとともに、始終点を結ぶ線をCanvas.Pen.Colorで引きます。
//OnDrawItemeイベントへ追加
//タイトル行 (中点が行頭にあるとき)
if Copy(Mes,1,1)='・' then
begin
font.Style:=[fsBold];
Pen.Color:=AskColor(StrToIntDef(Copy(S,5,1),0));
Moveto(Rect.Left,Rect.Top+4+textheight('漢')); //MoveToで始点を設定
LineTo(Rect.Right-10,Rect.Top+4+textheight('漢')); //LineToで終点を設定し、線を引きます。
end;
関数 | 書式 | 得られる値、結果 |
---|---|---|
MoveTo | MoveTo(x,y) | x, y へ描画の指定を移動(絵は描きません) |
LineTo | LineTo(x,y) | 前回の描画位置を始点に、x, y を終点にして、Pen の Color で線を引きます |
ListBox1DrawItem に色んな処理を付け足して、こんなことができる と試してみました。バラバラに分割して説明したので、全体がどうなっているのか? わかりにくかったと思います。
まとめに、全部をつないだ状態の ListBox1DrawItem イベントハンドラを再掲します。
procedure TForm1.ListBox1DrawItem(Control: TWinControl; Index: Integer;
Rect: TRect; State: TOwnerDrawState);
var
i,TpPos,Lv:longint; //i ループカウンタ TpPos文字表示の改行縦位置 Lvインデントレベル横位置
S,Mes:string; //Mes 文字列加工元 S加工後の文字列
begin
S:=ListBox1.Items[index]; //ListBoxから項目を読み出し
Rect.Left:=Rect.Left+8;
//閉じてる
if Copy(S,2,1)='-' then //閉じている項目は描画しない。
exit;
Lv:=StrToIntDef(Copy(S,3,1),0); //インデントのレベル 0-9
Mes:=Copy(S,8,length(S)-7); //文字列を読み出し
with ListBox1.Canvas do
begin
font:=ListBox1.font;
//下地塗り
Brush.Color:=ListBox1.Color;
FillRect(Rect);
//項目塗り 位置決め
Rect.Top:=Rect.Top+2;
Rect.Left:=Rect.Left+Lv*20; //インデントレベル×20pixを字下げ
//カラーバー塗り
brush.Color:=AskColor(StrToIntDef(Copy(S,5,1),0));
Pen.Color:=ListBox1.Color;
Rectangle(Rect.Left,Rect.bottom,Rect.Left+16,Rect.Top);
Rect.Left:=Rect.Left+16;
//項目塗り 塗色決め
if odSelected in state then
Brush.Color:= clActiveCaption //選択状態の色
else
brush.Color:=clBtnFace; //それ以外の色
FillRect(Rect); //項目塗り
//タイトル行 (中点が行頭にあるとき)
if Copy(Mes,1,1)='・' then
begin
font.Style:=[fsBold];
Pen.Color:=AskColor(StrToIntDef(Copy(S,5,1),0));
Moveto(Rect.Left,Rect.Top+4+textheight('漢'));
LineTo(Rect.Right-10,Rect.Top+4+textheight('漢'));
end;
//テキスト描画ループ
TpPos:=4; //textoutのTop位置
i:=1;
S:='';
while i<=length(Mes) do
begin
//\が改行記号置き換え
if Copy(Mes,i,1)='\' then
begin
textOut(Rect.left+8,Rect.Top+TpPos,S); //文字を表示してから
TpPos:=TpPos+textheight('漢')+2; //改行します
S:='';
font.Style:=[]; //タイトル行の太文字指定を解除します
end
else
S:=S+Copy(Mes,i,1); //改行がないときは文字列を伸ばします
if Rect.Right-Rect.Left-textWidth('漢')-10 < textwidth(S) then //漢1文字分引いてはみ出し1つ手前確認
begin
if TpPos+textheight('漢') > Rect.Bottom-Rect.Top then
exit; //アイテム描画域を超える場合は終了
textOut(Rect.left+8,Rect.Top+TpPos,S);
TpPos:=TpPos+textheight('漢')+2; //改行
S:='';
end;
if TpPos+textheight('漢') > Rect.Bottom-Rect.Top-2 then //縦位置がはみ出す場合は終了
exit;
i:=i+1;
end;
textOut(Rect.left+8,Rect.Top+TpPos,S); //改行も右端超えもないときの文字列表示
end;
end;
8 キーボードやマウスから操作できるようにします。
せっかく画面を作っても動かなければ、意味がありませんし、寂しいです。
キーボード操作で、インデントの上げ下げをできるようにします。
先ほど、選択中の項目を移動する処理でも紹介しましたが、キーボードで何かキーを押し下げると、KeyDownイベントが起こります。このイベントハンドラの中で、どのキーが押されているのかを判定して、必要な処理をします。
今度は、Shift と key の組み合わせで判定します。
if (key=VK_LEFT) and (shift=[ssShift]) then ~と、if文の判定に2つ条件を書くことで、組み合わせを表現します。
インデントを減らしてレベル上げ [Shift] + [←]
//ListBox1KeyDownイベントの中に書きます。
//←レベル上げ
if (key=VK_LEFT)and(shift=[ssShift]) then
begin
Lv:=StrToIntDef(Copy(Mes,3,1),0);
Lv:=Lv-1;
if Lv<0 then
Lv:=0;
S1:=Copy(Mes,1,2);
S2:=Copy(Mes,4,length(Mes)-3);
ListBox1.Items[ix]:=S1+IntToStr(Lv)+S2;
ListBox1.ItemIndex:=ListBox1.ItemIndex+1;
exit;
end;
インデントを増やしてレベル下げ [Shift] + [→]
//→レベル下げ
//ListBox1KeyDownイベントの中に書きます。
if (key=VK_Right)and(shift=[ssShift]) then
begin
if ListBox1.ItemIndex=0 then
exit; //先頭行レベルはゼロ固定
Lv:=StrToIntDef(Copy(Mes,3,1),0);
Lv:=Lv+1;
if Lv>9 then
Lv:=9;
S1:=Copy(Mes,1,2);
S2:=Copy(Mes,4,length(Mes)-3);
ListBox1.Items[ix]:=S1+IntToStr(Lv)+S2;
ListBox1.ItemIndex:=ListBox1.ItemIndex-1;
exit;
end;
項目を開閉は、DblClickイベントで処理しましょう。先ほどと同じく、オブジェクトインスペクタの ListBox1 のイベントの一覧表から、OnDblClick を探して、空欄をダブルクリックしてください。
処理の内容は、2文字目にある開閉フラグを書き換えているだけです。
フラグは、'+'が開きで、'-'が閉じ にしました。
procedure TForm1.ListBox1DblClick(Sender: TObject); //開閉
var
Mes,S,S1,S2:String;
begin
Mes:=ListBox1.Items[ListBox1.ItemIndex];
S:=Copy(Mes,2,1);
S1:=Copy(Mes,1,1);
S2:=Copy(Mes,3,length(Mes)-2); //バラバラにします。
if S='+' then //開閉フラグを反転します
S:='-'
else
S:='+';
ListBox1.Items[ListBox1.ItemIndex]:=S1+S+S2; //くっつけて書き戻し
end;
9 デバッグします。
ListBoxでオーナードローを使う一番のメリットがこれです。
StyleをlbStandardに戻すと、実行中でも、データ構造の中身が見ることができます。
キー入力やダブルクリックをすると、フラグが書き換わる様子がそのまま見て確認できます。なるほど~と、思ってくれたらうれしいです。
[button2] で標準の表示に戻ります。
[button3] でオーナードローに切り替わります。
9 ありがとうございます。
今回も、長くなりましたが、ここまでご覧いただきありがとうございます。
次回は――
いま作ったリストボックスに、memoから文字列を書き込んだり、ファイルに保存したりする予定です。