[ScrollView] Custom drag and scrolling behaviours for items inside ScrollViews

Hello ! :slight_smile:

Since updating the UI Toolkit package, we’re having some issues (on mobile devices) to handle custom dragging/scrolling behaviours with items inside a ScrollView.

Previously, we would prevent the input events from propagating, directly in the registered callbacks of the inner ScrollView items : until now, we had satisfactory results (we could handle everything as we wanted and scroll the ScrollView according to our own needs).

However, the ScrollView is now working a little differently :

  • It listens to PointerDown/UpEvents, now when trickling down
  • It listens to PointerCaptureEvents (never used before the whole capturing pointer paradigm :smile:)

(I guess the UITK team did these things so we could scroll along ScrollViews when dragging from a Button : before that, Buttons were catching all the inputs and prevented the user from scrolling).

I think I need to capture the pointer(s?) and prevent the PointerMove/PointerUp events from propagating ?..
If anyone has already tried to do this kind of stuff, I would be very happy to hear about your findings and results :wink:

Here’s an example of things we would like to do :

NB : this is just an example, I might be able to do this with a “reorderable” ListView -once I’ll know how to use them and decide it would be a good candidate for this use case.

Hi!

You’re right, the mobile scrolling support was extended to account for every control in the recent release.

So it does now use the PointerCapture events to allow a more targeted control. To prevent the default scroll behaviour from happening, you would need to CapturePointer() when your ReorderingElements are pressed, and ReleasePointer() when your reordering is over. Like you mentioned, stopping propagation on your events also helps to prevent undesired behaviour. Capturing the pointer will cancel the manipulation on the scroll view and should allow you to do what you want!

Let us know if you encounter more issues after adding the pointer capture!

Cheers!

1 Like

Little bump to say that I got back to the actual implementation of this, and you answer led me to the right direction, thanks :slight_smile:

Although, the draggable tile could be displayed behind the other ones : so I used an additionnal draggable/ghost tile to bypass the lack of z-order manual setting (the ghost tile imitate the dragging tile and is always displayed on top of all the tiles).


However, I have huge performance issues when manually scrolling the ScrollView (through code) while the pointer is moving :

  • Issue doesn’t appear when scrolling normally (=> relying on UITK ScrollView implementation)
  • Issue doesn’t appear when manually scrolling the ScrollView and pointer isn’t moving

It looks like there is a rather expensive style update triggered by RecomputeTopElementUnderPointer(). Setting recomputeTopElementUnderMouse to false in MouseEvents.cs “fixes” the issue.

The issue is not manually scrolling the ScrollView while the pointer is moving…

It’s manually scrolling the ScrollView outside of callbacks while the pointer is moving.
On my tests, the performance drops when modifying the scrollOffset in a repeated scheduled action or an async method, while the pointer is moving (it’s okay when it is not moving).

It’s unclear to me why RecomputeTopElementUnderPointers is so expensive in this particular context.

Here is my code :
With a repeated scheduled action

// Scheduled action in OnPointerDown callback
{
this.schedule.Execute(UpdateScrollWhileDragging).Every(16).Until(() => !_dragging);
}  

        private void UpdateScrollWhileDragging(TimerState timerState)
        {
            Vector2 pointerPosInScrollView = _scrollView.contentViewport.WorldToLocal(_currentPointerPos);

            float yNewScrollOffset = _scrollView.scrollOffset.y;
       
            if (pointerPosInScrollView.y < _ghostTileTemplateContainer.layout.height && _scrollView.scrollOffset.y >= 0)
            {
                // One tile and a half in one second
                var test = (timerState.deltaTime / 1000f) * 1.5f * _ghostTileTemplateContainer.layout.height;
                yNewScrollOffset -= test;

                if (yNewScrollOffset < 0)
                    yNewScrollOffset = 0;
            }
            else if (pointerPosInScrollView.y >
                     _scrollView.contentViewport.layout.height - _ghostTileTemplateContainer.layout.height)
            {
                var test = (timerState.deltaTime / 1000f) * 1.5f * _ghostTileTemplateContainer.layout.height;
                yNewScrollOffset += test;

                if (yNewScrollOffset > _scrollView.verticalScroller.highValue)
                    yNewScrollOffset = _scrollView.verticalScroller.highValue;
            }
            else
            {
                return;
            }

            _scrollView.verticalScroller.value = yNewScrollOffset;
            //RefreshGhostTilePosition();
        }

Modifying the periodicity doesn’t change anything : if the pointer is moving when the action is executed, we see a performance spike (costly style update).

With an async method

private async void UpdateScrollWhileDragging()
{
    while (_dragging)
    {
        Vector2 pointerPosInScrollView = _scrollView.contentViewport.WorldToLocal(_currentPointerPos);

        float yNewScrollOffset = _scrollView.scrollOffset.y;

        if (pointerPosInScrollView.y < _ghostTileTemplateContainer.layout.height &&
            _scrollView.scrollOffset.y >= 0)
        {
            // One tile and a half in one second
            var test = (Time.deltaTime) * 1.5f * _ghostTileTemplateContainer.layout.height;
            yNewScrollOffset -= test;

            if (yNewScrollOffset < 0)
                yNewScrollOffset = 0;
          
            _scrollView.verticalScroller.value = yNewScrollOffset;
        }
        else if (pointerPosInScrollView.y >
                 _scrollView.contentViewport.layout.height - _ghostTileTemplateContainer.layout.height)
        {
            var test = (Time.deltaTime) * 1.5f * _ghostTileTemplateContainer.layout.height;
            yNewScrollOffset += test;

            if (yNewScrollOffset > _scrollView.verticalScroller.highValue)
                yNewScrollOffset = _scrollView.verticalScroller.highValue;
          
            _scrollView.verticalScroller.value = yNewScrollOffset;
        }

        await Task.Yield();
    }
}

