気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

WPF RichTextBoxでScrollViewerイベントを捉えて末尾を検知する

f:id:AonaSuzutsuki:20220118150417p:plain

こんにちは。
前回に続き、またRichTextBoxネタです。
今回はRichTextBoxの内部で動くScrollViewerのイベントを捉えてスクロールを捕捉してついでに末尾を検知してみようと思います。
やることは単純で、TemplateからPART_ContentHostの名前がついたScrollViewerを取得してそれに対してイベントを登録するだけです。
一応RichTextBoxに絞った内容ですが、TextBoxなどPART_ContentHost名を持つScrollViewerを使ったコントロールは大抵これでいけると思います。

検証環境

項目 詳細
OS Windows 10 Pro x64 20H2
.Net .Net 6
Microsoft.Xaml.Behaviors.Wpf 1.1.39
Prism.Core 8.1.97

今回のサンプルはGithubのCatchableScrollEventRichTextBoxで閲覧できます。

実行結果

f:id:AonaSuzutsuki:20220118150417p:plain

サンプルを実行し、各RichTextBoxのコントロールを最下部までスクロールすると出力ログに「Reached the end of scroll. {○○}」と出ます。
一応コードビハインドとカスタムコントロール、ビヘイビアの3種類用意しました。

実装

下準備

WPF

まず下地としてWPFから記していきます。
通常のRichTextBoxからカスタムコントロール、ビヘイビアと定義しました。

Views/MainWindow.xaml

<Window x:Class="CatchableScrollEventRichTextBox.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:local="clr-namespace:CatchableScrollEventRichTextBox"
        xmlns:viewModels="clr-namespace:CatchableScrollEventRichTextBox.ViewModels"
        xmlns:controls="clr-namespace:CatchableScrollEventRichTextBox.Views.Controls"
        xmlns:behavior="clr-namespace:CatchableScrollEventRichTextBox.Views.Behavior"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance viewModels:MainWindowViewModel}"
        Title="MainWindow" Height="450" Width="800">

    <Window.DataContext>
        <viewModels:MainWindowViewModel />
    </Window.DataContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition />
            <RowDefinition Height="5" />
            <RowDefinition />
            <RowDefinition Height="5" />
            <RowDefinition />
            <RowDefinition Height="5" />
        </Grid.RowDefinitions>

        <RichTextBox Name="CodeBehindRichTextBox" VerticalScrollBarVisibility="Auto" />

        <controls:CatchableScrollEventRichTextBox Grid.Row="2" x:Name="CustomControlRichTextBox" VerticalScrollBarVisibility="Auto" ScrollEndedCommand="{Binding ReachedScrollEndCommand}" />

        <RichTextBox Grid.Row="4" Name="BehaviorRichTextBox" VerticalScrollBarVisibility="Auto">
            <i:Interaction.Behaviors>
                <behavior:CatchableSrcollEventBehavior ScrollEndedCommand="{Binding ReachedScrollEndCommand}" />
            </i:Interaction.Behaviors>
        </RichTextBox>

    </Grid>
</Window>

ViewModel

続いてViewModelです。
ここではイベントが発火した際のコマンドだけを定義しました。

ViewModels/MainWindowViewModel.cs

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;
using Prism.Commands;

namespace CatchableScrollEventRichTextBox.ViewModels
{
    public class MainWindowViewModel
    {
        public ICommand ReachedScrollEndCommand { get; set; }

        public MainWindowViewModel()
        {
            ReachedScrollEndCommand = new DelegateCommand<string>(ReachedScrollEnd);
        }

        public void ReachedScrollEnd(string from)
        {
            Debug.WriteLine($"Reached the end of scroll. {{{from}}}");
        }
    }
}

コードビハインド

ここまでできたら本題です。
コードビハインドだけではありませんが、基本はControl.TemplateプロパティからFindNameメソッドでPART_ContentHost名のオブジェクトを取得するだけです。
返り値はobject型ですが、実態はScrollViewerのはずなのでScrollViewerにキャストしてあげます。
ちなみに、Styleで独自のTemplateを定義した場合でも適用できます。

Views/MainWindow.xaml.cs

if (CodeBehindRichTextBox.Template.FindName("PART_ContentHost", CodeBehindRichTextBox) is not ScrollViewer scrollViewer)
    return;

