如何在Xamarin.Forms中真正避免多个按钮同时被点击?

21

我在视图中使用多个按钮,每个按钮都会打开自己的弹出页面。当同时点击多个按钮时,它们会同时打开不同的弹出页面。

我创建了一个示例内容页面,其中包含3个按钮(每个按钮都会打开不同的弹出页面),以演示此问题:

screenshots

XAML页面:

<ContentPage.Content>
    <AbsoluteLayout>

        <!-- button 1 -->
        <Button x:Name="button1" Text="Button 1"
            BackgroundColor="White" Clicked="Button1Clicked"
            AbsoluteLayout.LayoutFlags="All"
            AbsoluteLayout.LayoutBounds="0.5, 0.3, 0.5, 0.1"/>

        <!-- button 2 -->
        <Button x:Name="button2" Text="Button 2"
            BackgroundColor="White" Clicked="Button2Clicked"
            AbsoluteLayout.LayoutFlags="All"
            AbsoluteLayout.LayoutBounds="0.5, 0.5, 0.5, 0.1"/>

        <!-- button 3 -->
        <Button x:Name="button3" Text="Button 3"
            BackgroundColor="White" Clicked="Button3Clicked"
            AbsoluteLayout.LayoutFlags="All"
            AbsoluteLayout.LayoutBounds="0.5, 0.7, 0.5, 0.1"/>

        <!-- popup page 1 -->
        <AbsoluteLayout x:Name="page1" BackgroundColor="#7f000000" IsVisible="false"
            AbsoluteLayout.LayoutFlags="All"
            AbsoluteLayout.LayoutBounds="0.5, 0.5, 1.0, 1.0">
            <BoxView Color="Red"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.5, 0.75, 0.3"/>
            <Label Text="Button 1 clicked" TextColor="White"
                HorizontalTextAlignment="Center"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.45, 0.75, 0.05"/>
            <Button Text="Back" BackgroundColor="White" Clicked="Back1Clicked"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.6, 0.5, 0.1"/>
        </AbsoluteLayout>

        <!-- popup page 2 -->
        <AbsoluteLayout x:Name="page2" BackgroundColor="#7f000000" IsVisible="false"
            AbsoluteLayout.LayoutFlags="All"
            AbsoluteLayout.LayoutBounds="0.5, 0.5, 1.0, 1.0">
            <BoxView Color="Green"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.5, 0.75, 0.3"/>
            <Label Text="Button 2 clicked" TextColor="White"
                HorizontalTextAlignment="Center"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.45, 0.75, 0.05"/>
            <Button Text="Back" BackgroundColor="White" Clicked="Back2Clicked"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.6, 0.5, 0.1"/>
        </AbsoluteLayout>

        <!-- popup page 3 -->
        <AbsoluteLayout x:Name="page3" BackgroundColor="#7f000000" IsVisible="false"
            AbsoluteLayout.LayoutFlags="All"
            AbsoluteLayout.LayoutBounds="0.5, 0.5, 1.0, 1.0">
            <BoxView Color="Blue"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.5, 0.75, 0.3"/>
            <Label Text="Button 3 clicked" TextColor="White"
                HorizontalTextAlignment="Center"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.45, 0.75, 0.05"/>
            <Button Text="Back" BackgroundColor="White" Clicked="Back3Clicked"
                AbsoluteLayout.LayoutFlags="All"
                AbsoluteLayout.LayoutBounds="0.5, 0.6, 0.5, 0.1"/>
        </AbsoluteLayout>

    </AbsoluteLayout>
</ContentPage.Content>

C#事件处理程序:

void Button1Clicked(object sender, EventArgs e)
{
    // ... do something first ...
    page1.IsVisible = true;
    Console.WriteLine("Button 1 Clicked!");
}

void Button2Clicked(object sender, EventArgs e)
{
    // ... do something first ...
    page2.IsVisible = true;
    Console.WriteLine("Button 2 Clicked!");
}

