Handling edge-cases of MVVM

Hi!

google.com/+CosminStefan

cosmin@greenerpastures.ro

Goal for today?

We've all heard
of MVVM

Quick recap!

MVVM

Model-View-ViewModel

MVVM - Basic structure

Activities

Fragments

Views

Memory

Remote APIs

Database

Many options for the View - ViewModel binding

View

ViewModel

Via Data binding

    
    <data>

        <variable
            name="viewModel"
            type="io.greenerpastures.example.MyViewModel" />

    </data>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@{viewModel.name}"
        android:visibility="@{viewModel.isNameVisible}"
        tools:text="Min bonus" />

observes

View

ViewModel

Via LiveData

    
    fun onCreate(savedInstanceState: Bundle?) {
        ...
        
        viewModel.viewState
            .observe(this, Observer<ViewState> { data ->
                findViewById<TextView>(R.id.name).text = data.name
                findViewById<ImageView>(R.id.image).drawable = data.image
            })
    }

observes

View

ViewModel

Via RxJava

    
    fun onStart() {
        ...
        
        viewModel.viewState
            .subscribe { data ->
                findViewById<TextView>(R.id.name).text = data.name
                findViewById<ImageView>(R.id.image).drawable = data.image
            }
    }

observes

    
    fun onStart() {
        ...
        
        viewModel.viewState
            .subscribe { data ->
                findViewById<TextView>(R.id.name).text = data.name
                findViewById<ImageView>(R.id.image).drawable = data.image
            }
            .disposeOnStop()
    }

disposeOnStop() ?


class BaseActivity() : Activity() {

    private var compositeDisposable: CompositeDisposable = CompositeDisposable()
    
    protected fun Disposable.disposeOnStop() {
        compositeDisposable.add(this)
    }

    override fun onStop() {
        super.onStop()
        compositeDisposable.dispose()
    }
    ...
}

How about the more complex situations?

Animations

RecyclerView

Navigation

Animations

ViewModel doesn't have access to Views

Issue:


class MainViewModel : ViewModel() {

    var isVisible: ObservableBoolean = ObservableBoolean(true)
    ...

}

Solutions?

Animate all DataBinding changes

Option 1:


    override fun onStart() {
        super.onStart()
    
        binding.addOnRebindCallback(object : OnRebindCallback<ViewDataBinding>() {
            override fun onPreBind(binding: ViewDataBinding): Boolean {
                TransitionManager.beginDelayedTransition(binding.root as ViewGroup)
                return super.onPreBind(binding)
            }
        })
    }

    override fun onStart() {
        super.onStart()
    
        binding.addOnRebindCallback(object : OnRebindCallback<ViewDataBinding>() {
            override fun onPreBind(binding: ViewDataBinding): Boolean {
          
                return super.onPreBind(binding)
            }
        })
    }

No filtering

Very quick and easy

Basic animations only

Custom binding adapter

Option 2:


    @BindingAdapter("animatedVisibility")
    fun setAnimatedVisibility(target: View, isVisible: Boolean) {
        TransitionManager.beginDelayedTransition(target.rootView as ViewGroup)
        target.visibility = if (isVisible) View.VISIBLE else View.GONE

        // Or run any other animation using any other mechanism    
    }

Quick and easy

Same animation

Observe property changes

Option 3:


viewModel.isVisible.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
    override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
        TransitionManager.beginDelayedTransition(binding.root as ViewGroup)
        binding.view.visibility = if (viewModel.isVisible.get()) View.VISIBLE else View.GONE
        
        // Or run any other animation using any other mechanism    
    }

// NOTE: Requires cleaning up the OnPropertyChangeCallback via
// viewModel.isVisible.removeOnPropertyChangedCallback()

viewModel.isVisible.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
    override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
        TransitionManager.beginDelayedTransition(binding.root as ViewGroup)
        binding.view.visibility = if (viewModel.isVisible.get()) View.VISIBLE else View.GONE
        
        // Or run any other animation using any other mechanism    
    }

