Skip to content

Commit 4b89b0d

Browse files
committed
PanZoom: snap to pixel grid only at rest to kill spring-settle shiver (#177)
UpdateTransform() rounded both translations every frame. During a spring's settling tail the pan translation moves <1px over many frames, so per-frame rounding quantized that smooth glide into a 1px staircase while the unrounded Scale term kept sliding underneath it - a visible shiver, worst on high-refresh displays where the tail is sampled more finely. Rounding only buys crisp 1:1 / the NVIDIA #55 fix at rest (at fractional animation scales the image is sub-pixel sampled regardless), so gate it on a new CanvasViewState.SnapTranslation flag: round when settled, use raw floats during animation. Mirrors how XAML's Image behaves (layout rounding at rest, smooth sub-pixel resampling while a composition scale animation runs). Make PanZoomAnimationOnGoing the single source of truth - its setter keeps SnapTranslation == !value, so snapping can never be left off at rest. Every stop path (FinishSpringAnimation, StopAnimationSnappingToTarget, shrug completion) clears the flag before rebuilding the transform so the resting frame is snapped.
1 parent a851017 commit 4b89b0d

2 files changed

Lines changed: 52 additions & 15 deletions

File tree

Src/FlyPhotos/Display/Controllers/CanvasViewManager.cs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,23 @@ internal class CanvasViewManager
8585
/// <summary>
8686
/// Indicates if a pan or zoom animation is currently in progress.
8787
/// </summary>
88-
public bool PanZoomAnimationOnGoing { get; private set; }
88+
/// <remarks>
89+
/// This is the SINGLE SOURCE OF TRUTH for pixel snapping: the setter keeps
90+
/// <see cref="CanvasViewState.SnapTranslation"/> equal to <c>!value</c>, so snapping is on exactly when
91+
/// no animation is running. That makes it structurally impossible to leave snapping off at rest — every
92+
/// path that ends an animation flips this back to <c>false</c> and thereby re-enables snapping. Stop
93+
/// paths must still rebuild the transform (<c>UpdateTransform()</c>) AFTER setting this false so the
94+
/// resting frame is rebuilt with rounding applied.
95+
/// </remarks>
96+
public bool PanZoomAnimationOnGoing
97+
{
98+
get;
99+
private set
100+
{
101+
field = value;
102+
_canvasViewState.SnapTranslation = !value;
103+
}
104+
}
89105

90106
/// <summary>
91107
/// Fires when the view's "fitted to screen" state changes.
@@ -852,7 +868,7 @@ private void StartZoomAnimation(float targetScale, Point zoomAnchor)
852868
_zoomCenter = zoomAnchor;
853869

854870
_currentAnimation = AnimationType.SpringZoom;
855-
PanZoomAnimationOnGoing = true;
871+
PanZoomAnimationOnGoing = true; // setter turns OFF snapping for the duration of the spring
856872
ViewChanged?.Invoke();
857873
}
858874

@@ -885,7 +901,7 @@ private void StartSpringPanAndZoomAnimation(float targetScale, Point targetPosit
885901
_springPanZoomTargetCanvasSize = targetCanvasSize;
886902

887903
_currentAnimation = AnimationType.SpringPanAndZoom;
888-
PanZoomAnimationOnGoing = true;
904+
PanZoomAnimationOnGoing = true; // setter turns OFF snapping for the duration of the spring
889905
ViewChanged?.Invoke();
890906
}
891907

@@ -910,7 +926,7 @@ private void StartShrugAnimation()
910926
{
911927
_shrugStartPosition = _canvasViewState.ImagePos;
912928
_currentAnimation = AnimationType.Shrug;
913-
PanZoomAnimationOnGoing = true;
929+
PanZoomAnimationOnGoing = true; // setter turns OFF snapping for the duration of the wiggle
914930
_animationStopwatch.Restart();
915931
ViewChanged?.Invoke();
916932
}
@@ -954,8 +970,12 @@ private void FinishSpringAnimation()
954970
{
955971
_animationStopwatch.Stop();
956972
_currentAnimation = AnimationType.None;
957-
PanZoomAnimationOnGoing = false;
973+
PanZoomAnimationOnGoing = false; // setter re-enables snapping now that we are at rest
958974
_suppressZoomUpdateForNextAnimation = false;
975+
// Rebuild the transform AFTER snapping is back on so the final resting frame lands on the
976+
// device-pixel grid (crisp 1:1 / NVIDIA fix). The settled branch already wrote the exact target
977+
// Scale/ImagePos, so this just re-snaps them. See CanvasViewState.SnapTranslation.
978+
_canvasViewState.UpdateTransform();
959979
AnimationCompleted?.Invoke();
960980
}
961981

