Xamarin Forms CollectionView with SwipeView on items - tapping ImageButton selects cell

Viewed 327

I have an ImageButton in every cell in my CollectionView. When I tap on the ImageButton I expect it to capture the touch event and handle it, however it also passes the touch event up to the cell and selects that cell in the CollectionView.

Tapping the call changes the SelectedItem and opens the detail page for that contact. Tapping the ImageButton starts a call, but immediately switches to the detail page.

Here is a screenshot of the page: Contacts Page

The CollectionView is defined as:

<CollectionView
    x:Name="contactsList"
    ItemsSource="{Binding Contacts}"
    SelectionMode="Single"
    SelectedItem="{Binding SelectedContact, Mode=TwoWay}"
    ItemSizingStrategy="MeasureAllItems"
    IsGrouped="True"
    EmptyView="No Contacts">
    <CollectionView.ItemsLayout>
        <LinearItemsLayout Orientation="Vertical"/>
    </CollectionView.ItemsLayout>
    <CollectionView.GroupHeaderTemplate>
        ...
    </CollectionView.GroupHeaderTemplate>
    <CollectionView.ItemTemplate>
        <DataTemplate>
            <SwipeView
                x:DataType="models:Contact">
                ...
                <StackLayout
                    BackgroundColor="{StaticResource BackgroundColor}">
                    <Grid
                        Padding="0,15,0,10"
                        ColumnDefinitions="80,*,80"
                        RowDefinitions="*,*"
                        BackgroundColor="{StaticResource BackgroundColor}">
                        <Ellipse
                            Grid.Column="0"
                            Grid.Row="0"
                            Grid.RowSpan="2"
                            Fill="{Binding Colour, Converter={StaticResource intToBrushColor}}"
                            .../>
                        <Label
                            Grid.Column="0"
                            Grid.Row="0"
                            Grid.RowSpan="2"
                            Text="{Binding Initials}"
                            .../>
                        <Label
                            Grid.Column="1"
                            Grid.Row="0"
                            Text="{Binding FullName}"
                            .../>
                        <StackLayout
                            Grid.Column="1"
                            Grid.Row="1"
                            Orientation="Horizontal">
                            <Image
                                HeightRequest="15"
                                Source="{Binding WasOutgoing, Converter={StaticResource callDirectionToIcon}}"/>
                            <Label
                                Grid.Column="1"
                                Grid.Row="1"
                                Text="{Binding TimeStamp}"
                                .../>
                        </StackLayout>
                        <ImageButton
                            Grid.Column="2"
                            Grid.Row="0"
                            Grid.RowSpan="2"
                            Margin="0,0,15,0"
                            Padding="10"
                            BackgroundColor="Transparent"
                            Source="{StaticResource IconCalls}"
                            Command="{Binding BindingContext.CallCommand, Source={x:Reference contactsPage}}"
                            CommandParameter="{Binding .}"/>
                    </Grid>
                    <BoxView
                        Style="{StaticResource Seperator}"/>
                </StackLayout>
            </SwipeView>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

How do I make the ImageButton keep the touch event and stop the cell from being selected when the ImageButton is tapped?

Here are a few dirty workarounds I considered but these are not ideal:

  • Split the cell into two Grids and have two TapGestureRecognizers.
  • Track if the ImageButton was tapped and ignore the next selection change.

These are not ideal, will cost more and break MVVM pattern. The root cause of this issue is the ImageButton not keeping the touch event or marking it as handled.

Does anyone know a cleaner solution to this problem?

1 Answers

I've narrowed your problem down to use of SwipeView, in ItemTemplate. This seems to force the item to be selected.

Without it, works as intended.

I infer that SwipeView alters touch events, to force row selection, in order to perform its action.

See WORKAROUND below, for a hack fix.


xaml:

