空談録

世界で5人くらいに役立ちたい

Windows Store Appsで階層式メニューを考える

こちらはXAML Advent Calendarの19日目だったはずの記事です
遅れた理由については自分のスケジュール管理が雑すぎたとしか言いようがないのでなんとでもどうぞ
今回の件から、来年以降は何といわれてもAdvent Calendar、またそれに類するものに参加しないことでこのような事案を招かないよう努めていきたいと思います


今回は2階層式メニューの作成について考えてみたいと思います
需要が特になさそうですが、自分で使わないの作るのも深く考えにくいのでこのままやっていこうかなと
なおデザインセンスが全くないので標準コントロールでべたべた作っていきます。各自で直してください

どういうものを作るか

どういうものを作るかですが、とりあえず要件としては
・要素が複数の子要素を持ち、子要素を持たない要素を選択するまで選択していく
・一つ上の親要素、またメインコンテンツに戻れるようにする
・子要素を持つ場合は子要素のメニューを表示する

こんくらいかなぁと
あんまり複雑なのは汎用性もなさそうなのでシンプルに組んでいきます

デザインの案としては、戻れることを考えると
100,300,のこり で考えるといいかなぁと
100に親の要素、300に現在の要素、残りの部分でメインコンテンツという感じに配置するのが早そうです

追記(12/25):よく考えたら作りたいものの画像なかったので

https://portalvhdsrp3qt9v47nzbn.blob.core.windows.net/publicphoto/fspic141224.png


クラスの下準備

シンプルでいいので
・メニュー全体のバインド先
・メニューのペインのバインド先
・各要素のバインド先
だけ用意します。


その前にいつも通りの残念クラスをまず用意します

public abstract class BindBase : INotifyPropertyChanged
{
    protected void notifyChanged(string name)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(name));
        }

    }

    public event PropertyChangedEventHandler PropertyChanged;
}

次に各メニュー要素のバインド先となるクラスを作ります。PaneItemViewModelとします

public class PaneItemViewModel : BindBase
{
    private string _title = "";
    public string Title
    {
        get
        {
            return _title;
        }
        set
        {
            _title = value;
            notifyChanged("Title");
        }
    }

    public void InvokeCommand()
    {
        if (Index == -1)
        {
            // Transit Operation
        }
        else
        {
            CommandAction.Invoke(this);
        }

    }

    internal int Index { get; set; }

    public Action<PaneItemViewModel> CommandAction { get; set; }

    public TransitionPaneViewModel ChildViewModel { get; set; }
}

まだコード書きそうですがとりあえずこれで
Transit Operationの部分は後で書きます

ペインのバインド先はこれ

public class TransitionPaneViewModel : BindBase
{
    private Brush _backgroundBrush;
    public Brush BackgroundBrush
    {
        get
        {
            return _backgroundBrush;
        }
        set
        {
            _backgroundBrush = value;
            notifyChanged("BackgroundBrush");
        }
    }

    private string _title = "";
    public string Title
    {
        get
        {
            return _title;
        }
        set
        {
            _title = value;
            notifyChanged("Title");
        }
    }

    private ObservableCollection<PaneItemViewModel> _children = new ObservableCollection<PaneItemViewModel>();

    public ObservableCollection<PaneItemViewModel> Children
    {
        get
        {
            return _children;
        }
        set
        {
            _children = value;
            notifyChanged("Children");
        }
    }
}

こっちは書き換えないかなぁと思います

最後にメニュー全体のバインド先

public class MainMenuViewModel :BindBase
{
    private TransitionPaneViewModel _mainObject = null;
    public TransitionPaneViewModel MainObject
    {
        get
        {
            return _mainObject;
        }
        set
        {
            _mainObject = value;
            notifyChanged("MainObject");
        }

    }

    private TransitionPaneViewModel _pastObject = null;
    public TransitionPaneViewModel PastObject
    {
        get
        {
            return _pastObject;
        }
        set
        {
            _pastObject = value;
            notifyChanged("PastObject");
        }
    }

    private TransitionPaneViewModel _movingObject = null;
    public TransitionPaneViewModel MovingObject
    {
        get
        {
            return _movingObject;
        }
        set
        {
            _movingObject = value;
            notifyChanged("MovingObject");
        }
    }
}

たぶん足りない
またあとで書き直しそうですがとりあえずはこれで

ここまでやったらxamlのほうを書いていきます

メニューのホスト元の作成

まずでかいところから作っていきましょう
完全にXAMLで解決しないのでこのアドベントカレンダーの趣旨と外れる気もしますが、私はそのレベルではないので…

