Search Unity

Resolved Zoom in and out around the cursor

Discussion in 'UI Toolkit' started by RunninglVlan, Jul 20, 2021.

  1. RunninglVlan

    RunninglVlan

    Joined:
    Nov 6, 2018
    Posts:
    182
    I'm having trouble implementing zooming in and out around the cursor in UITK (having done it successfully in uGUI).
    Here are parts of uGUI solution:
    Code (CSharp):
    1. void IScrollHandler.OnScroll(PointerEventData eventData) {
    2.     // target is RectTransform
    3.     RectTransformUtility.ScreenPointToLocalPointInRectangle(target, eventData.position,
    4.         eventData.enterEventCamera, out var localPointInRect);
    5.     var scale = target.localScale;
    6.     // Calculate Vector2 newPivot based on localPointInRect
    7.     var deltaPivot = (target.pivot - newPivot) * scale.y;
    8.     target.pivot = newPivot;
    9.     // Calculate Vector3 deltaPosition based on deltaPivot
    10.     target.localPosition -= deltaPosition;
    11.  
    12.    
    13.     float newZoom = scale.y + eventData.scrollDelta.y * scaleStep;
    14.     newZoom = Mathf.Clamp(newZoom, scaleMin, scaleMax);
    15.     target.localScale = Vector3.one * newZoom;
    16. }
    I tried doing something like that, replaced
    ScreenPointToLocalPointInRectangle
    with
    ChangeCoordinatesTo
    ,
    pivot
    with
    style.transformOrigin
    , but it doesn't yet fully work.
    Then I looked at GraphView's
    ContentZoomer
    and based my code on it, but it still doesn't fully work. Could someone help? BTW, I don't exactly understand all position manipulations. Here's what I have right now:
    Code (CSharp):
    1. private void OnWheel(WheelEvent evt) {
    2.     // target and eventArea are VisualElements
    3.     // eventArea is where WheelEvent Callback is Registered
    4.     var position = target.transform.position;
    5.     var scale = target.transform.scale;
    6.     var vector2 = eventArea.ChangeCoordinatesTo(target, evt.localMousePosition);
    7.     var x = vector2.x + target.layout.x;
    8.     var y = vector2.y + target.layout.y;
    9.     var vector3 = position + Vector3.Scale(new Vector3(x, y, 0.0f), scale);
    10.  
    11.     var newZoom = scale.y - evt.delta.y * ScaleStep;
    12.     newZoom = Mathf.Clamp(newZoom, ScaleMin, ScaleMax);
    13.     var newScale = Vector3.one * newZoom;
    14.  
    15.     var newPosition = vector3 - Vector3.Scale(new Vector3(x, y, 0.0f), newScale);
    16.     target.transform.position = newPosition;
    17.     target.transform.scale = newScale;
    18. }
     
  2. phil-R

    phil-R

    Joined:
    Nov 20, 2020
    Posts:
    9
    Not sure exactly what eventArea is in line 6 of your code (Edit: I understand you have the event callback on a different element, and are transforming coordinates), but I removed it altogether and just put the event callback on the element I'm trying to zoom.

    Code (CSharp):
    1. var vector2 = evt.localMousePosition;
    Seems to work as expected, but I assume you want to have the zoomable asset inside a ScrollView. For that,
    transform.scale
    does not seem to effect the parent ScrollView, so I believe you'll need to adjust the absolute size with
    layout.width
    and
    layout.height
    .

    I'll work in this when I get a chance tomorrow.
     
    Last edited: Jul 22, 2021
  3. RunninglVlan

    RunninglVlan

    Joined:
    Nov 6, 2018
    Posts:
    182
    Actually I don't have a ScrollView. eventArea is a bigger element inside target, so that I could zoom outside of target too.
    I could probably register a callback on some parent element which doesn't move (target right now is a third element in the hierarchy - panel, template and target), but I'd still need to convert coordinates.
    Now that I think about it, I could register callback on the target too, it would trigger on eventArea too as it's part of target, but it would also trigger on all the other elements of target, which I probably don't want. But registering callback on the target and using just
    var vector2 = evt.localMousePosition
    still doesn't work =(
     
    Last edited: Jul 22, 2021
  4. RunninglVlan

    RunninglVlan

    Joined:
    Nov 6, 2018
    Posts:
    182
    Here's all the stripped down source to be able to better understand my problem and reproduce it.
    Code (UXML):
    1. <ui:UXML xmlns:ui="UnityEngine.UIElements">
    2.     <Style src="style.uss" />
    3.     <ui:VisualElement name="target" class="centered">
    4.         <UITK.Zoomable />
    5.         <ui:VisualElement name="bg" class="centered" />
    6.         <ui:VisualElement class="box" style="left: 100px; top: 150px" />
    7.     </ui:VisualElement>
    8. </ui:UXML>
    Code (USS):
    1. :root {
    2.     height: 100%;
    3.     background-color: white;
    4. }
    5.  
    6. #target {
    7.     width: 500px; height: 500px;
    8.     background-color: blue;
    9. }
    10.  
    11. #bg {
    12.     width: 300%; height: 200%;
    13.     background-color: red;
    14.     opacity: .5;
    15. }
    16.  
    17. .box {
    18.     position: absolute;
    19.     width: 200px; height: 200px;
    20.     background-color: green;
    21. }
    22.  
    23. .centered {
    24.     position: absolute;
    25.     left: 50%; top: 50%;
    26.     translate: -50% -50%;
    27. }
    Code (CSharp):
    1. using UnityEngine;
    2. using UnityEngine.UIElements;
    3.  
    4. namespace UITK {
    5.     public class Zoomable : VisualElement {
    6.         private VisualElement eventArea = null!;
    7.  
    8.         public Zoomable() {
    9.             RegisterCallback<GeometryChangedEvent>(Init);
    10.         }
    11.  
    12.         private void Init(GeometryChangedEvent evt) {
    13.             eventArea = parent.Q<VisualElement>("bg");
    14.             eventArea.RegisterCallback<WheelEvent>(OnWheel);
    15.             UnregisterCallback<GeometryChangedEvent>(Init);
    16.         }
    17.  
    18.         private void OnWheel(WheelEvent evt) {
    19.             var position = parent.transform.position;
    20.             var scale = parent.transform.scale;
    21.             var vector2 = eventArea.ChangeCoordinatesTo(parent, evt.localMousePosition);
    22.             var x = vector2.x + parent.layout.x;
    23.             var y = vector2.y + parent.layout.y;
    24.             var vector3 = position + Vector3.Scale(new Vector3(x, y, 0), scale);
    25.  
    26.             var newZoom = scale.y - evt.delta.y * .1f;
    27.             var newScale = Vector3.one * newZoom;
    28.  
    29.             var newPosition = vector3 - Vector3.Scale(new Vector3(x, y, 0), newScale);
    30.             parent.transform.position = newPosition;
    31.             parent.transform.scale = newScale;
    32.         }
    33.  
    34.         public new class UxmlFactory : UxmlFactory<Zoomable, UxmlTraits> { }
    35.     }
    36. }
    Then just create new scene with this UIDocument, a Camera and EventSystem. Here's what I get:
    upload_2021-7-22_12-44-34.gif
    When I want this (made with uGUI):
    upload_2021-7-22_13-1-29.gif
     
    Last edited: Jul 31, 2021
    Nexer8 likes this.
  5. RunninglVlan

    RunninglVlan

    Joined:
    Nov 6, 2018
    Posts:
    182
    Still couldn't figure this out.
    Here's the code I was talking about if anyone is interested. It kinda works, but twitches when the change in
    transformOrigin
    is too big.
    Code (CSharp):
    1. var scale = parent.transform.scale;
    2. var newPivot = eventArea.ChangeCoordinatesTo(parent, evt.localMousePosition);
    3.  
    4. var newZoom = scale.y - evt.delta.y * .1f;
    5. var newScale = Vector3.one * newZoom;
    6.  
    7. parent.style.transformOrigin = new TransformOrigin(newPivot.x, newPivot.y, 0);
    8. parent.transform.scale = newScale;
    upload_2021-7-26_21-18-18.gif
     
  6. RunninglVlan

    RunninglVlan

    Joined:
    Nov 6, 2018
    Posts:
    182
    Anyone?
    I've put everything in one
    EditorWindow
    file for everyone to reproduce the issue even faster, you can also switch between 2 implementations by clicking radio buttons.
    Code (CSharp):
    1. using UnityEditor;
    2. using UnityEngine;
    3. using UnityEngine.UIElements;
    4.  
    5. public class TestWindow : EditorWindow {
    6.     private VisualElement zoomable = null!;
    7.     private bool contentZoomer = true;
    8.  
    9.     [MenuItem("Window/Test")]
    10.     public static void ShowWindow() {
    11.         GetWindow<TestWindow>("Test").Show();
    12.     }
    13.  
    14.     void CreateGUI() {
    15.         zoomable = Zoomable();
    16.         var zoomableArea = ZoomableArea();
    17.         zoomableArea.RegisterCallback<WheelEvent>(OnWheel);
    18.         zoomable.Add(zoomableArea);
    19.         zoomable.Add(Box());
    20.  
    21.         rootVisualElement.Add(zoomable);
    22.         rootVisualElement.Add(DebugElements());
    23.     }
    24.  
    25.     private void OnWheel(WheelEvent evt) {
    26.         var target = evt.target as VisualElement;
    27.         var scale = zoomable.transform.scale;
    28.         var newPivot = target.ChangeCoordinatesTo(zoomable, evt.localMousePosition);
    29.         var newZoom = scale.y - evt.delta.y * .01f;
    30.         var newScale = Vector3.one * newZoom;
    31.         if (contentZoomer) {
    32.             // Why doesn't code based on UnityEditor.Experimental.GraphView.ContentZoomer work?
    33.             var x = newPivot.x + zoomable.layout.x;
    34.             var y = newPivot.y + zoomable.layout.y;
    35.             var vector3 = zoomable.transform.position + Vector3.Scale(new Vector3(x, y, 0), scale);
    36.             var newPosition = vector3 - Vector3.Scale(new Vector3(x, y, 0), newScale);
    37.             zoomable.transform.position = newPosition;
    38.         } else {
    39.             // What is missing here to work properly?
    40.             zoomable.style.transformOrigin = new TransformOrigin(newPivot.x, newPivot.y, 0);
    41.         }
    42.         zoomable.transform.scale = newScale;
    43.     }
    44.  
    45.     private static VisualElement Zoomable() {
    46.         var element = Centered(new VisualElement());
    47.         element.style.width = 300;
    48.         element.style.height = 300;
    49.         element.style.backgroundColor = Color.black;
    50.         return element;
    51.     }
    52.  
    53.     private static VisualElement ZoomableArea() {
    54.         var element = Centered(new VisualElement());
    55.         element.style.width = Length.Percent(300);
    56.         element.style.height = Length.Percent(200);
    57.         element.style.backgroundColor = Color.white;
    58.         element.style.opacity = .5f;
    59.         return element;
    60.     }
    61.  
    62.     private static VisualElement Box() {
    63.         var element = new VisualElement();
    64.         element.style.position = Position.Absolute;
    65.         element.style.width = 125;
    66.         element.style.height = 125;
    67.         element.style.backgroundColor = Color.black;
    68.         element.style.left = 50;
    69.         element.style.top = 75;
    70.         return element;
    71.     }
    72.  
    73.     private static VisualElement Centered(VisualElement element) {
    74.         element.style.position = Position.Absolute;
    75.         element.style.left = Length.Percent(50);
    76.         element.style.top = Length.Percent(50);
    77.         element.style.translate = new Translate(Length.Percent(-50), Length.Percent(-50), 0);
    78.         return element;
    79.     }
    80.  
    81.     private VisualElement DebugElements() {
    82.         var element = new VisualElement();
    83.         element.style.position = Position.Absolute;
    84.         element.style.flexDirection = FlexDirection.Row;
    85.  
    86.         var contentZoomerRadio = new RadioButton("Based on ContentZoomer") { value = contentZoomer };
    87.         contentZoomerRadio.RegisterValueChangedCallback(evt => contentZoomer = evt.newValue);
    88.         element.Add(contentZoomerRadio);
    89.  
    90.         var transformOriginRadio = new RadioButton("Change TransformOrigin");
    91.         transformOriginRadio.RegisterValueChangedCallback(evt => contentZoomer = !evt.newValue);
    92.         element.Add(transformOriginRadio);
    93.  
    94.         element.Add(new Button(Reset) { text = "Reset" });
    95.         return element;
    96.  
    97.         void Reset() {
    98.             zoomable.transform.position = Vector3.zero;
    99.             zoomable.transform.scale = Vector3.one;
    100.             zoomable.style.translate = new Translate(Length.Percent(-50), Length.Percent(-50), 0);
    101.         }
    102.     }
    103. }
     
    Last edited: Aug 2, 2021
  7. Nexer8

    Nexer8

    Joined:
    Dec 10, 2017
    Posts:
    271
    Converted some Javascript code I found online and combined it with the zoom function used by Unity's Graph View API. The result was this manipulator that you can add to VisualElements:
    Code (CSharp):
    1. using System;
    2. using UnityEngine;
    3. using UnityEngine.UIElements;
    4.  
    5. namespace Nexerate.Interface
    6. {
    7.     public class ZoomManipulator : Manipulator
    8.     {
    9.         #region Default Values
    10.         public static readonly float DefaultReferenceScale = 1;
    11.         public static readonly float DefaultMinScale = 0.25f;
    12.         public static readonly float DefaultMaxScale = 1;
    13.         public static readonly float DefaultScaleStep = 0.15f;
    14.         #endregion
    15.  
    16.         /// <summary>
    17.         /// Scale that should be computed when scroll wheel offset is at zero.
    18.         /// </summary>
    19.         public float ReferenceScale { get; set; } = DefaultReferenceScale;
    20.  
    21.         public float MinZoom { get; set; } = DefaultMinScale;
    22.         public float MaxZoom { get; set; } = DefaultMaxScale;
    23.  
    24.         /// <summary>
    25.         /// Relative scale change when zooming in/out (e.g. For 15%, use 0.15).
    26.         /// </summary>
    27.         /// <remarks>
    28.         /// Depending on the values of <c>minScale</c>, <c>maxScale</c> and <c>scaleStep</c>, it is not guaranteed that
    29.         /// the first and last two scale steps will correspond exactly to the value specified in <c>scaleStep</c>.
    30.         /// </remarks>
    31.         public float ZoomStep { get; set; } = DefaultScaleStep;
    32.  
    33.         protected override void RegisterCallbacksOnTarget()
    34.         {
    35.             zoom = target;
    36.             zoom.Origin(new TransformOrigin(0, 0, 0));
    37.  
    38.             target.RegisterCallback<PointerDownEvent>(PointerDown);
    39.             target.RegisterCallback<PointerMoveEvent>(PointerMove);
    40.             target.RegisterCallback<PointerUpEvent>(PointerUp);
    41.             target.RegisterCallback<WheelEvent>(OnWheel);
    42.         }
    43.  
    44.         protected override void UnregisterCallbacksFromTarget()
    45.         {
    46.             target.UnregisterCallback<PointerDownEvent>(PointerDown);
    47.             target.UnregisterCallback<PointerMoveEvent>(PointerMove);
    48.             target.UnregisterCallback<PointerUpEvent>(PointerUp);
    49.             target.UnregisterCallback<WheelEvent>(OnWheel);
    50.         }
    51.  
    52.         public ZoomManipulator() { }
    53.  
    54.         public ZoomManipulator(float min, float max, float step)
    55.         {
    56.             MinZoom = min;
    57.             MaxZoom = max;
    58.             ZoomStep = step;
    59.         }
    60.  
    61.         float scale = 1;
    62.         bool panning;
    63.         float pointX;
    64.         float pointY;
    65.         Vector2 start;
    66.  
    67.         public VisualElement zoom;
    68.         void SetTransform() => zoom.TranslateX(pointX).TranslateY(pointY).Scale(scale);
    69.  
    70.         void OnWheel(WheelEvent evt)
    71.         {
    72.             var x = evt.localMousePosition.x;
    73.             var y = evt.localMousePosition.y;
    74.  
    75.             var xs = (x - pointX) / scale;
    76.             var ys = (y - pointY) / scale;
    77.  
    78.             scale = CalculateNewZoom(scale, -evt.delta.y, ZoomStep, ReferenceScale, MinZoom, MaxZoom);//Calculate zoom
    79.  
    80.             pointX = x - xs * scale;
    81.             pointY = y - ys * scale;
    82.  
    83.             SetTransform();
    84.         }
    85.  
    86.         void PointerDown(PointerDownEvent evt)
    87.         {
    88.             evt.PreventDefault();
    89.             start = new Vector2(evt.localPosition.x - pointX, evt.localPosition.y - pointY);
    90.             panning = true;
    91.         }
    92.  
    93.         void PointerMove(PointerMoveEvent evt)
    94.         {
    95.             evt.PreventDefault();
    96.             if (!panning) return;
    97.             pointX = evt.localPosition.x - start.x;
    98.             pointY = evt.localPosition.y - start.y;
    99.             SetTransform();
    100.         }
    101.  
    102.         void PointerUp(PointerUpEvent evt)
    103.         {
    104.             panning = false;
    105.         }
    106.  
    107.         // Compute the parameters of our exponential model:
    108.         // z(w) = (1 + s) ^ (w + a) + b
    109.         // Where
    110.         // z: calculated zoom level
    111.         // w: accumulated wheel deltas (1 unit = 1 mouse notch)
    112.         // s: zoom step
    113.         //
    114.         // The factors a and b are calculated in order to satisfy the conditions:
    115.         // z(0) = referenceZoom
    116.         // z(1) = referenceZoom * (1 + zoomStep)
    117.         private static float CalculateNewZoom(float currentZoom, float wheelDelta, float zoomStep, float referenceZoom, float minZoom, float maxZoom)
    118.         {
    119.             #region Validation
    120.             if (minZoom <= 0)
    121.             {
    122.                 Debug.LogError($"The minimum zoom ({minZoom}) must be greater than zero.");
    123.                 return currentZoom;
    124.             }
    125.             if (referenceZoom < minZoom)
    126.             {
    127.                 Debug.LogError($"The reference zoom ({referenceZoom}) must be greater than or equal to the minimum zoom ({minZoom}).");
    128.                 return currentZoom;
    129.             }
    130.             if (referenceZoom > maxZoom)
    131.             {
    132.                 Debug.LogError($"The reference zoom ({referenceZoom}) must be less than or equal to the maximum zoom ({maxZoom}).");
    133.                 return currentZoom;
    134.             }
    135.             if (zoomStep < 0)
    136.             {
    137.                 Debug.LogError($"The zoom step ({zoomStep}) must be greater than or equal to zero.");
    138.                 return currentZoom;
    139.             }
    140.             #endregion
    141.  
    142.             currentZoom = Mathf.Clamp(currentZoom, minZoom, maxZoom);
    143.  
    144.             if (Mathf.Approximately(wheelDelta, 0)) return currentZoom;
    145.  
    146.             // Calculate the factors of our model:
    147.             double a = Math.Log(referenceZoom, 1 + zoomStep);
    148.             double b = referenceZoom - Math.Pow(1 + zoomStep, a);
    149.  
    150.             // Convert zoom levels to scroll wheel values.
    151.             double minWheel = Math.Log(minZoom - b, 1 + zoomStep) - a;
    152.             double maxWheel = Math.Log(maxZoom - b, 1 + zoomStep) - a;
    153.             double currentWheel = Math.Log(currentZoom - b, 1 + zoomStep) - a;
    154.  
    155.             // Except when the delta is zero, for each event, consider that the delta corresponds to a rotation by a
    156.             // full notch. The scroll wheel abstraction system is buggy and incomplete: with a regular mouse, the
    157.             // minimum wheel movement is 0.1 on OS X and 3 on Windows. We can't simply accumulate deltas like these, so
    158.             // we accumulate integers only. This may be problematic with high resolution scroll wheels: many small
    159.             // events will be fired. However, at this point, we have no way to differentiate a high resolution scroll
    160.             // wheel delta from a non-accelerated scroll wheel delta of one notch on OS X.
    161.             wheelDelta = Math.Sign(wheelDelta);
    162.             currentWheel += wheelDelta;
    163.  
    164.             // Assimilate to the boundary when it is nearby.
    165.             if (currentWheel > maxWheel - 0.5) return maxZoom;
    166.             if (currentWheel < minWheel + 0.5) return minZoom;
    167.          
    168.  
    169.             // Snap the wheel to the unit grid.
    170.             currentWheel = Math.Round(currentWheel);
    171.  
    172.             // Do not assimilate again. Otherwise, points as far as 1.5 units away could be stuck to the boundary
    173.             // because the wheel delta is either +1 or -1.
    174.  
    175.             // Calculate the corresponding zoom level.
    176.             return (float)(Math.Pow(1 + zoomStep, currentWheel + a) + b);
    177.         }
    178.     }
    179. }
    *Only some minor problems though. The Javascript example seems to work just fine, but when I replicated the code, the panning became jittery. Zoom also breaks at times (You will understand when you try and use this code). I suspect this has to do with the events acting weird and will see what I can do to fix it (But most likely a bug on Unity's side).

    *Oh, and also, the TranslateX, TranslateY and Scale functions do not exist outside of my API. Should be easily converted to: element.style.translate = (x, y) and element.style.scale = Vector3.One * scale.
     
    RunninglVlan likes this.
  8. RunninglVlan

    RunninglVlan

    Joined:
    Nov 6, 2018
    Posts:
    182
    Thanks, but this still doesn't work for me.
    I used it in my EditorWindow example like this:
    Code (CSharp):
    1. // zoomableArea.RegisterCallback<WheelEvent>(OnWheel);
    2. zoomable.AddManipulator(new ZoomManipulator());
    and it still didn't work.
    Replaced your extension methods with this code:
    Code (CSharp):
    1. // zoom.Origin(new TransformOrigin(0, 0, 0));
    2. zoom.style.transformOrigin = new TransformOrigin(0, 0, 0);
    3.  
    4. // zoom.TranslateX(pointX).TranslateY(pointY).Scale(scale);
    5. zoom.style.translate = new Translate(pointX, pointY, 0);
    6. zoom.style.scale = new Scale(Vector3.one * scale);
    upload_2021-8-1_0-19-40.gif
    BTW, haven't found documentation about manipulators, when should we use them?
     
  9. Nexer8

    Nexer8

    Joined:
    Dec 10, 2017
    Posts:
    271
    Hmmmm. It is a little weird why it does not work. In the link I attached, there is an example in javascript and everything works as expected there. The code I've made is literally just a conversion from that to UI Toolkit. Will investigate if this is a bug on my end or if this has to to with Unity's events.

    Also, manipulators are often used when you want elements to handle input. Especially if it is input you want to re-use. For example you could have a MouseManipulator which just captures the mouse movement inside of a VisualElement and triggers an event with the position. This could then be used for sliders, panning, scrollbars etc.
     
  10. Nexer8

    Nexer8

    Joined:
    Dec 10, 2017
    Posts:
    271
    Alright, so I figured it out and in retrospect it makes sense. Register callbacks to the target, but manipulate its first child.
    Code (CSharp):
    1. void Refresh()
    2. {
    3.     zoom = target.hierarchy[0];
    4.     zoom.Origin(new TransformOrigin(0, 0, 0));
    5. }
    Call this method when you are sure that the target will have its children. In my case, I call it in OnWheel() and PointerDown().

    Could be a good idea to make an element derived from VisualElement that ensures a child is always present and used as the content container.

    The problem with the other implementation was that we were using local mouse coordinates while moving the element. This caused the clitching.
     
    RunninglVlan likes this.
  11. RunninglVlan

    RunninglVlan

    Joined:
    Nov 6, 2018
    Posts:
    182
    Thanks for you help and your solution!
    I finally finished TransformOrigin solution too! Just move the element by difference between old and new
    worldBound.position
    .
    Code (CSharp):
    1. var scale = parent.transform.scale;
    2. var target = evt.target as VisualElement;
    3. var newOrigin = target.ChangeCoordinatesTo(parent, evt.localMousePosition);
    4.  
    5. var oldBound = parent.worldBound;
    6. parent.style.transformOrigin = new TransformOrigin(newOrigin.x, newOrigin.y, 0);
    7. var deltaPosition = oldBound.position - parent.worldBound.position;
    8. parent.transform.position += (Vector3)deltaPosition;
    9.  
    10. var newZoom = scale.y - evt.delta.y * .1f;
    11. parent.transform.scale = Vector3.one * newZoom;
     
    Nexer8 likes this.