🐋

Blazorで簡単にリストの追加/削除アニメーションを実現する [BlazorTransitionGroup]

2022/11/15に公開

初めに

Blazorでリストの削除や追加にアニメーションを付けるには少し工夫する必要があります.
Reactの場合も同様ですが,react-transition-groupというライブラリを利用すれば比較的簡単かつ宣言的に実現できます.

react-transition-group
https://github.com/reactjs/react-transition-group

アニメーションはより良いUI/UXを求めるにはとても重要になってきます.
アニメーションのためにコードが長くなったり,ViewとModelの分離ができなくなるのも面倒です.
現状調べた感じreact-transition-groupと同等の機能があるライブラリは存在しませんでした.
したがってBlazorでもreact-transition-groupのような機能が欲しいと考えたため自作することにしました.

作ったもの

Blazorでリストのアニメーションを実現するライブラリを作成しました.
以下のようなアニメーションの実装が楽になります.

トランジションの開始と終了を定義するのに役立つ単純なコンポーネントを公開します.
それ自体でスタイルをアニメーション化することはありません.
代わりに、遷移段階を公開し、クラスとグループ要素を管理し、便利な方法で DOM を操作して、実際の視覚的な遷移の実装をより簡単にします.

overview.gif

Githubリポジトリ(⭐をいただけるとメンテのモチベーションに繋がります!)
https://github.com/le-nn/blazor-transition-group

Nugetパッケージ
https://www.nuget.org/packages/BlazorTransitionGroup

デモページ
https://le-nn.github.io/blazor-transition-group/

インストール

dotnet add package BlazorTransitionGroup

またはNuget package managerで検索してください

利用方法

このライブラリは特にCSSや,アニメーション定義を含まないので,アニメーションについては自分で実装しなければいけません.

例えばサンプルのGrowアニメーションは以下の通りです.
コンポーネントの詳しいリファレンスはREADMEに記載しています.

// GrowTransition.razor
@using BlazorTransitionGroup

@inherits Transition

<div style="@ActualStyle" class="@Class">
    @ChildContent?.Invoke(TransitionState)
</div>

@code {
    string ActualStyle => $"opacity: {Opacity};transform:scale({Size});transition:opacity {Duration / 2}ms ease-in-out,transform {Duration}ms ease-in-out;{Style}";

    string Opacity => TransitionState switch {
        TransitionState.Entering or TransitionState.Entered => "1",
        _ => "0",
    };

    string Size => TransitionState switch {
        TransitionState.Entering or TransitionState.Entered => "1",
        _ => "0",
    };

    double Duration => TransitionState switch {
        TransitionState.Entering or TransitionState.Entered => DurationEnter,
        _ => DurationExit,
    };

    string HeightStyle => TransitionState switch {
        TransitionState.Entering or TransitionState.Entered => "100%",
        _ => "0%",
    };

    [Parameter]
    public string Height { get; set; } = "auto";

    [Parameter]
    public string Width { get; set; } = "auto";

    [Parameter]
    public string? Style { get; set; }

    [Parameter]
    public string? Class { get; set; }

    protected override void OnParametersSet() {
        base.OnParametersSet();
    }
}

作成したTransitionをグループ化してリストのアニメーションにするには,TransitionGroupでラップします.
気を付けなければいけないのは,TransitionTransitionGroupの直下に置かなければいけません.
そうでない場合は子ノードが削除された際にアニメーションが終了するのを待たず,すぐにツリーから消えてしまいます.

OK

<TransitionGroup>
    @foreach(var item in items) {
       <HogeTransition @key="item" />
    }
</TransitionGroup>

NG

<TransitionGroup>
    @foreach(var item in items) { 
        <div @key="item">
            <HogeTransition @key="item" />
        </div>
    }
</TransitionGroup>

また必ず@keyを指定するのを忘れないでください.

@using BlazorTransitionGroup

