Skip to content

Commit bbd91ec

Browse files
authored
Merge pull request #235 from timunie/fix/SnowFlakesControl_RemoveCustomHitTest
Don't use `ICustomHitTest`
2 parents 9d64520 + 32655dc commit bbd91ec

File tree

2 files changed

+89
-46
lines changed

2 files changed

+89
-46
lines changed

src/Avalonia.Samples/CustomControls/SnowflakesControlSample/Controls/SnowflakesControl.cs

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
using Avalonia;
77
using Avalonia.Controls;
88
using Avalonia.Data;
9+
using Avalonia.Input;
910
using Avalonia.Media;
10-
using Avalonia.Rendering;
1111
using Avalonia.Threading;
1212
using SnowflakesControlSample.Models;
1313

@@ -17,18 +17,18 @@ namespace SnowflakesControlSample.Controls;
1717
/// A control to render some <see cref="Snowflake">Snowflakes</see>. This control also adds the needed interaction logic
1818
/// for the game to operate.
1919
/// </summary>
20-
public class SnowflakesControl : Control, ICustomHitTest
20+
public class SnowflakesControl : Control
2121
{
2222
static SnowflakesControl()
2323
{
2424
// Make sure Render is updated whenever one of these properties changes
2525
AffectsRender<SnowflakesControl>(IsRunningProperty, SnowflakesProperty, ScoreProperty);
2626
}
27-
27+
2828
// We use a stopwatch to measure elapsed time between two render loops as it has higher precision compared to
2929
// other options. See: https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.stopwatch
3030
private readonly Stopwatch _stopwatch = new();
31-
31+
3232
// A collection which holds all current visible score infos to render.
3333
private readonly ICollection<ScoreHint> _scoreHintsCollection = new Collection<ScoreHint>();
3434

@@ -58,7 +58,7 @@ static SnowflakesControl()
5858
AvaloniaProperty.Register<SnowflakesControl, bool>(nameof(IsRunning));
5959

6060
private IList<Snowflake> _snowflakes = [];
61-
61+
6262
/// <summary>
6363
/// Gets or sets a List of <see cref="Snowflake">Snowflakes</see> to render.
6464
/// </summary>
@@ -69,7 +69,7 @@ public IList<Snowflake> Snowflakes
6969
}
7070

7171
private int _score;
72-
72+
7373
/// <summary>
7474
/// Gets or sets the current user score.
7575
/// </summary>
@@ -78,7 +78,7 @@ public int Score
7878
get => _score;
7979
set => SetAndRaise(ScoreProperty, ref _score, value);
8080
}
81-
81+
8282
/// <summary>
8383
/// Gets or sets a bool indicating if the Game whether the game is currently running.
8484
/// </summary>
@@ -87,7 +87,7 @@ public bool IsRunning
8787
get { return GetValue(IsRunningProperty); }
8888
set { SetValue(IsRunningProperty, value); }
8989
}
90-
90+
9191
/// <inheritdoc />
9292
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
9393
{
@@ -100,7 +100,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
100100
{
101101
// Resets and starts the stopwatch
102102
_stopwatch.Restart();
103-
103+
104104
// Clear any previous score hints
105105
_scoreHintsCollection.Clear();
106106
}
@@ -136,7 +136,7 @@ public override void Render(DrawingContext context)
136136

137137
// Bonus 1: Use a custom renderer using Skia-API to display the total score
138138
context.Custom(new ScoreRenderer(Bounds, $"Your score: {Score:N0}"));
139-
139+
140140
// Bonus 2: Render the score hint if any available.
141141
foreach (var scoreHint in _scoreHintsCollection.ToArray())
142142
{
@@ -145,7 +145,7 @@ public override void Render(DrawingContext context)
145145
{
146146
scoreHint.Update(elapsedMilliseconds);
147147
}
148-
148+
149149
// Use a formatted text to render the score hint.
150150
var formattedText =
151151
new FormattedText(
@@ -155,42 +155,60 @@ public override void Render(DrawingContext context)
155155
Typeface.Default,
156156
20,
157157
new SolidColorBrush(Colors.Yellow, scoreHint.Opacity));
158-
159-
context.DrawText(formattedText, scoreHint.GetTopLeftForViewport(Bounds, new Size(formattedText.Width, formattedText.Height)));
158+
159+
context.DrawText(formattedText,
160+
scoreHint.GetTopLeftForViewport(Bounds, new Size(formattedText.Width, formattedText.Height)));
160161
}
161162

162163
base.Render(context);
163164

164-
// Request next frame as soon as possible, if the game is running. Remember to reset the stopwatch.
165+
// Request the next frame as soon as possible if the game is running. Remember to reset the stopwatch.
165166
if (IsRunning)
166167
{
167-
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
168+
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
168169
_stopwatch.Restart();
169170
}
170171
}
171172