@@ -968,6 +988,10 @@ private void FinishSpringAnimation()
968988
/// </summary>
969989
private void StopAnimationSnappingToTarget()
970990
{
991+
// We are forcing the view to its settled target. Clear the animation flag FIRST (its setter
992+
// re-enables snapping) so the per-case UpdateTransform calls below build a snapped, device-pixel
993+
// aligned resting frame.
994+
PanZoomAnimationOnGoing = false;
971995
switch (_currentAnimation)
972996
{
973997
case AnimationType.SpringZoom:
@@ -993,7 +1017,6 @@ private void StopAnimationSnappingToTarget()
9931017

9941018
_animationStopwatch.Stop();
9951019
_currentAnimation = AnimationType.None;
996-
PanZoomAnimationOnGoing = false;
9971020
_suppressZoomUpdateForNextAnimation = false;
9981021
}
9991022

@@ -1122,11 +1145,10 @@ private void AnimateShrug()
11221145
_animationStopwatch.Stop();
11231146
// Animation finished. Ensure the image is back to its exact starting position.
11241147
_canvasViewState.ImagePos = _shrugStartPosition;
1148+
_currentAnimation = AnimationType.None;
1149+
PanZoomAnimationOnGoing = false; // setter re-enables snapping BEFORE the rebuild below
11251150
_canvasViewState.UpdateTransform();
11261151
ViewChanged?.Invoke();
1127-
1128-
_currentAnimation = AnimationType.None;
1129-
PanZoomAnimationOnGoing = false;
11301152
return;
11311153
}
11321154

Src/FlyPhotos/Display/State/CanvasViewState.cs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,27 @@ internal class CanvasViewState
1616

1717
public int Rotation = 0;
1818

19+
/// <summary>
20+
/// When true (the default, i.e. at rest), the translations are rounded to whole pixels so the image lands
21+
/// on the device-pixel grid — this keeps 1:1 rendering crisp and avoids the NVIDIA nearest-neighbour glitch
22+
/// (#55). <see cref="Controllers.CanvasViewManager"/> clears it for the duration of a pan/zoom/shrug
23+
/// animation: rounding a continuously-moving translation every frame quantizes the smooth spring-settle
24+
/// into a 1px staircase, producing a visible "shiver" in the settling tail (worst on high-refresh
25+
/// displays). Rounding buys nothing mid-animation anyway — the fractional Scale already sub-pixel-samples
26+
/// the image — so we round only when settled. This mirrors how the XAML Image element behaves (layout
27+
/// rounding at rest, smooth sub-pixel resampling during a composition scale animation).
28+
/// </summary>
29+
public bool SnapTranslation = true;
30+
1931
public void UpdateTransform()
2032
{
2133
Mat = Matrix3x2.Identity;
2234

23-
// Round the first translation to nearest integer to avoid subpixel rendering
24-
float translateX1 = MathF.Round((float)(-ImageRect.Width * 0.5f));
25-
float translateY1 = MathF.Round((float)(-ImageRect.Height * 0.5f));
35+
// Centering translation. Round to whole pixels when snapping (at rest) to avoid subpixel rendering.
36+
// This term is constant for a given image, so it never contributes to settle-shiver; gating it keeps
37+
// the matrix fully unsnapped during animation for consistency.
38+
float translateX1 = SnapTranslation ? MathF.Round((float)(-ImageRect.Width * 0.5f)) : (float)(-ImageRect.Width * 0.5f);
39+
float translateY1 = SnapTranslation ? MathF.Round((float)(-ImageRect.Height * 0.5f)) : (float)(-ImageRect.Height * 0.5f);
2640
Mat *= Matrix3x2.CreateTranslation(translateX1, translateY1);
2741

2842
// Scale operation remains unchanged
@@ -31,9 +45,10 @@ public void UpdateTransform()
3145
// Rotation remains unchanged
3246
Mat *= Matrix3x2.CreateRotation((float)(Math.PI * Rotation / 180f));
3347

34-
// Round the second translation to nearest integer to avoid subpixel rendering
35-
float translateX2 = MathF.Round((float)ImagePos.X);
36-
float translateY2 = MathF.Round((float)ImagePos.Y);
48+
// Pan translation. Per-frame rounding of this term during an animation is what causes the
49+
// settle-shiver, so round only when settled (SnapTranslation == true); see SnapTranslation.
50+
float translateX2 = SnapTranslation ? MathF.Round((float)ImagePos.X) : (float)ImagePos.X;
51+
float translateY2 = SnapTranslation ? MathF.Round((float)ImagePos.Y) : (float)ImagePos.Y;
3752
Mat *= Matrix3x2.CreateTranslation(translateX2, translateY2);
3853

3954
// Calculate inverse transform

0 commit comments

Comments
 (0)