Evita più clic rapidi sul pulsante


88

Ho un problema con la mia app che se l'utente fa clic sul pulsante più volte velocemente, vengono generati più eventi prima ancora che la mia finestra di dialogo che tiene premuto il pulsante scompaia

Conosco una soluzione impostando una variabile booleana come flag quando si fa clic su un pulsante in modo che i clic futuri possano essere prevenuti fino alla chiusura della finestra di dialogo. Tuttavia ho molti pulsanti e doverlo fare ogni volta per ogni pulsante sembra essere eccessivo. Non c'è altro modo in Android (o forse qualche soluzione più intelligente) per consentire solo l'azione dell'evento generata per clic sul pulsante?

Quel che è ancora peggio è che più clic rapidi sembrano generare più azioni di eventi prima che venga gestita anche la prima azione, quindi se voglio disabilitare il pulsante nel metodo di gestione del primo clic, ci sono già azioni di eventi esistenti nella coda in attesa di essere gestite!

Per favore aiutatemi grazie


possiamo aver implementato il codice usato il codice GreyBeardedGeek. Non sono sicuro di come hai usato la classe astratta nella tua attività?
CoDe


Ho risolto un problema simile utilizzando la contropressione in RxJava
arekolek

Risposte:


114

Ecco un listener onClick "senza rimbalzi" che ho scritto di recente. Gli dici qual è il numero minimo accettabile di millisecondi tra i clic. Implementa la tua logica in onDebouncedClickinvece dionClick

import android.os.SystemClock;
import android.view.View;

import java.util.Map;
import java.util.WeakHashMap;

/**
 * A Debounced OnClickListener
 * Rejects clicks that are too close together in time.
 * This class is safe to use as an OnClickListener for multiple views, and will debounce each one separately.
 */
public abstract class DebouncedOnClickListener implements View.OnClickListener {

    private final long minimumIntervalMillis;
    private Map<View, Long> lastClickMap;

    /**
     * Implement this in your subclass instead of onClick
     * @param v The view that was clicked
     */
    public abstract void onDebouncedClick(View v);

    /**
     * The one and only constructor
     * @param minimumIntervalMillis The minimum allowed time between clicks - any click sooner than this after a previous click will be rejected
     */
    public DebouncedOnClickListener(long minimumIntervalMillis) {
        this.minimumIntervalMillis = minimumIntervalMillis;
        this.lastClickMap = new WeakHashMap<>();
    }

    @Override 
    public void onClick(View clickedView) {
        Long previousClickTimestamp = lastClickMap.get(clickedView);
        long currentTimestamp = SystemClock.uptimeMillis();

        lastClickMap.put(clickedView, currentTimestamp);
        if(previousClickTimestamp == null || Math.abs(currentTimestamp - previousClickTimestamp) > minimumIntervalMillis) {
            onDebouncedClick(clickedView);
        }
    }
}

3
Oh, se solo ogni sito web potesse essere disturbato a implementare qualcosa del genere! Non più "non fare clic su Invia due volte o ti verrà addebitato due volte"! Grazie.
Floris

