Skip to content

Commit cc692a8

Browse files
authored
Disable typing when dropdown has answer (#2794)
* Improve dropdown behavior - Use `questionnaireViewItem.answers` state to check if the AutoComplete is editable or not - Remove keyListener to make the AutoComplete not editable - Hide keyboard after typing an option then clicking the shown option - Show clear icon when answer is selected * spotless
1 parent 2e5e184 commit cc692a8

File tree

6 files changed

+196
-11
lines changed

6 files changed

+196
-11
lines changed

datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryEspressoTest.kt

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023 Google LLC
2+
* Copyright 2023-2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import android.widget.FrameLayout
2222
import android.widget.TextView
2323
import androidx.test.espresso.Espresso.onData
2424
import androidx.test.espresso.Espresso.onView
25+
import androidx.test.espresso.PerformException
2526
import androidx.test.espresso.action.ViewActions.click
2627
import androidx.test.espresso.action.ViewActions.typeText
2728
import androidx.test.espresso.assertion.ViewAssertions.matches
@@ -51,6 +52,7 @@ import org.hl7.fhir.r4.model.Questionnaire
5152
import org.hl7.fhir.r4.model.QuestionnaireResponse
5253
import org.hl7.fhir.r4.model.Reference
5354
import org.hl7.fhir.r4.model.StringType
55+
import org.junit.Assert.assertThrows
5456
import org.junit.Before
5557
import org.junit.Rule
5658
import org.junit.Test
@@ -274,6 +276,81 @@ class DropDownViewHolderFactoryEspressoTest {
274276
.isEqualTo(3)
275277
}
276278

279+
@Test
280+
fun shouldPreventTypingWhenAnswerIsSelectedInAutoCompleteDropdown() {
281+
val preselectedAnswer =
282+
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
283+
addAnswer().value = StringType("Coding 1")
284+
}
285+
286+
val questionnaireItem =
287+
QuestionnaireViewItem(
288+
createAnswerOptions("Coding 1", "Coding 2", "Coding 3"),
289+
preselectedAnswer,
290+
validationResult = NotValidated,
291+
answersChangedCallback = { _, _, _, _ -> },
292+
)
293+
294+
val autoComplete = viewHolder.itemView.findViewById<AutoCompleteTextView>(R.id.auto_complete)
295+
296+
runOnUI {
297+
viewHolder.bind(questionnaireItem)
298+
autoComplete.showDropDown()
299+
}
300+
301+
assertThrows(PerformException::class.java) {
302+
onView(withId(R.id.auto_complete)).perform(typeText("new text"))
303+
}
304+
305+
assertThat(autoComplete.text.toString()).isEqualTo("Coding 1")
306+
}
307+
308+
@Test
309+
fun shouldSelectAndClearAnswerInAutoCompleteDropdown() {
310+
var selectedAnswers: List<QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent>? =
311+
null
312+
val answerOptions = listOf("Coding 1", "Coding 2", "Coding 3")
313+
314+
var questionnaireItem =
315+
QuestionnaireViewItem(
316+
createAnswerOptions(*answerOptions.toTypedArray()),
317+
responseValueStringOptions(),
318+
validationResult = NotValidated,
319+
answersChangedCallback = { _, _, answers, _ -> selectedAnswers = answers },
320+
)
321+
322+
val autoComplete = viewHolder.itemView.findViewById<AutoCompleteTextView>(R.id.auto_complete)
323+
324+
runOnUI {
325+
viewHolder.bind(questionnaireItem)
326+
autoComplete.showDropDown()
327+
}
328+
329+
// Test selection flow
330+
onView(withText("Coding 1"))
331+
.inRoot(isPlatformPopup())
332+
.check(matches(isDisplayed()))
333+
.perform(click())
334+
335+
assertThat(selectedAnswers).hasSize(1)
336+
assertThat((selectedAnswers!!.first().value as StringType).valueAsString).isEqualTo("Coding 1")
337+
338+
// Test clearing flow
339+
questionnaireItem =
340+
QuestionnaireViewItem(
341+
createAnswerOptions(*answerOptions.toTypedArray()),
342+
responseValueStringOptions().apply { answer = selectedAnswers },
343+
validationResult = NotValidated,
344+
answersChangedCallback = { _, _, answers, _ -> selectedAnswers = answers },
345+
)
346+
347+
runOnUI { viewHolder.bind(questionnaireItem) }
348+
349+
onView(withId(R.id.clear_input_icon)).perform(click())
350+
351+
assertThat(selectedAnswers).isEmpty()
352+
}
353+
277354
@Test
278355
fun shouldReturnFilteredWithNoResultsDropDownMenuItems() {
279356
val questionnaireViewItem =

datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2022-2024 Google LLC
2+
* Copyright 2022-2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,13 +19,17 @@ package com.google.android.fhir.datacapture.views.factories
1919
import android.content.Context
2020
import android.graphics.drawable.Drawable
2121
import android.text.Spanned
22+
import android.text.method.TextKeyListener
2223
import android.view.LayoutInflater
2324
import android.view.View
2425
import android.view.ViewGroup
26+
import android.view.inputmethod.InputMethodManager
2527
import android.widget.AdapterView
2628
import android.widget.ArrayAdapter
29+
import android.widget.ImageView
2730
import android.widget.TextView
2831
import androidx.appcompat.app.AppCompatActivity
32+
import androidx.core.view.doOnNextLayout
2933
import androidx.lifecycle.lifecycleScope
3034
import com.google.android.fhir.datacapture.R
3135
import com.google.android.fhir.datacapture.extensions.displayString
@@ -52,14 +56,28 @@ internal object DropDownViewHolderFactory :
5256
private lateinit var header: HeaderView
5357
private lateinit var textInputLayout: TextInputLayout
5458
private lateinit var autoCompleteTextView: MaterialAutoCompleteTextView
59+
private lateinit var clearInputIcon: ImageView
5560
override lateinit var questionnaireViewItem: QuestionnaireViewItem
5661
private lateinit var context: AppCompatActivity
5762

5863
override fun init(itemView: View) {
5964
header = itemView.findViewById(R.id.header)
6065
textInputLayout = itemView.findViewById(R.id.text_input_layout)
6166
autoCompleteTextView = itemView.findViewById(R.id.auto_complete)
67+
clearInputIcon = itemView.findViewById(R.id.clear_input_icon)
6268
context = itemView.context.tryUnwrapContext()!!
69+
autoCompleteTextView.setOnFocusChangeListener { view, hasFocus ->
70+
if (!hasFocus) {
71+
(view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
72+
.hideSoftInputFromWindow(view.windowToken, 0)
73+
}
74+
}
75+
clearInputIcon.setOnClickListener {
76+
context.lifecycleScope.launch {
77+
questionnaireViewItem.clearAnswer()
78+
autoCompleteTextView.doOnNextLayout { autoCompleteTextView.showDropDown() }
79+
}
80+
}
6381
}
6482

6583
override fun bind(questionnaireViewItem: QuestionnaireViewItem) {
@@ -130,6 +148,10 @@ internal object DropDownViewHolderFactory :
130148
}
131149
}
132150
}
151+
val isEditable = questionnaireViewItem.answers.isEmpty()
152+
if (!isEditable) autoCompleteTextView.clearFocus()
153+
autoCompleteTextView.keyListener = if (isEditable) TextKeyListener.getInstance() else null
154+
clearInputIcon.visibility = if (isEditable) View.GONE else View.VISIBLE
133155

