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.
Related
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
Is there a way to do something like this:
view.setOnClickListener { Log.e("","Hello") }
view.setOnClickListener { Log.e("","World") }
While letting both Log to show.
I have buttons on custom views with some universal actions to execute on click which I initialize them in constructor, now I want to setup some additional behaviors based on situations outside of class. And I notice doing things like above will simply overwrite the onClickListener.
What I can think of is to maybe store Array of Unit in the object and let view execute elements of it as on clicked. Then use accessor to append function to the array.
Is there a more simple way to keep existing actions and append new actions at the end?
with this code you can add click listener as much as you want :
class CustomView #JvmOverloads constructor(
context: Context?,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val clickCallBacks = mutableListOf<((view: View) -> Unit)>()
fun addClickListener(listener: ((view: View) -> Unit)) {
clickCallBacks.add(listener)
}
override fun setOnClickListener(l: OnClickListener?) {
super.setOnClickListener {
//TODO do what you want to do
l?.onClick(it)
for (listener in clickCallBacks) {
listener.invoke(it)
}
}
}
}
in your activity or fragment:
customView.view.addClickListener{ Log.e("","Hello") }
customView.view.addClickListener{ Log.e("","World") }
Why not use a flag and choose the behaviour depending on that flag like this.
Using a listener is a recipe for disaster because as it is a listener it is continuously listening ie the code inside will execute whenever that button is pressed and if there are two blocks one will take precedence over the other.
button.setOnClickListener{
when(flag){
"hello" -> Log.e("TAG","Hello")
"world" -> Log.e("TAG","World")
}
}
Background
I need to use a translations-SDK (Lokalise, docs here) that is intended to load strings resources from their servers.
This means that if you use getString , it will prefer what's on the server instead of what's on the app. This includes also the cases of inflation of layout XML files.
The problem
It seems that Android doesn't have a global resource handling that I can use. This is why the SDK says I should use one of these :
For Activity, I can override the callback of attachBaseContext.
For all other cases, that I need to get the resources of them, I can use LokaliseResources(context) .
Thing is, a lot of code in the app I work on doesn't involve an Activity. A lot of the UI on the app is floating (using SAW permission, AKA "System Alert Window").
This means that there is a lot of inflation of Views using just the Application class.
What I've tried
First I made a simple manager for this:
object TranslationsManager {
var resources: LokaliseResources? = null
#UiThread
fun initOnAppOnCreate(context: App) {
Lokalise.init(context, Keys.LOCALISE_SDK_TOKEN, Keys.LOCALISE_PROJECT_ID)
Lokalise.updateTranslations()
resources = LokaliseResources(context)
}
fun getResources(context: Context): Resources {
return resources ?: context.resources
}
}
I tried to perform various things using the library, but they crashed as it's not how the library works.
So these failed:
For the getResources of the class that extends Application, I tried to return the one of the SDK
Use attachBaseContext of the class that implements Application. This causes a crash since it needs to be initialized before, so I tried to initialize it right in this callback, but still got a crash.
For LayoutInflater, I tried to use LayoutInflater.from(new ContextThemeWrapper(...)) , and override its getResources callback, but it didn't do anything.
I tried to use Philology library by having this:
object MyPhilologyRepositoryFactory : PhilologyRepositoryFactory {
override fun getPhilologyRepository(locale: Locale): PhilologyRepository {
return object : PhilologyRepository {
override fun getPlural(key: String, quantityString: String): CharSequence? {
Log.d("AppLog", "getPlural $key")
return TranslationsManager.resources?.getString(quantityString)
?: super.getPlural(key, quantityString)
}
override fun getText(key: String): CharSequence? {
Log.d("AppLog", "getText $key")
return TranslationsManager.resources?.getString(key) ?: super.getText(key)
}
override fun getTextArray(key: String): Array<CharSequence>? {
Log.d("AppLog", "getTextArray $key")
TranslationsManager.resources?.getStringArray(key)?.let { stringArray ->
val result = Array<CharSequence>(stringArray.size) { index ->
stringArray[index]
}
return result
}
return super.getTextArray(key)
}
}
}
}
And on the class that extends Application, use this:
Philology.init(MyPhilologyRepositoryFactory)
ViewPump.init(ViewPump.builder().addInterceptor(PhilologyInterceptor).build())
But when inflation was used in the app (and actually everywhere), I never saw that this code is being used, ever.
That being said, this is what I've succeeded:
1.For all Activities/Services, indeed I've added usage of attachBaseContext as the SDK says:
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(LokaliseContextWrapper.wrap(newBase))
}
2.For all custom views, I've used what I've made:
override fun getResources(): Resources {
return TranslationsManager.getResources(context)
}
Both of these took quite some time to find and add manually, one after another.
Sadly, still there seem to be some important cases.
I've found that at least for layout inflation (in the custom views, for example), the layout XML files don't take the resources from the SDK.
I've found an article "Taming Android Resources and LayoutInflater for string manipulation" from 2020 (cache here) saying I could use some trick of ContextThemeWrapper a bit more complex than what I tried, but sadly it lacks some important information (implementation of cloneInContext for example) that I've failed to use:
class CustomContextWrapper(
private val base: Context,
private val dynamicStringMap: Map<String, String>
) : ContextWrapper(base) {
override fun getResources() = CustomResources(base.resources, dynamicStringMap)
override fun getSystemService(name: String): Any? {
if (Context.LAYOUT_INFLATER_SERVICE == name) {
return CustomLayoutInflater(LayoutInflater.from(baseContext), this)
}
return super.getSystemService(name)
}
}
class CustomLayoutInflater constructor(
original: LayoutInflater,
newContext: Context,
) : LayoutInflater(original, newContext) {
override fun cloneInContext(p0: Context?): LayoutInflater {
TODO("Not yet implemented")
}
override fun onCreateView(name: String, attrs: AttributeSet): View? {
try {
val view = createView(name, "android.widget.", attrs)
if (view is TextView) {
// Here we get original TextView and then return it after overriding text
return overrideTextView(view, attrs)
}
} catch (e: ClassNotFoundException) {
} catch (inflateException: InflateException) {
}
return super.onCreateView(name, attrs)
}
private fun overrideTextView(view: TextView, attrs: AttributeSet?): TextView {
val typedArray =
view.context.obtainStyledAttributes(attrs, intArrayOf(android.R.attr.text))
val stringResource = typedArray.getResourceId(0, -1)
view.text = view.resources.getText(stringResource)
typedArray.recycle()
return view
}
}
However, it said I could use a library called "ViewPump" (here, and it actually suggested to use Philology library here) that will do the trick for me, and that from Android 30 we could use ResourcesProvider and ResourcesLoader classes. Sadly I couldn't find an example to use any of these for the purpose I'm working on.
The questions
Is it really possible to use the trick that was mentioned on the article? What should be done to use it properly?
How can I use the "ViewPump"/"Philology" library to achieve the same thing?
Is there any way to offer resources globally instead of using all that I've mentioned? So that all resources will be using the translation SDK, no matter where and how I reach the resources ? This takes a lot of time already, as I need to go over many classes and add handling of resources myself...
Will any of the above cover all cases? For example not just the inflation, but other cases such as TextView.setText(resId) ?
As for the new classes of Android API 30, because they are very new, I've decided to ask about them in a new post, here.
EDIT: Talking with Lokalise support, they said they already do use ViewPump, which means that it probably works in cases that don't match what I have.
I've found success with a combination of using ViewPump to wrap the context of the view being inflated with your ContextWrapper.
class ContextWrappingViewInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): InflateResult {
val request = chain.request()
val newRequest = request.toBuilder()
.context(MyContextWrapper.wrap(request.context))
.build()
return chain.proceed(newRequest)
}
}
However I haven't found a solution to force custom view attributes to use your context for free. The issue is that internally, styled attributes fetch their resources from what has already been cached internally via XML files. Meaning, the view's context doesn't come into it at all.
A workaround for this is to fetch the resource ID from styled attributes and then delegate the actual resource fetching to context.
fun TypedArray.getStringUsingContext(context: Context, index: Int): String? {
if (hasValue(index)) {
return getResourceId(index, 0)
.takeIf { it != 0 }
?.let { context.getString(it) }
}
return null
}
Usage in CustomView:
init {
context.obtainStyledAttributes(attrs, R.styleable.CustomView).use { array ->
val myText = array.getStringUsingContext(context, R.styleable.CustomView_myText)
...
}
}
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 generic custom view like
class MyGenericCustomView<T>(context: Context, attrs: AttributeSet) : AnotherView(context, attrs) {
...
}
In the Activity/Fragment XML I have:
<package.name.MyGenericCustomView
android:id="#+id/custom_id"
....
/>
If I use the old way, I can get my "typed" custom view using something like:
override fun onCreate(...) {
...
val myCustomView = findViewById<MyGenericCustomView<String>>(R.id.custom_id)
...
}
But if I use the Android Kotlin Extension (synthetic) to have an object named with the same ID, I dont have a way to pass the Generic type, so
//custom_id is of type MyGenericCustomView<*>
One solution is to create a specific class like
class MySpecificCustomView(context: Context, attrs: AttributeSet) : MyGenericCustomView<String>(context, attrs) {
....
}
But I dont want to create this boilerplate class.
Is there any solution to specify a custom type using Kotlin Extensions only?
Thanks
Since you cannot specify the type parameter in the XML and type parameters are also erased in the byte code, you could simply cast the value to the proper generic type MyGenericCustomView<String>.
So something like that should work:
val myView = custom_id as MyGenericCustomView<String>
For better usage in your Activity/Fragment I would personally use lazy { } like this:
class MyActivity() : Activity(…) {
val myView by lazy { custom_id as MyGenericCustomView<String> }
...
}