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.
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.
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 ="<--- 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();
}
}
}