0

In my application, I have a feature such that a user is able to re-order items in a list, by long clicking to create a drag shadow, which the user can then drag and insert at a position of choice. Once dropped, the items are re-ordered.

I am struggling to develop a UI test for this. I am able to either successfully long click on the item, to create the drag shadow OR implement a dragging motion. I seem unable to combine the two, into one motion.

I am using Espresso and Barista in my Android UI Tests.

For the long click I used Barista's API:

longClickOn("ITEM");

For the dragging motion, I attempted to create my own Espresso ViewAction:

return new ViewAction() {
        @Override
        public Matcher<View> getConstraints() {
            return isAssignableFrom(ViewGroup.class);
        }

        @Override
        public String getDescription() {
            return "Swiping child " + srcIndex + " to child " + destIndex;
        }

        @Override
        public void perform(UiController uiController, View view) {
            ViewGroup parent = (ViewGroup) view;

            final View srcChild = parent.getChildAt(srcIndex);
            final View destChild = parent.getChildAt(destIndex);

            final CoordinatesProvider srcCoordinatesProvider = new CoordinatesProvider() {
                @Override
                public float[] calculateCoordinates(View view) {
                    int[] location = new int[2];
                    srcChild.getLocationInWindow(location);
                    float x = location[0] + (view.getMeasuredWidth() / 2);
                    float y = location[1] + (view.getMeasuredHeight() / 2);

                    return new float[] {x, y};
                }
            };

            final CoordinatesProvider destCoordinatesProvider = new CoordinatesProvider() {
                @Override
                public float[] calculateCoordinates(View view) {
                    int[] location = new int[2];
                    destChild.getLocationInWindow(location);
                    float x = location[0] + (view.getMeasuredWidth() / 2);
                    float y = location[1] + (view.getMeasuredHeight() / 2);

                    return new float[] {x, y};
                }
            };

            GeneralSwipeAction swipe = new GeneralSwipeAction(Swipe.FAST,
                    srcCoordinatesProvider, destCoordinatesProvider, Press.FINGER);
            swipe.perform(uiController, parent);
        }
    };

EDIT:

Following the answer from @Be_Negative, I've customised the given answer and came up with this:

private static ViewAction drag(final int srcIndex, final int destIndex) {
    return new ViewAction() {
        @Override
        public Matcher<View> getConstraints() {
            return isAssignableFrom(ViewGroup.class);
        }

        @Override
        public String getDescription() {
            return "Swiping child " + srcIndex + " to child " + destIndex;
        }

        @Override
        public void perform(UiController uiController, View view) {
            ViewGroup parent = (ViewGroup) view;

            uiController.loopMainThreadUntilIdle();
            final View srcChild = parent.getChildAt(srcIndex);
            final View destChild = parent.getChildAt(destIndex);

            final CoordinatesProvider coordinatesProvider = getCoordinatesProdvider();

            float[] precision = Press.PINPOINT.describePrecision();
            MotionEvent downEvent = MotionEvents.sendDown(uiController, coordinatesProvider.calculateCoordinates(srcChild), precision).down;

            try {
                long longPressTimeout = (long) (ViewConfiguration.getLongPressTimeout() * 1.5);
                uiController.loopMainThreadForAtLeast(longPressTimeout);

                float[][] steps = interpolateDragging(
                        coordinatesProvider.calculateCoordinates(srcChild),
                        coordinatesProvider.calculateCoordinates(destChild)
                );

                uiController.loopMainThreadUntilIdle();

                for(float[] step : steps) {
                    if( !MotionEvents.sendMovement(uiController, downEvent, step)) {
                        MotionEvents.sendCancel(uiController, downEvent);
                    }
                }

                if(!MotionEvents.sendUp(uiController, downEvent, coordinatesProvider.calculateCoordinates(destChild))) {
                    MotionEvents.sendCancel(uiController, downEvent);
                }
            } catch(Exception e) {
                System.out.println(e);
            } finally {
                downEvent.recycle();
            }
        }
    };
}

Unforunately, it throws an error at MotionEvents.sendMovement as follows:

Error performing 'inject motion event (corresponding down event: MotionEvent { action=ACTION_DOWN, actionButton=0, id[0]=0, x[0]=540.0, y[0]=302.0, toolType[0]=TOOL_TYPE_UNKNOWN, buttonState=BUTTON_PRIMARY, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=34586525, downTime=34586525, deviceId=0, source=0x1002 })' on view 'unknown'.
Roc Boronat
  • 11,395
  • 5
  • 47
  • 59