void Button3Clicked(object sender, EventArgs e)
{
    // ... do something first ...
    page3.IsVisible = true;
    Console.WriteLine("Button 3 Clicked!");
}

void Back1Clicked(object sender, EventArgs e)
{
    page1.IsVisible = false;
}

void Back2Clicked(object sender, EventArgs e)
{
    page2.IsVisible = false;
}

void Back3Clicked(object sender, EventArgs e)
{
    page3.IsVisible = false;
}

预期:
点击 button1 将打开 page1 弹出页面,点击弹出窗口中的返回按钮将隐藏弹出页面。button2button3 也有类似的行为。

实际:
同时点击多个按钮(例如button1button2)会打开两个弹出页面(page1page2)。 快速双击单个按钮也会触发同一个按钮两次。


避免双击的一些研究
通过在stackoverflow上搜索类似的问题(例如这个这个),我得出了一个结论:应该设置一个外部变量来控制事件是否执行。 这是我的Xamarin.forms中的实现:

使用C# struct作为外部变量,以便我可以在不同的类中访问此变量:

// struct to avoid multiple button click at the same time
public struct S
{
    // control whether the button events are executed
    public static bool AllowTap = true;

    // wait for 200ms after allowing another button event to be executed
    public static async void ResumeTap() {
        await Task.Delay(200);
        AllowTap = true;
    }
}

然后每个按钮事件处理程序都像这样进行修改(Button2Clicked()Button3Clicked()同样适用):

void Button1Clicked(object sender, EventArgs e)
{
    // if some buttons are clicked recently, stop executing the method
    if (!S.AllowTap) return; S.AllowTap = false; //##### * NEW * #####//

    // ... do something first ...
    page1.IsVisible = true;
    Console.WriteLine("Button 1 Clicked!");

    // allow other button's event to be fired after the wait specified in struct S
    S.ResumeTap(); //##### * NEW * #####//
}

这个功能基本上相当不错。双击同一个按钮会快速触发一次按钮事件,同时点击多个按钮也只会打开1个弹出页面。


真正的问题
就像上面所描述的那样,在修改代码(在struct S中添加共享状态变量AllowTap)后,仍然有可能打开多个弹出页面。例如,如果用户用两个手指按住button1button2,释放button1,等待大约一秒钟,然后释放button2,那么page1page2都会被打开。


解决问题的失败尝试
我曾试图在单击button1button2button3时禁用所有按钮,并在单击返回按钮时启用所有按钮。

void disableAllButtons()
{
    button1.IsEnabled = false;
    button2.IsEnabled = false;
    button3.IsEnabled = false;
}

void enableAllButtons()
{
    button1.IsEnabled = true;
    button2.IsEnabled = true;
    button3.IsEnabled = true;
}

然后每个按钮事件处理程序都像这样进行修改(对Button2Clicked()Button3Clicked()同样适用):

void Button1Clicked(object sender, EventArgs e)
{
    if (!S.AllowTap) return; S.AllowTap = false;

    // ... do something first ...
    disableAllButtons(); //##### * NEW * #####//
    page1.IsVisible = true;
    Console.WriteLine("Button 1 Clicked!");

    S.ResumeTap();
}

并且每个后退按钮事件处理程序都像这样进行修改(Back2Clicked()Back3Clicked() 同样适用):

void Back1Clicked(object sender, EventArgs e)
{
    page1.IsVisible = false;
    enableAllButtons(); //##### * NEW * #####//
}
然而,相同的问题仍然存在(能够按住另一个按钮并稍后释放它们以同时触发2个按钮)。
在我的应用程序中禁用多点触控不是选项,因为我需要在应用程序的其他页面中使用它。此外,弹出页面可能还包含多个按钮,这些按钮也会导致其他页面,因此无法仅在弹出页面中使用返回按钮来设置结构体S中的变量AllowTap
任何帮助将不胜感激。谢谢。 编辑
"真正的问题"影响安卓和iOS. 在安卓设备上,当用户按住按钮时,一旦按钮被禁用就不能再次激活该按钮。但这个"按住已禁用的按钮"的问题不会影响到iOS设备上的按钮。

