ListViewの矢印キーを使った移動

2026年9月1日火曜日

.NET WPF C#

画像ビューアのサムネイル一覧を作成するため、ListViewにItemsPanelとして水平方向のWrapPanelを置いて動作を確認していました。左矢印キーを繰り返し押していたら最後の項目から最初の項目まで次々に移動していけるだろうと思っていたら、行の先頭で止まります。WPFは行の先頭から1つ上の行の最後に遷移するようなフォーカス移動に対応していないようです。

環境

.NET 10、C#、WPF

必要なもの

NuGetパッケージ

ビヘイビア

対応方法は意外とシンプルでした。キーイベントを上書きしてフォーカス移動を自前でするだけです。これがユーザー操作のエミュレートとなり、勝手に(必要なら)項目の選択などをしてくれます。

using Common.Services;
using Microsoft.Xaml.Behaviors;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace Common.Behaviors
{
    /// <summary>
    /// ArrowKeyMovementBehavior を関連付けた ListView の矢印キーを使用した移動や選択の動作を変更します。
    /// 
    /// <para>
    /// 具体的には、左または右矢印キーを使用した移動や選択の動作を次のように Explorer に準拠したものにします。<br/>
    /// 
    /// <list type="number">
    /// <item><term>Shift キーが押されている場合 (Ctrlより優先)</term><description>キーボードフォーカスを左または右の項目に移動します。移動先からアンカー項目 (前回の選択操作で起点になった項目) までの範囲を選択します。</description></item>
    /// <item><term>Ctrl キーが押されている場合</term><description>キーボードフォーカスを左または右の項目に移動します。選択状態を変更しません。</description></item>
    /// <item><term>修飾キーが押されていない場合</term><description>キーボードフォーカスを左または右の項目に移動します。移動先の項目を選択します。</description></item>
    /// </list>
    /// </para>
    /// 
    /// <para>
    /// 次の条件を満たす ListView で使用してください。条件を満たさない ListView で使用すると意図しない動作を引き起こす可能性があります。
    /// 
    /// <list type="bullet">
    /// <item>水平方向の WrapPanel を ItemsPanel として使用している。</item>
    /// <item>null の項目を持たない。</item>
    /// <item>重複した項目を持たない。</item>
    /// </list>
    /// </para>
    /// </summary>
    public class ArrowKeyMovementBehavior : Behavior<ListView>
    {
        public static readonly DependencyProperty SerialProcessingServiceProperty = DependencyProperty.Register(
            nameof(SerialProcessingService),
            typeof(ISerialProcessingService),
            typeof(ArrowKeyMovementBehavior));

        private object? _focusedItem;

        public ISerialProcessingService SerialProcessingService
        {
            get => (ISerialProcessingService)GetValue(SerialProcessingServiceProperty);
            set => SetValue(SerialProcessingServiceProperty, value);
        }

        /// <summary>
        /// キー入力を使用してキーボードフォーカスの移動先の項目を返します。
        /// </summary>
        /// <param name="key"></param>
        /// <returns>キーボードフォーカスの移動先の項目。移動が不可能な場合 (最初の項目より前への移動など) は null を返します。</returns>
        private object? GetNextFocusedItem(Key key)
        {
            int focusedIndex = _focusedItem is null ? -1 : AssociatedObject.Items.IndexOf(_focusedItem);

            // キーボードフォーカスを持つ項目が存在しない場合、最初の項目を使用します。
            if (focusedIndex == -1)
            {
                focusedIndex = 0;
            }

            // 最初の項目より前へ、または最後の項目より先への移動の場合、不可能なので null を返します。
            int? nextFocusedIndex = key == Key.Left
                ? (focusedIndex == 0 ? null : focusedIndex - 1)
                : (focusedIndex == AssociatedObject.Items.Count - 1 ? null : focusedIndex + 1);

            return nextFocusedIndex is null ? null : AssociatedObject.Items[nextFocusedIndex.Value];
        }

        /// <summary>
        /// 指定した項目にキーボードフォーカスを設定します。
        /// </summary>
        /// <param name="newFocusedItem"></param>
        /// <returns></returns>
        private bool FocusItem(object newFocusedItem)
        {
            // 対象項目が表示領域に含まれるようにスクロールします (仮想化した ListView は表示領域外の項目コンテナーを作らないため)。
            AssociatedObject.ScrollIntoView(newFocusedItem);

            // 項目コンテナーを取得してキーボードフォーカスを設定します。
            return (AssociatedObject.ItemContainerGenerator is ItemContainerGenerator itemContainerGenerator)
                && (itemContainerGenerator.ContainerFromItem(newFocusedItem) is ListViewItem itemContainer)
                && itemContainer.Focus();
        }

        protected override void OnAttached()
        {
            base.OnAttached();
            AssociatedObject.PreviewGotKeyboardFocus += OnPreviewGotKeyboardFocus;
            AssociatedObject.PreviewKeyDown += OnPreviewKeyDown;
        }

        protected override void OnDetaching()
        {
            base.OnDetaching();
            AssociatedObject.PreviewGotKeyboardFocus -= OnPreviewGotKeyboardFocus;
            AssociatedObject.PreviewKeyDown -= OnPreviewKeyDown;
        }

        private void OnPreviewGotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
        {
            if (e.NewFocus is ListViewItem itemContainer)
            {
                _focusedItem = itemContainer.DataContext ?? throw new InvalidOperationException(string.Format("{0} は null の項目を扱うことができません。", nameof(ArrowKeyMovementBehavior)));
            }
        }

        private void OnPreviewKeyDown(object sender, KeyEventArgs e)
        {
            switch (e.Key)
            {
                case Key.Left:
                case Key.Right:
                    if (AssociatedObject.Items.Count == 0)
                    {
                        return;
                    }

                    e.Handled = true;

                    // フォーカス移動処理をキューに登録します (キーイベントが連続発生する場合があり、処理を確実に直列で行うため)。
                    SerialProcessingService?.TryEnqueue(async (CancellationToken shutdownToken) =>
                    {
                        shutdownToken.ThrowIfCancellationRequested();

                        await Application.Current.Dispatcher.InvokeAsync(() =>
                        {
                            if (GetNextFocusedItem(e.Key) is object nextFocusedItem)
                            {
                                FocusItem(nextFocusedItem);
                            }
                        });
                    });
                    return;
            }
        }
    }
}

