セマンティックズームの概要ビューをグラフ化するには

Windows ストア アプリでは、セマンティックズームが提供されています。ナビゲーションを容易にするために、詳細ビュー(ZoomIn)と概要ビュー(ZoomOut)に切り替えることができるコントロールが、セマンティックズームになります。Develpoper Campなどで、概要ビューをグラフ化したりという説明をしたりもします。が、実際にグラフ化するのはどうしたら良いか?というのが、今回の記事の趣旨になります。

最初に、次のような詳細ビュー(ZoomIn View)があるとします。
RssLandscape
これを概要ビュー(ZoomOut View)でグラフ化してみます。
RssZoomOut
簡単な棒グラフですが、これをどのように実現するかというと、色々なことを考える必要があります。たとえば、

  • グラフの最大値をどのように決めるか
  • グラフの長さに関係しますが、ポートレイトでの表現をどうするか。
  • グラフの色をどのように決めるか

などがあります。グラフの色に関しては、決め打ちしてしまえば良いのですが、グラフの100%の高さを様々な解像度に対応させようとすると、面倒なことが想像できることでしょう。たとえば、ランドスケープであれば 縦 < 横 という関係ですが、ポートレイトでは 横 < 縦 という反対の関係になりますし、データ バインドを使ってどのように表現するかというのも考えないといけないことになります。特にデータバインドを使わないと、詳細ビューと概要ビューの間のナビゲーションを実現できないので、データバインドの使用は必須と言えるでしょう。このように考えて、作成した詳細ビューと概要ビューの Grid View定義を次に示します。

 <SemanticZoom.ZoomedInView>
  <GridView x:Name="itemGridView"
            AutomationProperties.AutomationId="ItemGridView"
            AutomationProperties.Name="Grouped Items"
            ItemsSource="{Binding Source={StaticResource groupedItemsViewSource}}"
            ItemTemplate="{StaticResource ZoomIn250x250Template}"
            SelectionMode="Single"
            IsSwipeEnabled="True"
            IsItemClickEnabled="True"
            SelectionChanged="itemGridView_SelectionChanged"
            Loaded="itemGridView_Loaded"
            ItemClick="ItemView_ItemClick">
    <GridView.ItemsPanel>
        <ItemsPanelTemplate>
            <ItemsWrapGrid GroupPadding="0,0,70,0"/>
        </ItemsPanelTemplate>
    </GridView.ItemsPanel>
    <GridView.GroupStyle>
        <!-- 省略 -->
    </GridView.GroupStyle>
  </GridView>
</SemanticZoom.ZoomedInView>
<SemanticZoom.ZoomedOutView>
  <GridView x:Name="zoomOutItemGridView"
            AutomationProperties.AutomationId="ItemGridView"
            AutomationProperties.Name="Grouped Items"
            ItemsSource="{Binding CollectionGroups, Source={StaticResource groupedItemsViewSource}}"
            ItemTemplate="{StaticResource zoomOutGraphTemplate}"
            SelectionMode="None"
            IsSwipeEnabled="false" >
    <GridView.ItemsPanel>
        <ItemsPanelTemplate>
            <ItemsWrapGrid GroupPadding="0,0,70,0"/>
        </ItemsPanelTemplate>
    </GridView.ItemsPanel>
  </GridView>
</SemanticZoom.ZoomedOutView>

 

