2
votes

I'm trying to create a Tab component that I can databind to a singleton model object.

So I have a state object

public class AppState
{
    private List<TabValue> _tabValues = new List<TabValue>();
    private TabValue _selectedValue = null;

    public AppState()
    {
        Add(new TabValue { Title = "Tab 0", Contents = "Contents 0" });
        Add(new TabValue { Title = "Tab 1", Contents = "Contents 1" });
        Add(new TabValue { Title = "Tab 2", Contents = "Contents 2" });
    }

    public TabValue[] TabValues { get { return _tabValues.ToArray(); } }

    public void Add(TabValue tabValue)
    {
        this._tabValues.Add(tabValue);
        if (_selectedValue == null)
            _selectedValue = tabValue;
        OnChanged();
    }

    public TabValue SelectedValue
    {
        get { return _selectedValue; }
        set { _selectedValue = value; OnChanged(); }
    }

    public event Action Changed;
    protected virtual void OnChanged() { if (Changed != null) Changed(); }
}

public class TabValue
{
    public string Title { get; set; }
    public string Contents { get; set; }
}

I want to render this as a Tab control, and I want 2 way binding, i.e. when I change the AppState object the UI reflects it, and when the active tab in the UI Tab control is changed the state in the AppState is updated.

@inject AppState MyAppState

@{Debug.WriteLine("Page.BuildRenderTree " + MyAppState.TabValues.Count());}
<TabControl TabPageChanged="@MyTabIndexChanged">
    <ChildContent>
        @foreach (var tabData in MyAppState.TabValues)
        {
            Debug.WriteLine("Page.BuildRenderTree TabPage: " + tabData.Title);
            <TabPage @key="@tabData" Text="@tabData.Title" Selected="@(MyAppState.SelectedValue == tabData)">
                @tabData.Contents
            </TabPage>
        }
    </ChildContent>
</TabControl>

@code {
    private void MyTabIndexChanged(int tabIndex)
    {
        MyAppState.SelectedValue = MyAppState.TabValues[tabIndex];
        this.StateHasChanged();
    }

    protected override void OnInitialized()
    {
        MyAppState.Changed += this.StateHasChanged;
    }

    public void Dispose()
    {
        MyAppState.Changed -= this.StateHasChanged;
    }
}

I've added this code to the 'counter' page in the default VS generated project.

The TabControl code is based on the Blazor-Univerity code.

@{Debug.WriteLine("TabControl.BuildRenderTree " + Pages.Count);}
<div class="btn-group" role="group">
    @foreach (TabPage tabPage in Pages)
    {
        Debug.WriteLine("TabControl.BuildRenderTree TabHeader " + tabPage.Text);
        <button type="button"
                class="btn @GetButtonClass(tabPage)"
                @onclick="@(() => OnTabPageChanged(tabPage))">
            @tabPage.Text
        </button>
    }
</div>
<CascadingValue Value="this">
    @{Debug.WriteLine("TabControl.BuildRenderTree ChildContent " + Pages.Count);}
    @ChildContent
</CascadingValue>

@code {
    [Parameter]
    public RenderFragment ChildContent { get; set; }

    [Parameter]
    public EventCallback<int> TabPageChanged { get; set; }

    List<TabPage> Pages = new List<TabPage>();


    public TabPage ActivePage
    {
        get { return this.Pages.Where(t => t.Selected).FirstOrDefault() ?? this.Pages.FirstOrDefault(); }
    }

    protected virtual void OnTabPageChanged(TabPage tabPage)
    {
        TabPageChanged.InvokeAsync(Pages.IndexOf(tabPage));
        this.StateHasChanged();
    }


    internal void AddPage(TabPage tabPage)
    {
        Pages.Add(tabPage);
    }

    private string GetButtonClass(TabPage page)
    {
        return page.Selected ? "btn-primary" : "btn-secondary";
    }

    protected override void OnInitialized()
    {
        Debug.WriteLine("TabControl.OnInitialized " + Pages.Count);
        base.OnInitialized();
    }
}

And finally the TabPage

@{Debug.WriteLine($"TabPage.BuildRenderTree {Text}"); }
@if (Parent.ActivePage == this)
{
    <div>
        @ChildContent
    </div>
}