1
只要不可能同时激活2个按钮,我想这应该没问题。 - Patrick
1
好的。我正在尝试为您编写一个带有几个选项的答案。可能需要一些努力和创造力来理解我的英语,但我认为它可以解决您的问题 =)。 - Diego Rafael Souza
@Patrick,有没有值得50分赏金的答案?我刚发现了这个大问题,看起来它是普遍存在的,而不仅仅是Xamarin表单。我在Google Play商店进行了测试,你实际上可以同时点击两个东西,但这不会造成任何问题,因为它们使用简单的导航,最后点击的项目将首先显示,而第一个则会在其后面。这个bug应该引起更多关注。 - Ali123
@Ali123 谢谢你的建议,但我在这里发布的示例创建了一个绝对布局,当任何按钮被点击时,它覆盖了整个页面,并且这并不会使屏幕上的所有点击都无效。这就是为什么这个问题如此棘手的原因。 - Patrick
哦,也许是我从你那里得到了这个想法。我应该在打字之前认真阅读,这种情况发生了多次。抱歉,但是我的头脑现在非常忙碌。 - Ali123
显示剩余9条评论
9个回答

10

我在我的基本视图模型中有这个内容:

public bool IsBusy { get; set; }

protected async Task RunIsBusyTaskAsync(Func<Task> awaitableTask)
{
    if (IsBusy)
    {
        // prevent accidental double-tap calls
        return;
    }
    IsBusy = true;
    try
    {
        await awaitableTask();
    }
    finally
    {
        IsBusy = false;
    }
}

命令委托看起来应该是这样的:
    private async Task LoginAsync()
    {
        await RunIsBusyTaskAsync(Login);
    }

如果您有参数:

    private async Task LoginAsync()
    {
        await RunIsBusyTaskAsync(async () => await LoginAsync(Username, Password));
    }

登录方法应包含实际逻辑

    private async Task Login()
    {
        var result = await _authenticationService.AuthenticateAsync(Username, Password);

        ...
    }

此外,您还可以使用内联委托:

    private async Task LoginAsync()
    {
        await RunIsBusyTaskAsync(async () =>
        {
            // logic here
        });
    }

不需要设置IsEnabled。如果实际执行的逻辑不是异步的,可以将Func替换为Action。

您还可以绑定到IsBusy属性,例如ActivityIndicator。


6
我建议采用MVVM架构,并将每个按钮的 IsEnabled 属性与视图模型中的同一属性进行绑定,例如 AreButtonsEnabled

MyViewModel.cs:

private bool _areButtonsEnabled = true;

public bool AreButtonsEnabled
{
    get => _areButtonsEnabled;
    set
    {
        if (_areButtonsEnabled != value)
        {
            _areButtonsEnabled = value;
            OnPropertyChanged(nameof(AreButtonsEnabled)); // assuming your view model implements INotifyPropertyChanged
        }
    }
}

MyPage.xaml(仅展示一个按钮的代码):

...
<Button 
    Text="Back" 
    BackgroundColor="White" 
    Clicked="HandleButton1Clicked"
    AbsoluteLayout.LayoutFlags="All"
    AbsoluteLayout.LayoutBounds="0.5, 0.6, 0.5, 0.1"
    IsEnabled={Binding AreButtonsEnabled} />
...

然后对于每个按钮的事件处理程序,您可以将AreButtonsEnabled属性设置为false以禁用所有按钮。请注意,您应该先检查AreButtonsEnabled的值是否为true,因为在调用PropertyChanged事件之前,用户有可能会点击两次。但是,由于按钮的单击事件处理程序在主线程上运行,所以AreButtonsEnabled的值将在下一个HandleButtonXClicked被调用之前设置为false。换句话说,即使UI尚未更新,AreButtonsEnabled的值也将被更新。

MyPage.xaml.cs:

HandleButton1Clicked(object sender, EventArgs e)
{
    if (viewModel.AreButtonsEnabled)
    {
        viewModel.AreButtonsEnabled = false;
        // button 1 code...
    }
}

HandleButton2Clicked(object sender, EventArgs e)
{
    if (viewModel.AreButtonsEnabled)
    {
        viewModel.AreButtonsEnabled = false;
        // button 2 code...
    }
}

HandleButton3Clicked(object sender, EventArgs e)
{
    if (viewModel.AreButtonsEnabled)
    {
        viewModel.AreButtonsEnabled = false;
        // button 3 code...
    }
}

当您想要重新启用按钮时,只需调用viewModel.AreButtonsEnabled = true;


如果您想要一个“真正”的MVVM模式,可以将命令绑定到按钮上,而不是监听它们的Clicked事件。

MyViewModel.cs:

private bool _areButtonsEnabled = true;

public bool AreButtonsEnabled
{
    get => _areButtonsEnabled;
    set
    {
        if (_areButtonsEnabled != value)
        {
            _areButtonsEnabled = value;
            OnPropertyChanged(nameof(AreButtonsEnabled)); // assuming your view model implements INotifyPropertyChanged
        }
    }
}

public ICommand Button1Command { get; protected set; }

public MyViewModel()
{
    Button1Command = new Command(HandleButton1Tapped);
}

private void HandleButton1Tapped()
{
    // Run on the main thread, to make sure that it is getting/setting the proper value for AreButtonsEnabled
    // And note that calls to Device.BeginInvokeOnMainThread are queued, therefore
    // you can be assured that AreButtonsEnabled will be set to false by one button's command
    // before the value of AreButtonsEnabled is checked by another button's command.
    // (Assuming you don't change the value of AreButtonsEnabled on another thread)
    Device.BeginInvokeOnMainThread(() => 
    {
        if (AreButtonsEnabled)
        {
            AreButtonsEnabled = false;
            // button 1 code...
        }
    });
}

// don't forget to add Commands for Button 2 and 3

MyPage.xaml(仅显示一个按钮):

<Button 
    Text="Back" 
    BackgroundColor="White"
    AbsoluteLayout.LayoutFlags="All"
    AbsoluteLayout.LayoutBounds="0.5, 0.6, 0.5, 0.1"
    Command={Binding Button1Command}
    IsEnabled={Binding AreButtonsEnabled} />

现在您不需要在MyPage.xaml.cs的代码后台文件中添加任何代码。


1
你可以使用 Command 构造函数重载,它接受一个 canExecute 回调函数来更加干净地禁用按钮(而无需绑定到 IsEnabled)。 - Damian
感谢您采用MVVM方法解决问题。但是,如果在单击菜单中的3个按钮之一后还有更多按钮(导致其他弹出页面),那么何时调用viewModel.AreButtonsEnabled = true;以启用所有按钮呢? - Patrick
你的回答中的 MyViewModel.cs 中的 AreButtonsEnabled 是否与我问题中的 struct S 中的 AllowTap 有类似的功能? - Patrick
@Patrick 在我的回答中,设置 AreButtonsEnabled 的值将启用/禁用所有绑定到该属性的按钮。至于何时设置 AreButtonsEnabled = true;,您可以通过监听“Clicked”事件或绑定到后退按钮的“Command”来实现。换句话说,在弹出窗口上单击“返回”按钮时,设置 AreButtonsEnabled = true - sme
如果弹出页面包含除返回按钮之外的其他按钮(这对我正在开发的应用程序是正确的,但我不能只将我的整个应用程序代码发布到stackoverflow,因为我只需要提供一个最小的可行示例代码),而这些按钮还执行其他功能,那么我不能仅在单击某个按钮时设置AreButtonsEnabled = true。那么我需要多个AreButtonsEnabled变量来跟踪不同的按钮吗?但是以这种方式使用不同的变量并不是一个非常可扩展的解决方案,一旦我有很多弹出页面/按钮。 - Patrick
1
@Patrick 很可能是的。在这种情况下,我会为每个“集合”按钮设置一个AreButtonsEnabled属性,例如主屏幕上的按钮、弹出窗口中的按钮等都有各自对应的属性。至少我会这样做。 - sme