134156
displayValidationResult(questionnaireViewItem.validationResult)
135157
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<vector
2+
xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="24"
6+
android:viewportHeight="24"
7+
android:tint="?attr/colorControlNormal"
8+
>
9+
<path
10+
android:fillColor="@android:color/white"
11+
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
12+
/>
13+
</vector>

datacapture/src/main/res/layout/drop_down_view.xml

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
-->
1414
<LinearLayout
1515
xmlns:android="http://schemas.android.com/apk/res/android"
16+
xmlns:app="http://schemas.android.com/apk/res-auto"
1617
android:layout_width="match_parent"
1718
android:layout_height="wrap_content"
1819
android:layout_marginHorizontal="@dimen/item_margin_horizontal"
@@ -32,21 +33,40 @@
3233
android:layout_height="wrap_content"
3334
/>
3435

35-
<com.google.android.material.textfield.TextInputLayout
36-
style="?attr/questionnaireDropdownLayoutStyle"
37-
android:id="@+id/text_input_layout"
36+
<FrameLayout
3837
android:layout_width="match_parent"
3938
android:layout_height="wrap_content"
4039
>
4140

42-
<com.google.android.material.textfield.MaterialAutoCompleteTextView
43-
style="?attr/questionnaireDropDownSelectedTextStyle"
44-
android:id="@+id/auto_complete"
45-
android:drawablePadding="@dimen/icon_drawable_padding"
41+
<com.google.android.material.textfield.TextInputLayout
42+
android:id="@+id/text_input_layout"
43+
style="?attr/questionnaireDropdownLayoutStyle"
4644
android:layout_width="match_parent"
4745
android:layout_height="wrap_content"
46+
>
47+
48+
<com.google.android.material.textfield.MaterialAutoCompleteTextView
49+
android:id="@+id/auto_complete"
50+
style="?attr/questionnaireDropDownSelectedTextStyle"
51+
android:layout_width="match_parent"
52+
android:layout_height="wrap_content"
53+
android:drawablePadding="@dimen/icon_drawable_padding"
54+
/>
55+
56+
</com.google.android.material.textfield.TextInputLayout>
57+
58+
<ImageView
59+
android:id="@+id/clear_input_icon"
60+
android:layout_width="20dp"
61+
android:layout_height="20dp"
62+
android:layout_marginTop="@dimen/drop_down_clear_icon_margin_top"
63+
android:layout_marginEnd="@dimen/drop_down_clear_icon_margin_end"
64+
android:layout_gravity="end|center_vertical"
65+
android:src="@drawable/ic_clear"
66+
android:scaleType="fitCenter"
67+
app:tint="#999999"
4868
/>
4969

