EdCharbeneau / BlazorSize

Blazor browser size interop for matchMedia and browser window size at runtime.
335 stars 39 forks source link

MediaQuery.Matches should be nullable #35

Closed proff closed 3 years ago

proff commented 4 years ago

MediaQuery match is async operation. Because of this when i use on small screen

<MediaQuery Media="@Breakpoints.SmallDown" @bind-Matches="IsSmall" />
@if(IsSmall){
  <div>small</small>
}else{
  <div>large</div>
}
@code {
    bool IsSmall;
}

then i see how "large" changed to "small". May be in client-side mode this is not noticeable, but in server-side noticeable.

When Matches will be nullable, i can use:

<MediaQuery Media="@Breakpoints.SmallDown" @bind-Matches="IsSmall" />
@if(IsSmall != null){
  @if(IsSmall){
    <div>small</small>
  }else{
    <div>large</div>
  }
}
@code {
    bool? IsSmall;
}

and will be "small"at once.

Workaround:

<MediaQuery Media="@Breakpoints.SmallDown"  MatchesChanged="value => IsSmall = value" />
@if(IsSmall != null){
  @if(IsSmall == true){
    <div>small</small>
  }else{
    <div>large</div>
  }
}
@code {
    bool? IsSmall;
}
proff commented 4 years ago

true - matched false - not matched null - unknown

ViRuSTriNiTy commented 4 years ago

I think another workaround is to just define a match with the opposite breakpoint MediumUp. Then you can write something like:

@if (SmallDown)
{
  // small down stuff
}
@if (MediumUp)
{
 // medium up stuff
}

Had this issue to and i solved it this way as far as I remember.

EdCharbeneau commented 3 years ago

I feel that this comes down to making the best assumption about which default media query will fit the largest amount of users who load the application. There's not really a third state as far as media queries are concerned.

Example:

If my application displays a mobile friendly layout <Mobile> by default, a desktop user might see a "flash" of <Mobile> before a media query is resolved to show the <Desktop> layout.

Inversely, if I set my default behavior to display <Desktop>, and a mobile user visits, they will see a "flash" of <Desktop> before the <Mobile> layout is triggered.

There is no in between state here. Instead I would suggest wrapping both layouts in a loading message.

<MediaQuery Media="@Breakpoints.SmallDown" @bind-Matches="IsSmall" />
@if(!isLoading) {
   @if(IsSmall){
     <div>small</small>
   }else{
    <div>large</div>
   }
} else {
    <span>Loading</span>
}

@code {
    bool IsSmall;
    bool isLoading = true;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender) isLoading = false;
    }

}

My opinion:

true - matched false - not matched unknown - loading. The media query is technically still false as it does not match.

proff commented 3 years ago

yes, it's loading state, but your solution of problem is very unreliable. index component:

@page "/"
<h1>Hello, world!</h1>

Welcome to your new app.

<SurveyPrompt Title="How is Blazor working for you?" />
<Component1></Component1>
<Component2></Component2>

Component1:

<MediaQueryList>
    @using BlazorPro.BlazorSize
    @using System.Diagnostics
    <MediaQuery Media="@Breakpoints.SmallUp" @bind-Matches="IsLarge" />
    <h3>Component1</h3>
    @if (!isLoading)
    {
        @if (_isLarge)
        {
            <div>large</div>
        }
        else
        {
            <div>small</div>
        }
    }
    else
    {
        <span>Loading</span>
    }
</MediaQueryList>
@code
{
    bool _isLarge = false;
    Stopwatch stopwatch;
    private bool isLoading;

    public bool IsLarge
    {
        get => _isLarge;
        set
        {
            Console.WriteLine($"{stopwatch.ElapsedMilliseconds}, IsLarge changed from {_isLarge} to {value}");
            _isLarge = value;
        }
    }

    protected override void OnInitialized()
    {
        stopwatch = Stopwatch.StartNew();
    }

    protected override void OnAfterRender(bool firstRender)
    {
        if (firstRender) isLoading = false;
        Console.WriteLine($"{stopwatch.ElapsedMilliseconds}, firstRender: {firstRender}, IsLarge:{_isLarge}");
    }
}

Component2:

<h3>Component2</h3>
data: @data
@code {
    string data;

    protected override async Task OnInitializedAsync()
    {
        await base.OnInitializedAsync();
        //imitation of loading data
        await Task.Delay(10);
        data = "loaded";
    }
}

ping: 500ms (for clarity) изображение Console output (from 77ms to 1099ms loaded is true, but component is not loaded yet):

77, firstRender: True, IsLarge:False
1099, IsLarge changed from False to True
1102, firstRender: False, IsLarge:True

Component1 will be "small" after data loading in Component2 and before MediaQuery response. It may be any change in any part of application, including server push change (normal practice for server-side blazor).

EdCharbeneau commented 3 years ago

Note: This still needs proper documentation.

This issue isn't as much about "nullable" as it is the lifecycle of a Blazor component and the availability of JavaScript.

EdCharbeneau commented 3 years ago

The solution below works, even with Network latency. The reason I suggest this method is because I would use this same solution internally if I were to add support for Nullable. Rather than add a breaking change to BlazorSize by supporting nullable, in addition adding a new responsibility to the tool (detecting loading), I would rather those who need this approach implement their own loading pattern in their application.

If two components (per above) should load together, than the two components need to communicate their loading states to their parent, or the parent needs to dictate when loading has happened.

In the code below the component initializes with isLoading = true. Once OnAfterRender has been called, isLoading becomes false and the page is re-rendered with showing the loaded component.

If data is loaded during the OnInitializedAsync method, then BOTH the data and isLoading criteria should be met before showing the content.


<MediaQuery Media="@Breakpoints.SmallUp" @bind-Matches="IsLarge" />

<h3>Component1</h3>

@if (!isLoading)
{
    @if (IsLarge)
    {
        <div>large</div>
    }
    else
    {
        <div class="alert alert-danger">You should never see this on desktop (small)</div>
    }
}
else
{
    <span>Loading</span>
}

@code
{
    private bool isLoading = true;

    public bool IsLarge { get; set; }

    protected override void OnAfterRender(bool firstRender)
    {
        if (firstRender) isLoading = false;
    }
}

For patterns and practices on loading states please refer to [BlazorPro .Spinkit](https://github.com/EdCharbeneau/BlazorPro.Spinkit) or [Telerik UI for Blazor](https://demos.telerik.com/blazor-ui/loadercontainer/overview)
EdCharbeneau commented 3 years ago

@proff there's another way to accomplish exactly what you asked for with the MathchedChanged event.

@page "/nullable"
@if (IsSmall.HasValue)
{
    if (IsSmall.Value)
    {
        <h1>Small</h1>
    }
    else
    {
       <h1>Large</h1>
    }
} else
{
    <span>Waiting for JavaScript interop...</span>
}

<MediaQuery Media="@Breakpoints.SmallDown" MatchesChanged="@(matched => IsSmall = matched)"></MediaQuery>

@code {
    bool? IsSmall = null;
}
proff commented 3 years ago

And this way exactly same as my workaround from first comment.

EdCharbeneau commented 3 years ago

Indeed it is, I apologize for being daft.