しっぽを追いかけて

ぐるぐるしながら考えています

Unity と猫の話題が中心   掲載内容は個人の私見であり、所属組織の見解ではありません

さらに UI の仮想化(2)

UI に表示されるデータの仮想化(2) - しっぽを追いかけて では段階的な仮想化を行いましたが、まだまだ UX を向上させるためにできることがあります!

それは ISupportIncrementalLoading の段階的なデータ読み込みに時間がかかる場合、スクロールに時間がかかるという欠点の解消についてです

例えば 前回の記事 の下記の部分

    /// <summary>
    /// データを取得する
    /// </summary>
    /// <returns>写真情報</returns>
    private IEnumerable<ItemContainerViewModel> GetItems()
    {
        foreach (var photo in App.AppSettings.Data.Items)
        {
            var item = PhotoViewModel.ConvertFrom(photo);

            yield return new ItemContainerViewModel()
            {
                UniqueId = Guid.NewGuid().ToString(),
                ContentId = item.UniqueId,
                Content = item,
                IsClickable = true,
            };
        }
    }

これを下記のように変えてみます

    /// <summary>
    /// データを取得する
    /// </summary>
    /// <returns>写真情報</returns>
    private IEnumerable<ItemContainerViewModel> GetItems()
    {
        foreach (var photo in App.AppSettings.Data.Items)
        {
            // 読み込みに時間がかかるシミュレート
            Task.Delay(50).Wait();

            yield return new ItemContainerViewModel()
            {
                UniqueId = Guid.NewGuid().ToString(),
                ContentId = item.UniqueId,
                Content = PhotoViewModel.ConvertFrom(photo);,
                IsClickable = true,
            };
        }
    }

このようにデータの読み込みに時間がかかる場合を再現した場合、次の動画のような動作になります


引っかかるスクロール - YouTube

スクロール時に引っかかるためもう追加の表示がないかのように見えてしまいます

そこで、前回の Presenter のコードを下記のように変更します

/// <summary>
/// 画面に遷移したときの処理
/// </summary>
/// <param name="navigationParameter">遷移パラメータ</param>
/// <param name="navigationMode">遷移モード</param>
/// <param name="viewModelState">画面状態データ</param>
public override void OnNavigatedTo(object navigationParameter, NavigationMode navigationMode, Dictionary<string, object> viewModelState)
{
    base.OnNavigatedTo(navigationParameter, navigationMode, viewModelState);

    this.isLoading = false;
    this.loadingQueue = new BlockingCollection<ItemContainerViewModel>();
    this.isLoading = true;

    if (this.ViewModel.Items.Count > 0)
    {
        foreach (var item in this.ViewModel.Items.Where(i => i.Content == null))
        {
            this.loadingQueue.Add(item);
        }

        this.ViewModel.Items.Presenter = this;
        this.StartLoading();

        return;
    }

    this.ViewModel.Items = new IncrementalObservableCollection<ItemContainerViewModel>(this.GetItems()) { Presenter = this };
    this.StartLoading();
}

/// <summary>
/// 読み込み対象キュー
/// </summary>
private BlockingCollection<ItemContainerViewModel> loadingQueue = new BlockingCollection<ItemContainerViewModel>();

/// <summary>
/// データを取得する
/// </summary>
/// <returns>写真情報</returns>
private IEnumerable<ItemContainerViewModel> GetItems()
{
    foreach (var photo in App.AppSettings.Data.Items)
    {
        var item = new ItemContainerViewModel()
        {
            UniqueId = Guid.NewGuid().ToString(),
            ContentId = photo.UniqueId,
            Content = null,
            IsClickable = true,
        };

        // 読み込み対象のキューに追加
        this.loadingQueue.Add(item);

        // プレースホルダーとして追加
        yield return item;
    }
}

/// <summary>
/// 読み込み中フラグ
/// </summary>
private bool isLoading = true;

/// <summary>
/// 遅延読み込み処理の開始
/// </summary>
/// <returns>遅延読み込みの Task</returns>
private void StartLoading()
{
    Task.Run(() =>
    {
        while (this.isLoading)
        {
            var container = this.loadingQueue.Take();

            // 読み込み時間がかかる想定
            Task.Delay(50).Wait();

            if (this.isLoading)
            {
                // 中身を詰める
                this.View.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
                {
                    container.Content =
                        PhotoViewModel.ConvertFrom(
                            App.AppSettings.Data.Items.FirstOrDefault(i => container.ContentId.Equals(i.UniqueId)));
                });
            }
        }
    });
}

#region ISupportIncrementalLoading
/// <summary>
/// 読み込めるデータがこれ以上あるかどうか
/// </summary>
public bool HasMoreItems
{
    // 無限に取得する
    get { return true; }
}

/// <summary>
/// ビューから段階的な仮想化を初期化する
/// </summary>
/// <param name="count">読み込まれた項目の数</param>
/// <returns>読み込み操作の折り返し結果</returns>
public IAsyncOperation<LoadMoreItemsResult> LoadMoreItemsAsync(uint count)
{
    return Task.Run(() =>
    {
        var items = this.GetItems();

        foreach (var item in items)
        {
            this.View.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
            {
                this.ViewModel.Items.Add(item);
            });
        }

        return new LoadMoreItemsResult() { Count = (uint)items.Count() };
    })
    .AsAsyncOperation<LoadMoreItemsResult>();
}
#endregion //ISupportIncrementalLoading