50-
</com.google.android.material.textfield.TextInputLayout>
70+
</FrameLayout>
5171

5272
</LinearLayout>

datacapture/src/main/res/values/dimens.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@
9595

9696
<!-- Dropdown -->
9797
<dimen name="drop_down_padding">16dp</dimen>
98+
<dimen name="drop_down_clear_icon_margin_end">38dp</dimen>
99+
<dimen name="drop_down_clear_icon_margin_top">4dp</dimen>
98100

99101
<!-- Item Answer Media -->
100102
<dimen name="item_answer_media_image_size">48dp</dimen>

datacapture/src/test/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactoryTest.kt

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 Google LLC
2+
* Copyright 2023-2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.views.factories
1919
import android.view.View
2020
import android.widget.AutoCompleteTextView
2121
import android.widget.FrameLayout
22+
import android.widget.ImageView
2223
import android.widget.TextView
2324
import androidx.appcompat.app.AppCompatActivity
2425
import com.google.android.fhir.datacapture.R
@@ -319,6 +320,56 @@ class DropDownViewHolderFactoryTest {
319320
.isEqualTo(View.GONE)
320321
}
321322

323+
@Test
324+
fun shouldHideClearIconWhenTextIsEmpty() {
325+
val answerOption =
326+
Questionnaire.QuestionnaireItemAnswerOptionComponent().apply {
327+
value =
328+
Coding().apply {
329+
code = "code"
330+
display = "display"
331+
}
332+
}
333+
334+
viewHolder.bind(
335+
QuestionnaireViewItem(
336+
Questionnaire.QuestionnaireItemComponent().apply { addAnswerOption(answerOption) },
337+
QuestionnaireResponse.QuestionnaireResponseItemComponent(),
338+
validationResult = NotValidated,
339+
answersChangedCallback = { _, _, _, _ -> },
340+
),
341+
)
342+
343+
val clearIcon = viewHolder.itemView.findViewById<ImageView>(R.id.clear_input_icon)
344+
assertThat(clearIcon.visibility).isEqualTo(View.GONE)
345+
}
346+
347+
@Test
348+
fun shouldShowClearIconWhenTextIsNotEmpty() {
349+
val answerOption =
350+
Questionnaire.QuestionnaireItemAnswerOptionComponent().apply {
351+
value =
352+
Coding().apply {
353+
code = "code"
354+
display = "display"
355+
}
356+
}
357+
358+
viewHolder.bind(
359+
QuestionnaireViewItem(
360+
Questionnaire.QuestionnaireItemComponent().apply { addAnswerOption(answerOption) },
361+
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
362+
addAnswer().apply { value = answerOption.valueCoding }
363+
},
364+
validationResult = NotValidated,
365+
answersChangedCallback = { _, _, _, _ -> },
366+
),
367+
)
368+
369+
val clearIcon = viewHolder.itemView.findViewById<ImageView>(R.id.clear_input_icon)
370+
assertThat(clearIcon.visibility).isEqualTo(View.VISIBLE)
371+
}
372+
322373
@Test
323374
fun bind_readOnly_shouldDisableView() {
324375
viewHolder.bind(

0 commit comments

Comments
 (0)