まず親の要素を出すGridとメインのGridの2つを設置します。
どっちもWidthは300で固定して、親要素のGridはMargin.Leftを-200に、メインのGridのMargin.Leftは100にしておきます。
さらにメインの要素のGridの上にもう一つGridを追加します。こっちはあとで使います
ここまで行ったら残りの部分をRectangleで埋めておきます

文章だとすごいわかりづらいですが現在のXAMLがこんな感じになります

<Grid HorizontalAlignment="Left" Width="300" Margin="100,0,0,0" Name="MainObjectGrid" />
<Grid HorizontalAlignment="Left" Width="300" Margin="-200,0,0,0" Name="PastObjectGrid" />
<Grid HorizontalAlignment="Left" Width="300" Margin="100,0,0,0" Visibility="Collapsed" Name="MovingObjectGrid" />
<Rectangle Margin="400,0,0,0" Fill="Black" Opacity="0" />

名前はそれぞれPastObjectGrid、MainObjectGrid、MovingObjectGridにしてあります。Rectangleは使わないので名前つけていません

色つけてみるとこんな見た目

https://portalvhdsrp3qt9v47nzbn.blob.core.windows.net/publicphoto/fspic141219-2.png

センスのなさがすごいですが見分けやすいとは思います…たぶん
黄色のところがRectangle、青が親Grid、赤がメインのGridです

ここまでやったらRectangleにCallMethodActionを追加して、って思ったんですけど、CallMethodActionで通知すべきものなのか怪しい気がします
というのもこれ自体が単独のコントロールとして存在する場合、eventとして通知して、コントロールを持つページなどでCallMethodActionなりを呼ぶ方が正しい気も
今回みたいに完全にいじれるところならなんでもいいんですけど

というわけでMenuClosingとかでevent作っておきましょう
Cancelできる必要はないと思うので引数は適当に

RectangleのPointerPressedイベントにこんなコードを追加しておきます

internal event Action MenuClosing = null;

private void Rectangle_PointerPressed(object sender, PointerRoutedEventArgs e)
{
    MenuClosing();
}

ここまでやったらいったんMainPage.xamlにおいて試してみましょう

MainPage.xamlでの配置

特にメニューのコンテンツもないですが、いつやっても一緒なのでテストしやすいように先にやります

MainPage.xamlにとりあえずコントロールを配置します。位置はMarginで"0,0,0,0"です
普段VisibilityをCollapsedにしておいて、表示はボタンを押したらVisibleするようにしてみます

このときメインのコンテンツもスライドさせたいのでColumnDefinitionで分割します

さっくり書くとこんな感じ(Gridの中だけ)

<Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Rectangle Grid.Column="0" Width="400" Name="MenuSwitchRectangle" Visibility="Collapsed" />
    <Grid Grid.Column="1">
        <Button Content="Menu Open" HorizontalAlignment="Left" Margin="55,39,0,0" VerticalAlignment="Top" Click="Button_Click"/>
    </Grid>
    <local:MenuHostControl Margin="0,0,0,0" MenuClosing="MenuHostControl_MenuClosing" x:Name="MainMenuControl" Visibility="Collapsed" Grid.ColumnSpan="2" Grid.Column="0" />
</Grid>

Gridの中にメインのコンテンツを配置して、メニューを出すときはRectangleでColumnDefinitionをスライドさせます
随分と雑な設計…

イベントに関連付けられてるコードもこんな感じで単純です

private void MenuHostControl_MenuClosing()
{

    MenuSwitchRectangle.Visibility = Visibility.Collapsed;
    MainMenuControl.Visibility = Visibility.Collapsed;
}

private void Button_Click(object sender, RoutedEventArgs e)
{
    MenuSwitchRectangle.Visibility = Visibility.Visible;
    MainMenuControl.Visibility = Visibility.Visible;
}

ひとまず実行してみるとこんな感じ
なんか上に変なの載ってますがこっちの手元の順番と違ってるだけなので気にしないでください

実行前と実行後ですね。実行前の画像全くいらない

このときボタンのあるほうの(さっきRectangle置いたところ)をクリックなりするとメニューが閉じてれば大丈夫です

ちなみにこのとき、XAML側でGridより下の行にメニューのコントロール置かないとボタンが貫通して触れたりします。というかRectangleがまともに反応しなくなる可能性があるのでやめましょう
あとはメニューより下の行にGrid調整用のRectangleとか配置するのも厳しさがあるので注意

なんかすでに出てますがメニューの中身も作りましょう

メニューのペインを作る

こっちは簡単です

ViewModel的にもタイトルとListBoxさえあればあとはなんでもいいので今回はそのまま置くだけで