Easily customizable

Requires cleanup

Specific to view & prop

RecyclerView

Create DataBinding layout for each item

- Rule 1 -

Adapter only deals with item ViewModels

- Rule 2 -

Item ViewModels get bound to ViewHolder

- Rule 3 -

Building the base tooling

Item View Model & Holder


/**
 * The base ViewModel class for an item to be shown in a list.
 */
interface ItemViewModel {

}

/**
 * A [RecyclerView.ViewHolder] implementation that handles binding of an [ItemViewModel] with a
 * [ViewDataBinding].
 */
open class SimpleViewModelItemViewHolder<in VM : ItemViewModel, out DB : ViewDataBinding>
constructor(protected val binding: DB,
            private val viewModelBindingVarId: Int) : RecyclerView.ViewHolder(binding.root) {

    fun bind(item: VM) {
        this.binding.setVariable(viewModelBindingVarId, item)
        this.binding.executePendingBindings()
    }
}

/**
 * The base ViewModel class for an item to be shown in a list.
 */
interface ItemViewModel {

}

/**
 * The base ViewModel class for an item to be shown in a list.
 */
interface ItemViewModel {

}

/**
 * A [RecyclerView.ViewHolder] implementation that handles binding of an [ItemViewModel] with a
 * [ViewDataBinding].
 */
open class SimpleViewModelItemViewHolder<in VM : ItemViewModel, out DB : ViewDataBinding>
constructor(protected val binding: DB,
            private val viewModelBindingVarId: Int) : RecyclerView.ViewHolder(binding.root) {

}

Base Adapter


abstract class BaseViewModelItemAdapter<IVM : ItemViewModel, out B : ViewDataBinding>
    : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
        SimpleViewModelItemViewHolder<IVM, B>(
            inflateView(LayoutInflater.from(parent.context), parent, viewType),
            itemViewModelBindingVarId
        )

    abstract fun inflateView(inflater: LayoutInflater, parent: ViewGroup, viewType: Int): B

    abstract val itemViewModelBindingVarId: Int


    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) =
        (holder as SimpleViewModelItemViewHolder<IVM, B>).bind(getItem(position))

    abstract fun getItem(position: Int): IVM
}

abstract class BaseViewModelItemAdapter<IVM : ItemViewModel, out B : ViewDataBinding>
    : RecyclerView.Adapter<RecyclerView.ViewHolder>() {


}

abstract class BaseViewModelItemAdapter<IVM : ItemViewModel, out B : ViewDataBinding>
    : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
        SimpleViewModelItemViewHolder<IVM, B>(
            inflateView(LayoutInflater.from(parent.context), parent, viewType),
            itemViewModelBindingVarId
        )

    abstract fun inflateView(inflater: LayoutInflater, parent: ViewGroup, viewType: Int): B

    abstract val itemViewModelBindingVarId: Int

}

Practical example

Item layout


<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="viewModel"
            type="ro.greenerpastures.devfest2018.ProductItemViewModel" />
    </data>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@{viewModel.name}"
        android:textSize="18sp"
        tools:text="Main Text" />

</layout>

ViewModel & Adapter


class ProductItemViewModel(private val product: Product) : ItemViewModel {

    val name: String
        get() = product.name
}



class ProductAdapter : BaseViewModelItemAdapter<ProductItemViewModel, ProductItemBinding>() {

    var items: List<ProductItemViewModel> = listOf()
        set(value) {
            field = value
            notifyDataSetChanged() // DiffUtil should be used for a better experience
        }

    override fun getItem(position: Int): ProductItemViewModel = items[position]

    override fun getItemCount(): Int = items.size


    override val itemViewModelBindingVarId: Int
        get() = BR.viewModel

    override fun inflateView(inflater: LayoutInflater, parent: ViewGroup, viewType: Int) =
        ProductItemBinding.inflate(inflater, parent, false)
}

