Search code examples
c#timersynchronizationmauitimespan

How to perfectly synchronize a ViewModel with the loading of a View in a MAUI application?


I am working on a timer application. When you click on the "start" button of the home page, it navigates to another page where the elapsed time is shown.

The issue that I have, is that the view takes time to load on Android (probably because I am on debug mode and the device I use is relatively old), and the time count activity (in the ViewModel) is started a few seconds before the View has actually started.

The time count from the ViewModel is to be started by the View like this:

((TimerSessionViewModel)BindingContext).BeginSession();

And what the ViewModel is doing after this is counting the elapsed time basically with a simple synchronization mechanism that you can see in this snippet:

_timer = new Timer(TimerCallback, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(TimerToolkitConstants.TimerTickMilliseconds));

public void BeginSession()
{
    SessionIsStarted = true;
    SessionStarted?.Invoke(this, new EventArgs());
    OnSessionStart();
}


private void TimerCallback(object? state)
{
    if (SessionIsStarted && !TimerPaused)
    {
        SessionElapsedTime = SessionElapsedTime.Add(TimeSpan.FromMilliseconds(TimerToolkitConstants.TimerTickMilliseconds));
    }
}

-> So the whole idea is to have the View call "BeginSession" when it is loaded and ready to show the time count.

Now the issue that I have is that all the events offered by the View which I have tried (Loaded, Appearing, LayoutChanged...) are raised before the View actually appears on my phone. Now I am sure this is strongly due to the context (debug mode, relatively old device), but it would be ideal to be able to have something working well even in that contaxt so that I can know the app runs well even in poor conditions.

I am not sure if it is possible to do better than what I have here, but I have to ask for opinions in case there is and I am missing something. Thanks for any input on this.


Solution

  • For your time-critical operations I'd like to suggest a couple of optimizations to experiment with. The first is to avoid having to navigate to a new view. Use a OnePage architecture where views are overlapped on a common grid and the visibility is controlled by the value of OnePageState. This should give you very rapid switching from the main page view to what "looks like" a navigated page but really isn't.

    switching virtual views

    The second is to base your timing on a Stopwatch and use asynchronous Task.Delay calls to update the visible display. Even if it takes a few tens of ms to switch the view, the elapsed time begins at the moment the start command is invoked so it's still accounted for. You asked for perfect synchronization, and it's important to note that it won't reach the atomic precision of measuring time with nuclear isotopes but it's really not bad. You mentioned having an older Android so I posted a Clone on GitHub if you'd like to see how the performance is on your device.


    Start by making an IValueConverter class that returns true if two enum values are equal. Here, the default MAUI page now becomes invisible if the bound value of OnePageState is anything other than OnePageState.Main. Likewise, when the value becomes OnePageState.Timer the alternate virtual page becomes visible.


    IValueConverter

    
        class EnumToBoolConverter : IValueConverter
        {
            public object Convert(object unk, Type targetType, object parameter, CultureInfo culture)
            {
                if (unk is Enum enum1 && parameter is Enum @enum2)
                {
                    return enum1.Equals(@enum2);
                }
                return false;
            }
            public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
                throw new NotImplementedException();
        }
    

    Timer Layout Overlaps Maui Default

    Add the Timer view on top of the Maui default view in a common Grid.

    <!--1 x 1 Grid to contain overlapping virtual page representations-->
    <Grid x:Name="OnePageGrid">
        <!--Maui Default Virtual Page-->
        <ScrollView
            IsVisible="{Binding
                OnePageState, 
                Converter={StaticResource EnumToBoolConverter}, 
                ConverterParameter={x:Static local:OnePageState.Main}}">
            <VerticalStackLayout
                Spacing="25"
                Padding="30,0"
                VerticalOptions="Center">
                .
                .
                .            
        </ScrollView>
    

    Next, on top of the Maui default view, we stack an entirely different 'look' for the MainPage.Content.

       <!--Timer Virtual Page-->
       <Grid
           IsVisible="{Binding
               OnePageState, 
               Converter={StaticResource EnumToBoolConverter}, 
               ConverterParameter={x:Static local:OnePageState.Timer}}"
           RowDefinitions="50,*,50"
           BackgroundColor="MidnightBlue">
           <HorizontalStackLayout
               HorizontalOptions="Fill"
               BackgroundColor="White">
               <Button
                   Text ="&lt;--- Main Page" 
                   TextColor="CornflowerBlue"
                   BackgroundColor="White"
                   FontSize="Medium"
                   HorizontalOptions="Start"
                   Command="{Binding SetOnePageStateCommand}"
                   CommandParameter="{x:Static local:OnePageState.Main}"/>
           </HorizontalStackLayout>
           <Label
               Grid.Row="1"
               Text="{Binding TimerDisplay}"
               TextColor="White"
               FontSize="48"
               FontAttributes="Bold"
               HorizontalOptions="Center"
               VerticalOptions="Center"
               HorizontalTextAlignment="Center"
               VerticalTextAlignment="Center"
               BackgroundColor="Transparent">
               <Label.GestureRecognizers>
                   <TapGestureRecognizer Command="{Binding StartTimerCommand}"/>
               </Label.GestureRecognizers>
           </Label>
           <Grid
               Grid.Row="2"
               BackgroundColor="White">
               <Button
                   Text ="Reset" 
                   TextColor="CornflowerBlue"
                   BackgroundColor="White"
                   FontSize="Medium"
                   HorizontalOptions="CenterAndExpand"
                   Command="{Binding ResetTimerCommand}"/>
           </Grid>
       </Grid>
    </Grid>
    

    Suggestion: Avoid using a Timer

    Here's a way to streamline updating the elapsed time without using a Timer instance and the resulting baggage.

    
    
    Task _pollingTask;
    private Stopwatch _stopwatch = new Stopwatch();
    private async void OnStartTimer(object o)
    {
        if (!_stopwatch.IsRunning)
        {
            OnePageState = OnePageState.Timer;
            try
            {
                if (_cts != null)
                {
                    _cts.Cancel();
                }
                _cts = new CancellationTokenSource();
                var token = _cts.Token;
                _stopwatch.Restart();
                while (!token.IsCancellationRequested)
                {
                    var elapsed = _stopwatch.Elapsed;
                    MainThread.BeginInvokeOnMainThread(() =>
                    {
                        TimerDisplay =
                            elapsed < TimeSpan.FromSeconds(1) ?
                            elapsed.ToString(@"hh\:mm\:ss\.ff") :
                            elapsed.ToString(@"hh\:mm\:ss");
                    });
                    await Task.Delay(TimeSpan.FromSeconds(0.1), token);
                }
            }
            catch { }
            finally
            {
                _stopwatch.Stop();
                _pollingTask?.Dispose();
            }
        }
    }