2
Per quanto ne so, no. L'onClick dovrebbe sempre avvenire sul thread dell'interfaccia utente (ce n'è solo uno). Quindi più clic, anche se vicini nel tempo, dovrebbero essere sempre sequenziali, sullo stesso thread.
GreyBeardedGeek

45
@FengDai Interessante - questo "pasticcio" fa quello che fa la tua libreria, in circa 1/20 della quantità di codice. Hai una soluzione alternativa interessante, ma questo non è il posto giusto per insultare il codice funzionante.
GreyBeardedGeek

13
@GreyBeardedGeek Ci scusiamo per aver usato quella parolaccia.
Feng Dai

2
Questo è throttling, non debouncing.
Rewieer

71

Con RxBinding può essere fatto facilmente. Ecco un esempio:

RxView.clicks(view).throttleFirst(500, TimeUnit.MILLISECONDS).subscribe(empty -> {
            // action on click
        });

Aggiungi la seguente riga build.gradleper aggiungere la dipendenza RxBinding:

compile 'com.jakewharton.rxbinding:rxbinding:0.3.0'

2
La migliore decisione. Tieni presente che questo mantiene un forte collegamento alla tua vista, quindi il frammento non verrà raccolto dopo il normale ciclo di vita: avvolgilo nella libreria del ciclo di vita di Trello. Quindi verrà annullata la sottoscrizione e la visualizzazione verrà liberata dopo che il metodo del ciclo di vita scelto è stato chiamato (di solito suDestroyView)
Den Drobiazko

2
Questo non funziona con gli adattatori, può comunque fare clic su più elementi e farà il throttleFirst ma su ogni singola vista.
desgraci

1
Può essere implementato facilmente con Handler, non è necessario utilizzare RxJava qui.
Miha_x64

20

Ecco la mia versione della risposta accettata. È molto simile, ma non cerca di memorizzare le viste in una mappa che non credo sia una buona idea. Aggiunge anche un metodo di avvolgimento che potrebbe essere utile in molte situazioni.

/**
 * Implementation of {@link OnClickListener} that ignores subsequent clicks that happen too quickly after the first one.<br/>
 * To use this class, implement {@link #onSingleClick(View)} instead of {@link OnClickListener#onClick(View)}.
 */
public abstract class OnSingleClickListener implements OnClickListener {
    private static final String TAG = OnSingleClickListener.class.getSimpleName();

    private static final long MIN_DELAY_MS = 500;

    private long mLastClickTime;

    @Override
    public final void onClick(View v) {
        long lastClickTime = mLastClickTime;
        long now = System.currentTimeMillis();
        mLastClickTime = now;
        if (now - lastClickTime < MIN_DELAY_MS) {
            // Too fast: ignore
            if (Config.LOGD) Log.d(TAG, "onClick Clicked too quickly: ignored");
        } else {
            // Register the click
            onSingleClick(v);
        }
    }

    /**
     * Called when a view has been clicked.
     * 
     * @param v The view that was clicked.
     */
    public abstract void onSingleClick(View v);

    /**
     * Wraps an {@link OnClickListener} into an {@link OnSingleClickListener}.<br/>
     * The argument's {@link OnClickListener#onClick(View)} method will be called when a single click is registered.
     * 
     * @param onClickListener The listener to wrap.
     * @return the wrapped listener.
     */
    public static OnClickListener wrap(final OnClickListener onClickListener) {
        return new OnSingleClickListener() {
            @Override
            public void onSingleClick(View v) {
                onClickListener.onClick(v);
            }
        };
    }
}

Come utilizzare il metodo wrap? Puoi fare un esempio, grazie.
Mehul Joisar

1
@MehulJoisar In questo modo:myButton.setOnClickListener(OnSingleClickListener.wrap(myOnClickListener))
BoD

11

Possiamo farlo senza alcuna libreria. Basta creare una singola funzione di estensione:

fun View.clickWithDebounce(debounceTime: Long = 600L, action: () -> Unit) {
    this.setOnClickListener(object : View.OnClickListener {
        private var lastClickTime: Long = 0

        override fun onClick(v: View) {
            if (SystemClock.elapsedRealtime() - lastClickTime < debounceTime) return
            else action()

            lastClickTime = SystemClock.elapsedRealtime()
        }
    })
}

Visualizza suClic utilizzando il codice seguente:

buttonShare.clickWithDebounce { 
   // Do anything you want
}

Questo è fantastico! Anche se penso che il termine più appropriato qui sia "throttle" non "debounce". cioè clickWithThrottle ()
hopia

private var lastClickTime: Long = 0dovrebbe essere spostato fuori setOnClickListener {...} o lastClickTime sarà sempre 0.
Robert

9

Puoi utilizzare questo progetto: https://github.com/fengdai/clickguard per risolvere questo problema con una singola dichiarazione:

ClickGuard.guard(button);

AGGIORNAMENTO: questa libreria non è più consigliata. Preferisco la soluzione di Nikita. Usa invece RxBinding.


@Feng Dai, come usare questa libreria perListView
CAMOBAP

Come usarlo in Android Studio?
VVB

Questa libreria non è più consigliata. Si prega di utilizzare invece RxBinding. Vedi la risposta di Nikita.
Feng Dai

7

Ecco un semplice esempio:

public abstract class SingleClickListener implements View.OnClickListener {
    private static final long THRESHOLD_MILLIS = 1000L;
    private long lastClickMillis;

    @Override public void onClick(View v) {
        long now = SystemClock.elapsedRealtime();
        if (now - lastClickMillis > THRESHOLD_MILLIS) {
            onClicked(v);
        }
        lastClickMillis = now;
    }

    public abstract void onClicked(View v);
}

1
campione e simpatico!
Chulo

5

Solo un rapido aggiornamento sulla soluzione GreyBeardedGeek. Modificare la clausola if e aggiungere la funzione Math.abs. Impostalo in questo modo:

  if(previousClickTimestamp == null || (Math.abs(currentTimestamp - previousClickTimestamp.longValue()) > minimumInterval)) {
        onDebouncedClick(clickedView);
    }

L'utente può modificare l'ora sul dispositivo Android e metterlo in passato, quindi senza questo potrebbe portare a bug.

PS: non ho abbastanza punti per commentare la tua soluzione, quindi ho solo messo un'altra risposta.


5

Ecco qualcosa che funzionerà con qualsiasi evento, non solo con i clic. Fornirà anche l'ultimo evento anche se fa parte di una serie di eventi rapidi (come rx debounce ).

class Debouncer(timeout: Long, unit: TimeUnit, fn: () -> Unit) {

    private val timeoutMillis = unit.toMillis(timeout)

    private var lastSpamMillis = 0L

    private val handler = Handler()

    private val runnable = Runnable {
        fn()
    }

    fun spam() {
        if (SystemClock.uptimeMillis() - lastSpamMillis < timeoutMillis) {
            handler.removeCallbacks(runnable)
        }
        handler.postDelayed(runnable, timeoutMillis)
        lastSpamMillis = SystemClock.uptimeMillis()
    }
}


// example
view.addOnClickListener.setOnClickListener(object: View.OnClickListener {
    val debouncer = Debouncer(1, TimeUnit.SECONDS, {
        showSomething()
    })

    override fun onClick(v: View?) {
        debouncer.spam()
    }
})

1) Costruisci Debouncer in un campo del listener ma fuori dalla funzione di callback, configurato con timeout e callback fn che vuoi limitare.