class ProductItemViewModel(private val product: Product) : ItemViewModel {

    val name: String
        get() = product.name
}



class ProductAdapter : BaseViewModelItemAdapter<ProductItemViewModel, ProductItemBinding>() {

    var items: List<ProductItemViewModel> = listOf()
        set(value) {
            field = value
            notifyDataSetChanged() // DiffUtil should be used for a better experience
        }

    override fun getItem(position: Int): ProductItemViewModel = items[position]

    override fun getItemCount(): Int = items.size

}

class ProductItemViewModel(private val product: Product) : ItemViewModel {

    val name: String
        get() = product.name
}



class ProductAdapter : BaseViewModelItemAdapter<ProductItemViewModel, ProductItemBinding>() {


}

class ProductItemViewModel(private val product: Product) : ItemViewModel {

    val name: String
        get() = product.name
}

Hook everything together


class ProductListViewModel : ViewModel() {

    // Expose the list of items as a list of item view models
    val items = MutableLiveData<List<ProductItemViewModel>>()
    ...
}

class ProductListActivity : AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Setup ViewModel & RecyclerView
        val viewModel: ProductListViewModel = ...
        val recyclerView: RecyclerView = ...

        // Setup adapter
        val adapter = ProductAdapter()
        recyclerView.adapter = adapter

        // Make sure we update the data in the adapter when data in the ViewModel gets updated
        viewModel.items.observe(this, Observer { updatedItems: List<ProductItemViewModel> ->
            adapter.items = updatedItems
        })
    }
}

class ProductListViewModel : ViewModel() {

    // Expose the list of items as a list of item view models
    val items = MutableLiveData<List<ProductItemViewModel>>()
    ...
}

class ProductListActivity : AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Setup ViewModel & RecyclerView
        val viewModel: ProductListViewModel = ...
        val recyclerView: RecyclerView = ...

        // Setup adapter
        val adapter = ProductAdapter()
        recyclerView.adapter = adapter


    }
}

class ProductListViewModel : ViewModel() {

    // Expose the list of items as a list of item view models
    val items = MutableLiveData<List<ProductItemViewModel>>()
    ...
}

class ProductListActivity : AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Setup ViewModel & RecyclerView
        val viewModel: ProductListViewModel = ...
        val recyclerView: RecyclerView = ...

        
    }
}

class ProductListViewModel : ViewModel() {

    // Expose the list of items as a list of item view models
    val items = MutableLiveData<List<ProductItemViewModel>>()
    ...
}

Multi-type lists

Category 1

Product 1.1

Product 1.2

Category 2

Product 2.1

Product 2.2

Product 2.3

List ViewModel exposes mixed list of ItemViewModels

Solution:

Multi-type adapter


class ProductAdapter : BaseViewModelItemAdapter<ItemViewModel, ViewDataBinding>() {

    ...
    
    override fun getItemViewType(position: Int): Int =
        when (getItem(position)) {
            is CategoryItemViewModel -> VIEW_TYPE_CATEGORY
            is ProductItemViewModel -> VIEW_TYPE_PRODUCT
            else -> throw UnsupportedOperationException("Invalid view model type")
        }

    override fun inflateView(inflater: LayoutInflater, parent: ViewGroup, viewType: Int) =
        when (viewType) {
            VIEW_TYPE_PRODUCT -> ProductItemBinding.inflate(inflater, parent, false)
            VIEW_TYPE_CATEGORY -> CategoryItemBinding.inflate(inflater, parent, false)
            else -> throw UnsupportedOperationException("Invalid view type")
        }
}

class ProductAdapter : BaseViewModelItemAdapter<ItemViewModel, ViewDataBinding>() {

    ...
    
    override fun getItemViewType(position: Int): Int =
        when (getItem(position)) {
            is CategoryItemViewModel -> VIEW_TYPE_CATEGORY
            is ProductItemViewModel -> VIEW_TYPE_PRODUCT
            else -> throw UnsupportedOperationException("Invalid view model type")
        }

}

