Android gracefully handles repeated clicks

Posted by Nathan__02 on Sun, 23 Jan 2022 05:17:49 +0100

For Android apps on general mobile phones, the main interaction mode is click. After the user clicks, the App may update the UI in the page, open a new page, or initiate a network request. The Android system itself does not deal with repeated clicks. If users click multiple times in a short time, there may be problems such as opening multiple pages or repeatedly initiating network requests. Therefore, it is necessary to add code to deal with repeated clicks where it has an impact on repeated clicks.

Previous treatment

Previously, RxJava was used in the project, using third-party libraries RxBinding To prevent repeated clicks:

fun View.onSingleClick(interval: Long = 1000L, listener: (View) -> Unit) {
    RxView.clicks(this)
        .throttleFirst(interval, TimeUnit.MILLISECONDS)
        .subscribe({
            listener.invoke(this)
        }, {
            LogUtil.printStackTrace(it)
        })
}

But there is a problem. For example, if you use two fingers to click two different buttons at the same time, and the function of the buttons is to open a new page, two new pages may be opened. Because Rxjava is implemented for a single control to prevent repeated clicks, not multiple controls.

Current treatment

At present, time judgment is used. Only one click is responded within the time range. By saving the last click time to decorView in the Activity Window, all views in an Activity share the last click time.

fun View.onSingleClick(
    interval: Int = SingleClickUtil.singleClickInterval,
    isShareSingleClick: Boolean = true,
    listener: (View) -> Unit
) {
    setOnClickListener {
        val target = if (isShareSingleClick) getActivity(this)?.window?.decorView ?: this else this
        val millis = target.getTag(R.id.single_click_tag_last_single_click_millis) as? Long ?: 0
        if (SystemClock.uptimeMillis() - millis >= interval) {
            target.setTag(
                R.id.single_click_tag_last_single_click_millis, SystemClock.uptimeMillis()
            )
            listener.invoke(this)
        }
    }
}

private fun getActivity(view: View): Activity? {
    var context = view.context
    while (context is ContextWrapper) {
        if (context is Activity) {
            return context
        }
        context = context.baseContext
    }
    return null
}

The default value of the parameter issharessingleclick is true, which means that the control shares a last click time with other controls in the same Activity. It can also be manually changed to false, which means that the control only enjoys a last click time.

mBinding.btn1.onSingleClick {
    // Process single click
}

mBinding.btn2.onSingleClick(interval = 2000, isShareSingleClick = false) {
    // Process single click
}

Repeat clicks for other scene processing

Indirect settings Click

In addition to the click monitoring directly set on the View, there are also scenes where you need to deal with repeated clicks in other indirect settings, such as rich text and lists.

Therefore, the code for judging whether to trigger a single click is extracted as a separate method:

fun View.onSingleClick(
    interval: Int = SingleClickUtil.singleClickInterval,
    isShareSingleClick: Boolean = true,
    listener: (View) -> Unit
) {
    setOnClickListener { determineTriggerSingleClick(interval, isShareSingleClick, listener) }
}

fun View.determineTriggerSingleClick(
    interval: Int = SingleClickUtil.singleClickInterval,
    isShareSingleClick: Boolean = true,
    listener: (View) -> Unit
) {
    ...
}

Call determineTriggerSingleClick directly in the click monitoring callback to determine whether to trigger a single click. Let's take rich text and lists as examples.

Rich text

Inherit ClickableSpan and judge whether to trigger a single click in onClick callback:

inline fun SpannableStringBuilder.onSingleClick(
    listener: (View) -> Unit,
    isShareSingleClick: Boolean = true,
    ...
): SpannableStringBuilder = inSpans(
    object : ClickableSpan() {
        override fun onClick(widget: View) {
            widget.determineTriggerSingleClick(interval, isShareSingleClick, listener)
        }
        ...
    },
    builderAction = builderAction
)

There will be a problem. The widget in the onClick callback is to set the rich text control, that is, if there are multiple single clicks on the rich text, even if the issharsingleclick value is false, these single clicks will share the last click time of the set rich text control.

Therefore, special processing is required here. When isShareSingleClick is false, create a fake View to trigger the click event. In this way, multiple places in rich text where isShareSingleClick is false have their own fake View to exclusively enjoy the last click time.

class SingleClickableSpan(
    ...
) : ClickableSpan() {

    private var mFakeView: View? = null

    override fun onClick(widget: View) {
        if (isShareSingleClick) {
            widget
        } else {
            if (mFakeView == null) {
                mFakeView = View(widget.context)
            }
            mFakeView!!
        }.determineTriggerSingleClick(interval, isShareSingleClick, listener)
    }
    ...
}

Where rich text is set, use onSingleClick to realize single click:

mBinding.tvText.movementMethod = LinkMovementMethod.getInstance()
mBinding.tvText.highlightColor = Color.TRANSPARENT
mBinding.tvText.text = buildSpannedString {
    append("normalText")
    onSingleClick({
        // Process single click
    }) {
        color(Color.GREEN) { append("clickText") }
    }
}

list

The list uses the RecyclerView control, and the adapter uses a third-party library BaseRecyclerViewAdapterHelper.

Item Click:

adapter.setOnItemClickListener { _, view, _ ->
    view.determineTriggerSingleClick {
        // Process single click
    }
}

Item Child Click:

adapter.addChildClickViewIds(R.id.btn1, R.id.btn2)
adapter.setOnItemChildClickListener { _, view, _ ->
    when (view.id) {
        R.id.btn1 -> {
            // Handle normal clicks
        }
        R.id.btn2 -> view.determineTriggerSingleClick {
            // Process single click
        }
    }
}

Data binding

When using DataBinding, click events are sometimes set directly in the layout file, so in view Add the @ BindingAdapte annotation on onsingleclick to set the single click event in the layout file and adjust the code. At this time, it is necessary to replace listener: (view) - > unit in the project with listener: view OnClickListener.

@BindingAdapter(
    *["singleClickInterval", "isShareSingleClick", "onSingleClick"],
    requireAll = false
)
fun View.onSingleClick(
    interval: Int? = SingleClickUtil.singleClickInterval,
    isShareSingleClick: Boolean? = true,
    listener: View.OnClickListener? = null
) {
    if (listener == null) {
        return
    }

    setOnClickListener {
        determineTriggerSingleClick(
            interval ?: SingleClickUtil.singleClickInterval, isShareSingleClick ?: true, listener
        )
    }
}

Set single click in layout file:

<androidx.appcompat.widget.AppCompatButton
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@string/btn"
    app:isShareSingleClick="@{false}"
    app:onSingleClick="@{()->viewModel.handleClick()}"
    app:singleClickInterval="@{2000}" />

Process a single click in Code:

class YourViewModel : ViewModel() {

    fun handleClick() {
        // Process single click
    }
}

summary

For places where you set clicks directly on the View, if you need to handle repeated clicks, use onSingleClick. If you don't need to handle repeated clicks, use the original setOnClickListener.

For indirectly set clicks, if repeated clicks need to be processed, use determineTriggerSingleClick to judge whether a single click is triggered.

Project address

single-click , I think it's great to use. Please don't be stingy with your Star!



Author: Zhang Kun's notes
Link: https://www.jianshu.com/p/04ed8d18c335
Source: Jianshu
The copyright belongs to the author. For commercial reprint, please contact the author for authorization, and for non-commercial reprint, please indicate the source.

Topics: Android