@@ -147,17 +147,23 @@ public Func<long> TimeProvider
147147 public SKPoint Offset => _offset ;
148148
149149 /// <summary>Gets the composite transform matrix.</summary>
150+ /// <remarks>
151+ /// The matrix is composed as: screen offset, then center-origin, scale, rotate, and restore.
152+ /// This ensures pan moves content at 1:1 screen speed regardless of zoom level.
153+ /// </remarks>
150154 public SKMatrix Matrix
151155 {
152156 get
153157 {
154158 var w2 = _viewWidth / 2f ;
155159 var h2 = _viewHeight / 2f ;
160+ // Order (PreConcat reverses):
161+ // 1. Translate to center 2. Rotate 3. Scale 4. Translate to origin 5. Screen-space offset
156162 var m = SKMatrix . CreateTranslation ( w2 , h2 ) ;
157- m = m . PreConcat ( SKMatrix . CreateScale ( _scale , _scale ) ) ;
158163 m = m . PreConcat ( SKMatrix . CreateRotationDegrees ( _rotation ) ) ;
159- m = m . PreConcat ( SKMatrix . CreateTranslation ( _offset . X , _offset . Y ) ) ;
164+ m = m . PreConcat ( SKMatrix . CreateScale ( _scale , _scale ) ) ;
160165 m = m . PreConcat ( SKMatrix . CreateTranslation ( - w2 , - h2 ) ) ;
166+ m = m . PreConcat ( SKMatrix . CreateTranslation ( _offset . X , _offset . Y ) ) ;
161167 return m ;
162168 }
163169 }
@@ -474,29 +480,27 @@ private void OnEnginePanDetected(object? s, SKPanEventArgs e)
474480 if ( e . Handled || ( dragArgs ? . Handled ?? false ) )
475481 return ;
476482
477- // Update offset
478- var d = ScreenToContentDelta ( e . Delta . X , e . Delta . Y ) ;
479- _offset = new SKPoint ( _offset . X + d . X , _offset . Y + d . Y ) ;
483+ // Update offset (screen-space: apply delta directly)
484+ _offset = new SKPoint ( _offset . X + e . Delta . X , _offset . Y + e . Delta . Y ) ;
480485 TransformChanged ? . Invoke ( this , EventArgs . Empty ) ;
481486 }
482487
483488 private void OnEnginePinchDetected ( object ? s , SKPinchEventArgs e )
484489 {
485490 PinchDetected ? . Invoke ( this , e ) ;
486491
487- // Apply center movement as pan
492+ // Apply center movement as pan (screen-space)
488493 if ( IsPanEnabled )
489494 {
490- var panDelta = ScreenToContentDelta (
491- e . Center . X - e . PreviousCenter . X ,
492- e . Center . Y - e . PreviousCenter . Y ) ;
493- _offset = new SKPoint ( _offset . X + panDelta . X , _offset . Y + panDelta . Y ) ;
495+ var dx = e . Center . X - e . PreviousCenter . X ;
496+ var dy = e . Center . Y - e . PreviousCenter . Y ;
497+ _offset = new SKPoint ( _offset . X + dx , _offset . Y + dy ) ;
494498 }
495499
496500 if ( IsPinchEnabled )
497501 {
498502 var newScale = Clamp ( _scale * e . Scale , MinScale , MaxScale ) ;
499- AdjustOffsetForPivot ( e . Center , _scale , newScale , _rotation , _rotation ) ;
503+ AdjustOffsetForScalePivot ( e . Center , _scale , newScale ) ;
500504 _scale = newScale ;
501505 }
502506
@@ -511,7 +515,7 @@ private void OnEngineRotateDetected(object? s, SKRotateEventArgs e)
511515 return ;
512516
513517 var newRotation = _rotation + e . RotationDelta ;
514- AdjustOffsetForPivot ( e . Center , _scale , _scale , _rotation , newRotation ) ;
518+ AdjustOffsetForRotatePivot ( e . Center , _rotation , newRotation ) ;
515519 _rotation = newRotation ;
516520 TransformChanged ? . Invoke ( this , EventArgs . Empty ) ;
517521 }
@@ -538,7 +542,7 @@ private void OnEngineScrollDetected(object? s, SKScrollEventArgs e)
538542
539543 var scaleDelta = 1f + e . DeltaY * ScrollZoomFactor ;
540544 var newScale = Clamp ( _scale * scaleDelta , MinScale , MaxScale ) ;
541- AdjustOffsetForPivot ( e . Location , _scale , newScale , _rotation , _rotation ) ;
545+ AdjustOffsetForScalePivot ( e . Location , _scale , newScale ) ;
542546 _scale = newScale ;
543547 TransformChanged ? . Invoke ( this , EventArgs . Empty ) ;
544548 }
@@ -565,30 +569,73 @@ private void OnEngineGestureEnded(object? s, SKGestureStateEventArgs e)
565569
566570 #region Transform Helpers
567571
568- private SKPoint ScreenToContentDelta ( float dx , float dy )
572+ /// <summary>
573+ /// Adjusts the screen-space offset so that the point under screenPivot stays fixed
574+ /// when scale changes from oldScale to newScale.
575+ /// </summary>
576+ private void AdjustOffsetForScalePivot ( SKPoint screenPivot , float oldScale , float newScale )
569577 {
570- var inv = SKMatrix . CreateRotationDegrees ( - _rotation ) ;
571- var mapped = inv . MapVector ( dx , dy ) ;
572- return new SKPoint ( mapped . X / _scale , mapped . Y / _scale ) ;
578+ // Screen-space: pivot point relative to view center
579+ var w2 = _viewWidth / 2f ;
580+ var h2 = _viewHeight / 2f ;
581+
582+ // Point in screen space before applying offset
583+ // To keep screenPivot fixed: offset_new = offset_old + pivot * (1 - newScale/oldScale)
584+ // But since Matrix is: offset then center then scale etc, we need:
585+ // The content point under screenPivot must map to the same screen point after scale change.
586+
587+ // Before scale change: screenPivot maps to some content point P
588+ // After scale change: we want P to still be under screenPivot
589+ //
590+ // Let's derive: screenPivot = M * contentPoint
591+ // We want: screenPivot = M_new * contentPoint
592+ // So: offset_new = offset_old + (screenPivot - center) * (1 - oldScale/newScale)
593+
594+ var dx = screenPivot . X - w2 ;
595+ var dy = screenPivot . Y - h2 ;
596+ var factor = 1f - oldScale / newScale ;
597+
598+ _offset = new SKPoint (
599+ _offset . X + dx * factor ,
600+ _offset . Y + dy * factor ) ;
573601 }
574602
575- private void AdjustOffsetForPivot ( SKPoint screenPivot , float oldScale , float newScale , float oldRotDeg , float newRotDeg )
603+ /// <summary>
604+ /// Adjusts the screen-space offset so that the point under screenPivot stays fixed
605+ /// when rotation changes from oldRotation to newRotation.
606+ /// </summary>
607+ private void AdjustOffsetForRotatePivot ( SKPoint screenPivot , float oldRotationDeg , float newRotationDeg )
576608 {
609+ // Screen-space: rotate offset around the pivot point
577610 var w2 = _viewWidth / 2f ;
578611 var h2 = _viewHeight / 2f ;
579- var d = new SKPoint ( screenPivot . X - w2 , screenPivot . Y - h2 ) ;
580-
581- var rotOld = SKMatrix . CreateRotationDegrees ( - oldRotDeg ) ;
582- var qOld = rotOld . MapVector ( d . X , d . Y ) ;
583- qOld = new SKPoint ( qOld . X / oldScale , qOld . Y / oldScale ) ;
584-
585- var rotNew = SKMatrix . CreateRotationDegrees ( - newRotDeg ) ;
586- var qNew = rotNew . MapVector ( d . X , d . Y ) ;
587- qNew = new SKPoint ( qNew . X / newScale , qNew . Y / newScale ) ;
588-
589- _offset = new SKPoint (
590- _offset . X + qNew . X - qOld . X ,
591- _offset . Y + qNew . Y - qOld . Y ) ;
612+
613+ // The rotation happens around view center in screen space.
614+ // To keep screenPivot fixed, we need to adjust offset.
615+ //
616+ // Content under screenPivot: inverse_M(screenPivot)
617+ // After rotation change, we want inverse_M_new(screenPivot) to be the same content point.
618+ //
619+ // Simpler approach: rotate the offset around the pivot point
620+ var deltaDeg = newRotationDeg - oldRotationDeg ;
621+ var deltaRad = deltaDeg * ( float ) Math . PI / 180f ;
622+
623+ // Pivot relative to center
624+ var px = screenPivot . X - w2 ;
625+ var py = screenPivot . Y - h2 ;
626+
627+ // Offset relative to pivot
628+ var ox = _offset . X - px ;
629+ var oy = _offset . Y - py ;
630+
631+ // Rotate offset around pivot
632+ var cos = ( float ) Math . Cos ( deltaRad ) ;
633+ var sin = ( float ) Math . Sin ( deltaRad ) ;
634+ var rotX = ox * cos - oy * sin ;
635+ var rotY = ox * sin + oy * cos ;
636+
637+ // Translate back
638+ _offset = new SKPoint ( rotX + px , rotY + py ) ;
592639 }
593640
594641 private static float Clamp ( float value , float min , float max )
@@ -648,9 +695,8 @@ private void HandleFlingFrame()
648695
649696 Flinging ? . Invoke ( this , new SKFlingEventArgs ( _flingVelocityX , _flingVelocityY , deltaX , deltaY ) ) ;
650697
651- // Apply as pan offset
652- var d = ScreenToContentDelta ( deltaX , deltaY ) ;
653- _offset = new SKPoint ( _offset . X + d . X , _offset . Y + d . Y ) ;
698+ // Apply as pan offset (screen-space)
699+ _offset = new SKPoint ( _offset . X + deltaX , _offset . Y + deltaY ) ;
654700 TransformChanged ? . Invoke ( this , EventArgs . Empty ) ;
655701
656702 // Apply friction (FlingFriction: 0 = no friction, 1 = full friction)
@@ -713,7 +759,7 @@ private void HandleZoomFrame()
713759 // Apply scale change
714760 var oldScale = _scale ;
715761 var newScale = Clamp ( _zoomStartScale * cumulative , MinScale , MaxScale ) ;
716- AdjustOffsetForPivot ( _zoomFocalPoint , oldScale , newScale , _rotation , _rotation ) ;
762+ AdjustOffsetForScalePivot ( _zoomFocalPoint , oldScale , newScale ) ;
717763 _scale = newScale ;
718764 TransformChanged ? . Invoke ( this , EventArgs . Empty ) ;
719765
0 commit comments