<ContentPage.Content>
    <StackLayout>
        <CollectionView
            x:Name="contactsList"
            ItemsSource="{Binding Contacts}"
            SelectionMode="Single"
            SelectedItem="{Binding SelectedContact, Mode=TwoWay}"
            ItemSizingStrategy="MeasureAllItems" >
            <CollectionView.ItemTemplate>
                <DataTemplate>
                    <!--<SwipeView>-->
                        <StackLayout>
                            <Grid
                            Padding="0,15,0,10"
                            ColumnDefinitions="*,80">
                                <Label Grid.Column="0" Text="abcdef" />
                                <Button
                                Grid.Column="1"
                                Padding="4"
                                Text="Press Me"
                                Clicked="Button_Clicked"
                                />
                            </Grid>
                        </StackLayout>
                    <!--</SwipeView>-->
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </StackLayout>
</ContentPage.Content>

xaml.cs:

public partial class CollectionViewWithCellButtonPage : ContentPage
{
    private Model selectedContact;

    public CollectionViewWithCellButtonPage()
    {
        InitializeComponent();
        BindingContext = this;
    }

    private void Button_Clicked(object sender, EventArgs e)
    {

    }

    public ObservableCollection<Model> Contacts { get; set; } = new ObservableCollection<Model> {
        new Model(),
        new Model(),
        new Model(),
    };

    public Model SelectedContact {
        get => selectedContact;
        set => selectedContact = value;
    }
}

With breakpoints on SelectedContact setter, and on Button_Clicked, a click on button does not affect SelectedContact. Click elsewhere on row does. This is the desired behavior.

Then uncomment <SwipeView> and </SwipeView>.

Now, SelectedContact setter is called. BEFORE Button_Clicked.
Because the call is BEFORE, I don't see any easy fix.

Fixing this "right" probably requires custom renderer (per platform) for SwipeView.


WORKAROUND

Got it to work. But this is a hack.

  1. Delay action taken when SelectContact. This gives us time to find out if Button was pushed. (Step 2 will show _suppressSelection getting set.)
    private Model _selectedContact;
    private bool _suppressSelection;

    public Model SelectedContact
    {
        get => _selectedContact;
        set
        {
            Device.BeginInvokeOnMainThread(async () =>
            {
                await DelayedSetSelectedContact(value);
            });

        }
    }

    private async Task DelayedSetSelectedContact(Model value)
    {
        await Task.Delay(100);

        if (_suppressSelection)
        {
            // Button was pressed. DO NOTHING - DON'T select the item.

            // Clear state for next time.
            _suppressSelection = false;
        }
        else
        {
            _selectedContact = value;
            // ... Do your other work here ...
        }
    }
  1. Button click sets _suppressSelection. Make sure _suppressSelection can't get "stuck on".
    private System.Timers.Timer _buttonTimer;

    protected override void OnAppearing()
    {
        base.OnAppearing();

        // Make sure _suppressSelection can't get "stuck on".
        _buttonTimer = new System.Timers.Timer { Interval = 500, AutoReset = false };
        _buttonTimer.Elapsed += Timer_Elapsed;
    }

    private void Button_Clicked(object sender, EventArgs e)
    {
        // FIRST LINE in method - do this as early as possible.
        _suppressSelection = true;

        //... your main logic here ...

        // Make sure _suppressSelection can't get "stuck on".
        _buttonTimer.Start();
    }

    private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
    {
        // "if" line can be commented out. I just have it so breakpoint on following line is only hit if
        // timer is needed to do its job. Some sequences of item selection and button presses do hit that breakpoint.
        if (_suppressSelection)
            _suppressSelection = false;
    }
  1. Clean up when leave page.
    protected override void OnDisappearing()
    {
        base.OnDisappearing();

        // Stop timer. Release reference.
        if (_buttonTimer != null)
        {
            _buttonTimer.Stop();
            _buttonTimer = null;
        }
        // Clean up state, in case navigate back to page.
        _suppressSelection = false;
    }

Full code in CollectionViewWithCellButtonPage in ToolmakerSteve - repo XFormsSOAnswers.

Related