<Grid Background="{Binding BackgroundBrush}">
	<TextBlock HorizontalAlignment="Left" Height="35" Margin="10,10,0,0" TextWrapping="Wrap" Text="{Binding Title}" VerticalAlignment="Top" FontSize="24"/>
	<Path Data="M16.5,55 L381,55" Fill="#FFF4F4F5" Height="2" Margin="10,50,10,0" Stretch="Fill" Stroke="White" UseLayoutRounding="False" VerticalAlignment="Top" StrokeThickness="2"/>
	<ListBox Margin="10,73,10,10" ItemTemplate="{StaticResource DataTemplate1}" ItemsSource="{Binding Children}"/>
</Grid>

バインドだけ指定しておきます

ItemTemplateもタイトルとボタンだけです。ボタンは楽するために追加してます

<DataTemplate x:Key="DataTemplate1">
	<Grid Width="250">
		<TextBlock HorizontalAlignment="Left" Margin="2,12,0,0" TextWrapping="Wrap" Text="{Binding Title}" VerticalAlignment="Top" FontSize="18.667"/>
		<Button Content="Transit" HorizontalAlignment="Right" Margin="0,4,0,0" VerticalAlignment="Top" Width="92">
			<Interactivity:Interaction.Behaviors>
				<Core:EventTriggerBehavior EventName="Click">
					<Core:CallMethodAction TargetObject="{Binding}" MethodName="InvokeCommand"/>
				</Core:EventTriggerBehavior>
			</Interactivity:Interaction.Behaviors>
		</Button>
	</Grid>
</DataTemplate>

正直こっちもなんでもいいので動かすこと最優先です

ここまでやったらメニューのホスト元のXAMLに追加します。さっきのGrid置いてあったところですね
書き換えた後がこんな感じ

<Grid HorizontalAlignment="Left" Width="300" Margin="100,0,0,0" Name="MainObjectGrid"  DataContext="{Binding MainObject}">
    <local:TransitionPane DataContext="{Binding}" />
</Grid>
<Grid HorizontalAlignment="Left" Width="300" Margin="-200,0,0,0" Name="PastObjectGrid" DataContext="{Binding PastObject}">
    <local:TransitionPane DataContext="{Binding}" />
</Grid>
<Grid HorizontalAlignment="Left" Width="300" Margin="100,0,0,0" Visibility="Collapsed" Name="MovingObjectGrid" DataContext="{Binding MovingObject}">
    <local:TransitionPane DataContext="{Binding}" />
</Grid>
<Rectangle Margin="400,0,0,0" Fill="Black" Opacity="0" PointerPressed="Rectangle_PointerPressed" />

ついでにバインディングもしておきます。こう書かなくてもいい気がします

この状態での完成形はさっき出てたので、表示を見るためにサンプルデータでも作りましょう

サンプルデータの用意

トテモ ダルイ

階層を持ったデータを作成する必要があります
本来手でやることではないと思うんですが頑張るしかないので
サンプルデータをBlendで~ はうまくいかない自信があるのであれ

とりあえずごり押ししてみたのがこちら

https://gist.github.com/fantasticswallow/d57844eed7975c442436

もう埋め込む気力もないのでGistで確認してください…

そしたらMainPage.xamlコンストラクタでViewModelをDataContextに渡しておきます

private MainMenuViewModel _viewModel = new MainMenuViewModel();

public MainPage()
{
    this.InitializeComponent();
    var data = SampleDataModel.CreateSamplePaneVM();
    _viewModel.PastObject = data;
    _viewModel.MainObject = data.Children[2].ChildViewModel;
    MainMenuControl.DataContext = _viewModel;
}

表示が見れればいいので

実行結果がこんな感じです。データテンプレートのところ書き換えてます

https://portalvhdsrp3qt9v47nzbn.blob.core.windows.net/publicphoto/fspic141222-5.png

書き換えないとボタンが白くて見づらいです。頑張ってください(丸投げ)
というか今更ですけどこれだと何選択したか見えなくて割と意味がない…

メニューのVisualStateの定義

この記事いつまで書けばいいんですかね…(現在10200文字)

メニューなんですけど遷移アニメーションをつけるために状態を定義します

必要なのは
・初期状態
・初期遷移後(今のXAMLの状態)
・子のペインへの遷移後
の3つくらいです

とりあえず
・TransittedMain
・TransitionReady
・Transitted
・First
の3つのステートを定義します。

Firstは初期状態なので何も変更しません。TransitionReadyは失敗したので後の画像で見ても気にせず