基本はこれで取得できるのですが、ウィンドウが描写されてないタイミングではnullが返ってきます
そこでWindow.Loadedイベントを捕捉し、描写が完了したタイミングで取得するようにします。

Views/MainWindow.cs

Loaded += (sender, args) =>
{
    if (CodeBehindRichTextBox.Template.FindName("PART_ContentHost", CodeBehindRichTextBox) is not ScrollViewer scrollViewer)
        return;

    // ScrollViewerに対する処理
};

ここまで出来たら後はScrollViewer.ScrollChangedイベントを捕捉すればスクロールしたことを検出できます。
そしてそのイベントにより最下部までスクロールされたのを検知することができます。

最下部を検知するには ScrollViewer.VerticalOffsetプロパティScrollViewer.ScrollableHeightプロパティが一致するかどうか で判定ができます。

Views/MainWindow.cs

var isEnded = scrollViewer.VerticalOffset.Equals(scrollViewer.ScrollableHeight);
if (!isEnded)
    return;

あとはこれらを組み合わせて最下部にスクロールされた際の処理を行うだけです。

Views/MainWindow.cs

Loaded += (_, _) =>
{
    if (CodeBehindRichTextBox.Template.FindName("PART_ContentHost", CodeBehindRichTextBox) is not ScrollViewer scrollViewer)
        return;

    var prevVerticalOffset = .0;
    var prevScrollableHeight = .0;
    scrollViewer.ScrollChanged += (_, _) =>
    {
        if (prevVerticalOffset.Equals(scrollViewer.VerticalOffset) && prevScrollableHeight.Equals(scrollViewer.ScrollableHeight))
            return;

        prevVerticalOffset = scrollViewer.VerticalOffset;
        prevScrollableHeight = scrollViewer.ScrollableHeight;

        var isEnded = scrollViewer.VerticalOffset.Equals(scrollViewer.ScrollableHeight);
        if (!isEnded)
            return;

        if (DataContext is not MainWindowViewModel viewModel)
            return;

        viewModel.ReachedScrollEndCommand.Execute(CodeBehindRichTextBox.Name);
    };
};

全文

Views/MainWindow.cs

using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using CatchableScrollEventRichTextBox.ViewModels;

namespace CatchableScrollEventRichTextBox.Views
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            CodeBehindRichTextBox.Document = GenerateFlowDocument();
            CustomControlRichTextBox.Document = GenerateFlowDocument();
            BehaviorRichTextBox.Document = GenerateFlowDocument();

            Loaded += (_, _) =>
            {
                if (CodeBehindRichTextBox.Template.FindName("PART_ContentHost", CodeBehindRichTextBox) is not
                    ScrollViewer scrollViewer)
                    return;

                var prevVerticalOffset = .0;
                var prevScrollableHeight = .0;
                scrollViewer.ScrollChanged += (_, _) =>
                {
                    if (prevVerticalOffset.Equals(scrollViewer.VerticalOffset) && prevScrollableHeight.Equals(scrollViewer.ScrollableHeight))
                        return;

                    prevVerticalOffset = scrollViewer.VerticalOffset;
                    prevScrollableHeight = scrollViewer.ScrollableHeight;

                    var isEnded = scrollViewer.VerticalOffset.Equals(scrollViewer.ScrollableHeight);
                    if (!isEnded)
                        return;

                    if (DataContext is not MainWindowViewModel viewModel)
                        return;

                    viewModel.ReachedScrollEndCommand.Execute(CodeBehindRichTextBox.Name);
                };
            };
        }

        private FlowDocument GenerateFlowDocument()
        {
            var flowDocument = new FlowDocument();
            var blocks = new Block[]
            {
                new Paragraph(new Run("A")),
                new Paragraph(new Run("B")),
                new Paragraph(new Run("C")),
                new Paragraph(new Run("D")),
                new Paragraph(new Run("E")),
                new Paragraph(new Run("F")),
                new Paragraph(new Run("G")),
                new Paragraph(new Run("H")),
                new Paragraph(new Run("I")),
                new Paragraph(new Run("J")),
                new Paragraph(new Run("K")),
            };
            flowDocument.Blocks.AddRange(blocks);

            return flowDocument;
        }
    }
}

カスタムコントロール

