RadListView: Swipe Actions

The Swipe Actions feature is an improved version of the already familiar Swipe-to-Execute experience. The feature has been completely revamped for the Android platform to make it behave closer to its iOS counterpart. Keeping the same API a wider range of scenarios is supported. One of the main differences between the old Swipe-to-Execute behavior and Swipe Actions is the support for interactive elements within the item being swiped, i.e. the main content of the item. The new Swipe Actions behavior addresses several glitches with animations as well.

Here's an example of how the Swipe Actions behavior looks on Android and iOS:

RadListView: Swipe Actions RadListView: Swipe Actions

Enabling Swipe Actions in your RadListView

For this tutorial we will use the already familiar setup from the RadListView: Getting started section.

To enable the swipe-to-execute functionality in RadListView you first need to set the swipeActions property to true:

<RadListView #myListView [items]="dataItems" row="1" selectionBehavior="None" (itemSwipeProgressEnded)="onSwipeCellFinished($event)"
    (itemSwipeProgressStarted)="onSwipeCellStarted($event)" (itemSwipeProgressChanged)="onCellSwiping($event)" swipeActions="true">
</RadListView>

You can also set this property via code

Also note that we have provided handlers for the Swipe Actions events. These handlers will enable you to see which item is being swiped, at what offset and when the user has finished swiping.

Second, you need to define the content that will be shown to the user when they swipe an item. This is done via the tkListItemSwipeTemplate inline directive as shown in the HTML snippet below:

<GridLayout *tkListItemSwipeTemplate columns="auto, *, auto" class="gridLayoutLayout">
     <StackLayout id="mark-view" col="0" class="markViewStackLayout" (tap)="onLeftSwipeClick($event)">
        <Label text="mark" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
    </StackLayout>
    <StackLayout id="delete-view" col="2" class="deleteViewStackLayout" (tap)="onRightSwipeClick($event)">
        <Label text="delete" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
    </StackLayout>
</GridLayout>

The swipe template will be a GridLayout with three columns. The first and the last column each contain a StackLayout. These elements represent the actions revealed by swiping the item either left or right. The second column in the middle is set to take the remaining width of the item so that the two StackLayout elements are positioned at both ends of the item.

Implementing swipe actions

There are two popular ways to implement swipe actions. In the first approach you define a swipe threshold which makes the swiped item stick at a given position (the swipe limit) revealing the swipe actions. The user then taps on a given action to perform it.
In the second approach you simply listen for the itemSwipeProgressChangedEvent event and once the item being swiped reaches a given threshold, you execute the corresponding action when the swiped item is released. The following examples demonstrate how these approaches are implemented.

Tap-to-execute actions

In this scenario the user needs to swipe an item to reveal the actions and then tap on one to execute it. Let's use the swipe template from the XML snippet above:

<GridLayout *tkListItemSwipeTemplate columns="auto, *, auto" class="gridLayoutLayout">
     <StackLayout id="mark-view" col="0" class="markViewStackLayout" (tap)="onLeftSwipeClick($event)">
        <Label text="mark" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
    </StackLayout>
    <StackLayout id="delete-view" col="2" class="deleteViewStackLayout" (tap)="onRightSwipeClick($event)">
        <Label text="delete" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
    </StackLayout>
</GridLayout>

The behavior that we have to implement is as follows:

  • user swipes an item
  • user releases the item and it sticks open revealing the action(s)
  • user taps on an action, an event handler is called and the swiped item is closed

Note that we have subscribed for the tap events of the StackLayout elements that are used to represent the actions.

For that purpose we need to use the itemSwipeProgressStartedEvent event handler and define the swipe parameters that will determine where the swiped item will stick:

public onSwipeCellStarted(args: ListViewEventData) {
    var swipeLimits = args.data.swipeLimits;
    var swipeView = args['object'];
    var leftItem = swipeView.getViewById<View>('mark-view');
    var rightItem = swipeView.getViewById<View>('delete-view');
    swipeLimits.left = leftItem.getMeasuredWidth();
    swipeLimits.right = rightItem.getMeasuredWidth();
    swipeLimits.threshold = leftItem.getMeasuredWidth() / 2;
}

The args.data.swipeLimits object (you can read more about this object at the end of this article) can be used to define the distance an item can be swiped either left or right, as well as the threshold that determines the offset beyond which the swiped item will dock open at the limit position. In this particular scenario, the swipe limits are defined to be the corresponding width of the action element for the left and right sides. The threshold is defined to be half that width. Since the widths of the action elements are the same, we simply use the width of the left element.

So now we have a list with items that can be swiped open revealing an action. Using the tap event handlers we provided (as demonstrated in the XML snippet above), we now can understand when our users tap on a given action and execute it:

public onLeftSwipeClick(args: ListViewEventData) {
    console.log("Left swipe click");
    this.listViewComponent.listView.notifySwipeToExecuteFinished();
}

public onRightSwipeClick(args) {
    console.log("Right swipe click");
    this.dataItems.splice(this.dataItems.indexOf(args.object.bindingContext), 1);
}

