System.Windows.Forms.TextBoxにスクロール監視処理を後付けしてみるでござるの巻

全部読み終わるまでチェックさせない

本日は、Windows フォームアプリケーションで「利用規約に同意する」画面を作ってみようと思います。みなさんも一度はお目にかかったことのあるあの画面です。

  • テキストボックスには、長文のテキストデータが格納されている。

f:id:tercel_s:20140124234329p:plain:w480

  • テキストボックスを最後までスクロールさせると、「利用規約に同意する」がチェックできるようになる。

f:id:tercel_s:20140124234347p:plain:w480

  • 利用規約に同意する」をチェックすると、次へ進めるようになる。

f:id:tercel_s:20140124234417p:plain:w480

なんとなくイメージできましたでしょうか。

ただし政治上の理由により、テキストの表示はピュアな TextBox クラスのみ(つまりSystem.Windows.Forms.TextBox)で行うこととします。

  • 代替コンポーネント(RichTextBox等)の使用はダメ
  • 既存の TextBox を継承するのもダメ


一見すると簡単そうですが、なんと標準の TextBox にはスクロールまわりのイベントが一切用意されておらず、.Netしか知らない方はまず間違いなくはまります。

そんなわけで今回の真のテーマは、「TextBox オブジェクトのスクロール状態をいかにして捕捉するか」となります。


僕ならこう作る

まずは TextBox, CheckBox, Button を組み合わせて下図のような画面 (Form1.cs) を作ります。
名前はそれぞれデフォルトのまま textBox1, checkBox1, button1 としてあります。
f:id:tercel_s:20140124221713p:plain

次に、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#の初歩的な変態奥義で、「拡張メソッド」などと呼ばれていたりします。便利だけど使いすぎるとメンテしづらいコードになります。

さっそく実行してみよう。

初期状態。
f:id:tercel_s:20140124231045p:plain:w480

途中までスクロールさせたところ。
f:id:tercel_s:20140124231125p:plain:w480

完全に下までスクロールさせたところ。チェックボックスが有効になっている点に注目!
f:id:tercel_s:20140124231141p:plain:w480

チェックボックスにチェックを入れたところ。ボタンが押せるようになった!
f:id:tercel_s:20140124231359p:plain:w480

ボタンを押すと、アプリケーションが終了する。
f:id:tercel_s:20140124231630p:plain:w480

……うごいた!わはー

まとめ

  • System.Windows.Forms.Text でスクロールまわりを監視しようとすると、わりと面倒。
  • なんで TextBox はスクロールまわりのイベントが使えないんだろうね。
  • 今回は継承を封印したが、もし可能であれば TextBox のサブクラスを作って WndProc をオーバーライドすることでスクロールまわりのメッセージを処理することが可能。

*1:本当は += 演算子を使ってイベントを追加できればなお良かったのだが……。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.