データソースがGroupedItemViewSource(CollectionViewSourceで定義)を指定しており、概要ビュー(ZoomOut)は CollectionGroups を指定しています。CollectionGroupsとは、データソースのが表現するコレクション全体を意味する指定になりますから、グループのコレクションということになります。詳細ビューは、CollectionViewSourceで定義している ItemPathが示すグループが持つアイテム コレクションを示しています。では、概要ビュー(ZoomOut)のDataTemplate定義を示します。

 <DataTemplate x:Key="zoomOutGraphTemplate">
  <Grid HorizontalAlignment="Left" Width="100"
        Height="{Binding LandscapeHeight, Source={StaticResource GraphHeight}}"
        DataContext="{Binding Group}">
    <Border VerticalAlignment="Bottom" Margin="0"
            Background="{Binding GraphColor, Converter={StaticResource ColorToBrush}}" 
            Height="{Binding Converter={StaticResource CollectionToHeight}, ConverterParameter={Binding Source={StaticResource GraphHeight}}}" />
    <StackPanel VerticalAlignment="Bottom" Background="{ThemeResource ListViewItemOverlayBackgroundThemeBrush}">
      <TextBlock 
         Text="{Binding Items, Converter={StaticResource CollectionToCount}, FallbackValue=5}" 
         Foreground="{ThemeResource ListViewItemOverlaySecondaryForegroundThemeBrush}"
         Style='{StaticResource SubheaderTextBlockStyle}'
         TextWrapping='NoWrap' HorizontalAlignment='Center' Margin='10' />
      <TextBlock Text='{Binding Title}'
         Foreground='{ThemeResource ListViewItemOverlaySecondaryForegroundThemeBrush}'
         Style='{StaticResource TitleTextBlockStyle}'
         TextWrapping='NoWrap' HorizontalAlignment='Center' Margin='10' />
      <TextBlock Text='{Binding PubDate}'
         Foreground='{ThemeResource ListViewItemOverlaySecondaryForegroundThemeBrush}'
         Style='{StaticResource CaptionTextBlockStyle}'
         TextWrapping='NoWrap' HorizontalAlignment='Center' Margin='10' />
    </StackPanel>
    <ToolTipService.ToolTip>
      <ToolTip >
        <StackPanel Orientation='Vertical'>
          <TextBlock Text='{Binding Title}'
             Style='{StaticResource TitleTextBlockStyle}'/>
          <TextBlock
             Text='{Binding Items, Converter={StaticResource CollectionToCount}}'
             Style='{StaticResource TitleTextBlockStyle}' />
        </StackPanel>
      </ToolTip>
    </ToolTipService.ToolTip>
  </Grid>
</DataTemplate>

このDataTemplate定義で説明する箇所は、ルートであるGrid要素のHeightプロパティ、Boder要素のHeightプロパティとBackgroundプロパティ、それからTextBlockのTextプロパティのバインディングの定義になります。

Grid要素

Heightプロパティのバインディングが「{Binding LandscapeHeight, Source={StaticResource GraphHeight}}」と指定しています。これが何を表現するためかと言えば、グラフ全体の高さを決めるものになります。このために、ローカルリソースのGraphHeightにWindowSizeHelperクラスを指定しています。このクラスのコードを次に示します。

 class WindowSizeHelper : BindableBase
{
  const int TOP = 140;
  const int BOTTOM = 100;
  public WindowSizeHelper()
  {
    var window = Windows.UI.Xaml.Window.Current;
    CalcSize(window.Bounds.Width, window.Bounds.Height);
    window.SizeChanged += window_SizeChanged;
  }
  void window_SizeChanged(object sender,
              Windows.UI.Core.WindowSizeChangedEventArgs e)
  {
    CalcSize(e.Size.Width, e.Size.Height);
  }
  void CalcSize(double width, double height)
  {
    DisplayInformation display = null;
    double x, y;
    display = DisplayInformation.GetForCurrentView();
    if (display != null && (
        display.CurrentOrientation == DisplayOrientations.Portrait ||
        display.CurrentOrientation == DisplayOrientations.PortraitFlipped))
    {
        x = width;
        y = height;
    }
    else
    {
        x = height;
        y = width;
    }
    this.LandscapeHeight = x - TOP - BOTTOM;
    this.PortraitHeight = y - TOP - BOTTOM;
  }
  private double landscapeHeight;
  public double LandscapeHeight 
  {
    get { return this.landscapeHeight; }
    set { this.SetProperty(ref this.landscapeHeight, value); }
  }
  private double portraitHeight;
  public double PortraitHeight
  {
    get { return this.portraitHeight; }
    set { this.SetProperty(ref this.portraitHeight, value); }
  }
}

WindowsSizeHelperクラスは、Windowの縦と横の状態を判断した計算結果(TOPとBOTTOMを引いて、グラフ領域の高さ)をLandScapeHeightプロパティとして公開します。 このプロパティをGrid要素のHeightプロパティにバインドすることで、100%の大きさにおける高さを決定します。