Note that we call the notifySwipeToExecuteFinished() method to make sure the swipe item is closed after the action is performed.

Swipe-to-execute actions

In this scenario the user needs to swipe and release the item to execute the corresponding action. The item must be swiped at a given distance over the provided threshold so that the associated action is executed. To implement this approach you need to handle the following RadListView events:

  • itemSwipeProgressStartedEvent - used to specify the swipe limits in a way that will make the item return to its original place when released
  • itemSwipeProgressEndedEvent - used to determine if a swipe action has to be executed (in case the item was swiped beyond a given threshold)
  • itemSwipeProgressChangedEvent - in case you want to notify the user that they have swiped the item beyond a specified threshold and the swipe action will be executed

So let's look at the handler of the itemSwipeProgressStartedEvent event:

public onSwipeCellStarted(args: ListViewEventData) {
    var swipeLimits = args.data.swipeLimits;
    var swipeView = args['object'];
    var leftItem = swipeView.getViewById('mark-view');
    var rightItem = swipeView.getViewById('delete-view');
    swipeLimits.left = swipeLimits.right = args.data.x > 0 ? swipeView.getMeasuredWidth() / 2 : swipeView.getMeasuredWidth() / 2;
    swipeLimits.threshold = swipeView.getMeasuredWidth();
}

You can see that the width of the list control is used to specify the threshold. The limits for the left and right edges are defined to be half the size of the item (in this case we take the measured width of the swipe-view that comes from the comes from the tkListItemSwipeTemplate). That means that the user will never be able to swipe beyond the threshold and once released - the item will always dock at its original position. So what remains is to track the swipe progress using the itemSwipeProgressEndedEvent and trigger the action when the swiped item passes a threshold:

public onCellSwiping(args: ListViewEventData) {
    var swipeLimits = args.data.swipeLimits;
    var swipeView = args['swipeView'];
    var mainView = args['mainView'];
    var leftItem = swipeView.getViewById('mark-view');
    var rightItem = swipeView.getViewById('delete-view');

    if (args.data.x > swipeView.getMeasuredWidth() / 4 && !this.leftThresholdPassed) {
        console.log("Notify perform left action");
        var markLabel = leftItem.getViewById('mark-text');
        this.leftThresholdPassed = true;
    } else if (args.data.x < -swipeView.getMeasuredWidth() / 4 && !this.rightThresholdPassed) {
        var deleteLabel = rightItem.getViewById('delete-text');
        console.log("Notify perform right action");
        this.rightThresholdPassed = true;
    }
    if (args.data.x > 0) {
        var leftDimensions = View.measureChild(
            leftItem.parent,
            leftItem,
            layout.makeMeasureSpec(Math.abs(args.data.x), layout.EXACTLY),
            layout.makeMeasureSpec(mainView.getMeasuredHeight(), layout.EXACTLY));
        View.layoutChild(leftItem.parent, leftItem, 0, 0, leftDimensions.measuredWidth, leftDimensions.measuredHeight);
    } else {
        var rightDimensions = View.measureChild(
            rightItem.parent,
            rightItem,
            layout.makeMeasureSpec(Math.abs(args.data.x), layout.EXACTLY),
            layout.makeMeasureSpec(mainView.getMeasuredHeight(), layout.EXACTLY));

        View.layoutChild(rightItem.parent, rightItem, mainView.getMeasuredWidth() - rightDimensions.measuredWidth, 0, mainView.getMeasuredWidth(), rightDimensions.measuredHeight);
    }
}

In this particular case we have decided that the threshold beyond which an action is considered executed is 1/4 of the whole item width (in this case we take the measured width of the swipe-view that comes from the tkListItemSwipeTemplate). Here, we use two flags for each swiping direction. Once the offset of the item being swiped passes the chosen threshold (1/4 of the item width), we raise the flag and use it in the itemSwipeProgressEndedEvent to understand which action to trigger:

public onSwipeCellFinished(args: ListViewEventData) {
    var swipeView = args['object'];
    var leftItem = swipeView.getViewById('mark-view');
    var rightItem = swipeView.getViewById('delete-view');
    if (this.leftThresholdPassed) {
        console.log("Perform left action");
    } else if (this.rightThresholdPassed) {
        console.log("Perform right action");
    }
    this.leftThresholdPassed = false;
    this.rightThresholdPassed = false;
}

All swipe events exposed by RadListView provide you with a ListViewEventData object which in turn carries a SwipeOffsets object through its data property. This object exposes the following properties:

  • x - the X offset of the item being released after swiping
  • y - the Y offset of the item being released after swiping
  • swipeLimits - an instance of the SwipeLimits containing the dimensional limits which restrict the movement of the swiped item. Its properties are as follows:
    • top - determines how far from the top edge can an item can be swiped
    • left - determines how far from the left edge can an item can be swiped
    • right - determines how far from the right edge can an item can be swiped
    • bottom - determines how far from the bottom edge can an item can be swiped
    • threshold - determines the amount if pixels that needs to be surpassed in any swiping direction so that the item docks open at the provided swipe limit (left or right).
  • swipeView - the View element that represents the swipe content of beneath the item being swiped
  • mainView - the View element representing the main content of the item being swiped

