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
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
We are working with 5 people on a project.
I have custom TextView component in Android project.
Some of my team friends are using Android Textview (or AppCompatTextView) directly. I want to make it mandatory to use the text view that I created as a custom TextView.
How do I do this? I look forward to your help, thank you.
While coding guidelines and code reviews should catch those issues. You could also create a custom lint check and force your builds to fail on lint errors.
Something like this:
class TextViewDetector : ResourceXmlDetector() {
override fun getApplicableElements(): Collection<String>? {
return listOf(
"android.widget.TextView", "androidx.appcompat.widget.AppCompatTextView"
)
}
override fun visitElement(context: XmlContext, element: Element) {
context.report(
ISSUE, element, context.getLocation(element),
"Do not use TextView"
)
}
companion object {
val ISSUE: Issue = Issue.create(
"id",
"Do not use TextView",
"Use custom view",
CORRECTNESS, 6, Severity.ERROR,
Implementation(TextViewDetector::class.java, RESOURCE_FILE_SCOPE)
)
}
}
There is a guide, an example repository from google and an extensive api guide on how to write custom lint checks.
You can create your own ViewInflater
class MyViewInflater {
fun createView(
parent: View?, name: String?, context: Context,
attrs: AttributeSet, inheritContext: Boolean,
readAndroidTheme: Boolean, readAppTheme: Boolean, wrapContext: Boolean
): View {
// ...
val view: View = when (name) {
"TextView",
"androidx.appcompat.widget.AppCompatTextView",
"com.google.android.material.textview.MaterialTextView" -> createMyTextView(context, attrs)
//other views
}
//...
return view
}
fun createMyTextView(context: Context, attrs: AttributeSet) = MyTextView(context, attrs)
}
and install it in your app theme
<style name="Theme.MyAppTheme" parent="Theme.SomeAppCompatParentTheme">
<item name="viewInflaterClass">package.MyViewInflater</item>
</style>
It will return your View for all tags you specify
See AppCompatViewInflater
There's no technical way to do this. The answer is coding guidelines and code reviews.
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
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 have a Custom component in android with this layout.
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="#+id/mainLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.AppCompatEditText
android:id="#+id/editText"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</androidx.constraintlayout.widget.ConstraintLayout>
when using in another layout I find editText by this code.(Espresso)
val editText = onView(
allOf(withId(R.id.editText)
, isDescendantOfA(withId(R.id.mainLayout))
, isDescendantOfA(withId(R.id.mobileEdt))
)
)
I use this custom Component in all app and many layouts.
can I minify or convert to function in my app for doesn't write again and again?
Maybe I change the component layout, so I have to edit all withId in all test.
Your component has probably a class name. Let's say CustomEditText.
In that case you can implement a BoundedMatcher based custom matcher, which makes sure that it will only match view instances of your CustomEditText.
Simple implementation could look like this:
fun customEditWithId(idMatcher: Matcher<Int>): Matcher<View> {
return object : BoundedMatcher<View, CustomEditText>(CustomEditText::class.java!!) {
override fun describeTo(description: Description) {
description.appendText("with id: ")
idMatcher.describeTo(description)
}
override fun matchesSafely(textView: CustomEditText): Boolean {
return idMatcher.matches(textView.id)
}
}
}
then your assertion looks like this:
onView(customEditWithId(0)).perform(click());
it's obviously not a descendant of R.id.mobileEdt ...
val editText = onView(allOf(
withId(R.id.editText),
isDescendantOfA(withId(R.id.mainLayout))
))