コードビハインドとほぼ同じことをやるだけなんですが、コードビハインドに書くのは今後のことも考えると少々よろしくないのでカスタムコントロール化してしまいます。
違うところは、最下部を検知した際にViewModelのメソッドやコマンドを直接実行するのではなく、ScrollEndedCommandプロパティにバインディングされたコマンドを実行するようにします。
こうすることでViewとViewModelの依存が排除できます。

Views/Controls/CatchableScrollEventRichTextBox.cs

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;

namespace CatchableScrollEventRichTextBox.Views.Controls
{
    public class CatchableScrollEventRichTextBox : RichTextBox
    {
        #region Dependency Properties

        public static readonly DependencyProperty ScrollEndedCommandProperty = DependencyProperty.Register("ScrollEndedCommand", typeof(ICommand),
            typeof(CatchableScrollEventRichTextBox), new UIPropertyMetadata(null));

        #endregion

        #region Properties

        public ICommand ScrollEndedCommand
        {
            get => (ICommand)GetValue(ScrollEndedCommandProperty);
            set => SetValue(ScrollEndedCommandProperty, value);
        }

        #endregion

        public CatchableScrollEventRichTextBox()
        {
            Loaded += (sender, args) =>
            {
                if (Template.FindName("PART_ContentHost", this) is not ScrollViewer scrollViewer)
                    return;

                var prevVerticalOffset = .0;
                var prevScrollableHeight = .0;
                scrollViewer.ScrollChanged += (_, _) =>
                {
                    if (prevVerticalOffset.Equals(scrollViewer.VerticalOffset) && prevScrollableHeight.Equals(scrollViewer.ScrollableHeight))
                        return;

                    prevVerticalOffset = scrollViewer.VerticalOffset;
                    prevScrollableHeight = scrollViewer.ScrollableHeight;

                    var isEnded = scrollViewer.VerticalOffset.Equals(scrollViewer.ScrollableHeight);
                    if (!isEnded)
                        return;

                    ScrollEndedCommand.Execute(Name);
                };
            };
        }
    }
}

ビヘイビア

カスタムコントロールとは違うアプローチとして、RichTextBoxのビヘイビアで対応する方法もあります。
どっちも手間は大して変わらないんですが、RichTextBoxにスクロールイベント機能を追加する形になるので、コントロールとして管理しなくていいっていうメリットはあるかな?
やってることはコードビハインドやカスタムコントロールと同じなので割愛。

Views/Behavior/CatchableSrcollEventBehavior.cs

using Microsoft.Xaml.Behaviors;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace CatchableScrollEventRichTextBox.Views.Behavior
{
    public class CatchableSrcollEventBehavior : Behavior<RichTextBox>
    {
        #region Dependency Properties

        public static readonly DependencyProperty ScrollEndedCommandProperty = DependencyProperty.Register("ScrollEndedCommand", typeof(ICommand),
            typeof(CatchableSrcollEventBehavior), new UIPropertyMetadata(null));

        #endregion

        #region Properties

        public ICommand ScrollEndedCommand
        {
            get => (ICommand)GetValue(ScrollEndedCommandProperty);
            set => SetValue(ScrollEndedCommandProperty, value);
        }

        #endregion

        protected override void OnAttached()
        {
            base.OnAttached();

            AssociatedObject.Loaded += Loaded;
        }

        protected override void OnDetaching()
        {
            base.OnDetaching();

            AssociatedObject.Loaded -= Loaded;
        }

        public void Loaded(object sender, RoutedEventArgs args)
        {
            if (AssociatedObject.Template.FindName("PART_ContentHost", AssociatedObject) is not ScrollViewer scrollViewer)
                return;

            var prevVerticalOffset = .0;
            var prevScrollableHeight = .0;
            scrollViewer.ScrollChanged += (_, _) =>
            {
                if (prevVerticalOffset.Equals(scrollViewer.VerticalOffset) && prevScrollableHeight.Equals(scrollViewer.ScrollableHeight))
                    return;

                prevVerticalOffset = scrollViewer.VerticalOffset;
                prevScrollableHeight = scrollViewer.ScrollableHeight;

                var isEnded = scrollViewer.VerticalOffset.Equals(scrollViewer.ScrollableHeight);
                if (!isEnded)
                    return;

                ScrollEndedCommand.Execute(AssociatedObject.Name);
            };
        }
    }
}