{
169 | return Matchers.instanceOf(TextView::class.java)
170 | }
171 |
172 | override fun getDescription(): String {
173 | return "clicking on a ClickableSpan"
174 | }
175 |
176 | override fun perform(uiController: UiController?, view: View) {
177 | val textView = view as TextView
178 | val spannedString = textView.text as SpannedString
179 | if (spannedString.isEmpty()) {
180 | // TextView is empty, nothing to do
181 | throw NoMatchingViewException.Builder()
182 | .includeViewHierarchy(true)
183 | .withRootView(textView)
184 | .build()
185 | }
186 |
187 | // Get the links inside the TextView and check if we find textToClick
188 | val spans = spannedString.getSpans(
189 | 0, spannedString.length,
190 | ClickableSpan::class.java
191 | )
192 | if (spans.isNotEmpty()) {
193 | var spanCandidate: ClickableSpan?
194 | for (span in spans) {
195 | spanCandidate = span
196 | val start = spannedString.getSpanStart(spanCandidate)
197 | val end = spannedString.getSpanEnd(spanCandidate)
198 | val sequence = spannedString.subSequence(start, end)
199 | if (textToClick.toString() == sequence.toString()) {
200 | span.onClick(textView)
201 | return
202 | }
203 | }
204 | }
205 | throw NoMatchingViewException.Builder()
206 | .includeViewHierarchy(true)
207 | .withRootView(textView)
208 | .build()
209 | }
210 | }
211 | }
212 | }
--------------------------------------------------------------------------------
/single-click/src/androidTest/java/cc/taylorzhang/singleclick/SingleClickTestActivity.kt:
--------------------------------------------------------------------------------
1 | package cc.taylorzhang.singleclick
2 |
3 | import android.app.Activity
4 | import android.os.Bundle
5 | import android.widget.Button
6 | import android.widget.LinearLayout
7 | import android.widget.TextView
8 |
9 | /**
10 | *
11 | * author : Taylor Zhang
12 | * time : 2021/03/21
13 | * desc : Single click test Activity.
14 | * version: 1.0.0
15 | *
16 | */
17 | class SingleClickTestActivity : Activity() {
18 |
19 | lateinit var btn1: Button
20 |
21 | lateinit var btn2: Button
22 |
23 | lateinit var tvText: TextView
24 |
25 | override fun onCreate(savedInstanceState: Bundle?) {
26 | super.onCreate(savedInstanceState)
27 |
28 | val group = LinearLayout(this)
29 | group.orientation = LinearLayout.VERTICAL
30 |
31 | btn1 = Button(this)
32 | btn1.text = "btn1"
33 | group.addView(btn1)
34 |
35 | btn2 = Button(this)
36 | btn2.text = "btn2"
37 | group.addView(btn2)
38 |
39 | tvText = TextView(this)
40 | group.addView(tvText)
41 |
42 | setContentView(group)
43 | }
44 | }
--------------------------------------------------------------------------------
/single-click/src/androidTest/java/cc/taylorzhang/singleclick/ViewSingleClickTest.kt:
--------------------------------------------------------------------------------
1 | package cc.taylorzhang.singleclick
2 |
3 | import androidx.test.core.app.ActivityScenario
4 | import androidx.test.espresso.Espresso.onView
5 | import androidx.test.espresso.action.ViewActions.click
6 | import androidx.test.espresso.matcher.ViewMatchers.withText
7 | import androidx.test.ext.junit.rules.ActivityScenarioRule
8 | import androidx.test.ext.junit.runners.AndroidJUnit4
9 | import org.junit.Assert.assertEquals
10 | import org.junit.Before
11 | import org.junit.Rule
12 | import org.junit.Test
13 | import org.junit.runner.RunWith
14 |
15 | /**
16 | *
17 | * author : Taylor Zhang
18 | * time : 2021/03/21
19 | * desc : View single click test.
20 | * version: 1.0.0
21 | *
22 | */
23 | @RunWith(AndroidJUnit4::class)
24 | class ViewSingleClickTest {
25 |
26 | @get:Rule
27 | val rule = ActivityScenarioRule(SingleClickTestActivity::class.java)
28 |
29 | private lateinit var scenario: ActivityScenario
30 |
31 | @Before
32 | fun setup() {
33 | scenario = rule.scenario
34 | }
35 |
36 | @Test
37 | fun testOneViewSingleClick() {
38 | var btn1SingleClickedCount = 0
39 | val interval = 1500
40 |
41 | scenario.onActivity {
42 | it.btn1.onSingleClick(interval = 1500) { btn1SingleClickedCount++ }
43 | }
44 |
45 | onView(withText("btn1")).perform(click())
46 | onView(withText("btn1")).perform(click())
47 | onView(withText("btn1")).perform(click())
48 | assertEquals(1, btn1SingleClickedCount)
49 |
50 | Thread.sleep(interval.toLong())
51 | onView(withText("btn1")).perform(click())
52 | onView(withText("btn1")).perform(click())
53 | onView(withText("btn1")).perform(click())
54 | assertEquals(2, btn1SingleClickedCount)
55 | }
56 |
57 | @Test
58 | fun testTwoViewSingleClick() {
59 | var btn1SingleClickedCount = 0
60 | var btn2SingleClickedCount = 0
61 | val interval = 1500
62 |
63 | scenario.onActivity {
64 | it.btn1.onSingleClick(interval = interval) { btn1SingleClickedCount++ }
65 | it.btn2.onSingleClick(interval = interval) { btn2SingleClickedCount++ }
66 | }
67 |
68 | onView(withText("btn1")).perform(click())
69 | onView(withText("btn2")).perform(click())
70 | assertEquals(1, btn1SingleClickedCount)
71 | assertEquals(0, btn2SingleClickedCount)
72 |
73 | Thread.sleep(interval.toLong())
74 | onView(withText("btn2")).perform(click())
75 | onView(withText("btn1")).perform(click())
76 | assertEquals(1, btn1SingleClickedCount)
77 | assertEquals(1, btn2SingleClickedCount)
78 | }
79 |
80 | @Test
81 | fun testOneViewNotShareSingleClick() {
82 | var btn1SingleClickedCount = 0
83 | var btn2SingleClickedCount = 0
84 |
85 | scenario.onActivity {
86 | it.btn1.onSingleClick(isShareSingleClick = false) { btn1SingleClickedCount++ }
87 | it.btn2.onSingleClick { btn2SingleClickedCount++ }
88 | }
89 |
90 | onView(withText("btn1")).perform(click())
91 | onView(withText("btn2")).perform(click())
92 | assertEquals(1, btn1SingleClickedCount)
93 | assertEquals(1, btn2SingleClickedCount)
94 | }
95 |
96 | @Test
97 | fun testTwoViewNotShareSingleClick() {
98 | var btn1SingleClickedCount = 0
99 | var btn2SingleClickedCount = 0
100 |
101 | scenario.onActivity {
102 | it.btn1.onSingleClick(isShareSingleClick = false) { btn1SingleClickedCount++ }
103 | it.btn2.onSingleClick(isShareSingleClick = false) { btn2SingleClickedCount++ }
104 | }
105 |
106 | onView(withText("btn1")).perform(click())
107 | onView(withText("btn2")).perform(click())
108 | assertEquals(1, btn1SingleClickedCount)
109 | assertEquals(1, btn2SingleClickedCount)
110 | }
111 | }
--------------------------------------------------------------------------------
/single-click/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
--------------------------------------------------------------------------------
/single-click/src/main/java/cc/taylorzhang/singleclick/SingleClickUtil.kt:
--------------------------------------------------------------------------------
1 | package cc.taylorzhang.singleclick
2 |
3 | import android.text.SpannableStringBuilder
4 | import android.text.TextPaint
5 | import android.text.style.ClickableSpan
6 | import android.view.View
7 | import androidx.core.text.inSpans
8 | import java.util.concurrent.TimeUnit
9 |
10 | /**
11 | *
12 | * author : Taylor Zhang
13 | * time : 2021/03/19
14 | * desc : Single click util.
15 | * version: 1.0.0
16 | *
17 | */
18 | object SingleClickUtil {
19 |
20 | /**
21 | * Global single click interval.
22 | */
23 | var singleClickInterval: Int = 1000
24 | set(value) {
25 | if (value <= 0) {
26 | throw IllegalArgumentException("Single click interval must be greater than 0.")
27 | }
28 | field = value
29 | }
30 |
31 | /**
32 | * Register a callback to be invoked when this view is single clicked.
33 | *
34 | * @param listener Single click listener.
35 | */
36 | @JvmStatic
37 | fun onSingleClick(view: View, listener: View.OnClickListener) {
38 | view.onSingleClick(listener = listener)
39 | }
40 |
41 | /**
42 | * Register a callback to be invoked when this view is single clicked.
43 | *
44 | * @param interval Single click interval. Unit is [TimeUnit.MILLISECONDS].
45 | * @param listener Single click listener.
46 | */
47 | @JvmStatic
48 | fun onSingleClick(view: View, interval: Int, listener: View.OnClickListener) {
49 | view.onSingleClick(interval, listener = listener)
50 | }
51 |
52 | /**
53 | * Register a callback to be invoked when this view is single clicked.
54 | *
55 | * @param isShareSingleClick True if this view is share single click interval whit other view
56 | * in same Activity, false otherwise.
57 | * @param listener Single click listener.
58 | */
59 | @JvmStatic
60 | fun onSingleClick(view: View, isShareSingleClick: Boolean, listener: View.OnClickListener) {
61 | view.onSingleClick(isShareSingleClick = isShareSingleClick, listener = listener)
62 | }
63 |
64 | /**
65 | * Register a callback to be invoked when this view is single clicked.
66 | *
67 | * @param interval Single click interval. Unit is [TimeUnit.MILLISECONDS].
68 | * @param isShareSingleClick True if this view is share single click interval whit other view
69 | * in same Activity, false otherwise.
70 | * @param listener Single click listener.
71 | */
72 | @JvmStatic
73 | fun onSingleClick(
74 | view: View,
75 | interval: Int,
76 | isShareSingleClick: Boolean,
77 | listener: View.OnClickListener
78 | ) {
79 | view.onSingleClick(interval, isShareSingleClick, listener)
80 | }
81 |
82 | /**
83 | * Wrap appended text in [builderAction] in a [ClickableSpan].
84 | * If selected and single clicked, the [listener] will be invoked.
85 | *
86 | * @param listener Single click listener.
87 | * @see SpannableStringBuilder.inSpans
88 | */
89 | @JvmStatic
90 | fun onSingleClick(
91 | builder: SpannableStringBuilder,
92 | listener: View.OnClickListener,
93 | builderAction: SpannableStringBuilder .() -> Unit
94 | ) {
95 | builder.onSingleClick(listener, builderAction = builderAction)
96 | }
97 |
98 | /**
99 | * Wrap appended text in [builderAction] in a [ClickableSpan].
100 | * If selected and single clicked, the [listener] will be invoked.
101 | *
102 | * @param listener Single click listener.
103 | * @param interval Single click interval.Unit is [TimeUnit.MILLISECONDS].
104 | * @see SpannableStringBuilder.inSpans
105 | */
106 | @JvmStatic
107 | fun onSingleClick(
108 | builder: SpannableStringBuilder,
109 | interval: Int,
110 | listener: View.OnClickListener,
111 | builderAction: SpannableStringBuilder .() -> Unit
112 | ) {
113 | builder.onSingleClick(listener, interval, builderAction = builderAction)
114 | }
115 |
116 | /**
117 | * Wrap appended text in [builderAction] in a [ClickableSpan].
118 | * If selected and single clicked, the [listener] will be invoked.
119 | *
120 | * @param listener Single click listener.
121 | * @param isShareSingleClick True if this view is share single click interval whit other view
122 | * in same Activity, false otherwise.
123 | * @see SpannableStringBuilder.inSpans
124 | */
125 | @JvmStatic
126 | fun onSingleClick(
127 | builder: SpannableStringBuilder,
128 | isShareSingleClick: Boolean,
129 | listener: View.OnClickListener,
130 | builderAction: SpannableStringBuilder .() -> Unit
131 | ) {
132 | builder.onSingleClick(
133 | listener, isShareSingleClick = isShareSingleClick, builderAction = builderAction
134 | )
135 | }
136 |
137 | /**
138 | * Wrap appended text in [builderAction] in a [ClickableSpan].
139 | * If selected and single clicked, the [listener] will be invoked.
140 | *
141 | * @param listener Single click listener.
142 | * @param interval Single click interval.Unit is [TimeUnit.MILLISECONDS].
143 | * @param isShareSingleClick True if this view is share single click interval whit other view
144 | * in same Activity, false otherwise.
145 | * @param updateDrawStateAction Update draw state action.
146 | * @see SpannableStringBuilder.inSpans
147 | */
148 | @JvmStatic
149 | fun onSingleClick(
150 | builder: SpannableStringBuilder,
151 | interval: Int,
152 | isShareSingleClick: Boolean,
153 | listener: View.OnClickListener,
154 | updateDrawStateAction: ((TextPaint) -> Unit),
155 | builderAction: SpannableStringBuilder .() -> Unit
156 | ) {
157 | builder.onSingleClick(
158 | listener, interval, isShareSingleClick, updateDrawStateAction, builderAction
159 | )
160 | }
161 |
162 | /**
163 | * Determine whether to trigger a single click.
164 | *
165 | * @param listener Single click listener.
166 | */
167 | @JvmStatic
168 | fun determineTriggerSingleClick(view: View, listener: View.OnClickListener) {
169 | view.determineTriggerSingleClick(listener = listener)
170 | }
171 |
172 | /**
173 | * Determine whether to trigger a single click.
174 | *
175 | * @param interval Single click interval.Unit is [TimeUnit.MILLISECONDS].
176 | * @param listener Single click listener.
177 | */
178 | @JvmStatic
179 | fun determineTriggerSingleClick(view: View, interval: Int, listener: View.OnClickListener) {
180 | view.determineTriggerSingleClick(interval = interval, listener = listener)
181 | }
182 |
183 | /**
184 | * Determine whether to trigger a single click.
185 | *
186 | * @param isShareSingleClick True if this view is share single click interval whit other view
187 | * in same Activity, false otherwise.
188 | * @param listener Single click listener.
189 | */
190 | @JvmStatic
191 | fun determineTriggerSingleClick(
192 | view: View,
193 | isShareSingleClick: Boolean,
194 | listener: View.OnClickListener
195 | ) {
196 | view.determineTriggerSingleClick(
197 | isShareSingleClick = isShareSingleClick, listener = listener
198 | )
199 | }
200 |
201 | /**
202 | * Determine whether to trigger a single click.
203 | *
204 | * @param interval Single click interval.Unit is [TimeUnit.MILLISECONDS].
205 | * @param isShareSingleClick True if this view is share single click interval whit other view
206 | * in same Activity, false otherwise.
207 | * @param listener Single click listener.
208 | */
209 | @JvmStatic
210 | fun determineTriggerSingleClick(
211 | view: View,
212 | interval: Int,
213 | isShareSingleClick: Boolean,
214 | listener: View.OnClickListener
215 | ) {
216 | view.determineTriggerSingleClick(interval, isShareSingleClick, listener)
217 | }
218 | }
--------------------------------------------------------------------------------
/single-click/src/main/java/cc/taylorzhang/singleclick/SpannableStringBuilder.kt:
--------------------------------------------------------------------------------
1 | package cc.taylorzhang.singleclick
2 |
3 | import android.text.SpannableStringBuilder
4 | import android.text.TextPaint
5 | import android.text.style.ClickableSpan
6 | import android.view.View
7 | import androidx.core.text.inSpans
8 | import java.util.concurrent.TimeUnit
9 |
10 | /**
11 | *
12 | * author : Taylor Zhang
13 | * time : 2021/03/20
14 | * desc : SpannableStringBuilder extensions.
15 | * version: 1.0.0
16 | *
17 | */
18 |
19 | /**
20 | * Wrap appended text in [builderAction] in a [ClickableSpan].
21 | * If selected and single clicked, the [listener] will be invoked.
22 | *
23 | * @param listener Single click listener.
24 | * @param interval Single click interval.Unit is [TimeUnit.MILLISECONDS].
25 | * @param isShareSingleClick True if this view is share single click interval whit other view
26 | * in same Activity, false otherwise.
27 | * @param updateDrawStateAction Update draw state action.
28 | * @see SpannableStringBuilder.inSpans
29 | */
30 | inline fun SpannableStringBuilder.onSingleClick(
31 | listener: View.OnClickListener,
32 | interval: Int = SingleClickUtil.singleClickInterval,
33 | isShareSingleClick: Boolean = true,
34 | noinline updateDrawStateAction: ((TextPaint) -> Unit)? = null,
35 | builderAction: SpannableStringBuilder .() -> Unit
36 | ): SpannableStringBuilder = inSpans(
37 | SingleClickableSpan(
38 | listener, interval, isShareSingleClick, updateDrawStateAction
39 | ),
40 | builderAction = builderAction
41 | )
42 |
43 | /**
44 | * Single clickable span.
45 | */
46 | class SingleClickableSpan(
47 | private val listener: View.OnClickListener,
48 | private val interval: Int = SingleClickUtil.singleClickInterval,
49 | private val isShareSingleClick: Boolean = true,
50 | private val updateDrawStateAction: ((TextPaint) -> Unit)? = null,
51 | ) : ClickableSpan() {
52 |
53 | private var mFakeView: View? = null
54 |
55 | override fun onClick(widget: View) {
56 | if (isShareSingleClick) {
57 | widget
58 | } else {
59 | if (mFakeView == null) {
60 | mFakeView = View(widget.context)
61 | }
62 | mFakeView!!
63 | }.determineTriggerSingleClick(interval, isShareSingleClick, listener)
64 | }
65 |
66 | override fun updateDrawState(ds: TextPaint) {
67 | updateDrawStateAction?.invoke(ds)
68 | }
69 | }
--------------------------------------------------------------------------------
/single-click/src/main/java/cc/taylorzhang/singleclick/View.kt:
--------------------------------------------------------------------------------
1 | package cc.taylorzhang.singleclick
2 |
3 | import android.app.Activity
4 | import android.content.ContextWrapper
5 | import android.os.SystemClock
6 | import android.view.View
7 | import androidx.databinding.BindingAdapter
8 | import java.util.concurrent.TimeUnit
9 |
10 | /**
11 | *
12 | * author : Taylor Zhang
13 | * time : 2021/03/19
14 | * desc : View extensions.
15 | * version: 1.0.0
16 | *
17 | */
18 |
19 | /**
20 | * Register a callback to be invoked when this view is single clicked.
21 | *
22 | * @param interval Single click interval. Unit is [TimeUnit.MILLISECONDS].
23 | * @param isShareSingleClick True if this view is share single click interval whit other view
24 | * in same Activity, false otherwise.
25 | * @param listener Single click listener.
26 | */
27 | @BindingAdapter(
28 | *["singleClickInterval", "isShareSingleClick", "onSingleClick"],
29 | requireAll = false
30 | )
31 | fun View.onSingleClick(
32 | interval: Int? = SingleClickUtil.singleClickInterval,
33 | isShareSingleClick: Boolean? = true,
34 | listener: View.OnClickListener? = null
35 | ) {
36 | if (listener == null) {
37 | return
38 | }
39 |
40 | setOnClickListener {
41 | determineTriggerSingleClick(
42 | interval ?: SingleClickUtil.singleClickInterval, isShareSingleClick ?: true, listener
43 | )
44 | }
45 | }
46 |
47 | /**
48 | * Determine whether to trigger a single click.
49 | *
50 | * @param interval Single click interval.Unit is [TimeUnit.MILLISECONDS].
51 | * @param isShareSingleClick True if this view is share single click interval whit other view
52 | * in same Activity, false otherwise.
53 | * @param listener Single click listener.
54 | */
55 | fun View.determineTriggerSingleClick(
56 | interval: Int = SingleClickUtil.singleClickInterval,
57 | isShareSingleClick: Boolean = true,
58 | listener: View.OnClickListener
59 | ) {
60 | val target = if (isShareSingleClick) getActivity(this)?.window?.decorView ?: this else this
61 | val millis = target.getTag(R.id.single_click_tag_last_single_click_millis) as? Long ?: 0
62 | if (SystemClock.uptimeMillis() - millis >= interval) {
63 | target.setTag(R.id.single_click_tag_last_single_click_millis, SystemClock.uptimeMillis())
64 | listener.onClick(this)
65 | }
66 | }
67 |
68 | private fun getActivity(view: View): Activity? {
69 | var context = view.context
70 | while (context is ContextWrapper) {
71 | if (context is Activity) {
72 | return context
73 | }
74 | context = context.baseContext
75 | }
76 | return null
77 | }
--------------------------------------------------------------------------------
/single-click/src/main/res/values/ids.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------