class ProductAdapter : BaseViewModelItemAdapter<ItemViewModel, ViewDataBinding>() {


}

class ProductListViewModel : ViewModel() {

    // Expose the list of items as a mixt list of item view models, 
    // either CategoryItemViewModel or ProductItemViewModel
    val items = MutableLiveData<List<ItemViewModel>>()
    ...
}

Navigation

Why it's a hard problem?

Navigation must be driven by View Models

Context is required for Navigation

View Models mustn't reference Context

Solutions?

Event

Option 1:

View

ViewModel

observes

View

Navigate back

event

observes

event

once!

class MainViewModel : ViewModel() {

    val events: PublishSubject<Event> = PublishSubject.create()

    enum class Event {
        NAV_TO_DETAILS
    }
}
class MainActivity : Activity() {

  
    override fun onStart() {
        super.onStart()
        viewModel.events
            .subscribe {
                when (it) {
                    MainViewModel.Event.NAV_TO_DETAILS -> startActivity(..)
                }
            }
            .disposeOnStop()
    }
}

Using RxJava

class MainViewModel : ViewModel() {

    private val navigateToDetails = SingleLiveEvent<Boolean>()

}
class MainActivity : Activity() {

  
    override fun onStart() {
        super.onStart()

        viewModel.navigateToDetails.observe(this, Observer {
            startActivity(DetailsActivity...)
        })
    }
}

Using LiveData

Doesn't support multiple observers

Issue:

View

ViewModel

observes

View

event

observes

event

Wrapped Event

Option 2:

open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
        private set

    
}

Event with handled status

open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
        private set

    /** Fetches the content and prevents its use again. */
    fun consumeContentIfNotHandled(): T? =
        if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }

    /** Fetches the content, even if it's already been handled. */
    fun peekContent(): T = content
}
class MainViewModel : ViewModel() {

    val events: PublishSubject<Event> = PublishSubject.create()

    enum class Event {
        NAV_TO_DETAILS
    }
}
class MainActivity : Activity() {

  
    override fun onStart() {
        super.onStart()
        viewModel.events
            .subscribe {
                when (it.consumeContentIfNotHandled()) {
                    MainViewModel2.Event.NAV_TO_DETAILS -> startActivity(..)
                }
            }
            .disposeOnStop()
    }
}

Using RxJava

Using a command handler

Option 3:

View

ViewModel

Command Handler

Command Handler


class MainViewModel() {

    var commandHandler: Commands? = null

    interface Commands {
        fun navigateToDetails()
        
        fun showConfirmationDialog(): Single<Boolean>
    }


    fun onButtonClick() {
        commandHandler?.showConfirmationDialog()
            ?.subscribe { isConfirmed ->
                if(isConfirmed)
                    commandHandler?.navigateToDetails()
            }
    }
}

class MainViewModel() {

    var commandHandler: Commands? = null

    interface Commands {
        fun navigateToDetails()
        
        fun showConfirmationDialog(): Single<Boolean>
    }

}

Command Handler


abstract class BaseActivity() {

    abstract lateinit var viewModel: ViewModel
    abstract val commandHandler: Any

    override fun onStart() {
        super.onStart()
        
        viewModel.commandHandler = commandHandler
    }

    override fun onStop() {
        super.onStart()
        
        viewModel.commandHandler = null
    }
}

abstract class BaseActivity() {

    abstract lateinit var viewModel: ViewModel
    abstract val commandHandler: Any

    override fun onStart() {
        super.onStart()
        
        viewModel.commandHandler = commandHandler
    }

}

abstract class BaseActivity() {

    abstract lateinit var viewModel: ViewModel
    abstract val commandHandler: Any

}

Requires careful cleanup

More intuitive usage

Not always available (e.g. while in background)

What to keep in mind?

Architecture must
be central

Adapt, but be consistent

Use base building blocks

Thank you!

Made with Slides.com