初期のころのWindows ストアアプリでは御法度のように書いてあった横スクロールと縦スクロールによる横スクロールと縦スクロールが混在したアプリケーションですが、最近は、結構審査もゆる~くなったみたいなので、操作しにくくなかったらよさそうなのかなぁという雰囲気になってきてたりします。Facebookアプリとか、ストアアプリらしい???と疑問に思ったけれど、下のページで取り上げられたりしてるくらいだし。
ということで、縦スクロールと横スクロール混ぜるな危険なUIを作る方法を書いてみようと思います。ゴールのイメージとしてはマルチカラムのTwitterクライアントみたいになる感じです。
複数カラムを扱うようなデータ構造のクラスを定義する
まずは、画面に表示するためには裏でデータとっとかないといけないですね。ということで、クラスを定義します。ついでなんで、ReactivePropertyとMVVM LightのPCL版をNuGetからとってきて、それを使って作りました。最初にアプリのデータの最小単位。1つの項目を表すデータの入れ物です。これは、プロパティの中身が変わることを前提としてないので、単純なクラスにしました。
/// <summary>/// データの入れ物/// </summary>publicclass AppItem { /// <summary>/// テキスト/// </summary>publicstring Text { get; set; } }
コメント要らない感じがしますが、まあいいでしょう。
次は、カラムを表すクラスです。単純に列の名前と、列内で管理してるAppItemのコレクションを持っています。コレクションに追加するAppItemの条件を指定できるようにFunc<AppItem, bool>型のプロパティも追加してます。
/// <summary>/// 列を表すクラス。/// </summary>publicclass AppColumn : ObservableObject { privatestring columnName; /// <summary>/// 列の名前/// </summary>publicstring ColumnName { get { returnthis.columnName; } set { this.Set(refthis.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>/// <paramname="item"></param>publicvoid AddItem(AppItem item) { if ((this.AcceptItemCondition ?? (_ => true))(item)) { this.columnItems.Insert(0, item); } } /// <summary>/// 引数で渡されたAppItemのコレクションで列の中身をリセットする。/// </summary>/// <paramname="items"></param>publicvoid 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>publicclass MyAppContext { /// <summary>/// アプリの唯一のインスタンス/// </summary>publicstaticreadonly 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>privatevoid ResetColumnsItems() { foreach (var column inthis.Columns) { column.ResetItems(this.Items.AsEnumerable()); } } /// <summary>/// AppItemが追加されたことを全カラムに通知する。/// </summary>/// <paramname="item"></param>privatevoid NotifyAddAppItem(AppItem item) { foreach (var column inthis.Columns) { column.AddItem(item); } } /// <summary>/// AppItemの追加処理の開始/// </summary>publicvoid Start() { // 二重呼び出しはとりあえずアプリ殺す感じでif (timer != null) { thrownew 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>/// <paramname="name"></param>/// <paramname="predicate"></param>publicvoid 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>publicclass 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(); } privatevoid 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>publicclass 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(); } privatevoid 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>
実行してみよう
さて、なんとなく出来た気がするので実行して動きをみてみます。実行直後は、何も出てません。
テキストボックスにカラムの名前と、数字を入れるとカラムの追加ボタンが押せるようになるので、5個くらい追加してみました。既に、横スクロールが必要なくらいの幅になってますね。使い勝手を見るのには十分です。
開始ボタンを押すと、どんどんデータが追加されていきます。データがある程度増えた状態でも、指で横スクロール、縦スクロール、アイテムの選択をやるのは慣れればなんとかなりそうでしたが…
マウスで操作をしようとすると、ホイールスクロールで横スクロールしようとするとListViewにカーソルがさしかかった時点でListViewに横取りされて縦スクロールになっちゃう・・・。これはアカン。使いにくい。
結論
これは、ダメ絶対。
現実的なところとしては、横スクロールを無くして、1画面に収まりきる範囲内でいくつかカラム出す。ユーザーがWindowのサイズを変えて狭くしたら出すカラムの数を減らすという考えれば、当然な作りにするのがいいかな~と思いました。
一応ダウンロード
使いどころはないと思いますが、コードは以下からDL出来ます。使ってみて、使い勝手最悪なのを体験していただければ幸いです。こんなUIのアプリが出ないように・・・!!