Animating action views while swiping

Many mobile apps that implement the swipe-to-execute experience also animate the action views while the user is swiping. This can be easily achieved with RadListView in the itemSwipeProgressChangedEvent event. Let's consider the following swipe template:

<GridLayout *tkListItemSwipeTemplate columns="auto, *, auto">
    <GridLayout columns="*, *, *" col="0" id="left-stack">
        <GridLayout col="0" class="markGridLayout" (tap)="onLeftSwipeClick($event)" id="btnMark">
            <Label text="mark" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
        </GridLayout>
        <GridLayout col="1" class="archiveGridLayout" (tap)="onLeftSwipeClick($event)" id="btnArchive">
            <Label text="archive" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
        </GridLayout>
        <GridLayout col="2" class="unreadGridLayout" (tap)="onLeftSwipeClick($event)" id="btnUnread">
            <Label text="unread" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
        </GridLayout>
    </GridLayout>
    <GridLayout columns="*, *, *" col="2" id="right-stack">
        <GridLayout col="0" class="deleteGridLayout" (tap)="onRightSwipeClick($event)" id="btnDelete">
            <Label text="delete" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
        </GridLayout>
        <GridLayout col="1" class="readGridLayout" (tap)="onRightSwipeClick($event)" id="btnRead">
            <Label text="read" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
        </GridLayout>
        <GridLayout col="2" class="forwardGridLayout" (tap)="onRightSwipeClick($event)" id="btnForward">
            <Label text="forward" class="swipetemplateLabel" verticalAlignment="center" horizontalAlignment="center"></Label>
        </GridLayout>
    </GridLayout>
</GridLayout>

As you can see, we have a GridLayout instance as a root which, similarly to the scenarios described above, defines three columns two of which are taken by two more GridLayout instances. The nested instances are set up to have three columns with equally distributed widths. In each of the nested GridLayout elements are defined three StackLayout elements representing the swipe actions. Now, using the itemSwipeProgressChangedEvent we will animate the second-level GridLayout elements and let their own layout calculate the size of the StackLayout instances automatically:

public onCellSwiping(args: ListViewEventData) {
    var swipeLimits = args.data.swipeLimits;
    var swipeView = args['swipeView'];
    this.mainView = args['mainView'];
    this.leftItem = swipeView.getViewById('left-stack');
    this.rightItem = swipeView.getViewById('right-stack');

    if (args.data.x > 0) {
        var leftDimensions = View.measureChild(
            <View>this.leftItem.parent,
            this.leftItem,
            layout.makeMeasureSpec(Math.abs(args.data.x), layout.EXACTLY),
            layout.makeMeasureSpec(this.mainView.getMeasuredHeight(), layout.EXACTLY));
        View.layoutChild(<View>this.leftItem.parent, this.leftItem, 0, 0, leftDimensions.measuredWidth, leftDimensions.measuredHeight);
        this.hideOtherSwipeTemplateView("left");
    } else {
        var rightDimensions = View.measureChild(
            <View>this.rightItem.parent,
            this.rightItem,
            layout.makeMeasureSpec(Math.abs(args.data.x), layout.EXACTLY),
            layout.makeMeasureSpec(this.mainView.getMeasuredHeight(), layout.EXACTLY));

        View.layoutChild(<View>this.rightItem.parent, this.rightItem, this.mainView.getMeasuredWidth() - rightDimensions.measuredWidth, 0, this.mainView.getMeasuredWidth(), rightDimensions.measuredHeight);
        this.hideOtherSwipeTemplateView("right");
    }
}

private hideOtherSwipeTemplateView(currentSwipeView: string) {
    switch (currentSwipeView) {
        case "left":
            if (this.rightItem.getActualSize().width != 0) {
                View.layoutChild(<View>this.rightItem.parent, this.rightItem, this.mainView.getMeasuredWidth(), 0, this.mainView.getMeasuredWidth(), 0);
            }
            break;
        case "right":
            if (this.leftItem.getActualSize().width != 0) {
                View.layoutChild(<View>this.leftItem.parent, this.leftItem, 0, 0, 0, 0);
            }
            break;
        default:
            break;
    }
}

What we do here is check which swipe direction the user has chosen and trigger a layout for the specific GridLayout (left or right) so that it dynamically takes the space that the item being swiped reveales beneath. To improve the UX, we have defined limits for the swipe offset and a threshold which, once passed, makes the item stick open revealing all swipe actions:

public onSwipeCellStarted(args: ListViewEventData) {
    var swipeLimits = args.data.swipeLimits;
    swipeLimits.threshold = args['mainView'].getMeasuredWidth() * 0.2; // 20% of whole width
    swipeLimits.left = swipeLimits.right = args['mainView'].getMeasuredWidth() * 0.65 // 65% of whole width
}

Here are two screenshots demonstrating the behavior on Android and iOS:

RadListView: Swipe Actions RadListView: Swipe Actions