かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

マルチカラム&縦スクロールのアプリを作ってみよう

初期のころのWindows ストアアプリでは御法度のように書いてあった横スクロールと縦スクロールによる横スクロールと縦スクロールが混在したアプリケーションですが、最近は、結構審査もゆる~くなったみたいなので、操作しにくくなかったらよさそうなのかなぁという雰囲気になってきてたりします。Facebookアプリとか、ストアアプリらしい???と疑問に思ったけれど、下のページで取り上げられたりしてるくらいだし。

ということで、縦スクロールと横スクロール混ぜるな危険なUIを作る方法を書いてみようと思います。ゴールのイメージとしてはマルチカラムのTwitterクライアントみたいになる感じです。

複数カラムを扱うようなデータ構造のクラスを定義する

まずは、画面に表示するためには裏でデータとっとかないといけないですね。ということで、クラスを定義します。ついでなんで、ReactivePropertyとMVVM LightのPCL版をNuGetからとってきて、それを使って作りました。最初にアプリのデータの最小単位。1つの項目を表すデータの入れ物です。これは、プロパティの中身が変わることを前提としてないので、単純なクラスにしました。

/// <summary>
/// データの入れ物
/// </summary>
public class AppItem
{
    /// <summary>
    /// テキスト
    /// </summary>
    public string Text { get; set; }
}

コメント要らない感じがしますが、まあいいでしょう。

次は、カラムを表すクラスです。単純に列の名前と、列内で管理してるAppItemのコレクションを持っています。コレクションに追加するAppItemの条件を指定できるようにFunc<AppItem, bool>型のプロパティも追加してます。

/// <summary>
/// 列を表すクラス。
/// </summary>
public class AppColumn : ObservableObject
{
    private string columnName;

    /// <summary>
    /// 列の名前
    /// </summary>
    public string ColumnName
    {
        get { return this.columnName; }
        set { this.Set(ref this.columnName, value); }
    }

    /// <summary>
    /// 列に入ってるデータ
    /// </summary>
    private ObservableCollection<AppItem> columnItems = new ObservableCollection<AppItem>();

    /// <summary>
    /// 列に入ってるデータの外部公開用プロパティ(読み取り専用)
    /// </summary>
    public ReadOnlyObservableCollection<AppItem> ColumnItems { get; private set; }

    /// <summary>
    /// この列で受け入れるAppItemの条件
    /// </summary>
    public Func<AppItem, bool> AcceptItemCondition { get; set; }

    public AppColumn()
    {
        this.ColumnItems = new ReadOnlyObservableCollection<AppItem>(this.columnItems);
    }

    /// <summary>
    /// AcceptItemConditionで指定した条件にマッチするAppItemの場合追加する。
    /// </summary>
    /// <param name="item"></param>
    public void AddItem(AppItem item)
    {
        if ((this.AcceptItemCondition ?? (_ => true))(item))
        {
            this.columnItems.Insert(0, item);
        }
    }

    /// <summary>
    /// 引数で渡されたAppItemのコレクションで列の中身をリセットする。
    /// </summary>
    /// <param name="items"></param>
    public void ResetItems(IEnumerable<AppItem> items)
    {
        this.columnItems.Clear();
        foreach (var item in items)
        {
            this.AddItem(item);
        }
    }
}

最後にModelのクラスで全体管理をする人を作ります。色々やってるけど、列の管理や、0.1秒に1つAppItemを適当につくってコレクションに突っ込んだり、突っ込まれたAppItemを管理してる列に通知してます。System.Threading.Timerを使ってバックグラウンドでAppItemをコレクションに追加してるのが怖いので、コレクションの変更通知を受けて行う処理は全て同一のSynchronizationContextを経由してやるようにしました。これで十分に安全なのかはちょっと自信ない。

/// アプリ全体を表すクラス
/// </summary>
public class MyAppContext
{
    /// <summary>
    /// アプリの唯一のインスタンス
    /// </summary>
    public static readonly MyAppContext Instance = new MyAppContext();