5
将您的禁用调用 (disableAllButtons()) 移动到按钮的 Pressed 事件中。
这将在检测到第一次触摸时立即禁用其他按钮。 编辑
为了防止意外禁用所有内容,请创建一个自定义按钮渲染器,并挂钩本机事件以取消拖动退出时的禁用: iOS: UIControlEventTouchDragOutside 编辑
在该方案中已对 Android 进行测试,它具有与此问题描述相同的问题。

遗憾的是,只有在调用“Clicked”事件时才会调用按钮的“Released”事件。因此,如果不激活按钮的“Clicked”事件,则所有按钮都将保持禁用状态。感谢您的建议,现在我可以尝试找到涉及“Pressed”事件的解决方案。 - Patrick
刚刚尝试了短暂的超时。它比之前的解决方案效果更好,但仍有可能同时激活2个按钮(例如,按住“按钮1”,等待计时器结束,然后按下“按钮2”,然后释放“按钮1”)。 - Patrick
我认为你需要为iOS和Android创建自定义按钮渲染器。实际上,首先检查问题是在两个平台上还是只有一个平台上。 - Tim
1
添加到答案中。 - Tim
1
将您的逻辑缩小,使其仅禁用其他按钮。在Android上还有一种检测拖动的方法,但我不记得它了,而且我认为它比iOS更难。 - Tim
显示剩余4条评论

4
我认为以下代码适用于您。
void Button1Clicked(object sender, EventArgs e)
{
    disableAllButtons();

    // ... do something first ...
    page1.IsVisible = true;
    Console.WriteLine("Button 1 Clicked!");
}

void Button2Clicked(object sender, EventArgs e)
{
    disableAllButtons();

    // ... do something first ...
    page2.IsVisible = true;
    Console.WriteLine("Button 2 Clicked!");
}

void Button3Clicked(object sender, EventArgs e)
{
    disableAllButtons();

    // ... do something first ...
    page3.IsVisible = true;
    Console.WriteLine("Button 3 Clicked!");
}

void Back1Clicked(object sender, EventArgs e)
{
    enableAllButtons();
}

void Back2Clicked(object sender, EventArgs e)
{
    enableAllButtons();
}

void Back3Clicked(object sender, EventArgs e)
{
    enableAllButtons();
}



void disableAllButtons()
{
    button1.IsEnabled = false;
    button2.IsEnabled = false;
    button3.IsEnabled = false;

    disableAllPages();
}

void enableAllButtons()
{
    button1.IsEnabled = true;
    button2.IsEnabled = true;
    button3.IsEnabled = true;

    disableAllPages();
}

void disableAllPages()
{
    page1.IsVisible = false;
    page2.IsVisible = false;
    page3.IsVisible = false;
}

即使使用 disableAllPages() 隐藏了其他页面,用户仍然可以通过按住其他按钮切换到另一页。不过这是一个不错的尝试。 - Patrick

4

共享状态是您需要的。最不具侵入性和最通用的方法是这样 - 只需包装您的代码:

    private bool isClicked;
    private void AllowOne(Action a)
    {
        if (!isClicked)
        {
            try
            {
                isClicked = true;
                a();
            }
            finally
            {
                isClicked = false;
            }               
        }
    }

    void Button1Clicked(object sender, EventArgs e)
    {
        AllowOne(() =>
        {
            // ... do something first ...
            page1.IsVisible = true;
            Console.WriteLine("Button 1 Clicked!");
        });
    }

try..finally模式对于保证安全性很重要。如果a()抛出异常,状态不会受到影响。