Again : performance is okay when the pointer is not moving, but collapses when it is moving.

If I set the ScrollView scrollOffset in a PointerMove callback (like in the OnPointerMove ScrollView method), there is no style update as costly as in the previous profiler screenshots.

(profiler without deep profile :

)

I’m concerned we’ll see again this kind of performance issue when we’ll want to modify a style or a position during a user interaction : but I might have missed something

Thanks for reporting this issue. I’ll try to repro on my end.

1 Like

Additional info

About performance ( @uMathieu ) :
In the previous answer, the app is profiled using the Device Simulator. Performance is okayish on-device for the same studied case. However, just holding my finger down, or moving it, triggers relatively expensive Runtime Panels Updates.

On a light UI, these can take from 1ms to 3ms. On a heavier (not so heavy) UI, these take around 5 to 10 ms with numerous >10ms spikes. It feels strange, considering that there was nothing visually updated in this test (no hover/focus styles, no custom UI changes).
Deep profiling doesn’t show anything extraordinary. UpdateRuntimePanels() only sends/handles UI input events, but I am wondering if it’s not redundant to send both IMGUI and touch events ?

About pointer capture ( @griendeau_unity ), two things to take into account if someone wants to do something similar :

  • on mobile, there are currently pointer events related to touch (pointerId != 0) but also pointer events generated from IMGUI events (pointerId == 0). Both can be primary events. So I had situations where the ScrollView was scrolling according to the IMGUI/mouse event while my custom element captured the touch pointer event. (this was very wrong :p)

  • the ScrollView sends a useful PointerCancelEvent on cases where it is usually relevant for the ScrollView to keep the control.

  • I missed it but I just noticed that we can send a PointerCancelEvent to the contentContainer of the ScrollView. In this case, the ScrollView explicitly releases the scrolling.

Also, ScrollView listens to PointerCapture events, where it registers PointerMove events for scroll handling. For another UI system, I had to stop the propagation of PointerCapture events by putting a custom element between the ScrollView and its child buttons.

Is there any code for this? I’m looking for an example of the same to do in my project. Thank you in advance

Robert

Hey @smokinpuppy !
I cannot share my sample but can give you more hints if needed.

What you need is to :

  • listen to PointerDown events on the draggable item

  • CapturePointer with the pointer ID given by the pointer down events

  • At this point, you can hide the item being dragged and show a “dummy” item, displayed on top of all your list : this item shoud mimick the look of the draggable item (so it looks like the draggable item is starting to move)

  • listen to PointerMove/PointerLeave events on the elements that has captured the pointer

  • do your dragging logic in the PointerMove callbacks (move the “dummy” item according the pointer position)

  • at PointerLeave event :

  • hide the “dummy” item

  • reshow the draggable item, after updating its position in the hierarchy (use PlaceInFront/PlaceBehind for that)

This might require quite a load of work depending on the behaviours/animations that you need. You might want to take a look at the UI Toolkit’s ListView element, which supports drag and drop I think (not tested on mobile)

@Midiphony-panda thank you for the insight on this. I’ll try to see if I can make it work

Here my solution, thank you all
@Midiphony

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class DragController : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
{
    private RectTransform currentTransform;
    private GameObject instancePrefab;
    private GameObject mainContent;
    private Vector3 initialePosition;
  
    private int totalChild;

    void Awake()
    {
        currentTransform = this.GetComponent<RectTransform>();
        mainContent = currentTransform.parent.gameObject;
    }

    public void OnPointerDown(PointerEventData eventData)
    {
        initialePosition = currentTransform.position;
        totalChild = mainContent.transform.childCount;

        if (instancePrefab == null)
        {
            instancePrefab = Instantiate(this.gameObject, mainContent.transform.parent.transform);
            instancePrefab.GetComponent<Image>().enabled = false;
        }
    }

    public void OnDrag(PointerEventData eventData)
    {
        currentTransform.position = new Vector3(eventData.position.x, currentTransform.position.y, currentTransform.position.z);

        if (instancePrefab != null)
        {
            instancePrefab.GetComponent<Image>().enabled = true;
            currentTransform.GetComponent<Image>().enabled = false;

            instancePrefab.transform.position = currentTransform.position;
        }

        for (int i = 0; i < totalChild; i++)
        {
            if (i != currentTransform.GetSiblingIndex())
            {
                Transform otherTransform = mainContent.transform.GetChild(i);
                int distance = (int)Vector3.Distance(currentTransform.position, otherTransform.position);

                if (distance <= 20)
                {
                    Vector3 otherTransformOldPosition = otherTransform.position;
                    // Vertical
                    /*otherTransform.position = new Vector3(otherTransform.position.x, initialePosition.y,
                        otherTransform.position.z);
                    currentTransform.position = new Vector3(currentTransform.position.x, otherTransformOldPosition.y,
                        currentTransform.position.z);*/
                    // Horizontal
                    otherTransform.position = new Vector3(initialePosition.x, otherTransform.position.y,
                        otherTransform.position.z);
                    currentTransform.position = new Vector3(otherTransformOldPosition.x, currentTransform.position.y,
                        currentTransform.position.z);

                    currentTransform.SetSiblingIndex(otherTransform.GetSiblingIndex());
                    initialePosition = currentTransform.position;
                }
            }
        }
    }

    public void OnPointerUp(PointerEventData eventData)
    {
        currentTransform.position = initialePosition;
        instancePrefab.GetComponent<Image>().enabled = false;
        currentTransform.GetComponent<Image>().enabled = true;
        Destroy(instancePrefab);
        instancePrefab = null;
      
    }
}