    /// <summary>
    /// コレクションを操作するときに使用するSynchronizationContext
    /// </summary>
    private SynchronizationContext context = new SynchronizationContext();

    /// <summary>
    /// AppItemを追加するタイマー
    /// </summary>
    private Timer timer;

    /// <summary>
    /// アプリで管理する全AppItemを保持するコレクション
    /// </summary>
    private ObservableCollection<AppItem> Items { get; set; }

    /// <summary>
    /// アプリのカラムのコレクション
    /// </summary>
    public ObservableCollection<AppColumn> Columns { get; private set; }

    public MyAppContext()
    {
        // コレクションの初期化
        this.Items = new ObservableCollection<AppItem>();
        this.Columns = new ObservableCollection<AppColumn>();

        // ItemsにAppItemが追加されたら、全カラムに通知する。
        this.Items.ObserveAddChanged()
            .ObserveOn(this.context)
            .Subscribe(this.NotifyAddAppItem);

        // Itemsに大きな変更があったら、全カラムもリセットする
        this.Items.ObserveResetChanged()
            .ObserveOn(this.context)
            .Subscribe(_ => this.ResetColumnsItems());

        // カラムが追加されたら、追加されたカラムを初期化する
        this.Columns.ObserveAddChanged()
            .ObserveOn(context)
            .Subscribe(c => c.ResetItems(this.Items));
    }

    /// <summary>
    /// アプリで管理してるカラムのデータを全てリセットする。
    /// </summary>
    private void ResetColumnsItems()
    {
        foreach (var column in this.Columns)
        {
            column.ResetItems(this.Items.AsEnumerable());
        }
    }

    /// <summary>
    /// AppItemが追加されたことを全カラムに通知する。
    /// </summary>
    /// <param name="item"></param>
    private void NotifyAddAppItem(AppItem item)
    {
        foreach (var column in this.Columns)
        {
            column.AddItem(item);
        }
    }

    /// <summary>
    /// AppItemの追加処理の開始
    /// </summary>
    public void Start()
    {
        // 二重呼び出しはとりあえずアプリ殺す感じで
        if (timer != null)
        {
            throw new InvalidOperationException("既に開始しています");
        }

        // 0.1秒に1つのAppItemを追加する
        var r = new Random();
        this.timer = new Timer(_ => 
            {
                // 140文字くらいのテキストのAppItemを追加する
                var appItem = new AppItem
                {
                    Text = Enumerable
                        .Repeat("a", r.Next(140) + 1)
                        .Aggregate((x, y) => x + y)
                };
                this.Items.Insert(0, appItem);
            }, 
            null, 
            0, 
            100);
    }

    /// <summary>
    /// 列を追加する
    /// </summary>
    /// <param name="name"></param>
    /// <param name="predicate"></param>
    public void AddColumn(string name, Func<AppItem, bool> predicate)
    {
        this.Columns.Add(new AppColumn 
        {
            ColumnName = name,
            AcceptItemCondition = predicate
        });
    }
}

ViewModelを定義

お次は、AppColumnをラップする感じのColumnViewModelクラスです。こいつは単純にAppColumnを薄くラップしつつ、コレクションの操作を全てUIスレッド上でやるように専念してるだけです。

/// <summary>
/// AppColumnのViewModel
/// </summary>
public class ColumnViewModel
{
    /// <summary>
    /// 紐づくAppColumn
    /// </summary>
    public ReactiveProperty<AppColumn> Model { get; private set; }

    /// <summary>
    /// 画面に公開するAppItemのコレクション
    /// </summary>
    public ObservableCollection<AppItem> Items { get; private set; }

    public ColumnViewModel()
    {
        this.InitializeReactiveProperty();
    }

