feat(xml-inflater): replace unsupported views/layouts with error views

This fixes a bug where the child views of an unsupported layout were lost in the UI Designer's XML generation process
This commit is contained in:
Akash Yadav
2022-12-22 20:18:12 +05:30
parent aed7853e25
commit 91382440c7
9 changed files with 219 additions and 67 deletions
@@ -81,6 +81,20 @@ class ViewAdapterIndexGenerator {
indexClassBuilder.addMethod(
MethodSpec.methodBuilder("getAdapter")
.addAnnotation(ClassName.get("androidx.annotation", "Nullable"))
.addModifiers(PUBLIC, STATIC)
.addParameter(String::class.java, "view", FINAL)
.returns(adapterType)
.addStatement("var adapter = \$L.get(\$L)", INDEX_MAP_FIELD, "view")
.beginControlFlow("if (adapter == null)")
.addStatement("adapter = \$L.get(\$S)", INDEX_MAP_FIELD, env.viewElement().qualifiedName)
.endControlFlow()
.addStatement("return adapter")
.build()
)
indexClassBuilder.addMethod(
MethodSpec.methodBuilder("getViewAdapter")
.addAnnotation(ClassName.get("androidx.annotation", "Nullable"))
.addModifiers(PUBLIC, STATIC)
.addParameter(String::class.java, "view", FINAL)
Binary file not shown.
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.itsaky.androidide.inflater.unsupported.UnsupportedView
android:id="@+id/myView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"/>
<com.itsaky.androidide.inflater.unsupported.UnsupportedLayout
android:id="@+id/myLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<com.itsaky.androidide.inflater.unsupported.UnsupportedLayout
android:id="@+id/myLayout_withSupportedChildren"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/image_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<ImageView
android:id="@+id/image_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</com.itsaky.androidide.inflater.unsupported.UnsupportedLayout>
<com.itsaky.androidide.inflater.unsupported.UnsupportedLayout
android:id="@+id/myLayout_withUnsupportedChildren"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.itsaky.androidide.inflater.unsupported.UnsupportedView
android:id="@+id/image_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<com.itsaky.androidide.inflater.unsupported.UnsupportedView
android:id="@+id/image_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</com.itsaky.androidide.inflater.unsupported.UnsupportedLayout>
</LinearLayout>
@@ -48,8 +48,12 @@ internal class UiViewGroup(file: LayoutFile, name: String, view: ViewGroup) :
return 0
}
val adapter =
ViewAdapterIndex.getAdapter(name) as? IViewGroupAdapter
?: throw IllegalStateException("No view adapter for '$name'")
ViewAdapterIndex.getAdapter(name)
if (adapter !is IViewGroupAdapter) {
return childCount
}
return when (adapter.getLayoutBehavior(this)) {
TOP_LEFT -> childCount
VERTICAL -> computeViewIndexVertically(x, y)
@@ -21,6 +21,8 @@ import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import com.itsaky.androidide.inflater.R
import com.itsaky.androidide.utils.resolveAttr
@@ -30,11 +32,14 @@ import com.itsaky.androidide.utils.resolveAttr
*
* @author Akash Yadav
*/
class ErrorView(file: LayoutFile, context: Context, message: String) :
ViewImpl(file = file, name = NAME, view = createErrView(context, message)) {
companion object {
private val NAME = View::class.java.name
}
class ErrorView(file: LayoutFile, name: String, context: Context, message: String) :
ViewImpl(file = file, name = name, view = createErrView(context, message)) {
override val tag: String = name
}
class ErrorLayout(file: LayoutFile, name: String, context: Context) :
ViewGroupImpl(file = file, name = name, view = createErrLayout(context)) {
override val tag: String = name
}
private fun createErrView(context: Context, message: String): View {
@@ -45,3 +50,7 @@ private fun createErrView(context: Context, message: String): View {
setTextIsSelectable(true)
}
}
private fun createErrLayout(context: Context): ViewGroup {
return FrameLayout(context)
}
@@ -50,15 +50,16 @@ import com.itsaky.androidide.inflater.events.OnInflateViewEvent
import com.itsaky.androidide.inflater.internal.utils.IDTable
import com.itsaky.androidide.inflater.internal.utils.ViewFactory.createViewInstance
import com.itsaky.androidide.inflater.internal.utils.ViewFactory.generateLayoutParams
import com.itsaky.androidide.inflater.utils.isParsing
import com.itsaky.androidide.inflater.internal.utils.parseLayoutReference
import com.itsaky.androidide.inflater.utils.endParse
import com.itsaky.androidide.inflater.utils.isParsing
import com.itsaky.androidide.inflater.utils.startParse
import com.itsaky.androidide.lookup.Lookup
import com.itsaky.androidide.projects.ProjectManager
import com.itsaky.androidide.projects.api.AndroidModule
import com.itsaky.androidide.xml.widgets.Widget
import com.itsaky.androidide.xml.widgets.WidgetTable
import com.itsaky.androidide.xml.widgets.WidgetType
import java.io.File
/**
@@ -87,18 +88,14 @@ open class LayoutInflaterImpl : ILayoutInflater {
override fun inflate(file: File, parent: ViewGroup): List<IView> {
startInflation(file)
return doInflate(file, parent).apply {
finishInflation()
}
return doInflate(file, parent).apply { finishInflation() }
}
override fun inflate(file: File, parent: IViewGroup) : List<IView> {
override fun inflate(file: File, parent: IViewGroup): List<IView> {
startInflation(file)
return doInflate(file, parent).apply {
finishInflation()
}
return doInflate(file, parent).apply { finishInflation() }
}
override fun close() {
this.module = null
this.inflationEventListener = null
@@ -111,7 +108,7 @@ open class LayoutInflaterImpl : ILayoutInflater {
val (processor, module) = processXmlFile(file)
return doInflate(processor, parent, module)
}
protected open fun doInflate(file: File, parent: IViewGroup): List<IView> {
val (processor, module) = processXmlFile(file)
return doInflate(processor, parent, module)
@@ -124,7 +121,7 @@ open class LayoutInflaterImpl : ILayoutInflater {
): List<IView> {
return doInflate(processor, module) { wrap(parent) }
}
protected open fun doInflate(
processor: XmlProcessor,
parent: IViewGroup,
@@ -190,7 +187,12 @@ open class LayoutInflaterImpl : ILayoutInflater {
// TODO(itsaky): Handle views from libraries
val view: ViewImpl =
(if (widget == null) {
onCreateUnsupportedView("View with name '${element.name}' not found", parentView)
onCreateUnsupportedView(
element.name,
element.childCount > 0,
"View with name '${element.name}' not found",
parentView
)
} else {
onCreatePlatformView(widget, parentView, module, widgets)
})
@@ -224,7 +226,7 @@ open class LayoutInflaterImpl : ILayoutInflater {
val parentView = parent.view as ViewGroup
val adapter =
ViewAdapterIndex.getAdapter(view.name)
?: throw InflateException("No attribute adapter found for view ${view.name}")
?: throw InflateException("No attribute adapter found for view ${view.name}")
view.view.layoutParams = generateLayoutParams(parentView)
@@ -308,7 +310,12 @@ open class LayoutInflaterImpl : ILayoutInflater {
val v = createViewInstance(widget.qualifiedName, parent.context)
return componentFactory.createView(currentLayoutFile, widget.qualifiedName, v)
} catch (err: Throwable) {
onCreateUnsupportedView("Unable to create view for widget ${widget.qualifiedName}", parent)
onCreateUnsupportedView(
widget.qualifiedName,
widget.type == WidgetType.LAYOUT,
"Unable to create view for widget ${widget.qualifiedName}",
parent
)
}
}
@@ -360,15 +367,24 @@ open class LayoutInflaterImpl : ILayoutInflater {
return processor to module
}
private fun onCreateUnsupportedView(message: String, parent: ViewGroup): IView {
return ErrorView(currentLayoutFile, parent.context, message)
private fun onCreateUnsupportedView(
name: String,
isLayout: Boolean,
message: String,
parent: ViewGroup
): IView {
return if (isLayout) {
ErrorLayout(file = currentLayoutFile, name = name, context = parent.context)
} else {
ErrorView(file = currentLayoutFile, name = name, context = parent.context, message = message)
}
}
private fun List<IView>.finishInflation() {
inflationEventListener?.onEvent(InflationFinishEvent(this))
_primaryInflatingFile = null
}
private fun startInflation(file: File) {
this._primaryInflatingFile = file
IDTable.newRound()
@@ -18,7 +18,10 @@
package com.itsaky.androidide.inflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.inflater.internal.LayoutInflaterImpl
import com.itsaky.androidide.inflater.internal.ViewGroupImpl
@@ -39,9 +42,9 @@ class LayoutInflaterTest {
@Test
fun `test functionality`() {
inflaterTest {
requiresActivity { activity ->
requiresActivity {
val file = layoutFile("singleView")
val parent = LinearLayout(activity)
val parent = LinearLayout(this)
val inflater = ILayoutInflater.newInflater()
val inflated = inflater.inflate(file, parent)
inflated.apply {
@@ -56,8 +59,8 @@ class LayoutInflaterTest {
@Test
fun `test inflated view is of expected type`() {
inflaterTest { module ->
requiresActivity { activity ->
val parent = LinearLayout(activity)
requiresActivity {
val parent = LinearLayout(this)
val inflater = ILayoutInflater.newInflater() as LayoutInflaterImpl
viewToAdapter.keys.forEach { view ->
println("Testing layout inflater and adapter for ${view.qualifiedName}")
@@ -72,9 +75,13 @@ class LayoutInflaterTest {
assertThat(this)
.contains(newAttribute(inflated, INamespace.ANDROID, "id", "@+id/template_view"))
assertThat(this)
.contains(newAttribute(inflated, INamespace.ANDROID, "layout_height", "match_parent"))
.contains(
newAttribute(inflated, INamespace.ANDROID, "layout_height", "match_parent")
)
assertThat(this)
.contains(newAttribute(inflated, INamespace.ANDROID, "layout_width", "match_parent"))
.contains(
newAttribute(inflated, INamespace.ANDROID, "layout_width", "match_parent")
)
}
}
}
@@ -85,8 +92,8 @@ class LayoutInflaterTest {
@Test
fun `verify included view hierarchy`() {
inflaterTest { module ->
requiresActivity { activity ->
val parent = LinearLayout(activity)
requiresActivity {
val parent = LinearLayout(this)
val inflater = ILayoutInflater.newInflater()
val inflated = inflater.inflate(layoutFile("include"), parent)
assertThat(inflated).hasSize(1)
@@ -117,9 +124,9 @@ class LayoutInflaterTest {
@Test
fun `verify merged view hierarchy`() {
inflaterTest { module ->
requiresActivity { activity ->
val parent = LinearLayout(activity)
val inflater = ILayoutInflater.newInflater()
requiresActivity {
val parent = LinearLayout(this)
val inflater = ILayoutInflater.newInflater(module)
val inflated = inflater.inflate(layoutFile("merge"), parent)
assertThat(inflated).hasSize(1)
@@ -141,6 +148,54 @@ class LayoutInflaterTest {
}
}
@Test
fun `test unsupported views`() {
inflaterTest {
requiresActivity {
val parent = LinearLayout(this)
val inflater = ILayoutInflater.newInflater(it)
val inflated = inflater.inflate(layoutFile("unsupported_views"), parent)
assertThat(inflated).hasSize(1)
val root = inflated[0] as ViewGroupImpl
// verity the inflated layout hierarchy
// this takes care of verifying that the generated XML elements will have proper XML tagsl
assertThat(root.printHierarchy())
.isEqualTo(
"android.widget.LinearLayout\n" +
" com.itsaky.androidide.inflater.unsupported.UnsupportedView\n" +
" com.itsaky.androidide.inflater.unsupported.UnsupportedLayout\n" +
" com.itsaky.androidide.inflater.unsupported.UnsupportedLayout\n" +
" android.widget.ImageView\n" +
" android.widget.ImageView\n" +
" com.itsaky.androidide.inflater.unsupported.UnsupportedLayout\n" +
" com.itsaky.androidide.inflater.unsupported.UnsupportedView\n" +
" com.itsaky.androidide.inflater.unsupported.UnsupportedView\n"
)
// TextView is used to inflate unsupported views
assertThat(root[0].view).isInstanceOf(TextView::class.java)
// TextView is also used for layouts which do not have any child viewsl
assertThat(root[1].view).isInstanceOf(TextView::class.java)
// FrameLayout is used to inflate unsupported layouts with child views
assertThat(root[2].view).isInstanceOf(FrameLayout::class.java)
assertThat(root[3].view).isInstanceOf(FrameLayout::class.java)
assertThat((root[2] as ViewGroupImpl)[0].view).isInstanceOf(ImageView::class.java)
assertThat((root[2] as ViewGroupImpl)[1].view).isInstanceOf(ImageView::class.java)
// Unsupported child views
assertThat((root[3] as ViewGroupImpl)[0].view).isInstanceOf(TextView::class.java)
assertThat((root[3] as ViewGroupImpl)[1].view).isInstanceOf(TextView::class.java)
}
}
}
private fun layoutFile(name: String): File {
val app = ProjectManager.app ?: throw IllegalStateException("Project is not initialized")
return File(app.projectDir, "src/main/res/layout/$name.xml")
@@ -41,26 +41,24 @@ class ValueParsersTest {
@Test
fun `hardcoded dimension parser test`() {
inflaterTest { module ->
requiresActivity { activity ->
requiresActivity {
// Hardcoded dimensions
val units = arrayOf("dp", "sp", "pt", "px", "in", "mm")
val results = intArrayOf(1, 1, 2, 1, 160, 6)
for (i in units.indices) {
assertThat(parseDimension(activity, "1" + units[i], -1f).roundToInt())
.isEqualTo(results[i])
assertThat(parseDimension(this, "1" + units[i], -1f).roundToInt()).isEqualTo(results[i])
}
// Dimensions from platform resources
assertThat(parseDimension(activity, "@android:dimen/app_icon_size")).isEqualTo(48)
assertThat(parseDimension(activity, "@android:dimen/status_bar_height_portrait"))
.isEqualTo(24)
assertThat(parseDimension(this, "@android:dimen/app_icon_size")).isEqualTo(48)
assertThat(parseDimension(this, "@android:dimen/status_bar_height_portrait")).isEqualTo(24)
val exists =
try {
// The app module here targets android 31 and the dimension resource below was added in
// android 31. So, this should throw an exception
assertThat(parseDimension(activity, "@android:dimen/status_bar_height_default"))
assertThat(parseDimension(this, "@android:dimen/status_bar_height_default"))
.isEqualTo(-2)
true
} catch (err: IllegalArgumentException) {
@@ -69,15 +67,15 @@ class ValueParsersTest {
assertThat(exists).isFalse()
assertThat(parseDimension(activity, "@dimen/test_dimen_0dp", def = 1f)).isEqualTo(0)
assertThat(parseDimension(activity, "@dimen/test_dimen_1dp", def = 0f)).isEqualTo(1)
assertThat(parseDimension(activity, "@dimen/test_dimen_1dp_ref", def = 0f)).isEqualTo(1)
assertThat(parseDimension(activity, "@dimen/test_dimen_0dp_ref", def = 0f)).isEqualTo(0)
assertThat(parseDimension(this, "@dimen/test_dimen_0dp", def = 1f)).isEqualTo(0)
assertThat(parseDimension(this, "@dimen/test_dimen_1dp", def = 0f)).isEqualTo(1)
assertThat(parseDimension(this, "@dimen/test_dimen_1dp_ref", def = 0f)).isEqualTo(1)
assertThat(parseDimension(this, "@dimen/test_dimen_0dp_ref", def = 0f)).isEqualTo(0)
assertThat(parseDimension(activity, "@dimen/test_dimen_0pt", def = 1f)).isEqualTo(0)
assertThat(parseDimension(activity, "@dimen/test_dimen_1pt", def = 0f)).isEqualTo(1)
assertThat(parseDimension(activity, "@dimen/test_dimen_1pt_ref", def = 0f)).isEqualTo(1)
assertThat(parseDimension(activity, "@dimen/test_dimen_0pt_ref", def = 0f)).isEqualTo(0)
assertThat(parseDimension(this, "@dimen/test_dimen_0pt", def = 1f)).isEqualTo(0)
assertThat(parseDimension(this, "@dimen/test_dimen_1pt", def = 0f)).isEqualTo(1)
assertThat(parseDimension(this, "@dimen/test_dimen_1pt_ref", def = 0f)).isEqualTo(1)
assertThat(parseDimension(this, "@dimen/test_dimen_0pt_ref", def = 0f)).isEqualTo(0)
}
}
}
@@ -85,17 +83,17 @@ class ValueParsersTest {
@Test
fun `drawable parse test`() {
inflaterTest { module ->
requiresActivity { activity ->
parseDrawable(activity, "#ff0000").apply {
requiresActivity {
parseDrawable(this, "#ff0000").apply {
assertThat(this).isNotNull()
assertThat(this).isInstanceOf(ColorDrawable::class.java)
assertThat((this as ColorDrawable).color).isEqualTo(Color.RED)
}
parseDrawable(activity, "@android:drawable/ab_share_pack_material").apply {
parseDrawable(this, "@android:drawable/ab_share_pack_material").apply {
assertThat(this).isNotNull()
assertThat(this).isInstanceOf(BitmapDrawable::class.java)
}
parseDrawable(activity, "@android:drawable/action_bar_background").apply {
parseDrawable(this, "@android:drawable/action_bar_background").apply {
assertThat(this).isNotNull()
assertThat(this).isInstanceOf(GradientDrawable::class.java)
}
@@ -178,17 +176,16 @@ class ValueParsersTest {
@Test
fun `color parser test`() {
inflaterTest {
requiresActivity { activity ->
assertThat(parseColor(activity, "@android:color/red")).isEqualTo(Color.RED)
assertThat(parseColor(activity, "@android:color/black")).isEqualTo(Color.BLACK)
assertThat(parseColor(activity, "@android:color/white")).isEqualTo(Color.WHITE)
assertThat(parseColor(activity, "@android:color/transparent")).isEqualTo(Color.TRANSPARENT)
assertThat(parseColor(activity, "@color/test_color")).isEqualTo(Color.parseColor("#f44336"))
assertThat(parseColor(activity, "@color/test_color_ref"))
.isEqualTo(Color.parseColor("#f44336"))
requiresActivity {
assertThat(parseColor(this, "@android:color/red")).isEqualTo(Color.RED)
assertThat(parseColor(this, "@android:color/black")).isEqualTo(Color.BLACK)
assertThat(parseColor(this, "@android:color/white")).isEqualTo(Color.WHITE)
assertThat(parseColor(this, "@android:color/transparent")).isEqualTo(Color.TRANSPARENT)
assertThat(parseColor(this, "@color/test_color")).isEqualTo(Color.parseColor("#f44336"))
assertThat(parseColor(this, "@color/test_color_ref")).isEqualTo(Color.parseColor("#f44336"))
// TODO Implement color state list parser
assertThat(parseColor(activity, "@color/test_selector")).isEqualTo(Color.TRANSPARENT)
assertThat(parseColor(this, "@color/test_selector")).isEqualTo(Color.TRANSPARENT)
}
}
}
@@ -58,6 +58,6 @@ fun inflaterTest(block: (AndroidModule) -> Unit) {
endParse()
}
fun requiresActivity(block: (AppCompatActivity) -> Unit) {
block(XmlInflaterTest.activity)
fun requiresActivity(block: AppCompatActivity.() -> Unit) {
XmlInflaterTest.activity.block()
}