WinForms ListBoxをチェックボックスON中だけ自動スクロールする方法|TopIndexとTimerでゆっくりスクロール

WinFormsでログ表示などにListBoxを使うと、「チェックボックスがONの間だけ自動スクロールしたい」という要件がよくあります。SelectedIndexで最終行を選ぶ方法だと一瞬で最下部へ飛ぶため、TopIndexを使って1行ずつ進める実装に変えるのが近道です。

目次

よくある要件:チェックボックスON中だけListBoxを自動スクロールしたい

Windowsフォームアプリ(WinForms)では、簡易ログビューアや処理状況の表示に ListBox を使うケースが多いです。典型的には次のようなUIになります。

  • ListBox:ログ行・イベント行・ステータス行を上から順に追加していく
  • CheckBox:自動スクロールを有効/無効にする(例:Auto Scroll / Follow / 追従)
  • Timer:一定間隔でスクロール位置を更新する

ここで「チェックをONにしたら下方向へ自動スクロールしてほしい」という要求自体はシンプルですが、実装の最初の一手でつまずきがちです。

なぜSelectedIndexを最終行にすると“速すぎる”のか

まず、ありがちな実装は次のようなものです。

private void timer1_Tick(object sender, EventArgs e)
{
    // 末尾を選択して表示させる(ありがち)
    listBox1.SelectedIndex = listBox1.Items.Count - 1;
}

この方法は一見うまく動きます。ですが、「ゆっくりスクロール」にはなりません。理由は、SelectedIndexの挙動が“1行ずつ進む”ではなく、

  • 最終行を選択する
  • その選択行が見える位置までListBoxの表示領域を調整する(結果として最下部へジャンプ)

という動作になるためです。TimerのIntervalを大きくしても小さくしても、Tickのたびに「最下部へワープ」を繰り返すだけで、途中の行をなめらかに通過するようなスクロールにはなりません。

さらに、SelectedIndex方式には副作用もあります。

副作用起きること困りやすい場面
選択状態が変わる常に最終行が選択され、ユーザーが別行をクリックしても奪われやすいログの途中をクリックして詳細確認したい
イベントが発火するSelectedIndexChanged等が毎回走り、追加処理や連動処理が重いとUIが重くなる選択行の内容を別UIに反映している
“追従”と“アニメーション”が混ざる末尾に合わせたいだけなのに、アニメーション要件と衝突しやすい「ゆっくり下に流したい」要件

結論として、SelectedIndexで末尾を選ぶのは「末尾へ追従」用途には便利ですが、「ゆっくりスクロール」には向きません。

推奨解:TopIndexを1ずつ増やして“表示位置”を進める

「ゆっくりスクロール」を作るなら、選択行ではなく表示位置を動かします。WinFormsのListBoxには、先頭に表示されている項目のインデックスを表す TopIndex が用意されています。

  • TopIndex:現在表示されている先頭行のインデックス(0始まり)
  • TopIndexを 0→1→2… と増やすと、1行ずつ下にスクロールしたのと同じ表示になります

TimerのTickでTopIndexを+1するだけで、Intervalがほぼそのままスクロール速度になります。これが「速すぎる」問題を避ける一番シンプルで実用的な方法です。

基本実装:チェックONで開始、OFFで停止

まずは形だけでも動く最小構成の例です(WinForms Timerを想定)。

private readonly Timer _scrollTimer = new Timer();

public Form1()
{
    InitializeComponent();

    // スクロール速度(ミリ秒)。値を小さくすると速く、大きくすると遅くなる
    _scrollTimer.Interval = 50;
    _scrollTimer.Tick += ScrollTimer_Tick;
}

private void checkBoxAutoScroll_CheckedChanged(object sender, EventArgs e)
{
    if (checkBoxAutoScroll.Checked)
    {
        _scrollTimer.Start();
    }
    else
    {
        _scrollTimer.Stop();
    }
}

private void ScrollTimer_Tick(object sender, EventArgs e)
{
    if (listBox1.Items.Count == 0) return;

    // 1行ずつ下に進める
    int maxTopIndex = GetMaxTopIndex(listBox1);

    if (listBox1.TopIndex < maxTopIndex)
    {
        listBox1.TopIndex += 1;
    }
    else
    {
        // 末尾まで到達したら止める(要件次第)
        _scrollTimer.Stop();

        // 必要ならチェックを外す
        // checkBoxAutoScroll.Checked = false;
    }
}