    private void InitializeReactiveProperty()
    {
        // とりあえずプロパティの初期化
        this.Items = new ObservableCollection<AppItem>();
        this.Model = new ReactiveProperty<AppColumn>();

        // Modelに値が設定されたら、そのModelで管理してるAppItemをコピーする。
        this.Model
            .Where(c => c != null)
            .ObserveOnUIDispatcher()
            .Subscribe(c =>
            {
                this.Items.Clear();
                foreach (var i in c.ColumnItems)
                {
                    this.Items.Add(i);
                }
            });

        // Modelに値が追加されたら、Itemsプロパティにも要素を追加する。
        this.Model.Where(c => c != null)
            .Select(c => c.ColumnItems)
            .SelectMany(items => items.CollectionChangedAsObservable()
                .Where(e => e.Action == NotifyCollectionChangedAction.Add)
                .Select(e => new { Item = (AppItem)e.NewItems[0], Index = e.NewStartingIndex }))
            .ObserveOnUIDispatcher()
            .Subscribe(i => this.Items.Insert(i.Index, i.Item));
    }
}

最後に、MainPageに対応するViewModelを作ります。列の追加や、AppItem生成の開始に対応するコマンドを持つのと、列が追加されたときに対応するColumnViewModelを作ってます。

/// <summary>
/// MyAppContextの薄いラッパ
/// </summary>
public class MainPageViewModel
{
    /// <summary>
    /// ラップするMyAppContext
    /// </summary>
    private MyAppContext context;

    /// <summary>
    /// 画面に公開する列のコレクション
    /// </summary>
    public ReactiveCollection<ColumnViewModel> Columns { get; private set; }

    /// <summary>
    /// AppItem生成処理開始コマンド
    /// </summary>
    public ReactiveCommand StartCommand { get; private set; }

    /// <summary>
    /// 列追加コマンド
    /// </summary>
    public ReactiveCommand AddColumnCommand { get; private set; }

    /// <summary>
    /// 追加する列名
    /// </summary>
    [Required]
    public ReactiveProperty<string> ColumnName { get; private set; }

    /// <summary>
    /// 追加する列が受け入れる条件(テキストの長さを10で割った余り)
    /// </summary>
    [Required]
    [Range(0, 9)]
    public ReactiveProperty<string> ColumnTextLength { get; private set; }

    public MainPageViewModel() : this(MyAppContext.Instance)
    {
    }

    public MainPageViewModel(MyAppContext context)
    {
        this.context = context;
        this.InitializeReactiveProperty();
    }

    private void InitializeReactiveProperty()
    {
        // 入力用プロパティ。値の検証もするのでSetValidateAttributeをよんどく
        this.ColumnName = new ReactiveProperty<string>()
            .SetValidateAttribute(() => ColumnName);
        this.ColumnTextLength = new ReactiveProperty<string>()
            .SetValidateAttribute(() => ColumnTextLength);

        // 列追加コマンドの初期化。
        // 入力項目全てのエラーが無くなったら実行出来るようにする。
        this.AddColumnCommand = Observable.CombineLatest(
            this.ColumnName.ObserveErrorChanged,
            this.ColumnTextLength.ObserveErrorChanged)
            .Select(l => l.All(o => o == null))
            .ToReactiveCommand(false);
        // 列を追加
        this.AddColumnCommand.Subscribe(_ =>
        {
            var value = int.Parse(this.ColumnTextLength.Value);
            this.context.AddColumn(
                this.ColumnName.Value,
                // テキストの長さを10で割った余りが入力値と等しかったらOK
                item => item.Text.Length % 10 == value);
        });

        // 列が追加されたら、対応するColumnViewModelを作る
        this.Columns = this.context.Columns
            .ObserveAddChanged()
            .Select(c =>
            {
                var m = new ColumnViewModel();
                m.Model.Value = c;
                return m;
            })
            .ObserveOnUIDispatcher()
            .ToReactiveCollection();

        // AppItem生成処理開始コマンド作成
        this.StartCommand = new ReactiveCommand();
        this.StartCommand.Subscribe(_ => this.context.Start());
    }
}

画面作成

さて、やっと本題の画面ですが、こいつはGridViewの中にListViewを何個も横並びにするというカオスな感じで作ります。画面のDataContextには、先ほど定義したMainPageViewModelクラスを設定します。