Hopeful Llama
  • 728
  • 5
  • 26

1 Answers1

1

Here is my shot at this. I heavily relied on existing implementation of long press and swipe to combine them together. There are a couple of diviation thought - for instance the drag interpolator in the default implementation tend to undershoot, so I had to adjust it a little.

class DragAndDropAction(private val sourceViewPosition: Int,
                        private val targetViewPosition: Int) : ViewAction {

    override fun getConstraints(): Matcher<View> {
        return allOf(isDisplayed(), isAssignableFrom(RecyclerView::class.java))
    }

    override fun getDescription(): String {
        return "Drag and drop action"
    }

    override fun perform(uiController: UiController, view: View) {
        val recyclerView: RecyclerView = view as RecyclerView
        //Sending down
        recyclerView.scrollToPosition(sourceViewPosition)
        uiController.loopMainThreadUntilIdle()
        val sourceView = recyclerView.findViewHolderForAdapterPosition(sourceViewPosition).itemView

        val sourceViewCenter = GeneralLocation.VISIBLE_CENTER.calculateCoordinates(sourceView)
        val fingerPrecision = Press.FINGER.describePrecision()

        val downEvent = MotionEvents.sendDown(uiController, sourceViewCenter, fingerPrecision).down
        try {
            // Factor 1.5 is needed, otherwise a long press is not safely detected.
            val longPressTimeout = (ViewConfiguration.getLongPressTimeout() * 1.5f).toLong()
            uiController.loopMainThreadForAtLeast(longPressTimeout)

            //Drag to the position
            recyclerView.scrollToPosition(targetViewPosition)
            uiController.loopMainThreadUntilIdle()
            val targetView = recyclerView.findViewHolderForAdapterPosition(targetViewPosition).itemView
            val targetViewLocation = if (targetViewPosition > sourceViewPosition) {
                GeneralLocation.BOTTOM_CENTER.calculateCoordinates(targetView)
            } else {
                GeneralLocation.TOP_CENTER.calculateCoordinates(targetView)
            }

            val steps = interpolate(sourceViewCenter, targetViewLocation)

            for (i in 0 until steps.size) {
                if (!MotionEvents.sendMovement(uiController, downEvent, steps[i])) {
                    MotionEvents.sendCancel(uiController, downEvent)
                }
            }

            //Release
            if (!MotionEvents.sendUp(uiController, downEvent, targetViewLocation)) {
                MotionEvents.sendCancel(uiController, downEvent)
            }
        } finally {
            downEvent.recycle()
        }
    }

    private val SWIPE_EVENT_COUNT = 10

    private fun interpolate(start: FloatArray, end: FloatArray): Array<FloatArray> {
        val res = Array(SWIPE_EVENT_COUNT) { FloatArray(2) }

        for (i in 1..SWIPE_EVENT_COUNT) {
            res[i - 1][0] = start[0] + (end[0] - start[0]) * i / SWIPE_EVENT_COUNT
            res[i - 1][1] = start[1] + (end[1] - start[1]) * i / SWIPE_EVENT_COUNT
        }

        return res
    }
}

There is a ton of room for improvement, for instance ideally I would want my action to take in the viewmatchers instsead of positions. Other than that it seems to work all right.

Be_Negative
  • 4,892
  • 1
  • 30
  • 33
  • Thanks for the help. Unfortunately it's still not quite working. Please check my edit for more information. – Hopeful Llama Jan 22 '18 at 17:22
  • It seems that it starts touching one of system UI elements (keyboard, notification panel, or bottom buttons bar). I suspect that you need to incorporate scrolling into your view action. In my case, I was using recycler view and was scrolling to the item with the item being dragged. If you are not in the recyclerview, you can use [ScrollToAction](https://android.googlesource.com/platform/frameworks/testing/+/android-support-test/espresso/core/src/main/java/android/support/test/espresso/action/ScrollToAction.java) as a reference – Be_Negative Jan 22 '18 at 23:49
  • Actually, you can just call the view action from your custom `ViewMatchers.withParentIndex()` may be handy, or writing your own matcher that just matches the given view (espresso by default and rightfully does not provide such a matcher as it might be abused) – Be_Negative Jan 23 '18 at 01:30
  • Do you really need to interpolate? – John Glen Jun 12 '22 at 17:36