@code {
    private bool _Selected = false;

    [CascadingParameter]
    private TabControl Parent { get; set; }

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    [Parameter]
    public string Text { get; set; }

    [Parameter]
    public bool Selected
    {
        get { return _Selected; }
        set
        {
            if (_Selected == value)
                return;
            _Selected = value;
            StateHasChanged();
        }
    }

    protected override void OnInitialized()
    {
        if (Parent == null)
            throw new ArgumentNullException(nameof(Parent), "TabPage must exist within a TabControl");

        Debug.WriteLine($"TabPage.OnInitialized {Text}");
        base.OnInitialized();
        Parent.AddPage(this);
    }
}

So when I run this up it do not display my Tab Control, but with the help of the tracing I've added its obvious why

Page.BuildRenderTree 3
TabControl.OnInitialized 0    <-- NO TABS
TabControl.BuildRenderTree 0
TabControl.BuildRenderTree ChildContent 0
Page.BuildRenderTree TabPage: Tab 0
Page.BuildRenderTree TabPage: Tab 1
Page.BuildRenderTree TabPage: Tab 2
TabPage.OnInitialized Tab 0
TabPage.OnInitialized Tab 1
TabPage.OnInitialized Tab 2
TabPage.BuildRenderTree Tab 0
TabPage.BuildRenderTree Tab 1
TabPage.BuildRenderTree Tab 2

I seems at the point when the TabControl is rendered it does not yet know about its TabPages.

The bit I can't figure out is how to fix this....

I've tried a number of iterations of this and a number of 3rd party tab controls, and my UI always seems to be one refresh off of being up to date (note pressing the 'counter' button will cause the page to refresh as expected).

So my question is how do ensure the child TabPages are initialized when the TabControl is rendered.

This is currently running blazor-server-side.

Whats rendered on load

enter image description here

Whats rendered after forcing a refresh with the Counter 'Click me' Button

enter image description here

1
If I read well the code, you set the Selected property of your page when you click on a button, but in the TabPage you test the Parent.ActivePage.But this doesn't change. Set your test on _Selected of Selected property field of your TabPage - agua from mars
Sorry, I missed the code hooking the MyAppState.Changed event (So it should go something like this - TabPage button click sets MyAppState.SelectedValue, which fires MyAppState.Changed event which causes re-render), but the initial issue is the TabControl is rendered before its been provided with its tabs, so it renders empty. - Sprotty
According to your log, 3 tabs are rendered. - agua from mars
But at the point where the TabControl is rendered, it has no tabs, so no tab buttons are rendered (TabControl.BuildRenderTree 0). The Tabs themselves are then asked to render so the active tabs content is rendered, but with no header buttons. - Sprotty
Side note, but won't "Tab component that I can databind to a singleton" mean that when you have more than 1 user you have a problem about who controls the tab? And a race condition as well? - Henk Holterman

1 Answers

0
votes

Basically all my issues come down to the order in which things are created. The tabs are not created until after the render and in order to select the ActiveTab we (potentially) need to know about all the Tabs, so its a chicken and egg issue.

So we need to re-write the code in such a way that the tabs aren't needed until after they have been rendered.

TabSet.razor

<!-- Display the tab headers -->
<CascadingValue Value=this>
    <ul class="nav nav-tabs">
        @ChildContent
    </ul>

    <!-- Display body for only the active tab -->
    <div class="nav-tabs-body p-4">
        @if (HasActiveTab)
        {
            @ActiveTab?.ChildContent
        }
    </div>