173+
/// <inheritdoc />
174+
protected override void OnPointerEntered(PointerEventArgs e)
175+
{
176+
HitTestSnowFlakes(e.GetPosition(this));
177+
178+
base.OnPointerEntered(e);
179+
}
180+
181+
/// <inheritdoc />
182+
protected override void OnPointerMoved(PointerEventArgs e)
183+
{
184+
HitTestSnowFlakes(e.GetPosition(this));
185+
186+
base.OnPointerMoved(e);
187+
}
188+
172189
/// <summary>
173-
/// This method is needed to customize hit-testing. In our case we want the pointer hit only in case
174-
/// the game is running and the pointer is directly over one of our snowflakes.
190+
/// This method will check if the pointer has hit any snowflake. If so, it will remove the snowflake from the list
191+
/// and update the score.
175192
/// </summary>
176193
/// <param name="point">the pointer point to test.</param>
177-
/// <returns>true if the pointer hit the control, otherwise false</returns>
178-
/// <remarks>Used by the <see cref="ICustomHitTest"/>-interface, which this control implements.</remarks>
179-
public bool HitTest(Point point)
194+
private void HitTestSnowFlakes(Point point)
180195
{
181-
if (!IsRunning) return false;
196+
// if the game is not running, we don't need to do anything.
197+
if (!IsRunning) return;
182198

183-
var snowFlake = Snowflakes.FirstOrDefault(x => x.IsHit(point, Bounds));
184-
if (snowFlake != null)
199+
// loop through all snowflakes and check if the pointer is inside one of them.
200+
// Copy the list to avoid concurrent modification exceptions.
201+
foreach (var snowFlake in Snowflakes.ToArray())
185202
{
186-
Snowflakes.Remove(snowFlake);
187-
Score += snowFlake.GetHitScore();
188-
189-
// Add a text hint about the earned score. We also hand over the containing collection,
190-
// so it can auto-remove itself after 1 second.
191-
_scoreHintsCollection.Add(new ScoreHint(snowFlake, _scoreHintsCollection));
192-
}
203+
if (snowFlake.IsHit(point, Bounds))
204+
{
205+
Snowflakes.Remove(snowFlake);
206+
Score += snowFlake.GetHitScore();
193207

194-
return snowFlake != null;
208+
// Add a text hint about the earned score. We also hand over the containing collection,
209+
// so it can auto-remove itself after 1 second.
210+
_scoreHintsCollection.Add(new ScoreHint(snowFlake, _scoreHintsCollection));
211+
}
212+
}
195213
}
196214
}

src/Avalonia.Samples/CustomControls/SnowflakesControlSample/README.adoc

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ Here is the final class: xref:ViewModels/SnowflakeGameViewModel.cs[]
273273

274274
=== Step 3: Add the SnowflakesControl
275275

276-
Now it's time to add the needed `Control` to render our game. Therefore, we add a new folder `Controls` and inside we add a new class `SnowflakesControl.cs`. This class must inherit `Control`. In addition, we want to implement the interface `ICustomHitTest` in order to control hit-testing on our own.
276+
Now it's time to add the needed `Control` to render our game. Therefore, we add a new folder `Controls` and inside we add a new class `SnowflakesControl.cs`. This class must inherit `Control`.
277277

278278
The control needs some https://docs.avaloniaui.net/docs/guides/custom-controls/how-to-create-advanced-custom-controls[[AvaloniaProperties\]] to allow us to bind to it.
279279

@@ -374,7 +374,7 @@ public override void Render(DrawingContext context)
374374
// Request next frame as soon as possible, if the game is running. Remember to reset the stopwatch.
375375
if (IsRunning)
376376
{
377-
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
377+
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
378378
_stopwatch.Restart();
379379
}
380380
}
@@ -387,31 +387,56 @@ This is how a single render frame would look like:
387387
.Result of a single render loop
388388
image:_docs/single_render_frame.png[]
389389

390-
As we wanted to implement `ICustomHitTest`, we will add the needed interface members, which is just the following method here:
390+
As we want to check if our pointer is over any `Snowflake`, we will override the `OnPointerEntered` and `OnPointerMoved` methods. From the `PointerEventArgs` we can read the pointer position and forward it to a method that tests for hits,
391391

392392
[source,csharp]
393393
----
394-
public bool HitTest(Point point)
394+
/// <inheritdoc />
395+
protected override void OnPointerEntered(PointerEventArgs e)
395396
{
396-
if (!IsRunning) return false;
397+
HitTestSnowFlakes(e.GetPosition(this));
398+
base.OnPointerEntered(e);
399+
}
400+
401+
/// <inheritdoc />
402+
protected override void OnPointerMoved(PointerEventArgs e)
403+
{
404+
HitTestSnowFlakes(e.GetPosition(this));
405+
base.OnPointerMoved(e);
406+
}
407+
408+
/// <summary>
409+
/// This method will check if the pointer has hit any snowflake. If so, it will remove the snowflake from the list
410+
/// and update the score.
411+
/// </summary>
412+
/// <param name="point">the pointer point to test.</param>
413+
private void HitTestSnowFlakes(Point point)
414+
{
415+
// if the game is not running, we don't need to do anything.
416+
if (!IsRunning) return;
397417
398-
var snowFlake = Snowflakes.FirstOrDefault(x => x.IsHit(point, Bounds));
399-
if (snowFlake != null)
418+
// loop through all snowflakes and check if the pointer is inside one of them.
419+
// Copy the list to avoid concurrent modification exceptions.
420+
foreach (var snowFlake in Snowflakes.ToArray())
400421
{
401-
Snowflakes.Remove(snowFlake);
402-
Score += snowFlake.GetHitScore();
403-
}
422+
if (snowFlake.IsHit(point, Bounds))
423+
{
424+
Snowflakes.Remove(snowFlake);
425+
Score += snowFlake.GetHitScore();
404426
405-
return snowFlake != null;
427+
// Add a text hint about the earned score. We also hand over the containing collection,
428+
// so it can auto-remove itself after 1 second.
429+
_scoreHintsCollection.Add(new ScoreHint(snowFlake, _scoreHintsCollection));
430+
}
431+
}
406432
}
407433
----
408434

409435
What this method does is:
410436

411-
* If the game is not running, the control shouldn't receive any hit
412-
* If the game is running, search for any snowflake hit by the pointer
413-
** If one is found, remove it from the collection
414-
** If one is found, add the score to the total score
437+
* If the game is running, search for all snowflakes hit by the pointer
438+
** If any is found, remove it from the collection
439+
** If any is found, add the score to the total score
415440

416441
Here is the final class xref:Controls/SnowflakesControl.cs[]
417442

0 commit comments

Comments
 (0)