Blendで定義した結果がこちら
https://portalvhdsrp3qt9v47nzbn.blob.core.windows.net/publicphoto/fspic141222-6.png

なんか見えますが気にしないでください
実際にやりながら書いてるのですごい書くの遅い…

さて、定義したらおおもとのXAMLをいじります。っていうか位置を戻さないと話になりません…

PastObjectGridのWidthを400pxに、Margin.Leftを0にしてMainObjectGridを埋めます。
その後初期状態以外のVisualStateでPastObjectGridの位置をさっきの位置に指定します

そこまでやったらMovingObjectGridをついにいじります。Transittedのステートの時にVisibilityをVisibleに変更して、Margin.LeftをTransittedのときは-200にします。

これでとりあえずVisualStateの定義は終了です。続いて遷移アニメーションを定義していきます

遷移アニメーションの定義

もうあんな長いXAMLは書かなくていいはずなので頑張りましょう

大体同じことしかしないので初期状態からTransittedMainへの遷移だけを考えます

とりあえず縮めながらMarginをずらすんですけど、MarginはThicknessでWPF以外は確かイージング関数が適用できないので、RenderTransformで位置をずらします
RenderTransformでずらす場合はイージング関数が使えるのでスライドさせながら移動可能です

ちょっと画像ミスってるのがものすごい申し訳ないのですがそのまま行きます

VisualStateの"切り替え効果の追加"で*→TransittedMainを選択します
追加したら、その遷移アニメーションを選択した状態でPastObjectGridを選択します
そうしたらプロパティの変換のところにあるRenderTransformの平行移動のXの値を-200にします。

https://portalvhdsrp3qt9v47nzbn.blob.core.windows.net/publicphoto/fspic141222-7.png

画像の右下あたりのやつですね
わかる人いそうですがMovingObjectGridを選択しているのでまったく変更されません。はい
まあやることは同じなので…

平行移動させたらストーリーボードのキーフレームをずらします
初期値だと0.0の位置にキーフレームがあって意味ないのでキーフレームを0.5秒の位置に移動させます
そしてストーリーボードのRenderTransformのプロパティを選択するとKeyFrameのプロパティが出てくるので、イージング関数を好みのものにします。今回はExpornentialのEaseInOut、Expornentは9にしました

https://portalvhdsrp3qt9v47nzbn.blob.core.windows.net/publicphoto/fspic141222-8.png

まあこんな画像みたいな感じで選べるのでご自由に
そしてまたしてもMovingObjectGridです、変わるわけがない

今回はWidthも変更しますがTransitionReady→Transittedのときはここまでで大丈夫です
とはいえWidthは普通に変更してキーフレーム動かすだけですが

こうやって追加したアニメーションがこれ

<VisualStateGroup x:Name="VisualStateGroup">
	<VisualStateGroup.Transitions>
		<VisualTransition GeneratedDuration="0" To="TransittedMain">
			<Storyboard>
				<DoubleAnimation Duration="0:0:0.5" To="-200" Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.TranslateX)" Storyboard.TargetName="PastObjectGrid" d:IsOptimized="True">
					<DoubleAnimation.EasingFunction>
						<ExponentialEase EasingMode="EaseInOut" Exponent="9"/>
					</DoubleAnimation.EasingFunction>
				</DoubleAnimation>
				<DoubleAnimationUsingKeyFrames EnableDependentAnimation="True" Storyboard.TargetProperty="(FrameworkElement.Width)" Storyboard.TargetName="PastObjectGrid">
					<EasingDoubleKeyFrame KeyTime="0:0:0.5" Value="300">
						<EasingDoubleKeyFrame.EasingFunction>
							<CircleEase EasingMode="EaseInOut"/>
						</EasingDoubleKeyFrame.EasingFunction>
					</EasingDoubleKeyFrame>
				</DoubleAnimationUsingKeyFrames>
			</Storyboard>
		</VisualTransition>
	</VisualStateGroup.Transitions>

手書きするとここでGridにRenderTransformがないっていわれるのでCompositeTransformを追加しておきます(Blendでいじったら問題ないです)

こんな感じのを4つくらい追加します。いつやってもいいので放置

切り替えの実装

この記事切り替え書かないと意味ないんですよね…

切り替えについてはすごいざっくりいうと、DataContextChangedでチェックしてMovingObjectが変更されたら遷移とかすればいいです

実装の前にMenuHostControlのコンストラクタでVisualState変更します

public MenuHostControl()
{
    this.InitializeComponent();
    VisualStateManager.GoToState(this, "First", false);
}

さて、書く前にPaneItemViewModelのところを書き換えます
全部一緒なのでActionとしてしまいましょう