Border要素

VerticalAlignmentプロパティに「Bottom」を指定することで、グラフを下を基準にすることを表現しています。また、Heightプロパティのバインディングを「{Binding Converter={StaticResource CollectionToHeight}, ConverterParameter={Binding Source={StaticResource GraphHeight}}}」と指定しています。このバインディングが行うことは、グラフの高さを計算することです。このために、バインディング パスを省略(グループ オブジェクトが渡る)して、コンバーターにローカルリソースのCollectionToHeightConverterクラスを指定して、コンバーター パラメーターにローカルリソースのWindowsSizeHelperを指定しています。それでは、CollectionToHeightConverterクラスのコードを示します。

 class CollectionToHeightConverter : IValueConverter
{
  public object Convert(object value, Type targetType, 
                        object parameter, string language)
  {
    var group = (value as RssDataGroup);
    if (group == null)
      return 0;
    var sizeHelper = parameter as WindowSizeHelper;
    double x = 0;
    if (sizeHelper != null)
      x = sizeHelper.LandscapeHeight;
    else
      x = Constants.DEFAULT_GRAPH_HEIGHT;
    var height = x * group.Items.Count() / group.Max;
    if (x < height) height = x;
    return height;
  }
  public object ConvertBack(object value, Type targetType,
                            object parameter, string language)
  {
    throw new NotSupportedException();
  }
}

中心となる計算は、「height = x * group.Items.Count() / group.Max」です。この計算は、WindowsSizeHelperクラスを使ってグラフの最大値(100%が x)とコレクションの数(Items.Count)とコレクションの最大値(group.Max)によって棒グラフの長さを算出しています。このコンバーターには、既に説明したようにバインディング パスを指定していないのでグループオブジェクトが渡されます。そして、コンバーター パラメーターに WindowsSizeHelper クラスを指定しています。このコンバーター パラメーターも注意が必要です。なぜなら、コンバーター パラメーターには、データソースが持つメンバーを渡すことができないのです。この理由とグラフの最大値がデータに依存するのではなく解像度に依存することから、WindowsSizeHelperをローカルリソースに指定しています。後は、グラフ化のためだけにグループオブジェクト(RssDataGroup)に Maxプロパティ(アイテムの最大値)を設定しています。もちろん、グループオブジェクトの作成時に正しい最大値になるように計算していることは、言うまでもありません。ここまでの説明で、CollectionToHeightConverterによって棒グラフの高さが計算されることを理解することができたことでしょう。まだ説明していないのは、「if (x &lt; height) height = x」ですが、これはグラフの最大値を超えた場合になります(最後に、具体例を示します)。Backgroundプロパティに指定しているコンバーターは、バインドされたメンバーをSolidColorBrushオブジェクトに変換するものです。つまり、グループオブジェクトにGraphColorプロパティを持たせており、グループ毎にグラフの色を指定できるということです。
今度は、TextBlockのTextプロパティに指定している「{Binding Items, Converter={StaticResource CollectionToCount}, FallbackValue=5}」を説明します。指定しているコンバーターであるCollectionToCountConverterクラスの考え方は、Itemsコレクションを渡して件数を返すものになります。

 public class CollectionToCountConverter : IValueConverter
{
  public object Convert(object value, Type targetType,
                        object parameter, string language)
  {
    var collection = (value as IEnumerable).Cast<object>();
    if (collection == null)
      return 0;

    if (!collection.Any())
      return 0;

    return collection.Count();
  }
  public object ConvertBack(object value, Type targetType,
                            object parameter, string language)
  {
    throw new NotSupportedException();
  }
}

 

ここまでの説明で、グラフ化する仕組みそのものの説明は完了です。最後に、ポートレイト時の概要ビュー(ZoomOut)を示します。
RssPortrait

グラフの描画方法自体は、他にも色々な手法が考えられます。でも、少し工夫することで、セマンティックズームの概要ビューをビジュアルにすることができます。皆さんも、色々と工夫をしてみてください。