適切なマージンを持った GridView のスタイル定義 (3)
※Release Preview での状況は以下の記事を参照してください。
Release Preview における GridView の問題点 (1) - rebuild
2回にわたって GridView のスタイル定義について書きましたが、最後に ListView も含めて必要な作業をまとめておきます。
必要になる作業は以下の通りです。
- 適切なマージンを持った GridView / ListView のスタイルを定義する(予め定義しておけば使いまわせる)。
- GridView / ListView を囲んでいる ScrollViewer 削除して GridView / ListView を外に出す。この時、ScrollViewer に設定されていた Grid.Row, Margin, Visibility のプロパティは その中にあった GridView / ListView に設定する。
- GridView / ListView の Style に定義したスタイルを設定する。GridView に設定するのはランドスケープ用の物。
- ポートレート時の VisualState に定義されている、GridView の Margin を変える KeyFrame を削除する。代わりに GridView の Style をポートレート用の物に変える KeyFrame を追加する。
- スナップ時の VisualState に定義されている ScrollViewer の Visibility を変える KeyFrame を、GridView / ListView を対象とするように変更する。
以下に定義したスタイルと、グリッドアプリケーションの先頭ページをスタイルを使うように変更した物を載せます。
スタイル定義(CustomGridViewStyle.xaml)
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:StyleDefineTest"> <Style x:Key="LandscapeGridViewStyle" TargetType="GridView"> <Setter Property="Padding" Value="0,0,0,10"/> <Setter Property="IsTabStop" Value="False"/> <Setter Property="TabNavigation" Value="Once"/> <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/> <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/> <Setter Property="ScrollViewer.HorizontalScrollMode" Value="Enabled"/> <Setter Property="ScrollViewer.VerticalScrollMode" Value="Disabled"/> <Setter Property="ScrollViewer.ZoomMode" Value="Disabled"/> <Setter Property="IsSwipeEnabled" Value="True"/> <Setter Property="ItemContainerTransitions"> <Setter.Value> <TransitionCollection> <AddDeleteThemeTransition/> <ContentThemeTransition/> <ReorderThemeTransition/> <EntranceThemeTransition IsStaggeringEnabled="False"/> </TransitionCollection> </Setter.Value> </Setter> <Setter Property="ItemsPanel"> <Setter.Value> <ItemsPanelTemplate> <WrapGrid Orientation="Vertical"/> </ItemsPanelTemplate> </Setter.Value> </Setter> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="GridView"> <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}"> <ScrollViewer x:Name="ScrollViewer" HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}" HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}" Padding="{TemplateBinding Padding}" TabNavigation="{TemplateBinding TabNavigation}" VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" VerticalScrollMode="{TemplateBinding ScrollViewer.VerticalScrollMode}" ZoomMode="{TemplateBinding ScrollViewer.ZoomMode}"> <ItemsPresenter Margin="116,0,40,46"/> </ScrollViewer> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> <Style x:Key="PortraitGridViewStyle" TargetType="GridView"> <Setter Property="Padding" Value="0,0,0,10"/> <Setter Property="IsTabStop" Value="False"/> <Setter Property="TabNavigation" Value="Once"/> <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/> <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/> <Setter Property="ScrollViewer.HorizontalScrollMode" Value="Enabled"/> <Setter Property="ScrollViewer.VerticalScrollMode" Value="Disabled"/> <Setter Property="ScrollViewer.ZoomMode" Value="Disabled"/> <Setter Property="IsSwipeEnabled" Value="True"/> <Setter Property="ItemContainerTransitions"> <Setter.Value> <TransitionCollection> <AddDeleteThemeTransition/> <ContentThemeTransition/> <ReorderThemeTransition/> <EntranceThemeTransition IsStaggeringEnabled="False"/> </TransitionCollection> </Setter.Value> </Setter> <Setter Property="ItemsPanel"> <Setter.Value> <ItemsPanelTemplate> <WrapGrid Orientation="Vertical"/> </ItemsPanelTemplate> </Setter.Value> </Setter> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="GridView"> <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}"> <ScrollViewer x:Name="ScrollViewer" HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}" HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}" Padding="{TemplateBinding Padding}" TabNavigation="{TemplateBinding TabNavigation}" VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" VerticalScrollMode="{TemplateBinding ScrollViewer.VerticalScrollMode}" ZoomMode="{TemplateBinding ScrollViewer.ZoomMode}"> <ItemsPresenter Margin="96,0,10,56"/> </ScrollViewer> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> <Style x:Key="SnappedListViewStyle" TargetType="ListView"> <Setter Property="IsTabStop" Value="False"/> <Setter Property="TabNavigation" Value="Once"/> <Setter Property="IsSwipeEnabled" Value="True"/> <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/> <Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/> <Setter Property="ScrollViewer.HorizontalScrollMode" Value="Disabled"/> <Setter Property="ScrollViewer.VerticalScrollMode" Value="Enabled"/> <Setter Property="ScrollViewer.ZoomMode" Value="Disabled"/> <Setter Property="ItemContainerTransitions"> <Setter.Value> <TransitionCollection> <AddDeleteThemeTransition/> <ContentThemeTransition/> <ReorderThemeTransition/> <EntranceThemeTransition IsStaggeringEnabled="False"/> </TransitionCollection> </Setter.Value> </Setter> <Setter Property="ItemsPanel"> <Setter.Value> <ItemsPanelTemplate> <VirtualizingStackPanel Orientation="Vertical"/> </ItemsPanelTemplate> </Setter.Value> </Setter> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="ListView"> <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}"> <ScrollViewer x:Name="ScrollViewer" HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}" HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}" Padding="{TemplateBinding Padding}" TabNavigation="{TemplateBinding TabNavigation}" VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" VerticalScrollMode="{TemplateBinding ScrollViewer.VerticalScrollMode}" ZoomMode="{TemplateBinding ScrollViewer.ZoomMode}"> <ItemsPresenter Margin="10,-10,0,60"/> </ScrollViewer> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>
グループアイテム表示ページ(GroupedItemsPage.xaml)
<common:LayoutAwarePage x:Name="pageRoot" x:Class="StyleDefineTest.GroupedItemsPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:StyleDefineTest" xmlns:data="using:StyleDefineTest.Data" xmlns:common="using:StyleDefineTest.Common" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> <UserControl.Resources> <!-- Collection of grouped items displayed by this page --> <CollectionViewSource x:Name="groupedItemsViewSource" Source="{Binding Groups}" IsSourceGrouped="true" ItemsPath="Items" d:Source="{Binding ItemGroups, Source={d:DesignInstance Type=data:SampleDataSource, IsDesignTimeCreatable=True}}"/> </UserControl.Resources> <!-- This grid acts as a root panel for the page that defines two rows: * Row 0 contains the back button and page title * Row 1 contains the rest of the page layout --> <Grid Background="{StaticResource ApplicationPageBackgroundBrush}"> <Grid.RowDefinitions> <RowDefinition Height="140"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <!-- Back button and page title --> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Button x:Name="backButton" Click="GoBack" IsEnabled="{Binding Frame.CanGoBack, ElementName=pageRoot}" Style="{StaticResource BackButtonStyle}"/> <TextBlock x:Name="pageTitle" Text="{StaticResource AppName}" Grid.Column="1" Style="{StaticResource PageHeaderTextStyle}"/> </Grid> <!-- Horizontal scrolling grid used in most view states --> <GridView x:Name="itemGridView" AutomationProperties.AutomationId="ItemGridView" AutomationProperties.Name="Grouped Items" Margin="0,-3,0,0" ItemsSource="{Binding Source={StaticResource groupedItemsViewSource}}" ItemTemplate="{StaticResource Standard250x250ItemTemplate}" SelectionMode="None" IsItemClickEnabled="True" ItemClick="ItemView_ItemClick" Grid.Row="1" Style="{StaticResource LandscapeGridViewStyle}" d:LayoutOverrides="Width, Height"> <GridView.ItemsPanel> <ItemsPanelTemplate> <VirtualizingStackPanel Orientation="Horizontal"/> </ItemsPanelTemplate> </GridView.ItemsPanel> <GridView.GroupStyle> <GroupStyle> <GroupStyle.HeaderTemplate> <DataTemplate> <Grid Margin="1,0,0,6"> <Button AutomationProperties.Name="Group Title" Content="{Binding Title}" Click="Header_Click" Style="{StaticResource TextButtonStyle}"/> </Grid> </DataTemplate> </GroupStyle.HeaderTemplate> <GroupStyle.Panel> <ItemsPanelTemplate> <VariableSizedWrapGrid Orientation="Vertical" Margin="0,0,80,0"/> </ItemsPanelTemplate> </GroupStyle.Panel> </GroupStyle> </GridView.GroupStyle> </GridView> <!-- Vertical scrolling list only used when snapped --> <ListView x:Name="itemListView" AutomationProperties.AutomationId="ItemListView" AutomationProperties.Name="Grouped Items" ItemsSource="{Binding Source={StaticResource groupedItemsViewSource}}" ItemTemplate="{StaticResource Standard80ItemTemplate}" SelectionMode="None" IsItemClickEnabled="True" Grid.Row="1" Visibility="Collapsed" ItemClick="ItemView_ItemClick" Style="{StaticResource SnappedListViewStyle}" d:LayoutOverrides="Width, Height"> <ListView.GroupStyle> <GroupStyle> <GroupStyle.HeaderTemplate> <DataTemplate> <Grid Margin="7,7,0,0"> <Button AutomationProperties.Name="Group Title" Content="{Binding Title}" Click="Header_Click" Style="{StaticResource TextButtonStyle}"/> </Grid> </DataTemplate> </GroupStyle.HeaderTemplate> </GroupStyle> </ListView.GroupStyle> </ListView> <VisualStateManager.VisualStateGroups> <!-- Visual states reflect the application's view state --> <VisualStateGroup> <VisualState x:Name="FullScreenLandscape"/> <VisualState x:Name="Filled"/> <!-- The entire page respects the narrower 100-pixel margin convention for portrait --> <VisualState x:Name="FullScreenPortrait"> <Storyboard> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="backButton" Storyboard.TargetProperty="Style"> <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PortraitBackButtonStyle}"/> </ObjectAnimationUsingKeyFrames> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="itemGridView" Storyboard.TargetProperty="Style"> <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PortraitGridViewStyle}"/> </ObjectAnimationUsingKeyFrames> </Storyboard> </VisualState> <!-- The back button and title have different styles when snapped, and the list representation is substituted for the grid displayed in all other view states --> <VisualState x:Name="Snapped"> <Storyboard> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="backButton" Storyboard.TargetProperty="Style"> <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource SnappedBackButtonStyle}"/> </ObjectAnimationUsingKeyFrames> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="pageTitle" Storyboard.TargetProperty="Style"> <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource SnappedPageHeaderTextStyle}"/> </ObjectAnimationUsingKeyFrames> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="itemGridView" Storyboard.TargetProperty="Visibility"> <DiscreteObjectKeyFrame KeyTime="0" Value="Collapsed"/> </ObjectAnimationUsingKeyFrames> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="itemListView" Storyboard.TargetProperty="Visibility"> <DiscreteObjectKeyFrame KeyTime="0" Value="Visible"/> </ObjectAnimationUsingKeyFrames> </Storyboard> </VisualState> </VisualStateGroup> </VisualStateManager.VisualStateGroups> </Grid> </common:LayoutAwarePage>
これで、現在認識している問題は解決していると思います。
この問題は Consumer Preview で確認していますが、かなり大きな問題だと思いますので、今後のアップデートで何らかの対策が行われるのではないかと思います。
現状での対策方法もかなり面倒(スタイル定義以外は関係する画面全てで変更が必要)な物なので、すぐに対応が必要でなければ、今後のアップデートで対策が行われるか様子を見てから動いた方が良いでしょう。
適切なマージンを持った GridView のスタイル定義 (2)
※Release Preview での状況は以下の記事を参照してください。
Release Preview における GridView の問題点 (1) - rebuild
前回スタイル定義を行う事で適切なマージンを持った GridView が動作するようにしましたが、ポートレート表示においてマージンが適切に設定されない問題が残ってしまいました。
今回はポートレート表示での問題を解決したいと思います。
ポートレート表示での問題は、本来ポートレート表示になった時にマージンを変更しなければならないのにもかかわらず、スタイルに直接定義しているために変更が行われないために発生しています。
解決方法としてはいくつか考えられると思いますが、今回はポートレート表示用のスタイルを定義して切り替える事で対応してみたいと思います。
ポートレート表示用のスタイルは、先に作成したランドスケープ表示用のスタイルをそのままコピーして名前を変更し、ItemsPresenter のマージンをポートレート表示用の値に変更するだけです。
<Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="GridView"> <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}"> <ScrollViewer x:Name="ScrollViewer" HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}" HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}" Padding="{TemplateBinding Padding}" TabNavigation="{TemplateBinding TabNavigation}" VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" VerticalScrollMode="{TemplateBinding ScrollViewer.VerticalScrollMode}" ZoomMode="{TemplateBinding ScrollViewer.ZoomMode}"> <ItemsPresenter Margin="96,0,10,56"/> </ScrollViewer> </Border> </ControlTemplate> </Setter.Value> </Setter>
次にポートレート表示に変わったときにこのスタイルが使われるようにページの VisualState の記述を変更します。
元々 GridView の Margin を変更するようになっていた箇所を、 Style を変更するように変えます。
<VisualState x:Name="FullScreenPortrait"> <Storyboard> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="backButton" Storyboard.TargetProperty="Style"> <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PortraitBackButtonStyle}"/> </ObjectAnimationUsingKeyFrames> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="itemGridView" Storyboard.TargetProperty="Style"> <DiscreteObjectKeyFrame KeyTime="0" Value="{StaticResource PortraitGridViewStyle}"/> </ObjectAnimationUsingKeyFrames> </Storyboard> </VisualState>
これで、ポートレート表示でも適切なマージンが設定されるようになります。
実際にアプリを作成する場合は、さらにスナップ表示時の ListView についても同様にスタイル定義を行う必要があります。
次回、まとめとしてスナップ表示時も含んだスタイル定義を示します。
適切なマージンを持った GridView のスタイル定義 (1)
※Release Preview での状況は以下の記事を参照してください。
Release Preview における GridView の問題点 (1) - rebuild
以前(GridView の仮想化 - rebuild)書いた通り、現状の Visual Studio 11 Beta でテンプレートを使ってプロジェクトを作成した場合、ScrollViewer の中に GridView や ListView が配置されるため、いくつか動作に問題があります。
- コントロール内部のアイテムが仮想化されない。
- マウスのホイールでスクロールされない。
- カレントアイテム(白い枠が表示されている物)をキーボードで移動していってもスクロールされず、カレントアイテムが画面外になってしまう。
これらの問題を解決する方法としては、ScrollViewer の外に GridView / ListView を配置する事が考えられます。
これによって問題は解決しますが、今後はマージンが適切に設定されなくなってしまいます。
マージンの問題に関しては、これも前回紹介しましたがスタイルを定義して解決する方法を書かれている方がいます(程よい余白を持ったGridViewを定義しよう - かずきのBlog@hatena)。
今回はまずスタイルを定義してマージンを設定する手順を確認してみたいと思います。
スタイル定義
今回の作業は Blend の方がやり易いので Blend でグリッドアプリケーションのプロジェクトを作成します。
作成されるプロジェクトは Visual Studio で作成した場合と同じ物です。
プロジェクトを作成したら一度ビルドしておきます。
ビルドすると“オブジェクトとタイムライン”に画面上のコントロールツリーが表示されるようになります。
スタイル定義を行う前に、スタイルを定義するリソースディクショナリファイルを作成します。
これは、追加したファイルに定義する事で再利用可能とするためです。
リソースディクショナリはプロジェクトタブ上で「新しいアイテムの追加」を実行し、リソースディクショナリを選びます。
スタイルを定義する場合、元々のスタイルをコピーして編集すると簡単なので、まずはスタイルをコピーします。
コピーを行うにはコントロールツリー上の GridView を選択して右クリックし、表示されるメニューから「テンプレートの編集」-「コピーして編集」を実行します。
定義先は先程追加したリソースディクショナリを選択します。
これでリソースディクショナリに GridView の標準状態でのスタイルがコピーされましたので、これを変更します。
変更する箇所は、ScrollViewer の中に配置されている ItemsPresenter のみです。
ItemsPresenter は実際に GridView に追加された項目を表示する場所です。
この部分に適切なマージンが設定されていればいいので、ItemsPresenter のマージン設定を追加します。
設定する値は、テンプレートで GridView 自体に設定されている値です。
<Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="GridView"> <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}"> <ScrollViewer x:Name="ScrollViewer" HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}" HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}" Padding="{TemplateBinding Padding}" TabNavigation="{TemplateBinding TabNavigation}" VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}" VerticalScrollMode="{TemplateBinding ScrollViewer.VerticalScrollMode}" ZoomMode="{TemplateBinding ScrollViewer.ZoomMode}"> <ItemsPresenter Margin="116,0,40,46"/> </ScrollViewer> </Border> </ControlTemplate> </Setter.Value> </Setter>
このスタイルを GridView で使用されるように設定(スタイルをコピーした場合はすでに設定されています)すれば、適切なマージンを持った形で動作します。
GridView 単体でマージンを持った形で動作するようになりましたので、ScrollViewer から外に出します。
GridView の Grid.Row や Margin は、ScrollViewer に設定されていた通りにします。
ScrollViewer は削除してしまってかまいません。
この状態で実行すると適切なマージンが設定されている事、ホイールでのスクロール等が可能になっている事が確認できます。
これで画面が横になっている場合は問題なく動作しますが、画面を縦にしてポートレート表示にすると左右のマージンが大きすぎて問題があります。
元々のテンプレートを見るとわかりますが、ポートレート表示にした場合は、GridView のマージンを変更する事で適切な表示が行われるようになっています。
スタイルにマージンを定義してしまったため、ポートレート表示にした時のマージン変更が適切に行われなくなった事が問題の原因です。
次回は、ポートレート表示でも適切なマージンになるようにしてみたいと思います。
GridView の仮想化
※Release Preview での状況は以下の記事を参照してください。
Release Preview における GridView の問題点 (1) - rebuild
現在提供されている Visual Studio 11 Beta で“グリッド アプリケーション”や“分割アプリケーション”のテンプレートを使ってアプリを作成すると、GridView が配置されます。
この GridView の動作を確認してみると問題点があります。
ひとつは、GridView 上の項目表示が仮想化されていない事です。
ここでいう仮想化とは以前(ファイル一覧表示 - rebuild)取り上げたデータの仮想化(データを必要に応じて順次取得していく)とは違う話で、GridView に追加された項目を全て実体化(コントロールを作成)するのではなく、表示のために必要な部分のみ実体化し不要になった部分は破棄していく事によって使用する UI リソースを一定量に抑える仕組みです。
もう一つは、GridView のカレント項目(枠が表示される)をキーボードで移動させていくと、カレントの位置に対応してスクロールせず、カレント項目が画面外になってしまう事です。
問題点の確認(仮想化)
まず、問題点を確認します。
仮想化の確認を行うためには、GridView にある程度の数の項目を追加する必要がありますので、SampleDataSource クラスのコンストラクタの最後に以下のコードを追加して、項目が10000個になるようにします。
for (int i = 7; i < 10000; i++) { var group = new SampleDataGroup("Group-" + i.ToString(), "Group Title: " + i.ToString(), "Group Subtitle: " + i.ToString(), "Assets/DarkGray.png", "Group Description: "); this.ItemGroups.Add(group); }
次に、各項目の情報を取得しに来ている事を確認するために、SampleDataCommon クラスの Title プロパティの get にデバッグ出力を追加します。
public string Title { get { Debug.WriteLine(_title); return this._title; } set { this.SetProperty(ref this._title, value); } }
仮想化が行われているのであれば、画面に表示される項目+α程度の数だけ取得しにきて、それ以外はスクロールして表示範囲が変わらない限り取得されないはずです。
実行してみると、画面に表示される範囲を超えて取得に来ていて、1200個を超えたあたりで画面が勝手にスタート画面に戻ってしまいます(メモリ 2GB 環境で確認)。
仮想化されない事による問題は、追加された項目分のコントロールを作成し配置し終わらないと UI がブロックされたままになってしまう点と、コントロールを全て配置しようとするため、UI 用のリソースが大量に使用される点です。
確認用のサンプルで途中でスタート画面に戻ってしまうのは、リソースが不足した事が原因だと思われます。
※追記1
スタート画面に戻ってしまうのは、リソースの問題よりも起動が一定時間たっても終わらない事の方が原因かもしれません(最初に表示されるページの初期化中に UI がブロックされたままになるので、起動処理が長時間行われている状態になっている)。
問題点の確認(スクロール)
スクロールに関する問題は、仮想化確認用のサンプルで 10000 件追加していた箇所を、問題ない程度(100件等)に抑えれば確認できます。
アプリ実行後、Tab キーでフォーカスを移動すると、GridView 上のカレント項目に枠が表示されます。
この状態でカーソルキーで移動していくと、スクロールされずにカレント項目の枠が画面外に移動してしまいます。
スクロールしない事による問題は、キーボード操作による使い勝手が悪くなる点です。
問題発生原因の推測
このような問題が発生するのは、コントロールの配置に原因があると推測されます。
テンプレートで生成された xaml を見ると、GridView は ScrollViewer の中に配置されています。
<ScrollViewer x:Name="itemGridScrollViewer" AutomationProperties.AutomationId="GridScrollViewer" Grid.Row="1" Margin="0,-4,0,0" Style="{StaticResource HorizontalScrollViewerStyle}"> <GridView x:Name="itemGridView" AutomationProperties.AutomationId="ItemsGridView" AutomationProperties.Name="Items" Margin="116,0,116,46" ItemsSource="{Binding Source={StaticResource itemsViewSource}}" ItemTemplate="{StaticResource Standard250x250ItemTemplate}" SelectionMode="None" IsItemClickEnabled="True" ItemClick="ItemView_ItemClick"/> </ScrollViewer>
元々 GridView は内部に ScrollViewer を持っていて、追加された項目が多い場合は自分でスクロールを行うようになっていますし、項目の仮想化も行うようになっています(実際に仮想化を行っているのは GridView 内部に配置されている VirtualizingPanel から派生したクラスです)。
しかし、ScrollViewer の中に GridView を置いてしまうと、GridView 自体は追加された項目全てを表示できると判断し、自分でスクロールを行わないようになると考えられます。
このような状態になっているとすると、全ての項目が表示範囲に入ると判断されるので、仮想化処理自体は動作していても全てのコントロールを配置してしまい、仮想化されていない場合と同じ動きになってしまいます。
また、スクロールが不要と判断しているので、カーソル移動でカレント項目が移動してもスクロールはされません。
解決方法
推測した内容から考えると、解決するためには ScrollViewer の内部に GridView を配置するのをやめる必要があります。
単純に ScrollViewer をなくしてしまうとマージンの取り方が変わってしまうので、GridView 内部の Panel も含めて変更を行います。
<GridView Grid.Row="1" x:Name="itemGridView" AutomationProperties.AutomationId="ItemsGridView" AutomationProperties.Name="Items" Margin="0,-4,0,0" ItemsSource="{Binding Source={StaticResource itemsViewSource}}" ItemTemplate="{StaticResource Standard250x250ItemTemplate}" SelectionMode="None" IsItemClickEnabled="True" ItemClick="ItemView_ItemClick"> <GridView.ItemsPanel> <ItemsPanelTemplate> <WrapGrid Margin="116,0,116,46"/> </ItemsPanelTemplate> </GridView.ItemsPanel> </GridView>
変更した状態で実行すると、10000 件追加した場合でも画面が表示され操作可能になります。
最初に画面が表示された段階で取得されている項目は 24 個までで、以降は表示位置を移動する事で、表示範囲内に入る項目が取得されるようになります。
また、キーボードでカレント項目を移動していくと、それに合わせてスクロールされるようになります。
注意点
項目を 10000 件追加した場合でもとりあえず画面は表示されるようになりますが、この状態で正常に動作するわけではありません。
試しにキーボードでスクロールさせていくと、7000 件程度情報取得が行われた段階でアプリが落ちます。
これは、SampleDataCommon の Image プロパティが以下のように実装されており、情報の取得が行われると生成された Image が溜まってメモリが不足するためです。
public ImageSource Image { get { if (this._image == null && this._imagePath != null) { this._image = new BitmapImage(new Uri(SampleDataCommon._baseUri, this._imagePath)); } return this._image; } set { this._imagePath = null; this.SetProperty(ref this._image, value); } }
どうしても 10000 件追加する必要がある場合は、以下のように毎回 BitmapImage を作成して、保持しないようにする必要があります(保持していなければ、画面外に移動してコントロールが破棄された時に BitmapImage も消えるため、メモリが圧迫されない)。
public ImageSource Image { get { if (this._image == null && this._imagePath != null) { return new BitmapImage(new Uri(SampleDataCommon._baseUri, this._imagePath)); } return this._image; } set { this._imagePath = null; this.SetProperty(ref this._image, value); } }
ただし、10000 件も項目があるのであれば、一度に全て一覧に追加するのではなく、別の操作方法を考える必要があると思います。
ScrollViewer の取得
以上のように、ScrollViewer の中ではなく直接 GridView を配置する事で仮想化が動作するようになりますが、デメリットとして ScrollViewer が見えなくなり、スクロール位置の取得等ができなくなってしまいます。
この問題については、GridView が内部的に持っている ScrollViewer を取得する事で対応可能です。
以下のように、UI 要素の子をたどって指定した型のオブジェクトを返すメソッドを追加する事で、このメソッドを使って GridView 内の ScrollViewer を取得する事ができます。
public static T GetChild<T>(DependencyObject obj) where T : FrameworkElement { int count = VisualTreeHelper.GetChildrenCount(obj); for (int i = 0; i < count; i++) { var child = VisualTreeHelper.GetChild(obj, i); if (child is T) { return child as T; } var result = GetChild<T>(child); if (result != null) { return result; } } return null; } void ItemView_ItemClick(object sender, ItemClickEventArgs e) { var sv = GetChild<ScrollViewer>(itemGridView); if (sv != null) { Debug.WriteLine("offset = " + sv.HorizontalOffset.ToString()); } }
項目クリック時に ScrollViewer を取得して水平オフセットを参照してみましたが、正常に取得できていました。
補足
今回は GridView について取り上げましたが、ListView についても同じだと思います。
なお、解決方法を見るとテンプレートで生成される内容に問題があるように思われますが、標準的に用意されている物ですから、他に理由があって ScrollViewer 内に配置している可能性はあります。
そのため、今後のリリースで問題点が修正される(ScrollViewer 内部に配置しても問題ないようになる)可能性もありますので、Visual Studio がアップデートされた場合は再度確認が必要です。
※追記2
上記のように Panel にマージンを設定してしまうと、スクロールを止めた時に常に左側に余白が表示されてしまう事がわかりました。
このような問題がない方法を書いている方がいらっしゃいましたのでご紹介しておきます。
程よい余白を持ったGridViewを定義しよう - かずきのBlog@hatena
文字列の多言語化
Metro スタイルアプリで表示文字列を多言語化する方法について試してみたいと思います。
今回は“新しいアプリケーション”のテンプレートを使い、画面の中央に表示させた文字列を多言語化します。
多言語化前の画面は以下のようになります。
<Grid Background="{StaticResource ApplicationPageBackgroundBrush}"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" > <TextBlock Text="text1" FontSize="48"/> <TextBlock Text="text2" FontSize="48"/> </StackPanel> </Grid>
デフォルト言語
文字列を多言語化する場合、最初にデフォルト言語を決定する必要があります。
デフォルト言語として指定した言語のリソースは、OSの使用言語に対応するリソースが存在しなかった場合にも使用されます。
このため、多言語化する場合はデフォルト言語のリソースが必須となります。
デフォルト言語はプロジェクトファイル(.csproj)に記述されています。UI 上から変更する方法が見つからなかったので、一旦 Visual Studio を閉じて、テキストエディタでプロジェクトファイルを開いて変更します。
プロジェクトファイルの
日本語版の Visual Studio を使っている場合はデフォルトは日本語-日本(ja-JP)となっています。
多言語化して日本以外にアプリを配布する事を考えると、デフォルト言語が日本語では問題があると思われますので、英語-米国(en-US)に変更しておきます。
変更したら、Visual Studio を起動してプロジェクトファイルを読み込み直します。
デフォルト言語リソース追加
今回はプロジェクトルートに Resources フォルダーを作成し、その下にリソースを追加していきます。
最初にデフォルト言語のリソースを追加します。
リソースを追加するためには、ます言語名-国名のフォルダーを作成します(言語名のみの指定も可能です)。
英語-米国を追加するので en-US という名前になります。
続いて作成したフォルダー内に、リソースファイルを追加します。
追加すると、名前と値を入力する画面が表示されます。
ここで、その言語の文字列を入力していきます。
文字列の名前の付け方は2種類あります。
ひとつは、{Uid}.{プロパティ名} という形式の物です。
Uid は xaml 上でコントロールに指定する識別情報です。プロパティ名は文字列を設定する対象になります。
例えば Uid として Title と指定した TextBlock に文字列を設定する場合は、リソースの名前を Title.Text とします。
<Grid Background="{StaticResource ApplicationPageBackgroundBrush}"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" > <TextBlock Text="text1" FontSize="48" x:Uid="Title"/> <TextBlock Text="text2" FontSize="48"/> </StackPanel> </Grid>
ここまで追加した段階で実行すると、リソースとして指定した文字列が表示されます。
なお、リソースで指定した文字列が表示されるのは実行時のみです。Visual Studio でデザインしている段階では、Text プロパティに指定した文字列がそのまま表示されます。
もうひとつの名前の付け方は、プロパティに関係なく付ける物です。
こちらは、xaml 上で直接指定するのではなく、ソース上で取得して使用します。
例えば、Body という名前で文字列を追加します。
この段階で文字列の取得は可能ですが、ソース上でコントロールに設定しますのでコントロールに名前を付けておきます。
<Grid Background="{StaticResource ApplicationPageBackgroundBrush}"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" > <TextBlock Text="text1" FontSize="48" x:Uid="Title"/> <TextBlock Text="text2" FontSize="48" x:Name="bodyText"/> </StackPanel> </Grid>
ソース上で ResourceLoader を使って文字列を取得してコントロールに設定するようにします。
protected override void OnNavigatedTo(NavigationEventArgs e) { var rl = new ResourceLoader(); bodyText.Text = rl.GetString("Body"); }
実行すると以下のようにリソースに指定した文字列が表示されます。
なお、プロパティ名を指定する形式のリソースは ResourceLoader では読み込めませんので注意が必要です。
非同期メソッド呼び出し後のスレッドについて
Metro スタイルアプリを作成する場合、時間がかかる処理は全て非同期メソッドを呼び出す事になります。
UI スレッドから、await を指定して非同期メソッドを呼び出した場合、非同期メソッド内の処理は別スレッドで処理され、UI スレッドをブロックしないようになっています。
そして、非同期メソッド後の処理は UI スレッドに戻って実行されるようになっていて、そのままコントロールにアクセスできます。
例として、以前 HTTP 通信の確認を行った時のソースを一部取り出してみます。
HTTP通信と検索機能 - rebuild
public async void SearchData(string keyword) { // 指定された検索キーワードを使ってリクエストURLを生成 var sb = new StringBuilder(); sb.Append("http://api.atnd.org/events/?count=30&format=json"); if (!String.IsNullOrEmpty(keyword)) { sb.AppendFormat("&keyword={0}", keyword); } // リクエストを送信して結果文字列を取得 var client = new HttpClient(); client.MaxResponseContentBufferSize = 1024 * 1024 * 10; var result = await client.GetStringAsync(sb.ToString()); // レスポンスを解析して一覧に追加 var rootElement = Windows.Data.Json.JsonObject.Parse(result); var eventElements = rootElement.GetNamedArray("events"); foreach (var elem in eventElements) { var title = elem.GetObject().GetNamedString("title"); var started_at = elem.GetObject().GetNamedString("started_at"); var place = elem.GetObject().GetNamedString("place"); var event_url = elem.GetObject().GetNamedString("event_url"); var item = new EventDataItem(title, started_at, place, event_url); _source.Items.Add(item); } }
このソースのメソッドを UI スレッドから呼び出した場合、GetStringAsync メソッドが非同期で実行され、その後の処理は UI スレッドで実行されます。
このため、最後の _source.Items.Add(item) の部分をそのまま実行しても動作します(この部分はコントロールにバインドされているデータソースへの追加になりますので、UI スレッド以外から呼び出すと例外が発生します)。
これで動作上は問題ありませんが、例としてあげたソースのように、通信処理後になんらかの処理を行ってからコントロールに設定するような事を考えると、通信後の処理で UI スレッドで実行する必要があるのはコントロールへの設定だけです。
それ以外の通信結果の解析部分については、UI スレッドで処理する必要はありませんし、UI スレッドで処理してしまうと、その間 UI をブロックしてしまうという問題もあります。
そのため、非同期処理後に行っている処理に時間がかかるような場合は、非同期処理後に UI スレッドに戻らないように指定をします。
非同期処理後に UI スレッドに戻らないようにするためには、Task クラスの ConfigureAwait メソッドに引数として false を指定して呼び出します。
Task クラスは非同期メソッドの戻り値として返されますので、非同期メソッドに続けて記述すれば呼び出せます。
例としてあげたソースを変更してみます。
public async void SearchDataEx(string keyword) { // 指定された検索キーワードを使ってリクエストURLを生成 var sb = new StringBuilder(); sb.Append("http://api.atnd.org/events/?count=30&format=json"); if (!String.IsNullOrEmpty(keyword)) { sb.AppendFormat("&keyword={0}", keyword); } // リクエストを送信して結果文字列を取得 var client = new HttpClient(); client.MaxResponseContentBufferSize = 1024 * 1024 * 10; var result = await client.GetStringAsync(sb.ToString()).ConfigureAwait(false); // レスポンスを解析して一覧に追加 var rootElement = Windows.Data.Json.JsonObject.Parse(result); var eventElements = rootElement.GetNamedArray("events"); foreach (var elem in eventElements) { var title = elem.GetObject().GetNamedString("title"); var started_at = elem.GetObject().GetNamedString("started_at"); var place = elem.GetObject().GetNamedString("place"); var event_url = elem.GetObject().GetNamedString("event_url"); var item = new EventDataItem(title, started_at, place, event_url); Dispatcher.InvokeAsync(CoreDispatcherPriority.Normal, (s, a) => { _source.Items.Add(item); }, this, null); } }
GetStringAsync に続けて ConfigureAwait(false) の呼び出しを行っています。
これで、以降の処理は UI スレッドではなく別スレッドで実行されるようになります。
UI スレッド以外で動作するようになったため、最後の _source.Items.Add(item) は
Dispatcher を使って UI スレッドで動作するようにする必要があります。
非同期動作に関しては MSDN マガジンに詳しい説明が載っていますので、詳細はそちらを参考にして下さい。
http://msdn.microsoft.com/ja-jp/magazine/hh456402.aspx
ローミングとトースト
Metro スタイルアプリには、同じ Microsoft アカウント(Windows Live ID)でログオンしてアプリを使った時にデータを同期させるローミング機能があります。
この機能を使って複数マシンでデータを同期させてみたいと思います。
また、画面上の通知を行うトースト機能も使ってみます。
基本構成
今回は“新しいアプリケーション”のテンプレートを使って作成します。
作成したアプリに“基本ページ”を追加し、起動時に追加したページが表示されるようにしておきます(App.xaml.cs を変更)。
protected override void OnLaunched(LaunchActivatedEventArgs args) { var rootFrame = new Frame(); rootFrame.Navigate(typeof(BasicPage)); Window.Current.Content = rootFrame; Window.Current.Activate(); }
画面設計
タイトルの下に保存、読み込みを行うためのボタンと入力用の TextBox を追加します。
<Grid Grid.Row="1" Margin="120,0,120,46"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <Grid Grid.Row="0" Margin="0, 0, 0, 20"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <Button x:Name="saveButton" Grid.Column="0" Content="保存" Width="150" Margin="0, 0, 20, 0" Click="saveButton_Click"/> <Button x:Name="loadButton" Grid.Column="1" Content="読込" Width="150" Click="loadButton_Click"/> </Grid> <TextBox Grid.Row="1" x:Name="bodyText" TextWrapping="Wrap" AcceptsReturn="True" /> </Grid>
アプリケーションマニフェスト
トーストを使用するためにアプリケーションマニフェストの設定を行います。
マニフェストを開いて、“アプリケーション UI”タブの“通知”のところにある“トースト対応”を“はい”に変更します。
この設定を行わないとトースト通知を呼び出しても何も表示されないので注意が必要です(例外も発生せず表示されないだけなので気付きにくいです)。
なお“タイル”の“背景色”も変更しています。この色がトーストの背景でも使用されます。
トースト通知
トースト通知を行う処理を追加します。
protected override void OnNavigatedTo(NavigationEventArgs e) { ApplicationData.Current.DataChanged += Current_DataChanged; } void Current_DataChanged(ApplicationData sender, object args) { NotifyUpdate(false); } private void NotifyUpdate(bool isLocal) { string text1 = "データを更新しました"; if (!isLocal) { text1 = "データが更新されました"; } string test2 = String.Format("RoamingStorage : Usage = {0} KB / Quota = {1} KB", ApplicationData.Current.RoamingStorageUsage, ApplicationData.Current.RoamingStorageQuota); // データ更新をトーストで通知 NotifyToast(text1, test2); } private void NotifyToast(string test1, string test2) { // 文字列を2行表示するトーストテンプレートを取得 var toastXml = ToastNotificationManager.GetTemplateContent(ToastTemplateType.ToastText02); // テキストノードを追加 var textNodes = toastXml.GetElementsByTagName("text"); textNodes[0].AppendChild(toastXml.CreateTextNode(test1)); textNodes[1].AppendChild(toastXml.CreateTextNode(test2)); // トースト表示 var toast = new ToastNotification(toastXml); ToastNotificationManager.CreateToastNotifier().Show(toast); }
まず、ページ表示時の OnNavigatedTo で DataChanged イベントのハンドラを登録します。
このイベントはローミングデータが更新された時に発生します。
イベントが発生したらトースト通知を行います。
トースト通知を行うために通知用のテンプレートを取得します。今回はテキストが2行表示できる物を使用します。
取得したテンプレートにテキストを追加して、ToastNotifier を使って表示します。
ローミング設定
ローミング設定を使ってテキストの保存や読み込みを行います。
自分で保存した時もトースト通知を行います。
private void saveButton_Click(object sender, RoutedEventArgs e) { // ローミング設定にテキストを保存 ApplicationData.Current.RoamingSettings.Values["bodyText"] = bodyText.Text; NotifyUpdate(true); } private void loadButton_Click(object sender, RoutedEventArgs e) { // ローミング設定から読み込んだテキストを反映 var text = ApplicationData.Current.RoamingSettings.Values["bodyText"] as string; if (String.IsNullOrEmpty(text)) { bodyText.Text = ""; } else { bodyText.Text = text; } }
ここでは、RoamingSettings を使って保存していますが RoamingFolder を取得してファイルを作成する事もできます。
実行
同じ Microsoft アカウント でログオンした2台のマシンで作成したアプリを実行します。
まず、片方のマシンでテキストを入力し保存します。
すると、保存した事がトーストで通知されます。
保存したマシンとは別のマシンでアプリを起動したまましばらく待っていると、更新された事が通知されます。
通知された後で読み込みを行うと、別マシンで保存したテキストが表示されます。
ローミングの動作について
ローミングについて動作させた結果からわかった事、推測できる事をまとめます。
- アプリから保存したローミングデータはまずローカルに保存され、その後OSがクラウド上のデータを更新しているようです。他マシンでデータ更新を行った場合、クラウド上のデータ更新が行われるまでは、ローカルにある以前のデータが取得されます。
- 保存したデータが実際にクラウド上に反映されるまでにはしばらくかかります。試した範囲では、アプリで保存後10分程度たってから他マシンで更新イベントが発生しました。また、保存後にマシンをスリープして再開すると、そのタイミングでデータ更新イベントが発生しました(10分たっていなくても)。
- 更新イベントは自マシンで保存した場合でも発生します。
以上のように、ローミング設定は瞬時に反映されるような物ではありませんので、それを考慮して使用する必要があります。
短い間隔で2台のマシンで更新すれば上書きされてどちらかの更新結果が反映されないという状態が比較的簡単に発生しますので、絶対に上書きされてはいけないようなデータの保存には使用できないと思います。
トーストの動作について
トーストについてもわかった事をまとめます。
- PCの詳細設定に通知に関する設定があり、アプリ毎に通知を表示するかどうかをオン/オフできます。オフにするとトーストが表示されなくなります。
トーストはユーザーがオフにできますので、重要な情報の通知には使用できません。