</CascadingValue>
@code {
    [Parameter]
    public RenderFragment ChildContent { get; set; }

    [Parameter]
    public EventCallback<int> TabChanged { get; set; }

    [Parameter]
    public int SelectedIndex { get; set; }


    public List<Tab> Tabs = new List<Tab>();

    public void AddTab(Tab tab)
    {
        Tabs.Add(tab);
        StateHasChanged();
    }

    public void RemoveTab(Tab tab)
    {
        Tabs.Remove(tab);
    }

    public bool HasActiveTab { get { return SelectedIndex >= 0 && SelectedIndex < Tabs.Count; } }

    public bool IsActiveTab(Tab tab)
    {
        if (!HasActiveTab)
            return false;

        // NOTE : We may not have all the tabs loaded at this point
        int tabIndex = Tabs.IndexOf(tab);
        return tabIndex == SelectedIndex;
    }

    // Note : unsafe to call before the parent has rendered all its children (i.e. ChildContent)
    // instead use IsActiveTab
    public Tab ActiveTab
    {
        get
        {
            return Tabs[SelectedIndex];
        }
        set
        {
            SelectedIndex = this.Tabs.IndexOf(value);
            TabChanged.InvokeAsync(SelectedIndex);
            StateHasChanged();
        }
    }
}

Tab.razor

@implements IDisposable

<li>
    <a @onclick="Activate" class="nav-link @TitleCssClass" role="button">
        @Title
    </a>
</li>

@code {
    [CascadingParameter]
    public TabSet ContainerTabSet { get; set; }

    [Parameter]
    public string Title { get; set; }

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    // NOTE: use IsActiveTab within the Tab, NOT ActiveTab
    private string TitleCssClass => ContainerTabSet.IsActiveTab(this) ? "active" : null;

    private void Activate()
    {
        ContainerTabSet.ActiveTab = this;
    }

    // attach the tab to the tabset
    protected override void OnInitialized()
    {
        base.OnInitialized();
        ContainerTabSet.AddTab(this);
    }

    // detach the tab to the tabset
    public void Dispose()
    {
        this.ContainerTabSet.RemoveTab(this);
    }
}

The following shows the order of the rendering. Note the Tab now only renders its header section. The TabSet takes responsibility for rendering the selected Tabs contents. The interaction between the Tab and the TabSet works fine, but is only aware of the current Tab and the ones already rendered (an unknown number of Tabs have yet to be rendered and as long as we don't write code that assumes they exist, its all good).

// TabSet.Tabs is empty
TabSet            <ul class="nav nav-tabs">
TabSet                 @ChildContent
TabSet             </ul>

// TabSet.Tabs is empty
Tab0               <a @onclick="Activate" class="nav-link @TitleCssClass" role="button">
Tab0                   @Title
Tab0               </a>

Tab0.OnInitialized ContainerTabSet.AddTab(this);

// TabSet.Tabs contains (Tab0)
Tab1               <a @onclick="Activate" class="nav-link @TitleCssClass" role="button">
Tab1                   @Title
Tab1               </a>

Tab1.OnInitialized ContainerTabSet.AddTab(this);

// TabSet.Tabs contains (Tab0, Tab1)
More Tabs ...

// TabSet.Tabs is complete and contains (Tab0, Tab1, ...)
TabSet                 @if (HasActiveTab)
TabSet                 {
TabSet                     @ActiveTab?.ChildContent
TabSet                 }

There are a number of potential issues still. When a Tab header is rendered we need to know if its the selected tab as the style changes accordingly. So lets look at TitleCssClass.

private string TitleCssClass => ContainerTabSet.IsActiveTab(this) ? "active" : null;

This looks simple enough, but we need to look at IsActiveTab in more detail.

public bool IsActiveTab(Tab tab)
{
    if (!HasActiveTab)
        return false;

    // NOTE : We may not have all the tabs loaded at this point
    int tabIndex = Tabs.IndexOf(tab);
    return tabIndex == SelectedIndex;
}

This is a safe way of finding out if a given tab is the selected one without needing all the tabs to be loaded. If we had used ActiveTab == this then we would run into trouble as the code for ActiveTab looks something like this 'Tabs[SelectedIndex]' and if Tabs is only partially loaded then we could end up with an out of range index.

Another point is that when a tab is removed we must inform the tabset that its gone. We do this in the Tab.Dispose method. There is an assumption here that the framework will call Dispose at an appropriate time.

One oddity is the need for the @if (HasActiveTab) guard in the TabSet razor code. It seems the the very first run through it either has not tabs or it has not rendered the child Tabs. The guard fixes the issues and is not needed after the first load. If anyone could shed some light on this I'd be interested.