こんな感じのコードに書き換えます

public void InvokeCommand()
{
    if (Index == -1)
    {
        TransitAction(this);
    }
    else
    {
        CommandAction.Invoke(this);
    }

}

public static Action<PaneItemViewModel> TransitAction { get; set; }

これでTransitActionに関連付ければよくなりました
TransitActionはMainPage.xamlで変更してみます(これはどこでもいいです。というかxamlの部分に依存しないので呼ばれれば動作するはず)

public MainPage()
{
    this.InitializeComponent();
    var data = SampleDataModel.CreateSamplePaneVM();
    _viewModel.PastObject = data;
    PaneItemViewModel.TransitAction = (p) => transitionOperate(p);
    MainMenuControl.DataContext = _viewModel;
}

private void transitionOperate(PaneItemViewModel pvm)
{
    if (_viewModel.MainObject == null)
    {
        _viewModel.MainObject = pvm.ChildViewModel;
    }
    else
    {
        var cvm = _viewModel.MainObject;
        _viewModel.MainObject = pvm.ChildViewModel;
        _viewModel.MovingObject = cvm;
    }
}

まったくVisualStateとかいじってないですがこれはMainHostControl側で変更します

ところでこのときFirst→TransittedMainとTransittedMain→Transittedの2種類の遷移が存在します
このためMainObjectGridとMovingObjectGridの2つにおいてDataContextChangedを利用した遷移を実装します

まずMainObjectGridのほうから
こっちはFirstのときだけ変更するので、Firstかどうかを判定するフラグかなんか立てて実行します

こんな感じで

private bool isFirstTransition = true;

private void MainObjectGrid_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
    if (args.NewValue == null)
    {
        return;
    }
    if (isFirstTransition)
    {
        isFirstTransition = false;
        VisualStateManager.GoToState(this, "TransittedMain", true);
    }
}

あとはMainObjectGridがnullの時はisFirstTransitionをtrueにするとかで戻したときも動作しそうです

そしてもう片方のMovingObjectGridの変更
こっちは条件管理は楽ですが後処理が複雑になります

まずMainHostControlのMovingObjectGrid_DataContextChangedのコードから

private void Storyboard_Completed(object sender, object e)
{
    if (isTransitting)
    {
        isTransitting = false;
        VisualStateManager.GoToState(this, "TransittedMain", true);
        MainPane_MoveCompleted();
    }
}

private void MovingObjectGrid_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{
    if (args.NewValue != null)
    {
        isTransitting = true;
        VisualStateManager.GoToState(this, "Transitted",true);
        
    }
}

private bool isTransitting = false;

internal event Action MainPane_MoveCompleted = null;

遷移していることを検知して、遷移し終わったら変なイベントで通知するようにしています
このとき、TransittedからTransittedMainに戻さないとMovingObjectGridが残るので確実に戻します

イベントで通知されたらMainPage側で後処理します

private void MainMenuControl_MainPane_MoveCompleted()
{
    // Stack.Push(_viewModel.PastObject);
    _viewModel.PastObject =  _viewModel.MovingObject;
    _viewModel.MovingObject = null;
}

戻すことを考えるとStackでPastObjectを管理する必要がありますが、現在戻す機構は考えていないのでこのままで

とりあえず実行した結果が次の画像です。一個にまとめてます。アニメーションまでは伝えにくい…

https://portalvhdsrp3qt9v47nzbn.blob.core.windows.net/publicphoto/fspic141222-10.png

こんな感じで遷移すればここまでは大丈夫です。
ちなみに一回実行すると再起動するまでこの状態が残るのですごいよくないです

課題事項

もうマヂ無理…

すでに手遅れですけどくっそ長いのでもう一回に分けます
残るお仕事は
・PastObjectGridを選択して戻せるようにする
・戻すときのアニメーション

の2つです。どうにかなるのか

ここまでのソースは https://portalvhdsrp3qt9v47nzbn.blob.core.windows.net/publicphoto/SwitchableLeftMenuApp.zip から落とせます。あんまりうれしくない
割と書くのもめんどうなのでxamlとかの最終結果はこっち見てください


以上です。お疲れ様でした。5時間くらい書いてた気がします

いまいち何言ってるのかわからない気もしますし質も低い気がしますがまあ倉庫的な扱いで…

この辺で


追記1:この続きその1
Windows Store Appsで階層式メニューを考えるやつの途中経過 - 空談録
ペイン2枚で完結するならその1の記事で終わります

(12/27)その2も書きました
Windows Store Appsで階層式メニューを考えるやつ その3 - 空談録
その2で完結します