複数のテキストボックスを同期したい(WPF + Rx編)

2015/07/19追記: もっといい方法をこちらに公開しています。

本日のテーマ

  • 1つのウィンドウの中に複数のテキストボックスが配置されたWPFアプリを考える
  • テキストボックスのどれかを変更すると、他のテキストボックスにもすべて同じ変更が適用されるようにしたい

下図は、本日のサンプル画面です。一番上のテキストボックスを変更すると、下2つのテキストボックスの内容が自動的に変化します(左)。また、2番目のテキストボックスを変更したときは、上下のテキストボックスが変化します(右)。

f:id:tercel_s:20150718220256p:plain:w270 f:id:tercel_s:20150718220304p:plain:w270

なぜこんな要望が出たかは知らない。しかし世の中には知らない方が幸せなこともあるので (゚ε゚)キニシナイ!!

背景

初めに断わっておくと、今回のテーマはどちらかというと画面設計のアンチパターンに近いです。そのため、おそらく使いどころはほぼ無いのではと思っています。

以前、下図のようなタブベースの画面を作らされ っていたことがありました。

f:id:tercel_s:20150718224243p:plain:w270 f:id:tercel_s:20150718224252p:plain:w270

どのタブページの上部にもテキストボックスが配置されており、入力内容に基づいて様々な情報をタブページ内に表示する仕組みでした。

で、どういう話の行きがかりだったか、「各タブページの上部にあるテキストボックスを同期してくれたまえ」と言われてしまいました。

さて、今の僕ならどう実装するだろう。

2015/07/19追記: この記事に記載した方法よりもいい方法をこちらに公開しています。

開発環境

この記事を書くために使用した開発環境は、Windows 8.1 + Visual Studio 2013 for Desktop です。

言語は C# 5.0 です。

この記事が対象とするアプリケーションの種類は WPF です。

あと NuGet で Rx-Main というライブラリをインストールしました。

実装

実装における厄介な点は、テキストボックスが同期元と同期先で相互参照してしまうということです。

そのため、何も考えずに書くと、関連するテキストボックス同士がお互いにTextChangedを呼び合い、余計なイベントが発生してしまうかも知れません。

イベントを抑制するために誰もが思いつく手っ取り早い方法は、いわゆる「制御フラグ」をフィールドに追加することです。しかし、これはプログラムに副作用をもたらす悪手です。もう少し安全な方法を考えねばなりません。

というわけで、今回心がけたことは以下の3点です。

  • 制御フラグは使わない
  • ifの使用は最低限に留める*1
  • 不必要なイベント処理は行わない

以下の実装例は、画面上に配置したテキストボックスtextBox1textBox3を同期させるコードです。

画面上に表示された3つのテキストボックスのうち、どれか1つを選んで入力値を変更すると、他のテキストボックスの値も自動的に変更されるようになります。

TextBoxEx クラス

まず適当なところに以下のような Static クラスを作ります。こいつのせいでTextBoxが多少グレードアップします。

public static class TextBoxEx
{
    public static IObservable<string> GetTextPusher(this TextBox textBox)
    {
        return Observable.FromEvent<TextChangedEventHandler, TextChangedEventArgs>
            (
                h => (sender, e) => h(e),
                h => textBox.TextChanged += h,  // アタッチ
                h => textBox.TextChanged -= h  // デタッチ
            ).Select(_ => textBox.Text);
    }
}

あとは、以下のコードをコンストラクタInitializeComponent();の直後あたりにでも適当に書けばいいと思います。

なお、ラムダの中の一時変数の命名の雑っぷりを見れば、いかにやる気がないかをお解りいただけることと思います。

// 同期させたいテキストボックスたち
var targets = new[] { textBox1, textBox2, textBox3 };

// 同期の呪いをかける
targets.Select(t => t.GetTextPusher())
    .Merge()
    .Subscribe(x =>
        {
            var f = targets.Where(t => t.Text != x).FirstOrDefault();
            if (f != null) f.Text = x;                       
        });

やっていることはこれだけです。

  • 各テキストボックス textBox1textBox3 で発生した「テキストの変更イベント」をまとめて扱えるよう、イベントの合成(Merge)を行う
  • イベントが発生したら、古い値が残っているテキストボックスを新しい値xで上書きする

なお、今回のコードは、イベントの一時無効化は行っていません。

そのため、テキストボックスを新しい値で上書きしたタイミングで、イベント処理が(間接的に)再帰呼び出しされます。targets.Where(t => t.Text != x).FirstOrDefault()nullを返したら再帰を抜けます。

ソース(いちおう完全版)

参考までに、XAMLも載せておきます。

MainWindow.xaml

<Window x:Class="Sample150717_WPF_.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <TextBox x:Name="textBox1" Height="23" Margin="10,10,10,0" Text="" VerticalAlignment="Top"/>
        <TextBox x:Name="textBox2" Height="23" Margin="10,40,10,0" Text="" VerticalAlignment="Top"/>
        <TextBox x:Name="textBox3" Height="23" Margin="10,70,10,0" Text="" VerticalAlignment="Top"/>
    </Grid>
</Window>

MainWindow.xaml.cs

using System;
using System.Linq;
using System.Reactive.Linq;
using System.Windows;
using System.Windows.Controls;

namespace Sample150717_WPF_
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            var targets = new[] { textBox1, textBox2, textBox3 };
            targets.Select(t => t.GetTextPusher())
                .Merge()
                .Subscribe(x =>
                    {
                        var f = targets.Where(t => t.Text != x).FirstOrDefault();
                        if (f != null) f.Text = x;
                    });
        }
    }

    public static class TextBoxEx
    {
        public static IObservable<string> GetTextPusher(this TextBox textBox)
        {
            return Observable.FromEvent<TextChangedEventHandler, TextChangedEventArgs>
                (
                    h => (sender, e) => h(e),
                    h => textBox.TextChanged += h,
                    h => textBox.TextChanged -= h
                ).Select(_ => textBox.Text);
        }
    }
}

いじょ。

*1:ifの濫用はロジックがとっ散らかる。

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