private static int GetMaxTopIndex(ListBox listBox)
{
    // ListBoxで表示できる行数(概算)
    int visibleCount = Math.Max(1, listBox.ClientSize.Height / listBox.ItemHeight);

    // 最終行が見える“最後のページ”のTopIndex
    return Math.Max(0, listBox.Items.Count - visibleCount);
}

ここでの重要ポイントは次の2つです。

  • Tickごとに TopIndexを+1 する(ジャンプではなくステップ)
  • 無限に増えないように 止めどころ(maxTopIndex) を計算する

止めどころの考え方:Items.Count – 表示できる行数

TopIndexは「先頭に表示されている行」を表します。したがって、最終行を画面内に収めたい場合は、

  • 最終行のインデックス:Items.Count – 1
  • 画面に入る行数:visibleCount
  • 最終行が見える最後のTopIndex:Items.Count – visibleCount

になります(0未満になったら0に丸める)。この計算を入れておくと、末尾でピタッと止まり、例外も避けやすくなります。

項目意味代表式補足
TopIndex先頭表示行のインデックスlistBox1.TopIndex0始まり
visibleCount表示できる行数(概算)ClientSize.Height / ItemHeightIntegralHeightがtrueなら比較的安定
maxTopIndex最終行が見える最後のTopIndexItems.Count – visibleCount0未満は0にする

スクロール速度を調整するコツ

「ゆっくりスクロール」に見えるかどうかは、主に Timer.Interval と 1回のTickで進める行数 の組み合わせで決まります。おすすめの調整方法は次の通りです。

  • まずは Interval 30〜80ms 程度から試す(体感で滑らかになりやすい)
  • 速すぎるならIntervalを大きくする(例:100〜200ms)
  • 遅すぎるならIntervalを小さくする、または1Tickで進める行数を2〜3にする
調整項目変えるとどうなるかメリット注意点
Timer.Intervalを小さくより頻繁にスクロールが進む滑らかに見えやすい他処理が重いとTickが間引かれる
Timer.Intervalを大きく間隔が空き、カクカクしやすいCPU負荷を上げにくい“紙芝居感”が出ることがある
1Tickの進行行数を増やす速く進む表示行数が多い長いリストでも追いつきやすい滑らかさは落ちる

ListBoxは「項目単位のスクロール」が基本で、ピクセル単位でヌルヌル動かすタイプではありません。まずは“自然に読める速度”を狙い、Intervalとステップを調整するのが現実的です。

自動スクロールを“ユーザーの操作”で止める設計も大事

チェックボックスでON/OFFできるとはいえ、実運用では「スクロール追従中に、ユーザーが途中の行を見たい」ケースが必ず出ます。自動スクロールが常に勝ってしまうとストレスになります。

簡易的には、ユーザーがListBoxを操作したら自動スクロールを止める(または一時停止する)設計が有効です。

public Form1()
{
    InitializeComponent();

    listBox1.MouseWheel += (_, __) => StopAutoScrollByUser();
    listBox1.MouseDown  += (_, __) => StopAutoScrollByUser();
    listBox1.KeyDown    += (_, __) => StopAutoScrollByUser();
}

private void StopAutoScrollByUser()
{
    if (!checkBoxAutoScroll.Checked) return;

    _scrollTimer.Stop();
    checkBoxAutoScroll.Checked = false;
}

「一時停止」なのか「完全停止」なのかはアプリの性格次第ですが、少なくとも ユーザー操作を優先する だけで使い勝手が大きく改善します。

応用:Timerではなくasync/awaitで“スクロールループ”を書く

System.Windows.Forms.Timerでも問題ない一方で、処理の見通しを良くしたい場合は async/await でループを書く方法もあります。チェックボックスがONの間だけループし、OFFになったらキャンセルする形にすると、開始・停止の制御がシンプルになります。

using System.Threading;
using System.Threading.Tasks;

private CancellationTokenSource? _autoScrollCts;