1
@Patrick 我明白了。在这种情况下,使用一个IsOpen共享状态,在弹出窗口显示时设置它,并在关闭时清除它。因此,即使在1秒内释放按钮处理程序的情况下,也不会引起问题。 - Robin
那么我可能需要一堆共享状态变量来跟踪不同弹出页面中的按钮,是吗? - Patrick
@Patrick。不,只需要一个静态的就可以了,因为你每次只允许弹出一个弹窗页面。在显示弹窗之前将其设置为 true,在处理关闭弹窗的事件处理程序中将其设置为 false。在您的情况下,这看起来像是弹窗上的“返回”按钮。 - Robin
1
在这种情况下,我会为页面创建一个基类,每个页面都有自己的IsPopupOpen属性,它会阻止任何其他同级按钮打开该页面的新弹出窗口。但是当您打开一个弹出窗口时,您需要将父弹出窗口的引用传递给新的子弹出窗口。因此,每当子弹出窗口关闭时,它会将其父级的IsPopupOpen属性设置为false。而且,这将在您选择探索的任何弹出窗口深度上起作用。只要避免通过在层次结构的每个级别上创建新页面来创建循环引用即可。 - Robin
如何编写使用异步和等待任务的上述代码。 - samdubey
显示剩余5条评论

1

最简单的方法:

使用Xamarin.CommunityToolkit中的AsyncCommandhttps://learn.microsoft.com/en-us/xamarin/community-toolkit/objectmodel/asynccommand

在这里,您可以将allowsMultipleExecutions设置为false

在这种情况下,只有在实际未运行时才会执行您的命令。
因此,您可以避免在代码中使用很多CanExecute属性。

示例:

NavigateToNextViewCommand = new AsyncCommand(NavigateToNewView, allowsMultipleExecutions: false);

特别是在Android上,当您导航到一个新视图并发生许多处理时,有时屏幕转换之间会出现一些延迟,尤其是在旧设备上。

当然,在理想的世界中,您可能有一些性能问题,您可以修复它以更快地显示下一个视图,但是用户仍然有机会多次点击您的按钮,那么您的导航堆栈中就会有2个相同类型的视图。使用AsyncCommand,您可以避免这种情况。

也许对某些人有所帮助。


我相信即使allowsMultipleExecutions=false,您仍然可以多次执行命令。它会禁用有界按钮,但如果用户在其被禁用之前快速点击该按钮,则命令将执行多次。 - Daniel

0

从@Robin的解决方案这里开始,我意识到以下内容。这可以防止在.NET MAUI中出现双击。

private bool isClicked;
private void AllowOne(Action a)
{
    if (!isClicked)
    {
        isClicked = true;
        a();
        Dispatcher.StartTimer(TimeSpan.FromMilliseconds(250), () =>
        {
            isClicked = false;
            return false;
        });
    }
}

private void cmdButton_Clicked(object sender, EventArgs e)
{
    AllowOne(() =>
    {
        //Do your stuff here...
    });
}

这应该是不允许双击的最简单和最有效的方法。

另外,我决定删除 Try..finally 语句,因为如果有未处理的错误,它将由 Sentry 处理,我将收到通知等等。使用 try catch 我永远不会意识到应用程序中存在未处理的错误。


0

我认为最简单的方法是使用已经具有内置功能的命令。

受到这里的答案的启发,我实现了一个ExecuteOnceAsyncCommand(基于AsyncCommand),它依赖于已经内置在命令中的CanExecute功能。

Xamarin Forms按钮自动使用CanExecute来启用或禁用,因此您的ViewModel层中不需要特殊标志。

这里是ExecuteOnceAsyncCommand的要点,请将您的命令替换为此命令,它应该可以工作:


0

你可以通过命令行实现:

    bool CanNavigate = true;

    public ICommand OpenPageCommand
    {
        get
        {
            return new Command<View>(async (v) => await 
             GotoPage2ndPage(v), (v) => CanNavigate);
        }
    }


    private async Task GotoPage2ndPage(View view)
    {
        CanNavigate = false;

        // Logic for goto 2nd page

        CanNavigate = true;
    }


    Note : Where v indicates CommandParameter.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接