2) Chiama il metodo spam del tuo Debouncer nella funzione di richiamata dell'ascoltatore.


5

Quindi, questa risposta è fornita dalla libreria ButterKnife.

package butterknife.internal;

import android.view.View;

/**
 * A {@linkplain View.OnClickListener click listener} that debounces multiple clicks posted in the
 * same frame. A click on one button disables all buttons for that frame.
 */
public abstract class DebouncingOnClickListener implements View.OnClickListener {
  static boolean enabled = true;

  private static final Runnable ENABLE_AGAIN = () -> enabled = true;

  @Override public final void onClick(View v) {
    if (enabled) {
      enabled = false;
      v.post(ENABLE_AGAIN);
      doClick(v);
    }
  }

  public abstract void doClick(View v);
}

Questo metodo gestisce i clic solo dopo che è stato gestito il clic precedente e nota che evita più clic in un frame.


4

Soluzione simile utilizzando RxJava

import android.view.View;

import java.util.concurrent.TimeUnit;

import rx.android.schedulers.AndroidSchedulers;
import rx.functions.Action1;
import rx.subjects.PublishSubject;

public abstract class SingleClickListener implements View.OnClickListener {
    private static final long THRESHOLD_MILLIS = 600L;
    private final PublishSubject<View> viewPublishSubject = PublishSubject.<View>create();

    public SingleClickListener() {
        viewPublishSubject.throttleFirst(THRESHOLD_MILLIS, TimeUnit.MILLISECONDS)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Action1<View>() {
                    @Override
                    public void call(View view) {
                        onClicked(view);
                    }
                });
    }

    @Override
    public void onClick(View v) {
        viewPublishSubject.onNext(v);
    }

    public abstract void onClicked(View v);
}

3

Un Handlerthrottler basato sull'app Signal .

import android.os.Handler;
import android.support.annotation.NonNull;

/**
 * A class that will throttle the number of runnables executed to be at most once every specified
 * interval.
 *
 * Useful for performing actions in response to rapid user input where you want to take action on
 * the initial input but prevent follow-up spam.
 *
 * This is different from a Debouncer in that it will run the first runnable immediately
 * instead of waiting for input to die down.
 *
 * See http://rxmarbles.com/#throttle
 */
public final class Throttler {

    private static final int WHAT = 8675309;

    private final Handler handler;
    private final long    thresholdMs;

    /**
     * @param thresholdMs Only one runnable will be executed via {@link #publish} every
     *                  {@code thresholdMs} milliseconds.
     */
    public Throttler(long thresholdMs) {
        this.handler     = new Handler();
        this.thresholdMs = thresholdMs;
    }

    public void publish(@NonNull Runnable runnable) {
        if (handler.hasMessages(WHAT)) {
            return;
        }

        runnable.run();
        handler.sendMessageDelayed(handler.obtainMessage(WHAT), thresholdMs);
    }

    public void clear() {
        handler.removeCallbacksAndMessages(null);
    }
}

Utilizzo di esempio:

throttler.publish(() -> Log.d("TAG", "Example"));

Esempio di utilizzo in un OnClickListener:

view.setOnClickListener(v -> throttler.publish(() -> Log.d("TAG", "Example")));

Esempio di utilizzo di Kt:

view.setOnClickListener {
    throttler.publish {
        Log.d("TAG", "Example")
    }
}

O con un'estensione:

fun View.setThrottledOnClickListener(throttler: Throttler, function: () -> Unit) {
    throttler.publish(function)
}

Quindi utilizzo di esempio:

view.setThrottledOnClickListener(throttler) {
    Log.d("TAG", "Example")
}

2

È possibile utilizzare Rxbinding3 a tale scopo. Basta aggiungere questa dipendenza in build.gradle

build.gradle

implementation 'com.jakewharton.rxbinding3:rxbinding:3.1.0'

Quindi nella tua attività o frammento, usa il codice qui sotto

your_button.clicks().throttleFirst(10000, TimeUnit.MILLISECONDS).subscribe {
    // your action
}

1

Uso questa classe insieme al databinding. Funziona alla grande.

/**
 * This class will prevent multiple clicks being dispatched.
 */
class OneClickListener(private val onClickListener: View.OnClickListener) : View.OnClickListener {
    private var lastTime: Long = 0

    override fun onClick(v: View?) {
        val current = System.currentTimeMillis()
        if ((current - lastTime) > 500) {
            onClickListener.onClick(v)
            lastTime = current
        }
    }

    companion object {
        @JvmStatic @BindingAdapter("oneClick")
        fun setOnClickListener(theze: View, f: View.OnClickListener?) {
            when (f) {
                null -> theze.setOnClickListener(null)
                else -> theze.setOnClickListener(OneClickListener(f))
            }
        }
    }
}

E il mio layout è simile a questo

<TextView
        app:layout_constraintTop_toBottomOf="@id/bla"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:gravity="center"
        android:textSize="18sp"
        app:oneClick="@{viewModel::myHandler}" />

1

Un modo più significativo per gestire questo scenario consiste nell'usare l'operatore Throttling (Throttle First) con RxJava2. Passaggi per raggiungere questo obiettivo in Kotlin:

1). Dipendenze: - Aggiungi la dipendenza rxjava2 nel file a livello di app build.gradle.

implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation 'io.reactivex.rxjava2:rxjava:2.2.10'

2). Costruisci una classe astratta che implementi View.OnClickListener e contenga il primo operatore throttle per gestire il metodo OnClick () della vista. Lo snippet di codice è come:

import android.util.Log
import android.view.View
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.subjects.PublishSubject
import java.util.concurrent.TimeUnit

abstract class SingleClickListener : View.OnClickListener {
   private val publishSubject: PublishSubject<View> = PublishSubject.create()
   private val THRESHOLD_MILLIS: Long = 600L

   abstract fun onClicked(v: View)

   override fun onClick(p0: View?) {
       if (p0 != null) {
           Log.d("Tag", "Clicked occurred")
           publishSubject.onNext(p0)
       }
   }

   init {
       publishSubject.throttleFirst(THRESHOLD_MILLIS, TimeUnit.MILLISECONDS)
               .observeOn(AndroidSchedulers.mainThread())
               .subscribe { v -> onClicked(v) }
   }
}

3). Implementa questa classe SingleClickListener al clic di visualizzazione in attività. Ciò può essere ottenuto come:

override fun onCreate(savedInstanceState: Bundle?)  {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)

   val singleClickListener = object : SingleClickListener(){
      override fun onClicked(v: View) {
       // operation on click of xm_view_id
      }
  }
xm_viewl_id.setOnClickListener(singleClickListener)
}

L'implementazione di questi passaggi precedenti nell'app può semplicemente evitare i clic multipli su una vista fino a 600 mS. Buona programmazione!


0

Questo è risolto in questo modo

Observable<Object> tapEventEmitter = _rxBus.toObserverable().share();
Observable<Object> debouncedEventEmitter = tapEventEmitter.debounce(1, TimeUnit.SECONDS);
Observable<List<Object>> debouncedBufferEmitter = tapEventEmitter.buffer(debouncedEventEmitter);

debouncedBufferEmitter.buffer(debouncedEventEmitter)
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(new Action1<List<Object>>() {
      @Override
      public void call(List<Object> taps) {
        _showTapCount(taps.size());
      }
    });

0

Ecco una soluzione piuttosto semplice, che può essere utilizzata con lambda:

view.setOnClickListener(new DebounceClickListener(v -> this::doSomething));

Ecco lo snippet pronto per copia / incolla:

public class DebounceClickListener implements View.OnClickListener {

    private static final long DEBOUNCE_INTERVAL_DEFAULT = 500;
    private long debounceInterval;
    private long lastClickTime;
    private View.OnClickListener clickListener;

