全部読み終わるまでチェックさせない
本日は、Windows フォームアプリケーションで「利用規約に同意する」画面を作ってみようと思います。みなさんも一度はお目にかかったことのあるあの画面です。
- テキストボックスには、長文のテキストデータが格納されている。
- テキストボックスを最後までスクロールさせると、「利用規約に同意する」がチェックできるようになる。
- 「利用規約に同意する」をチェックすると、次へ進めるようになる。
なんとなくイメージできましたでしょうか。
ただし政治上の理由により、テキストの表示はピュアな TextBox クラスのみ(つまりSystem.Windows.Forms.TextBox)で行うこととします。
- 代替コンポーネント(RichTextBox等)の使用はダメ
- 既存の TextBox を継承するのもダメ
一見すると簡単そうですが、なんと標準の TextBox にはスクロールまわりのイベントが一切用意されておらず、.Netしか知らない方はまず間違いなくはまります。
そんなわけで今回の真のテーマは、「TextBox オブジェクトのスクロール状態をいかにして捕捉するか」となります。
僕ならこう作る
まずは TextBox, CheckBox, Button を組み合わせて下図のような画面 (Form1.cs) を作ります。
名前はそれぞれデフォルトのまま textBox1, checkBox1, button1 としてあります。
次に、TextBox にスクロール監視処理を外付けするため、新しくクラスを作ってプロジェクトに追加します。クラスといってもインスタンス化はしません。ただ置いておくだけで TextBox がパワーアップする魔法のクラスです。
名前は TextBoxEx とでもしておきましょうか。
TextBoxEx.cs
using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Windows.Forms; namespace Tercel.GUIEx { public static partial class TextBoxEx { private static readonly int VERTICAL_SCROLL_BAR = 1; [StructLayout(LayoutKind.Sequential)] private struct SCROLLINFO { public uint cbSize; public uint fMask; public int nMin; public int nMax; public uint nPage; public int nPos; public int nTrackPos; } private enum ScrollInfoMask { RANGE = 0x1, PAGE = 0x2, POS = 0x4, DISABLENOSCROLL = 0x8, TRACKPOS = 0x10, ALL = RANGE | PAGE | POS | DISABLENOSCROLL | TRACKPOS } private static SCROLLINFO info = new SCROLLINFO() { cbSize = (uint)Marshal.SizeOf(info), fMask = (int)ScrollInfoMask.POS + (int)ScrollInfoMask.PAGE + (int)ScrollInfoMask.RANGE + (int)ScrollInfoMask.DISABLENOSCROLL }; [DllImport("user32.dll")] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetScrollInfo(IntPtr hWnd, int nBar, out SCROLLINFO lpScrollInfo); private static readonly Dictionary<TextBox, EventHandler> eventsInfoDictionary = new Dictionary<TextBox, EventHandler>(); private static readonly Dictionary<TextBox, bool> touchdownInfoDictionary = new Dictionary<TextBox, bool>(); private static readonly EventHandler checkTouchdownAndExecuteCallbackMethod = (sender, e) => { TextBox textBox = sender as TextBox; if (!eventsInfoDictionary.ContainsKey(textBox)) return; GetScrollInfo(textBox.Handle, VERTICAL_SCROLL_BAR, out info); bool isTouchdown = (info.nPos + info.nPage) > info.nMax; if (!touchdownInfoDictionary.ContainsKey(textBox)) touchdownInfoDictionary[textBox] = !isTouchdown; if (touchdownInfoDictionary[textBox] == isTouchdown) return; if (isTouchdown) eventsInfoDictionary[textBox](textBox, new EventArgs()); touchdownInfoDictionary[textBox] = isTouchdown; }; public static void SetTouchdownEvent(this TextBox textBox, EventHandler eventHandler) { if (eventsInfoDictionary.ContainsKey(textBox)) ResetTouchdownEvent(textBox); textBox.SizeChanged += checkTouchdownAndExecuteCallbackMethod; textBox.TextChanged += checkTouchdownAndExecuteCallbackMethod; textBox.MouseCaptureChanged += checkTouchdownAndExecuteCallbackMethod; textBox.KeyDown += (sender, e) => checkTouchdownAndExecuteCallbackMethod(textBox, null); textBox.MouseWheel += (sender, e) => checkTouchdownAndExecuteCallbackMethod(textBox, null); textBox.MouseUp += (sender, e) => checkTouchdownAndExecuteCallbackMethod(textBox, null); } public static void ResetTouchdownEvent(this TextBox textBox) { if (!eventsInfoDictionary.ContainsKey(textBox)) return; textBox.SizeChanged -= checkTouchdownAndExecuteCallbackMethod; textBox.TextChanged -= checkTouchdownAndExecuteCallbackMethod; textBox.MouseCaptureChanged -= checkTouchdownAndExecuteCallbackMethod; textBox.KeyDown -= (sender, e) => checkTouchdownAndExecuteCallbackMethod(textBox, null); textBox.MouseWheel -= (sender, e) => checkTouchdownAndExecuteCallbackMethod(textBox, null); textBox.MouseUp -= (sender, e) => checkTouchdownAndExecuteCallbackMethod(textBox, null); } } }
うあぁきもい。
昔懐かし Windows APIのレガシーなコードと、今をときめくモダンなラムダ式が華麗に混ざり合って実に変態的。
ただ、こいつのすごいところは、利用側のコードが果てしなくシンプルになること。
最初に作った GUI をエディタで開き、Initialize() の下にちょっとした小細工を追加します。
ほんとうにちょっとだけです。
Form1.cs
using System; using System.Windows.Forms; using Tercel.GUIEx; namespace Sample20140124 { public partial class Form1 : Form { public Form1() { InitializeComponent(); // 初期状態は、チェックボックスもボタンも使用不可に checkBox1.Enabled = false; button1.Enabled = false; // テキストボックスに超長い文章をセットする SetVeryLongText(textBox1); // ↓今回の一番いいとこ // 一番下までスクロールしたらチェックボックスを有効化 textBox1.SetTouchdownEvent((sender, e) => checkBox1.Enabled = true); // チェックボックスにチェックが入ったら // ボタンを有効化する checkBox1.CheckedChanged += (sender, e) => button1.Enabled = checkBox1.Checked; // ボタンがクリックされたら画面を閉じる button1.Click += (sender, e) => Application.Exit(); } private void SetVeryLongText(TextBox textBox) { textBox.Text = @"あのイーハトーヴォのすきとおった風、 夏でも底に冷たさをもつ青いそら、 うつくしい森で飾られたモリーオ市、 (あまりにもうざいので途中省略) 皆十四、五の少年ばかりだった。"; textBox.Select(0, 0); } } }
Form側のコードはたったこれだけ。
TextBoxEx の存在感がまったく見えないくらいシンプルです。
注目すべきは SetTouchdownEvent() です。まるで TextBox のインスタンスメソッドのようですが、こんなメソッドは TextBox クラスにはありません。いったいどこから……そう、TextBoxEx で新たに作ったメソッドです。それがまるで TextBox のインスタンスメソッドのように使えてしまうのです*1。継承を使っていないのに、こういうことが許されるのです。
これはC#の初歩的な変態奥義で、「拡張メソッド」などと呼ばれていたりします。便利だけど使いすぎるとメンテしづらいコードになります。
さっそく実行してみよう。
初期状態。
途中までスクロールさせたところ。
完全に下までスクロールさせたところ。チェックボックスが有効になっている点に注目!
チェックボックスにチェックを入れたところ。ボタンが押せるようになった!
ボタンを押すと、アプリケーションが終了する。
……うごいた!わはー!
まとめ
- System.Windows.Forms.Text でスクロールまわりを監視しようとすると、わりと面倒。
- なんで TextBox はスクロールまわりのイベントが使えないんだろうね。
- 今回は継承を封印したが、もし可能であれば TextBox のサブクラスを作って WndProc をオーバーライドすることでスクロールまわりのメッセージを処理することが可能。