Refactor of the previous two blog posts into application class
In the previous parts we created a basic animation loop where the position is updated with the elapsed time. In this part we are going to refactor the code a bit. All the code was in the main page. The code related to moving and rendering the circle will be put in an application class called PixelApplication. The infrastructure to trigger the updates will stay in the main page. Also code is added to detect the canvas size or the screen width and height in the initialization phase.
The PixelApplication class has three methods. Init which is called once at the start and gets passed in the canvas size.
Update which updates the position of the circle and finally Render which renders the circle, it also gets passed in a elapsed time
to account for the extra passed time since the update was called and it also uses the remaintime to interpolate the new position linearly.
When refactoring the logic into a class we could have also refactored the code for the circle into its own class. This will be done in a later part.
Note, the application class now assumes that the width and height of the screen doesn't change. If this is needed a method to change the width and heigth
of the screen needs to be added.
public class PixelApplication { private SKColor _fillColor = new SKColor(20, 20, 40); private double x; private double y; private double vx; private double vy; private const int r = 50; private const double _dt = 0.01; private double remainingTime = 0.0; private double width; private double height; // Called once with the screen width and height, used for setting up the initial state public void Init(double w, double h) { width = w; height = h; // Initialize position x = 300.0; y = 200.0; // Initialize velocity vx = 100.0; vy = 200.0; } // Called with a certain time interval where dt is the elapsed time since the last call public void Update(double dt) { remainingTime += dt; if (remainingTime > 0.133333) { remainingTime = 0.033333; Debug.WriteLine($"Remaining time is too large {remainingTime}"); } while (remainingTime >= _dt) { // Update position based on velocity x += _dt * vx; y += _dt * vy; // Check collision with side of the screen and reverse velocity if ((x <= r && vx < 0) || (x > width - r && vx > 0)) vx = -vx; // Y axis Top and bottom of the screen if ((y <= r && vy < 0) || (y > height - r && vy > 0)) vy = -vy; remainingTime -= _dt; } } // Called when the canvas is drawn to the screen public void Render(SKCanvas canvas, double updateDelta) { // Extra elapsed time since last stopwatch restart var dt = updateDelta + remainingTime; // Update the position with the extra elapsed time distance float xp = (float)(x + dt * vx); float yp = (float)(y + dt * vy); canvas.Clear(_fillColor); using (SKPaint skPaint = new SKPaint()) { skPaint.Style = SKPaintStyle.Fill; skPaint.IsAntialias = true; skPaint.Color = SKColors.Blue; skPaint.StrokeWidth = 10; canvas.DrawCircle(xp, yp, r, skPaint); } } }
To get the screen size in the initialization of the application we need to remove the PaintService event from the MainPage.xaml.
<Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition/> </Grid.RowDefinitions> <Label x:Name="fpsLabel" HorizontalOptions="Start" VerticalOptions="Start" Margin="20,0"/> <forms:SKCanvasView x:Name="canvasView" Grid.Row="1" /> </Grid>
In the OnAppearing we now subscribe to the PaintService event. When the event is triggered for the first time, we clear the screen, unsubscribe again from the PaintService and then subscribe our OnPainting to the PaintService event. Also we initialize the timer loop and call the PixelApp.Init method with the width and height of the canvas. In the timer loop we call the PixelApp.Update where we pass in the elapsed time. In the OnPainting we call the PixelApp.Render with the Canvas and the elapsed time since the update was called.
public partial class MainPage : ContentPage { readonly PixelApplication _pixelApp = new PixelApplication(); private bool _pageActive; private readonly Stopwatch _stopWatch = new Stopwatch(); private double _fpsAverage = 0.0; private const int _fpsWanted = 30; private int _fpsCount = 0; private const double timerInterval = 1.0 / _fpsWanted; public MainPage() { InitializeComponent(); } protected override void OnAppearing() { base.OnAppearing(); canvasView.PaintSurface += CanvasView_PaintSurface; } private void CanvasView_PaintSurface(object sender, SKPaintSurfaceEventArgs e) { var surface = e.Surface; var canvas = surface.Canvas; canvas.Clear(SKColors.Black); canvasView.PaintSurface -= CanvasView_PaintSurface; canvasView.PaintSurface += OnPainting; Init(); } protected override void OnDisappearing() { _pageActive = false; base.OnDisappearing(); } private void Init() { _pageActive = true; var ts = TimeSpan.FromMilliseconds(1000.0/_fpsWanted); Device.StartTimer(ts, TimerLoop); var width = canvasView.CanvasSize.Width; var height = canvasView.CanvasSize.Height; Debug.WriteLine($"Init width {width} height {height}"); _pixelApp.Init(width, height); } private bool TimerLoop() { var dt = _stopWatch.Elapsed.TotalSeconds; _stopWatch.Restart(); var fps = dt > 0 ? 1.0 / dt : 0; // When the fps is too low reduce the load by skipping if (fps > 0 && fps < 7) { Debug.WriteLine("Skipping Frame"); return _pageActive; } _fpsAverage += fps; _fpsCount++; if (_fpsCount == _fpsWanted) { fps = _fpsAverage / _fpsCount; fpsLabel.Text = "fps: " + fps.ToString("N3", CultureInfo.InvariantCulture) + " dt: " + dt.ToString(CultureInfo.InvariantCulture); _fpsCount = 0; _fpsAverage = 0.0; } _pixelApp.Update(dt); // Trigger the OnPainting event canvasView.InvalidateSurface(); return _pageActive; } private void OnPainting(object sender, SKPaintSurfaceEventArgs e) { var surface = e.Surface; var canvas = surface.Canvas; _pixelApp.Render(canvas, _stopWatch.Elapsed.TotalSeconds); } }
When everything is working you should see the same blue circle bouncing around on the screen as in the previous tutorial.