Skip to content

Commit a3d3c4c

Browse files
committed
Introduce flowbinding-android for platform bindings. Add view bindings for clicked, long clicked and focus changed events.
1 parent 805ac9c commit a3d3c4c

File tree

15 files changed

+367
-1
lines changed

15 files changed

+367
-1
lines changed

README.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@ The library is a work in progress. We currently have bindings for the **Material
1616

1717
### Platform Bindings
1818

19-
TBA.
19+
- [ ] View
20+
- [x] `fun View.clicks(): Flow<Unit>`
21+
- [x] `fun View.focusChanges(emitImmediately: Boolean = false): Flow<Boolean>`
22+
- [x] `fun View.longClicks(): Flow<Unit>`
23+
- [ ] Widget
24+
TBA
2025

2126
### Material Components Bindings
2227

flowbinding-android/build.gradle

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
plugins {
2+
id 'flowbinding-plugin'
3+
id 'com.android.library'
4+
id 'kotlin-android'
5+
id 'com.vanniktech.maven.publish'
6+
id 'io.github.reactivecircus.firestorm'
7+
}
8+
9+
android {
10+
defaultConfig {
11+
testApplicationId 'reactivecircus.flowbinding.android.test'
12+
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
13+
}
14+
}
15+
16+
dependencies {
17+
implementation project(':flowbinding-common')
18+
19+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.kotlinx.coroutines}"
20+
21+
androidTestImplementation project(':testing-infra')
22+
androidTestImplementation project(':flowbinding-android:fixtures')
23+
}
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
plugins {
2+
id 'flowbinding-plugin'
3+
id 'com.android.library'
4+
id 'kotlin-android'
5+
}
6+
7+
dependencies {
8+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}"
9+
implementation "androidx.fragment:fragment-ktx:${versions.androidx.fragment}"
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<manifest package="reactivecircus.flowbinding.android.fixtures" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package reactivecircus.flowbinding.android.fixtures.view
2+
3+
import androidx.fragment.app.Fragment
4+
import reactivecircus.flowbinding.android.fixtures.R
5+
6+
class AndroidViewFragment : Fragment(R.layout.fragment_android_view)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:tools="http://schemas.android.com/tools"
4+
android:layout_width="match_parent"
5+
android:layout_height="match_parent"
6+
android:gravity="center_horizontal"
7+
android:orientation="vertical"
8+
android:padding="16dp"
9+
tools:ignore="HardcodedText">
10+
11+
<Button
12+
android:id="@+id/button"
13+
android:layout_width="wrap_content"
14+
android:layout_height="wrap_content"
15+
android:text="My Button" />
16+
17+
<EditText
18+
android:id="@+id/editText1"
19+
android:layout_width="match_parent"
20+
android:layout_height="wrap_content"
21+
android:layout_margin="16dp" />
22+
23+
<EditText
24+
android:id="@+id/editText2"
25+
android:layout_width="match_parent"
26+
android:layout_height="wrap_content"
27+
android:layout_margin="16dp" />
28+
29+
</LinearLayout>