<TransitionGroup>
    @foreach (var (i, text, id) in _items) {
        @if (i % 2 is 0) {
            <GrowTransition @key="@($"{text}-{id}")" Context="state">
                <div class="item d-flex p-3 align-items-center shadow mt-3 rounded-3 bg-white">
                    <button class="btn btn-danger" @onclick="@(() => Remove((i, text, id)))">
                        <i class="oi oi-trash" />
                    </button>
                    <div class="p-1 mx-3" style="width:100px;">@state</div>
                    <div class="p-1 mx-3">@text</div>
                </div>
            </GrowTransition>
        }
        else {
            <SlideTransition @key="@($"{text}-{id}")">
                <div class="item d-flex p-3 align-items-center shadow mt-3 rounded-3 bg-white">
                    <button class="btn btn-danger" @onclick="@(() => Remove((i, text, id)))">
                        <i class="oi oi-trash" />
                    </button>
                    <div class="p-1 mx-3" style="width:100px;"></div>
                    <div class="p-1 mx-3">@text</div>
                </div>
            </SlideTransition>
        }
    }
</TransitionGroup>

<div class="d-flex mt-4">
    <input @bind-value="_text " />
    <button class="btn btn-primary" @onclick="Add"> ADD</button>
</div>

@code {
    string _text = "";
    int _i = 3;

    List<(int Index, string Text, Guid Key)> _items = new() {
        (0, "item 1", Guid.NewGuid()),
        (1, "item 2", Guid.NewGuid()),
        (2, "item 3", Guid.NewGuid()),
    };

    void Add() {
        if (string.IsNullOrWhiteSpace(_text)) {
            return;
        }

        _items.Add((_i++, _text, Guid.NewGuid()));
        _text = "";
    }

    void Remove((int, string, Guid) text) {
        _items.Remove(text);
    }
}

デモについて全ソースコードはこちらにあります.
https://github.com/le-nn/blazor-transition-group/tree/main/samples/BlazorTransitionGroup.Samples/Demo

動作原理について(おまけ)

ReactとBlazorのVirtual DOM Treeの仕組みの違いにより,Blazorではreact-transition-groupで行わていることを簡単に実現することはできません.
Reactで入れ子のComponentを表現する際,RenderTreeのノード(children: ReactNode[] )を直接触ることができるため,
Treeを構築する際にmap関数で
簡単にフィルターしたり別の配列にキャッシュしたりすることができます.
Blazorの場合は一筋縄にはいきません.
ReactのReactNodeとBlazorのRenderFragmentの仕組みは根本的違います.
BlazorではReactNodeのようにRenderTreeのItemを直接扱う方法がありません.
BlazorのRenderFragmentはDelegateであるためです.

React

function Component(props) {
  return (
    <div>
        {props.children} // ReactNode array
    </div>
  );
}

Blazor

<div>
@ChildContent // Delegate
</div>
@code {
    RenderFragment? ChildContent { get; set; }
}

そこで私は,フレームワークにより生成されたVirtual DOM Tree(RenderTreeFrames)をさらにラップするラッパーを作成して,
遅延ビルドにより,削除されたノードの復元を実現することにしました.

1.まずRenderTreeBuilderから1次元配列のRenderTreeFrameを再評価することにより,
木構造として表現するためのラッパーを作成しキャッシュしておきます.

2.次にTreeの状態が更新されたとき,
再びキャッシュしたRenderTreeFrameのラッパーからTreeを条件に応じて再構築します.

それにより新しいRenderTreeBuilderをViewにレンダリングすることができます.
Treeが更新されたとき,たとえRenderTreeBuilderから削除されていても,
アニメーションが行われているノード(子component)は,
キャッシュからFrameを復元しアニメーションが終了するまで待つことができます.

削除されたノードを復元するラッパー(RenderFrameBuilder)
https://github.com/le-nn/blazor-transition-group/blob/main/src/BlazorTransitionGroup/Internal/RenderFrameBuilder.cs#L91

RenderFrameBuilderを作成する関数
https://github.com/le-nn/blazor-transition-group/blob/main/src/BlazorTransitionGroup/TransitionGroup.cs#L120

現状では,TransionはTransitionGroupの直下に置かなければ動作しないため完全にreact-transition-groupを再現出来ていません.
また,RenderTreeのラッパーを作成しさらにRenderTreeFrameを再構築するので多少のパフォーマンスは犠牲にしなければいけません.
しかし簡単にではありますが,200アイテム以上かつ少し複雑な子コンポーネントを持つTransitionGroupのレンダー時間を計測したところ約5ms以下だったのでオーバーヘッドはほとんど気にならないものとします.

結論

react-transition-groupに近い体験ができるようになるため,
アニメーションを宣言的に実現するには大いに役立つでしょう😎

Discussion