Say I have a Custom View built from scratch that looks like this:
class CustomTextView #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : View(context, attrs, defStyleAttr, defStyleRes) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL_AND_STROKE
textSize = 48f
color = Color.BLUE
strokeWidth = 3f
}
override fun onDraw(canvas: Canvas?) {
canvas?.drawText("Text from Custom view", width / 2f, height / 2f, paint)
}
}
This is very simple drawing Text on Canvas. And in a fragment layout, I add a TextView and my CustomText view like the following:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="32dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Text from Text View" />
<com.example.testing.views.CustomTextView
android:layout_width="250dp"
android:layout_height="32dp"
android:layout_marginTop="10dp" />
</LinearLayout
My espresso test file looks like:
#RunWith(AndroidJUnit4::class)
class MyFragmentTest {
private lateinit var scenario: FragmentScenario<MyFragment>
#Before
fun setup() {
scenario = launchFragmentInContainer(themeResId = R.style.Theme_Testing)
scenario.moveToState(Lifecycle.State.STARTED)
}
#Test
fun testNormalTextView() { // -> PASSED
onView(withText("Text from Text View")).check(matches(isDisplayed()))
}
#Test
fun testCustomTextView() { // -> FAILED NoMatchingView Exception
onView(withText("Text from Custom View")).check(matches(isDisplayed()))
}
}
When I run the tests on my physical device, it passes only testNormalTextView but it fails on testCustomTextView. How do I make these Espresso test pass with Custom Views?
From the official docs, withText() viewMatcher works with Textviews.
Returns a matcher that matches TextView based on its text property value.
In your case your custom view is extending View class.
Following are two ways which i will suggest.
Make your custom view extend TextView. [If your requirement is to access only the view with the specific text regardless of it's id]
Use withId() viewMatcher instead of withText(), passing id of your customview given in xml layout. You need to give id to your custom view in xml. [If you want to check view with specific id, not with the text it holds]
In your xml
<com.example.testing.views.CustomTextView
android:id="#+id/my_custom_view"
android:layout_width="250dp"
android:layout_height="32dp"
android:layout_marginTop="10dp" />
In your testFunction
#Test
fun testCustomTextView() {
onView(withId(R.id.my_custom_view)).check(matches(isDisplayed()))
}
Update:
For recyclerview, you can use onData() instead of onView() passing matcher in argument.
You can find further info about testing adapterViews here
Related
My app has an AutoCompleteTextView that queries a Room database and displays the search results in the dropdown as the user types.
The results are displayed correctly but the dropdown's height is always too large for the number of items (see screenshot below).
Basically the AutoCompleteTextView has a TextWatcher that sends the current text to the ViewModel, which in turn queries the database. Once the results are received, the ViewModel updates the view state (a Flow) and the fragment, which observes it, reassigns the AutoCompleteTextView's adapter with the new data.
Dropdown item layout
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="#+id/root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="#dimen/spacing_8">
<GridLayout
android:id="#+id/imageContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
... />
<com.google.android.material.textview.MaterialTextView
android:id="#+id/txtName"
style="#style/TextAppearance.MaterialComponents.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
... />
</androidx.constraintlayout.widget.ConstraintLayout>
Fragment layout
<com.google.android.material.textfield.TextInputLayout>
...
<com.google.android.material.textfield.MaterialAutoCompleteTextView
android:id="#+id/autoCompleteTextView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="#dimen/spacing_16"/>
</com.google.android.material.textfield.TextInputLayout>
Adapter
internal class StationAdapter(
context: Context,
private val stations: List<UiStation>
) : ArrayAdapter<String>(
context,
R.layout.item_station,
stations.map { it.name }
) {
fun getStation(position: Int): UiStation = stations[position]
override fun getCount(): Int = stations.size
override fun getItem(position: Int): String = stations[position].name
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return convertView ?: run {
val inflater = LayoutInflater.from(context)
val binding = ItemStationBinding.inflate(inflater, parent, false).apply {
val station = stations[position]
imageContainer.addIconsForModes(station.modes)
txtName.text = station.name
}
binding.root
}
}
...
}
Fragment
private fun handleState(state: StationSearchViewState) = with(state) {
...
stations?.let {
val adapter = StationAdapter(
context = requireContext(),
stations = it
)
binding.autoCompleteTextView.setAdapter(adapter)
}
}
Now these are the other things I tried as well but didn't work (got the same result):
Passing all of the database table's results to the adapter and implementing a custom filter
Implementing a function to submit new data to the adapter and calling notifyDataSetChanged()
Setting the AutoCompleteTextView's android:dropDownHeight property to wrap_content
The only thing that seems to work is when I use an ArrayAdapter with the android.R.layout.simple_list_item_1 layout for the items
After 3 days working hard to figure this out I finally managed to find a solution, which is partly in this answer to an unrelated question.
Here is my code:
private const val DROPDOWN_ITEM_MAX_COUNT = 5
private const val PADDING = 34
class CustomAutoCompleteTextView(
context: Context,
attributeSet: AttributeSet?
) : MaterialAutoCompleteTextView(context, attributeSet) {
override fun onFilterComplete(count: Int) {
val itemCount = if (count > DROPDOWN_ITEM_MAX_COUNT) {
DROPDOWN_ITEM_MAX_COUNT
} else {
count
}
val individualItemHeight = (height / 2) + PADDING
dropDownHeight = itemCount * individualItemHeight
super.onFilterComplete(count)
}
}
Essentially I had to create a custom AutoCompleteTextView which, once the data is filtered, takes an item count (no greater than the maximum I've set) and multiplies it by the height of an individual item on my dropdown (a) plus a padding (b) to make sure the dropdown view always wraps the items.
a: I reached this value with a bit of trial and error but it still works across different screen sizes and densities
b: not mandatory. I just thought adding a bit of padding made it look nicer
End result
Per https://developer.android.com/jetpack/compose/interop/interop-apis , ComposeView and AbstractComposeView should facilitate interoperability between Compose and existing XML based apps.
I've had some success when deploying to a device, but XML previews that include Compose elements aren't working for me.As a simplified example, consider:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="state"
type="ObservableState" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TestAtom
android:layout_width="match_parent"
android:layout_height="match_parent"
app:state="#{state}" />
</LinearLayout>
Making use of the following custom view file:
data class ObservableState(val text: ObservableField<String> = ObservableField("Uninitialized"))
data class State(val text: MutableState<String> = mutableStateOf(String()))
class TestAtom
#JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
val state by mutableStateOf(State())
fun setState(observableState: ObservableState?) {
state.text.value = observableState?.text?.get() ?: "null"
}
#Composable
override fun Content() {
if (isInEditMode) {
val text = remember { mutableStateOf("Hello Edit Mode Preview!") }
TestAtomCompose(State(text = text))
} else {
TestAtomCompose(state)
}
}
}
#Composable
fun TestAtomCompose(state: State) {
Text(text = "Text: " + state.text.value, modifier = Modifier.wrapContentSize())
}
#Preview(name = "Test", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
#Composable
fun PreviewTestCompose() {
val text = remember { mutableStateOf("Hello World!") }
TestAtomCompose(State(text = text))
}
I have tried many variations and iterations on this, however none successfully allowed a Compose-based custom view to render in an XML layout preview. (the #Compose preview works as expected) The above example results in a preview render error: java.lang.IllegalStateException: ViewTreeLifecycleOwner not found from android.widget.LinearLayout.
1) Has anyone found a tactic to allow complex Compose-based custom views to render in XML preview panes?
2) Is there a way to transmit XML preview pane selection options, like theme and light/dark mode, to an embedded ComposeView to update its preview?
3) Is there a way to assign specific sample data from XML to a ComposeView to cause it to render differently in the preview? (similar to tools: functionality)
In order to preview in XML design a custom ComposableView that extend from AbstractComposeView you will need to tell to your view to use a custom recomposer.
AfzalivE from GitHub provide a gist about that:
https://gist.github.com/AfzalivE/43dcef66d7ae234ea6afd62e6d0d2d37
Copy this file in your project, then override the onAttachToWindow method with the following:
override fun onAttachedToWindow() {
if (isInEditMode) {
setupEditMode()
}
super.onAttachedToWindow()
}
FYI: Google is working on a fix about this issue:
https://issuetracker.google.com/u/1/issues/187339385?pli=1
How can I add Jetpack Compose & xml in the same activity? An example would be perfect.
If you want to use a Compose in your XML file, you can add this to your layout file:
<androidx.compose.ui.platform.ComposeView
android:id="#+id/my_composable"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
and then, set the content:
findViewById<ComposeView>(R.id.my_composable).setContent {
MaterialTheme {
Surface {
Text(text = "Hello!")
}
}
}
If you want the opposite, i.e. to use an XML file in your compose, you can use this:
AndroidView(
factory = { context ->
val view = LayoutInflater.from(context).inflate(R.layout.my_layout, null, false)
val textView = view.findViewById<TextView>(R.id.text)
// do whatever you want...
view // return the view
},
update = { view ->
// Update the view
}
)
If you want to provide your composable like a regular View (with the ability to specify its attributes in XML), subclass from AbstractComposeView.
#Composable
fun MyComposable(title: String) {
Text(title)
}
// Do not forget these two imports for the delegation (by) to work
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
class MyCustomView #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {
var myProperty by mutableStateOf("A string")
init {
// See the footnote
context.withStyledAttributes(attrs, R.styleable.MyStyleable) {
myProperty = getString(R.styleable.MyStyleable_myAttribute)
}
}
// The important part
#Composable override fun Content() {
MyComposable(title = myProperty)
}
}
And this is how you would use it just like a regular View:
<my.package.name.MyCustomView
android:id="#+id/myView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:myAttribute="Helloooooooooo!" />
Thanks to ProAndroidDev for this article.
Footnote
To define your own custom attributes for your view, see this post.
Also, make sure to use -ktx version of the AndroidX Core library to be able to access useful Kotlin extension functions like Context::withStyledAttributes:
implementation("androidx.core:core-ktx:1.6.0")
https://developer.android.com/jetpack/compose/interop?hl=en
To embed an XML layout, use the AndroidViewBinding API, which is provided by the androidx.compose.ui:ui-viewbinding library. To do this, your project must enable view binding.
AndroidView, like many other built-in composables, takes a Modifier parameter that can be used, for example, to set its position in the parent composable.
#Composable
fun AndroidViewBindingExample() {
AndroidViewBinding(ExampleLayoutBinding::inflate) {
exampleView.setBackgroundColor(Color.GRAY)
}
}
Updated : When you want to use XML file in compose function
AndroidView(
factory = { context ->
val view = LayoutInflater.from(context).inflate(R.layout.test_layout, null, false)
val edittext= view.findViewById<EditText>(R.id.edittext)
view
},
update = { }
)
I'm developing a custom search view and I need to add a listener so the viewmodel can perform the search with the query using databinding, I'm currently having issues setting up the binding adapter with the query parameter, here are the relevant parts:
This is the custom view with the listener event:
class MySearchView #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = android.R.attr.editTextStyle
) : AppCompatEditText(context, attrs, defStyleAttr) {
var onSearchListener: ((query: String) -> Unit)? = null
}
This is the binding adapter:
#BindingAdapter("onSearchListener")
fun MySearchView.setOnSearchListener(event: (query: String) -> Unit) {
onSearchListener = event
}
This is the XMl layout part:
<com.xxx.MySearchView
android:id="#+id/txt_search"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="#string/label_search"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:onSearchListener="#{(query) -> viewModel.onSearchTriggered(query)}"/>
Finally my method in the viewmodel:
fun onSearchTriggered(query: String) {
Log.d("search", "search: $query")
}
When I compile the XML fails telling me that it cannot find the method above, here is the error:
cannot find method onSearchTriggered(java.lang.Object) in class com.xxx.MyViewModel
I've tried before with custom listeners without parameters and everything worked just fine so I assume I must be doing something wrong when adding the parameter, any ideas?
I do not think that would work as the type of parameter cannot be specified in XML, but I was hoping passing viewModel::onSearchTriggered would work but it looks like it does not. You could use one of the following to do this:
You can declare a variable instead of a function in the ViewModel:
val onSearchTriggered = { query: String ->
Log.d("search", "search: $query")
}
You can then directly pass the variable like so:
<com.xxx.MySearchView
android:id="#+id/txt_search"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="#string/label_search"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:onSearchListener="#{viewModel.onSearchTriggered}"/>
You could create an interface for your listener:
interface OnSearchListener {
fun onSearch(String)
}
#BindingAdapter("onSearchListener")
fun MySearchView.setOnSearchListener(listener: OnSearchListener) {
onSearchListener = listener::onSearch
}
You can then use it like so:
<com.xxx.MySearchView
android:id="#+id/txt_search"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="#string/label_search"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:onSearchListener="#{viewModel::onSearchTriggered}"/>
I want to create custom class which extends TextInputLayout and have sort of an access into its inner EditText, so I can set focus listener on it. Based on focus state and text in the EditText the error shows in TextInputLayout. Below is my current simple code where edit text is null. How can I achieve so have custom TextInputLayout that can communicate with its inner EditText ?
class CustomTextInputLayout : TextInputLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
init {
Log.e("_TEST", this.editText?.toString() ?: "null") // gives null
editText?.setOnFocusChangeListener { v, hasFocus ->
if (hasfocus.not()) {
val textToCheck = editText?.text.toString()
this.error = someValidationFunction(textToCheck)
}
}
}
}
<com.app.utility.CustomTextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</com.app.utility.CustomTextInputLayout>
The editText isn't initialized in init method yet. First, the parent will be laid out then the child.
Please, check this method
onLayout(boolean changed, int left, int top, int right, int bottom)
Views lifecycle is very important if you want to create custom views or viewgroups.
https://developer.android.com/reference/android/view/ViewGroup.html