flowbinding-android/gradle.properties

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
POM_ARTIFACT_ID=flowbinding-android
2+
POM_NAME=FlowBinding Android
3+
POM_DESCRIPTION=Kotlin Flow binding APIs for Android platform UI widgets
4+
POM_PACKAGING=aar
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package reactivecircus.flowbinding.android.view
2+
3+
import android.view.View
4+
import androidx.test.filters.LargeTest
5+
import org.amshove.kluent.shouldEqual
6+
import org.junit.Test
7+
import reactivecircus.blueprint.testing.action.clickView
8+
import reactivecircus.flowbinding.android.fixtures.view.AndroidViewFragment
9+
import reactivecircus.flowbinding.android.test.R
10+
import reactivecircus.flowbinding.testing.FlowRecorder
11+
import reactivecircus.flowbinding.testing.launchTest
12+
import reactivecircus.flowbinding.testing.recordWith
13+
14+
@LargeTest
15+
class ViewClickedFlowTest {
16+
17+
@Test
18+
fun viewClicks() {
19+
launchTest<AndroidViewFragment> {
20+
val recorder = FlowRecorder<Unit>(testScope)
21+
val view = getViewById<View>(R.id.button)
22+
view.clicks().recordWith(recorder)
23+
24+
recorder.assertNoMoreValues()
25+
26+
clickView(R.id.button)
27+
recorder.takeValue() shouldEqual Unit
28+
recorder.assertNoMoreValues()
29+
30+
cancelTestScope()
31+
32+
clickView(R.id.button)
33+
recorder.assertNoMoreValues()
34+
}
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package reactivecircus.flowbinding.android.view
2+
3+
import android.widget.EditText
4+
import androidx.test.filters.LargeTest
5+
import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread
6+
import org.amshove.kluent.shouldEqual
7+
import org.junit.Test
8+
import reactivecircus.blueprint.testing.action.clickView
9+
import reactivecircus.flowbinding.android.fixtures.view.AndroidViewFragment
10+
import reactivecircus.flowbinding.android.test.R
11+
import reactivecircus.flowbinding.testing.FlowRecorder
12+
import reactivecircus.flowbinding.testing.launchTest
13+
import reactivecircus.flowbinding.testing.recordWith
14+
15+
@LargeTest
16+
class ViewFocusChangedFlowTest {
17+
18+
@Test
19+
fun viewFocusChanges() {
20+
launchTest<AndroidViewFragment> {
21+
val recorder = FlowRecorder<Boolean>(testScope)
22+
val editText = getViewById<EditText>(R.id.editText1)
23+
editText.focusChanges().recordWith(recorder)
24+
25+
recorder.assertNoMoreValues()
26+
27+
clickView(R.id.editText1)
28+
recorder.takeValue() shouldEqual true
29+
recorder.assertNoMoreValues()
30+
31+
clickView(R.id.editText2)
32+
recorder.takeValue() shouldEqual false
33+
recorder.assertNoMoreValues()
34+
35+
cancelTestScope()
36+
37+
clickView(R.id.editText1)
38+
recorder.assertNoMoreValues()
39+
}
40+
}
41+
42+
@Test
43+
fun viewFocusChanges_programmatic() {
44+
launchTest<AndroidViewFragment> {
45+
val recorder = FlowRecorder<Boolean>(testScope)
46+
val editText1 = getViewById<EditText>(R.id.editText1)
47+
val editText2 = getViewById<EditText>(R.id.editText2)
48+
editText1.focusChanges().recordWith(recorder)
49+
50+
recorder.assertNoMoreValues()
51+
52+
runOnUiThread { editText1.requestFocus() }
53+
recorder.takeValue() shouldEqual true
54+
recorder.assertNoMoreValues()
55+
56+
runOnUiThread { editText2.requestFocus() }
57+
recorder.takeValue() shouldEqual false
58+
recorder.assertNoMoreValues()
59+
60+
cancelTestScope()
61+
62+
runOnUiThread { editText1.requestFocus() }
63+
recorder.assertNoMoreValues()
64+
}
65+
}
66+
67+
@Test
68+
fun viewFocusChanges_emitImmediately() {
69+
launchTest<AndroidViewFragment> {
70+
val recorder = FlowRecorder<Boolean>(testScope)
71+
val editText = getViewById<EditText>(R.id.editText1).apply {
72+
runOnUiThread { requestFocus() }
73+
}
74+
editText.focusChanges(emitImmediately = true).recordWith(recorder)
75+
76+
recorder.takeValue() shouldEqual true
77+
recorder.assertNoMoreValues()
78+
79+
clickView(R.id.editText2)
80+
recorder.takeValue() shouldEqual false
81+
recorder.assertNoMoreValues()
82+
83+
cancelTestScope()
84+
85+
clickView(R.id.editText1)
86+
recorder.assertNoMoreValues()
87+
}
88+
}
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package reactivecircus.flowbinding.android.view
2+
3+
import android.view.View
4+
import androidx.test.filters.LargeTest
5+
import org.amshove.kluent.shouldEqual
6+
import org.junit.Test
7+
import reactivecircus.blueprint.testing.action.longClickView
8+
import reactivecircus.flowbinding.android.fixtures.view.AndroidViewFragment
9+
import reactivecircus.flowbinding.android.test.R
10+
import reactivecircus.flowbinding.testing.FlowRecorder
11+
import reactivecircus.flowbinding.testing.launchTest
12+
import reactivecircus.flowbinding.testing.recordWith
13+
14+
@LargeTest
15+
class ViewLongClickedFlowTest {
16+
17+
@Test
18+
fun viewLongClicks() {
19+
launchTest<AndroidViewFragment> {
20+
val recorder = FlowRecorder<Unit>(testScope)
21+
val view = getViewById<View>(R.id.button)
22+
view.longClicks().recordWith(recorder)
23+
24+
recorder.assertNoMoreValues()
25+
26+
longClickView(R.id.button)
27+
recorder.takeValue() shouldEqual Unit
28+
recorder.assertNoMoreValues()
29+
30+
cancelTestScope()
31+
32+
longClickView(R.id.button)
33+
recorder.assertNoMoreValues()
34+
}
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<manifest package="reactivecircus.flowbinding.android" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package reactivecircus.flowbinding.android.view
2+
3+
import android.view.View
4+
import androidx.annotation.CheckResult
5+
import kotlinx.coroutines.ExperimentalCoroutinesApi
6+
import kotlinx.coroutines.channels.awaitClose
7+
import kotlinx.coroutines.flow.Flow
8+
import kotlinx.coroutines.flow.callbackFlow
9+
import kotlinx.coroutines.flow.conflate
10+
import reactivecircus.flowbinding.common.checkMainThread
11+
import reactivecircus.flowbinding.common.safeOffer
12+
13+
/**
14+
* Create a [Flow] of click events on the [View] instance.
15+
*
16+
* Note: Created flow keeps a strong reference to the [View] instance
17+
* until the coroutine that launched the flow collector is cancelled.
18+
*
19+
* Example of usage:
20+
*
21+
* ```
22+
* view.clicks()
23+
* .onEach {
24+
* // handle view clicked
25+
* }
26+
* .launchIn(uiScope)
27+
* ```
28+
*/
29+
@CheckResult
30+
@UseExperimental(ExperimentalCoroutinesApi::class)
31+
fun View.clicks(): Flow<Unit> = callbackFlow {
32+
checkMainThread()
33+
val listener = View.OnClickListener {
34+
safeOffer(Unit)
35+
}
36+
setOnClickListener(listener)
37+
awaitClose { setOnClickListener(null) }
38+
}.conflate()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package reactivecircus.flowbinding.android.view
2+
3+
import android.view.View
4+
import androidx.annotation.CheckResult
5+
import kotlinx.coroutines.ExperimentalCoroutinesApi
6+
import kotlinx.coroutines.channels.awaitClose
7+
import kotlinx.coroutines.flow.Flow
8+
import kotlinx.coroutines.flow.callbackFlow
9+
import kotlinx.coroutines.flow.conflate
10+
import reactivecircus.flowbinding.common.checkMainThread
11+
import reactivecircus.flowbinding.common.safeOffer
12+
import reactivecircus.flowbinding.common.startWithCurrentValue
13+
14+
/**
15+
* Create a [Flow] of focus changed events on the [View] instance,
16+
* where the value emitted indicates whether the [View] has focus.
17+
*
18+
* Note: Created flow keeps a strong reference to the [View] instance
19+
* until the coroutine that launched the flow collector is cancelled.
20+
*
21+
* @param emitImmediately whether to emit the current value (if any) immediately on flow collection.
22+
*
23+
* Example of usage:
24+
*
25+
* ```
26+
* view.focusChanges()
27+
* .onEach {
28+
* // handle view focus changed
29+
* }
30+
* .launchIn(uiScope)
31+
* ```
32+
*/
33+
@CheckResult
34+
@UseExperimental(ExperimentalCoroutinesApi::class)
35+
fun View.focusChanges(emitImmediately: Boolean = false): Flow<Boolean> = callbackFlow {
36+
checkMainThread()
37+
val listener = View.OnFocusChangeListener { _, hasFocus ->
38+
safeOffer(hasFocus)
39+
}
40+
onFocusChangeListener = listener
41+
awaitClose { onFocusChangeListener = null }
42+
}
43+
.startWithCurrentValue(emitImmediately) { hasFocus() }
44+
.conflate()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package reactivecircus.flowbinding.android.view
2+
3+
import android.view.View
4+
import androidx.annotation.CheckResult
5+
import kotlinx.coroutines.ExperimentalCoroutinesApi
6+
import kotlinx.coroutines.channels.awaitClose
7+
import kotlinx.coroutines.flow.Flow
8+
import kotlinx.coroutines.flow.callbackFlow
9+
import kotlinx.coroutines.flow.conflate
10+
import reactivecircus.flowbinding.common.checkMainThread
11+
import reactivecircus.flowbinding.common.safeOffer
12+
13+
/**
14+
* Create a [Flow] of long click events on the [View] instance.
15+
*
16+
* Note: Created flow keeps a strong reference to the [View] instance
17+
* until the coroutine that launched the flow collector is cancelled.
18+
*
19+
* Example of usage:
20+
*
21+
* ```
22+
* view.longClicks()
23+
* .onEach {
24+
* // handle view long clicked
25+
* }
26+
* .launchIn(uiScope)
27+
* ```
28+
*/
29+
@CheckResult
30+
@UseExperimental(ExperimentalCoroutinesApi::class)
31+
fun View.longClicks(): Flow<Unit> = callbackFlow {
32+
checkMainThread()
33+
val listener = View.OnLongClickListener {
34+
safeOffer(Unit)
35+
}
36+
setOnLongClickListener(listener)
37+
awaitClose { setOnLongClickListener(null) }
38+
}.conflate()

settings.gradle

+6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
rootProject.name = "FlowBinding"
22
include ":flowbinding-common"
3+
4+
include ":flowbinding-android"
5+
includeProject(":flowbinding-android:fixtures", "flowbinding-android/fixtures")
6+
37
include ":flowbinding-material"
48
includeProject(":flowbinding-material:fixtures", "flowbinding-material/fixtures")
9+
510
include ":flowbinding-viewpager2"
611
includeProject(":flowbinding-viewpager2:fixtures", "flowbinding-viewpager2/fixtures")
12+
713
include ":testing-infra"
814

915
/**

0 commit comments

Comments
 (0)