直列処理サービス

キーイベントは連続発生することがあるので、フォーカス移動を確実に直列で実行させるためにサービスを使います。このサービスをViewModel経由でビヘイビアに渡すことになると思います。サービスに分離する主な理由はリソースの解放が必要だからです。

using System.Threading.Channels;

namespace Common.Services
{
    /// <summary>
    /// SerialProcessingService クラスの公開メンバーを定義するインターフェイスです。
    /// </summary>
    public interface ISerialProcessingService
    {
        bool TryEnqueue(Func<CancellationToken, Task> callback);
    }

    /// <summary>
    /// 登録したデリゲートを登録した順序で直列に実行します。
    /// </summary>
    public sealed class SerialProcessingService: ISerialProcessingService, IAsyncDisposable
    {
        private Channel<Func<CancellationToken, Task>> _queue = Channel.CreateUnbounded<Func<CancellationToken, Task>>(new() { SingleReader = true });
        private Task _processQueueTask;
        private CancellationTokenSource _shutdownTokenSource = new();
        private bool _disposed;

        public SerialProcessingService()
        {
            // サービスコンテナーの解放処理との関係で背景スレッドを使用しています。
            _processQueueTask = Task.Run(() => ProcessQueueAsync());
        }

        private async Task ProcessQueueAsync()
        {
            try
            {
                CancellationToken shutdownToken = _shutdownTokenSource.Token;

                await foreach (Func<CancellationToken, Task> callback in _queue.Reader.ReadAllAsync(shutdownToken))
                {
                    await callback(shutdownToken);
                }
            }
            catch (OperationCanceledException)
            {
            }
        }

        /// <summary>
        /// デリゲートをキューに登録します。
        /// </summary>
        /// <param name="callback">登録するデリゲート。実行時にサービスが終了するとキャンセルされるトークンが引数として渡されます。</param>
        /// <returns></returns>
        public bool TryEnqueue(Func<CancellationToken, Task> callback)
        {
            if (_disposed)
            {
                return false;
            }

            return _queue.Writer.TryWrite(callback);
        }

        public async ValueTask DisposeAsync()
        {
            if (Interlocked.Exchange(ref _disposed, true))
            {
                return;
            }

            _queue.Writer.Complete();
            _shutdownTokenSource.Cancel();
            await _processQueueTask.ConfigureAwait(false);
            _shutdownTokenSource.Dispose();
        }
    }
}
rommxbbu6v6

.NET、C#、WPFの話題が多いです。

人気の投稿 / 新着記事

人気記事 新着記事
  • 読み込み中...

ラベル

.NET (4) C# (4) WPF (4)

ブログ アーカイブ

目次

このブログを検索