private async void checkBoxAutoScroll_CheckedChanged(object sender, EventArgs e)
{
    // 既存ループがあれば停止
    _autoScrollCts?.Cancel();
    _autoScrollCts = null;

    if (!checkBoxAutoScroll.Checked)
        return;

    var cts = new CancellationTokenSource();
    _autoScrollCts = cts;

    try
    {
        while (!cts.IsCancellationRequested)
        {
            if (listBox1.Items.Count > 0)
            {
                int maxTopIndex = GetMaxTopIndex(listBox1);

                if (listBox1.TopIndex < maxTopIndex)
                {
                    listBox1.TopIndex += 1;
                }
                else
                {
                    // 末尾到達でOFF(必要なら)
                    checkBoxAutoScroll.Checked = false;
                    break;
                }
            }

            // ここがスクロール速度(ミリ秒)
            await Task.Delay(50, cts.Token);
        }
    }
    catch (TaskCanceledException)
    {
        // OFFにしたときはここに来る
    }
}

この書き方のメリットは、TimerのStart/StopとTickイベントの分散が減り、「ONの間だけ動く」がコード上でも直感的になる点です。注意点としては、長い処理をこのループ内に入れるとUIが固まるので、スクロール以外の重い処理は別に逃がすことです。

応用:ListBoxにはScrollイベントがないので“ユーザースクロール検知”を補う

自動スクロールの使い勝手を上げるには、「ユーザーがスクロールしたら自動スクロールを解除する」挙動が効きます。ただしWinFormsのListBoxは標準でScrollイベントを持たないため、スクロールバーをドラッグした操作まできっちり拾いたい場合は、ListBoxを派生させてWindowsメッセージを拾う方法があります。

public class ScrollAwareListBox : ListBox
{
    public event EventHandler? UserVScrolled;

    private const int WM_VSCROLL = 0x0115;
    private const int WM_MOUSEWHEEL = 0x020A;

    protected override void WndProc(ref Message m)
    {
        base.WndProc(ref m);

        if (m.Msg == WM_VSCROLL || m.Msg == WM_MOUSEWHEEL)
        {
            UserVScrolled?.Invoke(this, EventArgs.Empty);
        }
    }
}

フォーム側では、UserVScrolledを受けてチェックをOFFにするだけです。

private void Form1_Load(object sender, EventArgs e)
{
    scrollAwareListBox1.UserVScrolled += (_, __) =>
    {
        if (checkBoxAutoScroll.Checked)
        {
            _scrollTimer.Stop();
            checkBoxAutoScroll.Checked = false;
        }
    };
}

この仕組みを入れておくと、「スクロールバーを触ったのにすぐ追いかけてきて戻される」といったストレスが減ります。もし環境によっては自動スクロール時にも同じメッセージが発生してしまう場合は、スクロール中だけフラグを立てて無視する、といったガードを追加してください。

ログビューア用途:項目が追加され続ける場合の考え方

ListBoxをログビューアとして使う場合、スクロール対象は「既存の長いリストをゆっくり流す」だけではなく、Itemsが増え続ける 状況になります。このときは次の2パターンを分けて考えると整理しやすいです。

モード目的おすすめ操作向いている場面
追従(Follow)常に最新行が見えること末尾ページのTopIndexへ一気に合わせるリアルタイムログ、監視ツール
再生(Auto Scroll)読める速度で下へ流すTopIndexを1ずつ増やす(今回の方法)デモ表示、履歴の自動閲覧

「追従」なら、わざわざアニメーションさせずに最終ページへ合わせる方が実用的な場合も多いです。TopIndexを直接末尾ページへ設定するだけで済みます。

private void ScrollToBottomPage(ListBox listBox)
{
    if (listBox.Items.Count == 0) return;
    listBox.TopIndex = GetMaxTopIndex(listBox);
}

一方で「再生(ゆっくり)」が要件なら、Itemsが増え続けると maxTopIndex も増え続けます。処理が追いつかない場合は、

  • 1Tickで進める行数を増やす(例:2〜5行)
  • 一定距離以上離れていたら一気に追いつく(“ワープ”を許容する条件分岐)
  • 最終的に追従モードへ切り替える

など、アプリの目的に合わせた調整が必要になります。

代替案:Win32メッセージで“1行スクロール”を発行する

TopIndexで十分なことがほとんどですが、「OS標準のスクロール動作に寄せたい」「Win32メッセージで統一的に制御したい」という場合は、ListBoxにスクロールメッセージを送る方法もあります。

代表例は WM_VSCROLL + SB_LINEDOWN で、縦スクロールの“1行下”を発行します。P/Invokeが必要になります。

