I am trying to mix Jetpack compose with some legacy code that we have. Was hoping that it would be an easy fix since this is a part of the app that is rarely used. The problem at hand is that i am trying to add legacy view that has databinding to an already made compose view
The View
#SuppressLint("ViewConstructor")
class TimeAndDateScroller #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
timePickerViewModel: TimeAndDatePickerViewModel,
) : LinearLayout(context, attrs, defStyle) {
var binding: ViewTimePickerBinding? = null
init {
binding = ViewTimePickerBinding.inflate(LayoutInflater.from(context), this, true).apply {
this.viewModel = timePickerViewModel
}
}
}
Compose View
AndroidView(
factory = {
TimeAndDateScroller(it, timePickerViewModel = viewModel).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
}, update = {
}
)
ViewModel and XML
The view model is passed down correctly as far as i understand. But the values inside the view model is not triggering the listening xml view
val isPickerEnabled: LiveData<Boolean> = selectedOption
.map {
it != TimeParamType.NOW
}
.asLiveData(Dispatchers.Main)
The value above is found in the view model. But the corresponding xml listener is never triggered
android:alpha="#{viewModel.isPickerEnabled() ? 1f : 0.4f}"
In the viewModel you would normally add:
val isPickerEnabledValue = ObservableField<Boolean>()
init {
isPickerEnabledValue.set(false)
}
Then this will be updated based on the live data in the activity/fragment:
vm.isPickerEnabledLiveData.observe(this, isEnabled ->
vm.isPickerEnabledValue.set(isEnabled)
}
)
And then in the xml it would be:
android:alpha="#{viewModel.isPickerEnabledValue ? 1f : 0.4f}"
So since this is hybrid with compose, instead of the ObservableField we would use:
var isPickerEnabledValue: State<Boolean> = stateOf(false)
and then in Compose you could use:
AndroidView(
factory = {
viewModel.isPickerEnabledValue = viewModel.isPickerEnabledLiveData.observeAsState()
The key is you can't get data out of live data directly, but have to observer it in some way, while ObservableField or State can be used directly.
Related
First of all, sorry for my english, this is google translator.
I am learning mvvm, maybe I have some misconception but I think I can understand it well.
My goal is to load an image in an imageview, but the final path of the image is built with the width of the imageview
GET /images
"images": {
"avatar": [64, 128],
"xxxxxx": [512, 640, 1024]
}
GET /user/1234567
"user": {
"username": "john",
"avatar": "123123.png"
}
Now if I want to display john's avatar in a 64px image the final path would be:
myserver.com/images/64/123123.png
If on the other hand, the size of the imageview is 600px I would use:
myserver.com/images/128/123123.png
To do this I have created a customview that extends an imageview and I use a usecase
#AndroidEntryPoint
class MyImageView(
context: Context, attrs: AttributeSet? = null,
) : ImageView(context, attrs) {
#Inject
lateinit var pictureUseCase: PictureUseCase
fun build(type: Picture.Type, endpoint: String) {
val type = when (type) {
0 -> Picture.Type.AVATAR
1 -> Picture.Type.XXXXXX
else -> throw Exception("unexpected")
}
val base = pictureUseCase(type, measuredWidth)
val awesomeurl = base + endpoint // Here i have the final url
// Picasso...
}
}
This code generates an error without importance in the execution of the app but it breaks the preview of the view
_layoutlib_._internal_.kotlin.UninitializedPropertyAccessException: lateinit property pictureUseCase has not been initialized
Is there an annotation to disable it in the preview?
Can I use a viewmodel inside a view?
Would there be a problem if I use this customview + viewmodel inside an adapter with hundreds of elements?
I have a state class
object SomeState {
data class State(
val mainPhotos: List<S3Photo>? = emptyList(),
)
}
VM load data via init and updates state
class SomeViewModel() {
var viewState by mutableStateOf(SomeState.State())
private set
init {
val photos = someSource.load()
viewState = viewState.cope(mainPhotos = photos)
}
}
Composable takes data from state
#Composable
fun SomeViewFun(
state = SomeState.State
) {
HorizontalPager(
count = state .mainPhotos?.size ?: 0,
) {
//view items
}
}
The problem is that count in HorizontalPager always == 0, but in logcat and debugger i see that list.size() == 57
I have a lot of screen with arch like this and they works normaly. But on this screen view state doesn't updates and i can't understand why.
UPDATE
VM passes to Composable like this
#Composable
fun SomeDistanation() {
val viewModel: SomeViewModel = hiltViewModel()
SomeViewFun(
state = viewModel.state
)
}
Also Composable take Flow<ViewEffect> and etc, but in this question it doesn't matter, because there is no user input or side effects
UPDATE 2
The problem was in data source. All code in question work correctly. Problem closed.
object wrapping is completely redundant (no fields, no functions), you can remove it (also, change the name so it won't confuse with compose's State):
data class MyState(
val mainPhotos: List<S3Photo>? = emptyList(),
)
According to Android Developers, you need to create the state in the view model, and observe the state in the composable function - your code is a bit unclear for me so I'll just show you how I do it in my apps.
create the state in the view model:
class SomeViewModel() {
private val viewState = mutableStateOf(MyState())
// Expose as immutable so it won't be edited
fun getState(): State<MyState> = viewState
init {
val photos = someSource.load()
viewState.value = viewState.value.copy(mainPhotos = photos)
}
}
observe the state in the composable function:
#Composable
fun SomeDistanation() {
val viewModel: SomeViewModel = hiltViewModel()
val state: MyState by remember { viewModel.getState() }
SomeViewFun(state)
}
Now you'll get automatic recomposition in case the state changes.
I know it's possible to use a custom view instead of a basic map marker in Google Maps doing what's described in this answer. I was curious if it was possible to achieve a similar affect with Compose?
Since ComposeView is a final class, couldn't extend that directly so I was thinking of having a FrameLayout that could add it as a child. Although this seems to be causing a race condition since composables draw slightly differently from normal android Views.
class MapMarkerView : FrameLayout {
constructor(context: Context) : super(context) {
initView()
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
initView()
}
private fun initView() {
val composeView = ComposeView(context).apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
composeView.setContent {
// this runs asynchronously making the the bitmap generation not include composable at runtime?
Text(text = "2345", modifier = Modifier.background(Color.Green, RoundedCornerShape(4.dp)))
}
addView(composeView)
}
}
Most articles I have read have some callback function for generating a bitmap like this which wouldn't necessarily work in this use case where each view is generated dynamically and needed to be converted to a bitmap right away for the map.
The MapMarkerView you are attempting to create is an indirect child of ViewGroup. ComposeView is also an indirect child of ViewGroup which means you can directly substitute a ComposeView in places where you intend to use this class. The ComposeView class is not meant to be subclassed. The only thing you really need to be concerned with as a developer is its setContent method.
val composeView: View = ComposeView(context).apply{
setContent {
Text(text = "2345", modifier = Modifier.background(Color.Green, RoundedCornerShape(4.dp)))
}
}
Now you can replace the MapMarkerView with this composeView in your code or even use it at any location where a View or any of it's subclasses is required.
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 am rendering a form based on JSON response that I fetch from the server.
My use case involves listening to a click from a radio button, toggling the visibility of certain text fields based on the radioButton selection, and refreshing the layout with the visible textView.
The expected output should be to update the same view with the textView now visible, but I'm now seeing the same form twice, first with default state, and second with updated state.
Have I somehow created an entirely new model_ class and passing it to the controller? I just want to change the boolean field of the existing model and update the view.
My Model Class
#EpoxyModelClass(layout = R.layout.layout_panel_input)
abstract class PanelInputModel(
#EpoxyAttribute var panelInput: PanelInput,
#EpoxyAttribute var isVisible: Boolean,
#EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var context: Context,
#EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var textChangedListener: InputTextChangedListener,
#EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var radioButtonSelectedListener: RadioButtonSelectedListener,
#EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var validationChangedListener: ValidationChangedListener
) : EpoxyModelWithHolder<PanelInputModel.PanelInputHolder>() {
#EpoxyAttribute var imageList = mutableListOf<ImageInput>()
override fun bind(holder: PanelInputHolder) {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
generateViews(holder, inflater, panelInput.elements) // Generates textViews, radioButtons, etc, based on ElementType enum inside Panel input
}
fun generateRadioButtonView(element: Element) {
// Created a custom listener and calling its function
radioButtonSelectedListener.radioButtonSelected(chip.id, chip.text.toString())
}
fun generateTextView() {
// Show/hide textView based on isVisible value
}
My Controller Class
class FormInputController(
var context: Context,
var position: Int, // Fragment Position in PagerAdapter
var textChangedListener: InputTextChangedListener,
var radioButtonSelectedListener: RadioButtonSelectedListener,
var validationChangedListener: ValidationChangedListener
) : TypedEpoxyController<FormInput>() {
override fun buildModels(data: FormInput?) {
val panelInputModel = PanelInputModel_(
data as PanelInput,
data.isVisible,
context,
textChangedListener,
radioButtonSelectedListener,
validationChangedListener
)
panelInputModel.id(position)
panelInputModel.addTo(this)
}
}
My fragment implements the on radio button checked listener, modifies the formInput.isVisible = true and calls formInputController.setData(componentList)
Please help me out on this, thanks!
I don't think you are using Epoxy correctly, that's not how it's supposed to be.
First of all, let's start with the Holder: you should not inflate the view inside of bind/unbind, just set your views there. Also, the view is inflated for you from the layout file you are specifying at R.layout.layout_panel_input, so there is no need to inflate at all.
You should copy this into your project:
https://github.com/airbnb/epoxy/blob/master/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/helpers/KotlinEpoxyHolder.kt
And create your holder in this way:
class PanelInputHolder : KotlinHolder() {
val textView by bind<TextView>(R.id.your_text_view_id)
val button by bind<Button>(R.id.your_button_id)
}
Let's move to your model class: you should remove these variables from the constructor as they are going to be a reference for the annotation processor to create the actual class.
Also, don't set your layout res from the annotation as that will not be allowed in the future.
Like so:
#EpoxyModelClass
class PanelInputModel : EpoxyModelWithHolder<PanelInputHolder>() {
#EpoxyAttribute
lateinit var text: String
#EpoxyAttribute(DoNotHash)
lateinit var listener: View.OnClickListener
override fun getDefaultLayout(): Int {
return R.layout.layout_panel_input
}
override fun bind(holder: PanelInputHolder) {
// here set your views
holder.textView.text = text
holder.textView.setOnClickListener(listener)
}
override fun unbind(holder: PanelInputHolder) {
// here unset your views
holder.textView.text = null
holder.textView.setOnClickListener(null)
}
}
Loop your data inside the controller not inside the model:
class FormInputController : TypedEpoxyController<FormInput>() {
override fun buildModels(data: FormInput?) {
data?.let {
// do your layout as you want, with the models you specify
// for example a header
PanelInputModel_()
.id(it.id)
.text("Hello WOrld!")
.listener { // do something here }
.addTo(this)
// generate a model per item
it.images.forEach {
ImageModel_()
.id(it.imageId)
.image(it)
.addTo(this)
}
}
}
}
When choosing your id, keep in mind that Epoxy will keep track of those and update if the attrs change, so don't use a position, but a unique id that will not get duplicated.