<Page.DataContext>
    <vm:MainPageViewModel />
</Page.DataContext>

GridViewと関連するDataTempalteなどの定義は以下のとおり。ポイントは、ListViewのIsSwipeEnabledをFalseして、GridViewの横スクロール時にListViewを横にスワイプしてもイベントを横取りしないようにしている点くらいです。

... データテンプレートの定義 ...
<Page.Resources>
    <!-- GridViewの中身は横並び -->
    <ItemsPanelTemplate x:Key="ColumnsGridViewItemPanelTemplate">
        <VirtualizingStackPanel Orientation="Horizontal"/>
    </ItemsPanelTemplate>
        
    <!-- AppItemを表示するためのテンプレート -->
    <DataTemplate x:Key="AppItemDataTemplate">
        <Grid d:DesignWidth="509.552" d:DesignHeight="97.612">
            <TextBlock TextWrapping="Wrap" Text="{Binding Text}" Style="{StaticResource BodyTextBlockStyle}" Margin="5"/>
        </Grid>
    </DataTemplate>
        
    <!-- 列を表示するためのテンプレート -->
    <DataTemplate x:Key="ColumnViewModelDataTemplate">
        <Grid d:DesignWidth="346.866" d:DesignHeight="343.881">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="10" />
                <RowDefinition/>
            </Grid.RowDefinitions>
            <!-- ヘッダに列名を表示して、その下にListViewでAppItemを表示する。 -->
            <TextBlock TextWrapping="Wrap" Text="{Binding Model.Value.ColumnName}" Style="{StaticResource TitleTextBlockStyle}"/>
            <ListView 
                ItemsSource="{Binding Items}" 
                Grid.Row="2" 
                ItemTemplate="{StaticResource AppItemDataTemplate}" 
                IsSwipeEnabled="False">
            </ListView>
        </Grid>
    </DataTemplate>
</Page.Resources>

... GridViewの定義...
<GridView 
    Grid.Row="1" 
    ItemsSource="{Binding Columns}" 
    Margin="0,20,0,0"                      
    Padding="120,0,0,0"
    ItemsPanel="{StaticResource ColumnsGridViewItemPanelTemplate}" 
    ItemTemplate="{StaticResource ColumnViewModelDataTemplate}"
    SelectionMode="None">
    <GridView.ItemContainerStyle>
        <Style TargetType="GridViewItem">
            <Setter Property="VerticalContentAlignment" Value="Top" />
            <Setter Property="Margin" Value="0,0,80,0" />
            <Setter Property="Width" Value="400" />
        </Style>
    </GridView.ItemContainerStyle>
</GridView>

実行してみよう

さて、なんとなく出来た気がするので実行して動きをみてみます。実行直後は、何も出てません。

f:id:okazuki:20131223022450p:plain

テキストボックスにカラムの名前と、数字を入れるとカラムの追加ボタンが押せるようになるので、5個くらい追加してみました。既に、横スクロールが必要なくらいの幅になってますね。使い勝手を見るのには十分です。

f:id:okazuki:20131223022643p:plain

開始ボタンを押すと、どんどんデータが追加されていきます。データがある程度増えた状態でも、指で横スクロール、縦スクロール、アイテムの選択をやるのは慣れればなんとかなりそうでしたが…

f:id:okazuki:20131223023323p:plain

マウスで操作をしようとすると、ホイールスクロールで横スクロールしようとするとListViewにカーソルがさしかかった時点でListViewに横取りされて縦スクロールになっちゃう・・・。これはアカン。使いにくい。

結論

これは、ダメ絶対。

現実的なところとしては、横スクロールを無くして、1画面に収まりきる範囲内でいくつかカラム出す。ユーザーがWindowのサイズを変えて狭くしたら出すカラムの数を減らすという考えれば、当然な作りにするのがいいかな~と思いました。

一応ダウンロード

使いどころはないと思いますが、コードは以下からDL出来ます。使ってみて、使い勝手最悪なのを体験していただければ幸いです。こんなUIのアプリが出ないように・・・!!