ポイントは次の部分

/// <summary>
/// 読み込み対象キュー
/// </summary>
private BlockingCollection<ItemContainerViewModel> loadingQueue = new BlockingCollection<ItemContainerViewModel>();

/// <summary>
/// データを取得する
/// </summary>
/// <returns>写真情報</returns>
private IEnumerable<ItemContainerViewModel> GetItems()
{
    foreach (var photo in App.AppSettings.Data.Items)
    {
        var item = new ItemContainerViewModel()
        {
            UniqueId = Guid.NewGuid().ToString(),
            ContentId = photo.UniqueId,
            Content = null,
            IsClickable = true,
        };

        // 読み込み対象のキューに追加
        this.loadingQueue.Add(item);

        // プレースホルダーとして追加
        yield return item;
    }
}

/// <summary>
/// 読み込み中フラグ
/// </summary>
private bool isLoading = true;

/// <summary>
/// 遅延読み込み処理の開始
/// </summary>
/// <returns>遅延読み込みの Task</returns>
private void StartLoading()
{
    Task.Run(() =>
    {
        while (this.isLoading)
        {
            var container = this.loadingQueue.Take();

            // 読み込み時間がかかる想定
            Task.Delay(50).Wait();

            if (this.isLoading)
            {
                // 中身を詰める
                this.View.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
                {
                    container.Content =
                        PhotoViewModel.ConvertFrom(
                            App.AppSettings.Data.Items.FirstOrDefault(i => container.ContentId.Equals(i.UniqueId)));
                });
            }
        }
    });
}

データを読み込む際に読み込む件数分の中身が空(ItemContainerViewModel.Content = null)の ViewModel をコレクションに追加するとともに、BlockingCollection のキューを利用して別スレッドで実際のデータの中身を後から詰めるようにしている点です

BlockingCollectionIProducerConsumerCollection のインターフェースを実装したスレッドセーフなコレクションで、Take メソッドで1つのアイテムを取り出す際にコレクションが空の場合はスレッドをブロックして待機する機能を持っています

そのため、StartLoading メソッド内でキューに中身が入るとそのデータを読みに行き、読み込みが完了したら随時 ViewModel にデータをセットするという処理を繰り返すようになっています

あとは画面側をこの遅延表示に対応させます

<GridView.ItemTemplate>
    <DataTemplate>
        <Grid>
            <Grid Visibility="{Binding Content, Converter={StaticResource NullableToVisibilityConverter}}">
                <Grid.Clip>
                    <RectangleGeometry Rect="0,0,272,272" />
                </Grid.Clip>
                <Grid.ChildrenTransitions>
                    <TransitionCollection>
                        <AddDeleteThemeTransition />
                    </TransitionCollection>
                </Grid.ChildrenTransitions>
                <Image HorizontalAlignment="Center"
                        VerticalAlignment="Top"
                        AutomationProperties.Name="{Binding Content.Title}"
                        Source="{Binding Content.ImageUri}"
                        Stretch="UniformToFill" />

                <Border VerticalAlignment="Bottom"
                        Background="{ThemeResource ListViewItemOverlayBackgroundThemeBrush}"
                        Padding="15">
                    <TextBlock FontSize="18"
                                Foreground="{ThemeResource ListViewItemOverlayForegroundThemeBrush}"
                                Style="{StaticResource BaseTextBlockStyle}"
                                TextTrimming="CharacterEllipsis"
                                TextWrapping="NoWrap">
                        <Run Text="Photo: " /><Run Text="{Binding Content.Title}" /><Run Text=" by " /><Run Text="{Binding Content.Owner}" />
                    </TextBlock>
                </Border>
            </Grid>
            <Border Background="{ThemeResource ListViewItemOverlayBackgroundThemeBrush}" Visibility="{Binding Content, Converter={StaticResource NullableToVisibilityConverter}, ConverterParameter='True'}">
                <ProgressRing Width="100"
                                Height="100"
                                HorizontalAlignment="Center"
                                VerticalAlignment="Center"
                                IsActive="True" />
            </Border>
        </Grid>
    </DataTemplate>
</GridView.ItemTemplate>

といってもやることは上記のように ItemTemplate の中で Content の中身が null かどうかによって ProgressRing を表示するかどうかを制御するだけです

Visibility の制御に利用している NullableToVisibilityConverter は Binding 対象が null でない場合に Visible に変換し、null の場合に Collapsed に変換するコンバータで、ConverterParameter = True の場合はその逆の結果に変換するようなものです

読み込みが完了した際にちょっとした動きがつくようにこっそり AddDeleteThemeTransition のインタラクションも追加しています

上記の修正を行った場合の動作の様子は次の動画の通りです


プレースホルダーによる遅延ローディング - YouTube

スクロールがひっかかることなくまずはインジケータが表示され、読み込みが終わったものから中身が表示されていくのがわかると思います