using System.Runtime.InteropServices;

private const int WM_VSCROLL  = 0x0115;
private const int SB_LINEDOWN = 1;

[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);

private void ScrollDownByMessage()
{
    SendMessage(listBox1.Handle, WM_VSCROLL, (IntPtr)SB_LINEDOWN, IntPtr.Zero);
}

TimerのTickでScrollDownByMessageを呼べば、1行ずつのスクロールが進みます。あとは「何回送ったら止めるか」を考えます。

メッセージ方式で止める目安

WM_VSCROLLで1行ずつ送る場合でも、最終的にはTopIndex相当の位置まで到達したら止めます。つまり、「残り何行スクロール可能か」を計算してカウントダウンする設計が分かりやすいです。

private int _remainingScrollSteps;

private void StartMessageScroll()
{
    _remainingScrollSteps = GetMaxTopIndex(listBox1) - listBox1.TopIndex;
    if (_remainingScrollSteps <= 0) return;

    _scrollTimer.Start();
}

private void ScrollTimer_Tick(object sender, EventArgs e)
{
    if (_remainingScrollSteps <= 0)
    {
        _scrollTimer.Stop();
        return;
    }

    ScrollDownByMessage();
    _remainingScrollSteps--;
}

なお、WinFormsのTopIndex自体が内部的にListBoxのメッセージ(LB_SETTOPINDEX)を使っているため、“やっていることはほぼ同じ”です。特殊な事情がない限り、保守性の観点ではTopIndexのほうが読みやすく、デバッグもしやすいでしょう。

方式実装のしやすさ挙動の安定性おすすめ度
TopIndexを+1高い(プロパティ操作だけ)高い(範囲チェックしやすい)最優先
WM_VSCROLLを送る中(P/Invokeが必要)中(環境差・制御の見通し)用途が合うなら
SelectedIndexを末尾に高い高い(ただしワープ)追従用途限定

つまずきポイント集:例外・カクつき・意図しない挙動

最後に、実装時によくある落とし穴と対策をまとめます。

Items.Countが0のとき

空のListBoxでTopIndexやSelectedIndexを触ると例外や想定外の挙動が出ます。Tickでは最初に Items.Count == 0 を弾いておくのが安全です。

TopIndexが範囲外になると例外になる

TopIndexは無制限に増やせるわけではありません。maxTopIndexを計算して上限を守る、または次のように丸めると事故を防げます。

int next = listBox1.TopIndex + 1;
int max  = GetMaxTopIndex(listBox1);
listBox1.TopIndex = Math.Min(next, max);

UIが重いとTimerは正確に刻まれない

WinFormsのTimerはUIスレッドで動くため、重い処理(ファイルI/O、計算、描画負荷)があるとTickが遅れます。スクロールが“ガタつく”ときは、

  • 重い処理を別スレッドに逃がす(ただしUI更新はInvoke)
  • ログ追加をまとめて行い、BeginUpdate/EndUpdateで描画回数を減らす
  • Intervalを小さくしすぎない(無理に滑らかさを狙わない)

といった対策が効きます。

ログ追加が大量な場合はBeginUpdate/EndUpdateが効く

1行ずつ追加して毎回再描画すると、Itemsが多いほど重くなります。追加がまとまるタイミングがあるなら、次のように描画を抑制できます。

listBox1.BeginUpdate();
try
{
    // 例:まとめて追加
    foreach (var line in lines)
    {
        listBox1.Items.Add(line);
    }
}
finally
{
    listBox1.EndUpdate();
}

このとき「追従モード」であれば、追加後に一度だけScrollToBottomPageを呼ぶと軽く済みます。

まとめ:ポイントは“最下部へ飛ばす”ではなく“1行ずつ進める”

  • SelectedIndexで末尾を選ぶ方法は、末尾へジャンプするため「ゆっくりスクロール」になりにくい
  • チェックボックスON中だけゆっくりスクロールしたいなら、Timerで TopIndexを1ずつ増やす のが分かりやすい
  • 末尾で止めるために maxTopIndex = Items.Count – visibleCount を計算して上限を管理する
  • 必要ならWM_VSCROLL(SB_LINEDOWN)で1行スクロールを送る方法もある

「追従」と「アニメーション(再生)」を分けて設計すると、WinFormsのListBoxでもストレスの少ない自動スクロールが作れます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次