    public DebounceClickListener(final View.OnClickListener clickListener) {
        this(clickListener, DEBOUNCE_INTERVAL_DEFAULT);
    }

    public DebounceClickListener(final View.OnClickListener clickListener, final long debounceInterval) {
        this.clickListener = clickListener;
        this.debounceInterval = debounceInterval;
    }

    @Override
    public void onClick(final View v) {
        if ((SystemClock.elapsedRealtime() - lastClickTime) < debounceInterval) {
            return;
        }
        lastClickTime = SystemClock.elapsedRealtime();
        clickListener.onClick(v);
    }
}

Godere!


0

Basato sulla risposta di @GreyBeardedGeek ,

  • Crea debounceClick_last_Timestampsuids.xml per taggare il timestamp del clic precedente.
  • Aggiungi questo blocco di codice in BaseActivity

    protected void debounceClick(View clickedView, DebouncedClick callback){
        debounceClick(clickedView,1000,callback);
    }
    
    protected void debounceClick(View clickedView,long minimumInterval, DebouncedClick callback){
        Long previousClickTimestamp = (Long) clickedView.getTag(R.id.debounceClick_last_Timestamp);
        long currentTimestamp = SystemClock.uptimeMillis();
        clickedView.setTag(R.id.debounceClick_last_Timestamp, currentTimestamp);
        if(previousClickTimestamp == null 
              || Math.abs(currentTimestamp - previousClickTimestamp) > minimumInterval) {
            callback.onClick(clickedView);
        }
    }
    
    public interface DebouncedClick{
        void onClick(View view);
    }
    
  • Utilizzo:

    view.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            debounceClick(v, 3000, new DebouncedClick() {
                @Override
                public void onClick(View view) {
                    doStuff(view); // Put your's click logic on doStuff function
                }
            });
        }
    });
    
  • Utilizzando lambda

    view.setOnClickListener(v -> debounceClick(v, 3000, this::doStuff));
    

0

Metti un piccolo esempio qui

view.safeClick { doSomething() }

@SuppressLint("CheckResult")
fun View.safeClick(invoke: () -> Unit) {
    RxView
        .clicks(this)
        .throttleFirst(300, TimeUnit.MILLISECONDS)
        .subscribe { invoke() }
}

1
si prega di fornire un collegamento o una descrizione di che cos'è il vostro RxView
BekaBot

0

La mia soluzione, è necessario chiamare removeallquando usciamo (distruggiamo) dal frammento e dall'attività:

import android.os.Handler
import android.os.Looper
import java.util.concurrent.TimeUnit

    //single click handler
    object ClickHandler {

        //used to post messages and runnable objects
        private val mHandler = Handler(Looper.getMainLooper())

        //default delay is 250 millis
        @Synchronized
        fun handle(runnable: Runnable, delay: Long = TimeUnit.MILLISECONDS.toMillis(250)) {
            removeAll()//remove all before placing event so that only one event will execute at a time
            mHandler.postDelayed(runnable, delay)
        }

        @Synchronized
        fun removeAll() {
            mHandler.removeCallbacksAndMessages(null)
        }
    }

0

Direi che il modo più semplice è usare una libreria di "caricamento" come KProgressHUD.

https://github.com/Kaopiz/KProgressHUD

La prima cosa al metodo onClick sarebbe chiamare l'animazione di caricamento che blocca istantaneamente tutta l'interfaccia utente fino a quando lo sviluppatore non decide di liberarla.

Quindi avresti questo per l'azione onClick (questo usa Butterknife ma ovviamente funziona con qualsiasi tipo di approccio):

Inoltre, non dimenticare di disabilitare il pulsante dopo il clic.

@OnClick(R.id.button)
void didClickOnButton() {
    startHUDSpinner();
    button.setEnabled(false);
    doAction();
}

Poi:

public void startHUDSpinner() {
    stopHUDSpinner();
    currentHUDSpinner = KProgressHUD.create(this)
            .setStyle(KProgressHUD.Style.SPIN_INDETERMINATE)
            .setLabel(getString(R.string.loading_message_text))
            .setCancellable(false)
            .setAnimationSpeed(3)
            .setDimAmount(0.5f)
            .show();
}

public void stopHUDSpinner() {
    if (currentHUDSpinner != null && currentHUDSpinner.isShowing()) {
        currentHUDSpinner.dismiss();
    }
    currentHUDSpinner = null;
}

E puoi usare il metodo stopHUDSpinner nel metodo doAction () se lo desideri:

private void doAction(){ 
   // some action
   stopHUDSpinner()
}

Riattivare il pulsante in base alla logica dell'app: button.setEnabled (true);

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.