Tutorial Android Sobre LiveData

En este tutorial te explicaré cómo funciona el componente de arquitectura LiveData en tus Apps Android. La idea es presentar el fundamento conceptual del componente y luego crear un proyecto de Ejemplo.

Consulta el índice en la siguiente tabla si deseas dirigirte a un lugar específico:

Android LiveData

La clase abstracta LiveData representa una envoltura para datos que deseas observar dentro de un ciclo de vida.

Esta clase empareja un objeto LifecycleOwner (ver ciclos de vida) con otro Observer a fin de que el observer notifique datos solo si el owner está en estado activo (Lifecycle.State.STARTED y Lifecycle.State.RESUMED).

Dicho comportamiento es mucho más poderoso que los Campos Observables si deseamos evitar la revisión de ciclos de vida.

De esta forma, al usar LiveData podremos:

  • Evitar leaks de memoria al dejar de recibir cambios de datos cuando el ciclo de vida se encuentra en inactividad
  • Mantener la UI actualizada al cambiar los datos (la vista recibe actualizaciones de estado en vez de solicitarlas)
  • Recibe los datos más nuevos incluso en cambios de configuración

Teniendo en cuenta esto, pasemos a comprender los pasos para incluir el componente en nuestro proyecto Android Studio y como observar las variables para actualizar nuestros views.

Como Usar Objetos LiveData

Los siguientes son los pasos básicos que debes seguir para recibir actualizaciones de un dato para disponer de actualizaciones de UI y procesamiento de eventos.

1. Declarar Dependencia Gradle Para LiveData

Usa la siguiente línea para incluir en tus proyectos la funcionalidad de LiveData:

// LiveData
implementation 'androidx.lifecycle:lifecycle-livedata:2.2.0'

Recuerda usar la versión más reciente del componente lifecycle. En mi caso es la 2.2.0.

2. Crear Objetos LiveData

LiveData puede rastrear desde tipos elementales como Integer o String hasta colecciones y objetos personalizados.

El lugar donde serán declarados estos objetos normalmente será el ViewModel asociado al controlador de UI que sea LifecycleOwner.

Ejemplo:

    public class LoginViewModel extends ViewModel {

        private MutableLiveData<String> user;

        private MutableLiveData<String> password;

        public MutableLiveData<String> getUser() {
            if (user == null) {
                user = new MutableLiveData<>();
            }
            return user;
        }

        public MutableLiveData<String> getPassword() {
            if (password == null) {
                password = new MutableLiveData<>();
            }
            return password;
        }

        // ...
    }

Como ves, los Strings del usuario y password son envueltos con la subclase MutableLiveData<T> (en la actualización veremos su rol) dentro del correspondiente ViewModel.

Adicionalmente exponemos sus valores con métodos get*() con el fin de observarlos desde la vista o un layout con Data Binding.

3. Observar Objetos LiveData

Normalmente (a menos que tu caso de uso lo disponga diferente) declararemos la observación en los métodos onCreate() de los LifecycleOwners para conseguir el valor lo más pronto posible del LiveData (STARTED).

La idea es llamar al método observe() y pasar la pareja owner-observador. El controlador Observer.onChanged() nos pasará el valor más reciente de la variable rastreada y podremos aplicar las acciones correspondientes.

Ejemplo:

public class LoginActivity extends AppCompatActivity {

    private LoginViewModel model;
    private TextInputLayout mPasswordInputLayout;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // ...

        // Crear view model.
        model = new ViewModelProvider(this).get(LoginViewModel.class);

        // Observar pasando el par owner-observer
        model.getPassword().observe(this, new Observer<String>() {
            @Override
            public void onChanged(String password) {
                mPasswordInputLayout.setError(
                        password.length() < 6 ? "Son mínimo 6 caracteres" : null);
            }
        });
    }
}

En la actividad de ejemplo anterior obtenemos el valor del password y le aplicamos observe() para setear un error basados en el último valor del String que onChanged() nos notifica.

4. Actualizar Valores De Objetos LiveData

Debido a que LiveData es abstracta nace la subclase MutableLiveData para que podamos actualizar el contenido de nuestros datos y alertar al observador.

La actualización se realiza con los métodos setValue() y postValue(). El primero modifica el valor en el hilo principal directamente y el segundo asigna el valor si el código está un hilo de trabajo diferente.

Ejemplo:

// Actualizar password por cada edición
mPasswordTextField.addTextChangedListener(new TextWatcher() {
    @Override
    public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {

    }

    @Override
    public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {

    }

    @Override
    public void afterTextChanged(Editable editable) {
        model.getPassword().setValue(editable.toString());
    }
});

La actualización del LiveData se da cuando el cambio es escuchado por el TextWatcher asociado al EditText del password.

Al llamar a setValue() en cada edición se llegará a onChanged() y aplicaremos la validación del error que definimos antes.

Transformar Objetos LiveData

Las transformaciones (Transformations) nacen de querer representar el valor de salida de un LiveData con otra interpretación o hacer que un LiveData reemplace la observación de otro.

map()

El método map(source, mapFunction) transforma cada ítem del LiveData pasado como fuente (source) aplicando una función definida (mapFunction).

Android LiveData, Transformations.map()
Al transformar el valor 8 con la función el resultado es 13, valor que también será observable

Como resultado tendrás otro LiveData que emite los valores finales producidos por la función.

Por ejemplo:

private MutableLiveData<List<String>> items = new MutableLiveData<>();

private LiveData<String> itemsNumber =
Transformations.map(items, x -> String.valueOf(x.size()));

El anterior código se usa una lista de items como fuente y la conversión a String de su tamaño como función.

La salida se asigna al LiveData llamado itemsNumber que estará notificando el número de artículos cuando la lista cambie.

switchMap()

El método switchMap(source, switchMapFunction) cancela la observación del LiveData previo (resultado) y suscribe una nueva para el siguiente producto de la función switchMapFunction.

Es decir, cambiamos la observación hacia el nuevo LiveData y nos despreocupamos del anterior, ya que no emitirá actualizaciones.

Por ejemplo:

private MutableLiveData<String> label = new MutableLiveData<>("New");

private LiveData<List<Product>> products = Transformations.switchMap(
        label,
        y -> mProductsRepository.getProductsByLabel(y)
);

Este ejemplo muestra el intercambio continuo de una LiveData que guarda a una lista de productos.

Cada que label cambia su valor, switchMap() retorna el resultado de la consulta en el repositorio. Dejando de observar los productos previos y suscribiendo el observador a los entrantes.

Ejemplo: Administrar Categorías De Gastos

Ejemplo Android LiveData
Pantallas del Aplicativo de ejemplo

El ejemplo seguiremos de ejemplo administra las categorías de una aplicación Android hipotética que se encarga de gastos personales.

La idea consiste en proveer creación, edición y eliminación de categorías asociadas a los gastos.

Descargar Proyecto Android Studio

A través de este ejemplo quiero mostrarte los fragmentos de código relevantes donde apliqué el uso de LiveData en cada caso de uso.

Puedes descargar el código completo y funcional del proyecto en Android Studio desde aquí:

¡Veamos la resolución!

Lista De Categorías

La interfaz de la lista es simple. Desplegamos el nombre de las categorías en cada ítem y añadimos un FAB para iniciar la creación de nuevos ítems.

Ejemplo Android LiveData: RecyclerView
Lista de categorías

A eso le sumamos la presentación de una SnackBar cuando se retorne de una cualquiera de las operaciones sobre las categorías.

Ejemplo Android LiveData: Snackbar
Se muestra Snackbar luego de una edición

Datos A Observar Con LiveData

Necesitamos percibir los siguientes cambios:

  • La actualización de los ítems de lista
  • El cambio del título de la Toolbar con la cantidad de ítems
  • La visibilidad de la lista en caso de ausencia de ítems

Añadido a eso también podemos usar LiveData para representar los eventos de:

  • Apertura de actividad para crear/editar categorías
  • Aparición del mensaje de éxito de operaciones

Para resolver el caso de uso se crearon los siguientes componentes:

Arquitectura MVVM para lista de categorías

Donde la CategoriesActivity actúa como la vista principal, apoyándose en CategoriesAdapter para inflar un RecyclerView.

CategoriesViewModel ejercerá de intermediario ante el CategoriesRepository y la actividad para cargar y mostrar objetos del tipo Category.

Entidad Category

La clase Category contiene el ID y el nombre de la categoría. Adicionalmente posee dos constructores. Uno para crear un objeto nuevo y otro para autogenerar el id de forma secuencial.

public class Category {
    private final int id;
    private final String name;

    public Category(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public Category(String name) {
        this(CategoriesRepository.autogeneratedId, name);
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    // TODO: Más atributos...
}

Repositorio De Categorías

En este ejemplo se usa una fuente de datos en memoria (dummyData) soportado por un HashMap.

Se le proporcionan datos iniciales para visualizar la lista los cuales se cubren con un LiveData para permitir su observación hacia la vista (observableCategories).

public class CategoriesRepository {

    private static CategoriesRepository sInstance;

    // Indice simulador para autogeneración de IDs
    public static int autogeneratedId = 6;

    private final MutableLiveData<HashMap<Integer, Category>> observableCategories;
    private final HashMap<Integer, Category> dummyData;

    private CategoriesRepository() {
        // Inserción por defecto de datos
        dummyData = new HashMap<>();
        dummyData.put(1, new Category(1, "Alimentación"));
        dummyData.put(2, new Category(2, "Servicios"));
        dummyData.put(3, new Category(3, "Ropa"));
        dummyData.put(4, new Category(4, "Diversión"));
        dummyData.put(5, new Category(5, "Salud"));

        observableCategories = new MutableLiveData<>();
        observableCategories.setValue(dummyData);
    }

    public static CategoriesRepository getsInstance() {
        if (sInstance == null) {
            sInstance = new CategoriesRepository();
        }
        return sInstance;
    }

    public LiveData<Category> getCategory(int categoryId) {
        return new MutableLiveData<>(observableCategories.getValue().get(categoryId));
    }

    public void addCategory(Category category) {
        dummyData.put(category.getId(), category);
        observableCategories.setValue(dummyData);
        autogeneratedId++;
    }

    public void delete(int categoryId) {
        dummyData.remove(categoryId);
        observableCategories.setValue(dummyData);
    }

    public LiveData<HashMap<Integer, Category>> getCategories() {
        return observableCategories;
    }
}

Actualizar Categorías

Para retornar las categorías del repositorio tendrás un LiveData que cubra un HashMap<Integer, Category>.

También existe un LiveData para un String que representa un filtro (filter) hipotético sobre las categorías.

De esta forma puedes usar switchMap() para generar una carga inicial que se actualice constantemente con el LiveData retornado por CategoriesRepository.getCategories().

public class CategoriesViewModel extends ViewModel {

    private final CategoriesRepository repository;

    private final MutableLiveData<String> filter = new MutableLiveData<>("*");

    private final LiveData<HashMap<Integer, Category>> categories;  


    public CategoriesViewModel() {
        repository = CategoriesRepository.getsInstance();
        categories = Transformations.switchMap(filter, y -> repository.getCategories());
        
    }


    public LiveData<HashMap<Integer, Category>> getCategories() {
        return categories;
    }

}

Asignamos las categorías al RecyclerView a través de Data Binding en el layout content_main.xml.

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/categories_list"
    ...
    app:items ="@{viewModel.categories}">

Ya que no existe un método set para los ítems de este view, verás creado un Binding Adapter que obtiene el adaptador del recycler y asigna el contenido:

public class BindingAdapters {

    @BindingAdapter("items")
    public static void setItems(RecyclerView list, HashMap<Integer, Category> items) {
        CategoriesAdapter adapter = (CategoriesAdapter) list.getAdapter();

        if (items != null) {
            adapter.setCategoriesList(new ArrayList<>(items.values()));

        }
    }

}

Data Binding De Items

El adaptador de categorías asigna como parámetro de binding a cada objeto en onBindViewHolder().

@Override
public void onBindViewHolder(@NonNull CategoryViewHolder holder, int position) {    
    holder.binding.setCategory(mCategoriesList.get(position));
    holder.binding.executePendingBindings();
}

De esta forma se asigna el nombre de la categoría a cada ítem en category_item.xml.

<?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"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="category"
            type="com.herpro.livedata.model.Category" />
        
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        ...>

        <TextView
            android:id="@+id/category_name"
            android:text="@{category.name}"
            ... />

        
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Actualizar Título De La ActionBar

La cantidad de items (size) es un LiveData entero que obedece a una transformación map(), cuyo producto es el valor de size() del hash map de categorías.

size = Transformations.map(categories, HashMap::size);

Dicho valor se observa en el método setupToolbar() desde la actividad, donde asignamos el string de formato categories_number si existen items, de lo contrario asignamos zero_categories.

    private void setupToolbar() {
        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        // Observamos cantidad de items y actualizamos título
        mViewModel.getCategoriesNumber().observe(this, integer -> {
            String title;
            if (integer > 0) {
                title = getString(R.string.categories_number, integer);
            } else {
                title = getString(R.string.zero_categories);
            }
            getSupportActionBar().setTitle(title);
        });
    }

Actualizar Visibilidad De Lista

Similar al número de ítems, determinar cuándo ocultar la lista (MutableLiveData<Boolean>) y mostrar el texto de ausencia de items es una transformación map(). La función de salida es la comprobación del método isEmpty() del mapa con categorías.

empty = Transformations.map(categories, HashMap::isEmpty);   

El valor resultante se lo bindearemos a la lista y al texto de vacío en content_main.xml.

<androidx.constraintlayout.widget.ConstraintLayout
>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/categories_list"
            app:emptyState="@{!viewModel.empty}"

>

        </androidx.recyclerview.widget.RecyclerView>

        <TextView
            android:id="@+id/empty_state_text"
            
            app:emptyState="@{viewModel.empty}"
/>
    </androidx.constraintlayout.widget.ConstraintLayout>

Donde el atributo app:emptyState representa el intercambio de visibilidad en el siguiente binding adapter:

    @BindingAdapter("emptyState")
    public static void show(View v, boolean show) {
        v.setVisibility(show ? View.VISIBLE : View.GONE);
    }

El intercambio de visibilidades se vería así:

Ejemplo Android LiveData: Ocultar lista
Ocultar lista de categorías si no existen items

Procesar Retornos De Otra Activity

indicar el momento para mostrar la snackbar debido al evento de navegación desde AddEditCategoryActivity, es un aspecto que podemos seguir con LiveData.

Sin embargo, debemos asegurarnos que sean observados una sola vez. Característica que conseguimos si envolvemos objetos con la clase Event (ver LiveData with SnackBar, Navigation and other events).

/**
 * Clase genérica que permite hacer seguimientos a eventos con LiveData
 */
public class Event<T> {

    private final T mContent;

    private boolean hasBeenHandled = false;

    public Event(T content) {
        if (content == null) {
            throw new IllegalArgumentException("los valores null en Event no son permitidos.");
        }
        mContent = content;
    }

    public T getContentIfNotHandled() {
        if (hasBeenHandled) {
            return null;
        } else {
            hasBeenHandled = true;
            return mContent;
        }
    }

    public boolean hasBeenHandled() {
        return hasBeenHandled;
    }

    public T peekContent() {
        return mContent;
    }
}

Lo primero será recibir el resultado de la actividad de creación/edición en onActivityResult(), donde invocaremos el método handleActivityResult() del view model:

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    mViewModel.handleActivityResult(requestCode, resultCode);
}

¿Qué hace este método?

Comprueba los códigos de petición y de resultado provenientes. Basados en el valor, despacharemos el mensaje de la snackbar en el evento:

public void handleActivityResult(int requestCode, int resultCode) {
    if (AddEditCategoryActivity.REQUEST_CODE == requestCode) {
        switch (resultCode) {
            case AddEditCategoryActivity.EDIT_RESULT_OK:
                mSnackbarText.setValue(new Event<>(R.string.updated_category_message));
                break;
            case AddEditCategoryActivity.ADD_RESULT_OK:
                mSnackbarText.setValue(new Event<>(R.string.added_category_message));
                break;
            case AddEditCategoryActivity.DELETE_RESULT_OK:
                mSnackbarText.setValue(new Event<>(R.string.deleted_category_message));
                break;
        }
    }
}

Completamos el flujo observando el cambio de valores desde setupSnackbar() ejecutado en onCreate().

private void setupSnackbar() {
    // Mostrar snackbar en resultados positivos de operaciones (crear, editar y eliminar)
    mViewModel.getSnackbarText().observe(this, integerEvent -> {
        Integer stringId = integerEvent.getContentIfNotHandled();
        if (stringId != null) {
            Snackbar.make(findViewById(R.id.coordinator),
                    stringId, Snackbar.LENGTH_LONG).show();
        }
    });
}

El método getContentIfNotHandled() obtiene el valor del id del string si es la primera vez que se verifica. A su vez marca la bandera de Event para marcarlo como consumido.

Manejar Evento Para Crear/Editar

Navegar hacia AddEditCategoryActivity también puede ser representado con un objeto Event.

En el view model tendremos el LiveData asociado a esta acción y modificaremos su valor con addEditCategory().

public class CategoriesViewModel extends ViewModel {    

    private final MutableLiveData<Event<Integer>> mOpenCategoryEvent = new MutableLiveData<>();


    // Abrir la categoría a editar
    public void addEditCategory(int categoryId) {
        mOpenCategoryEvent.setValue(new Event<>(categoryId));
    }

    public MutableLiveData<Event<Integer>> getOpenCategoryEvent() {
        return mOpenCategoryEvent;
    }  

}

El rastreo lo registraremos en onCreate() de la actividad con el método setupNavigation().

private void setupNavigation() {
    // Abrir actividad para crear o editar categoría
    mViewModel.getOpenCategoryEvent().observe(this, integerEvent -> {
        Integer id = integerEvent.getContentIfNotHandled();
        if (id != null) {
            goToAddEdit(id);
        }
    });
}

El método goToAddEdit() inicia la actividad de creación/edición asignando el ID de la categoría si este está presente.

private void goToAddEdit(int categoryId) {
    Intent intent = new Intent(this, AddEditCategoryActivity.class);

    if (categoryId > 0) {
        // Abrir en modo edición
        intent.putExtra(EXTRA_CATEGORY_ID, categoryId);
    }
    startActivityForResult(intent, AddEditCategoryActivity.REQUEST_CODE);
}
Iniciar navegación desde el FAB

Aquí bindeamos la ejecución de addEditCategory() al atributo android:onClick() del FAB en activity_categories.xml:

<com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|end"
            android:layout_margin="@dimen/fab_margin"
            android:onClick="@{()->viewModel.addEditCategory(0)}"
            app:srcCompat="@drawable/ic_add" />
Iniciar navegación desde ítem de lista

El layout del ítem también tiene data binding, en consecuencia asignamos a la escucha de clicks del padre la ejecución del método.

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:onClick="@{() -> viewModel.addEditCategory(category.id)}"
        android:layout_height="wrap_content"
        android:minHeight="?listPreferredItemHeight">

Crear Y Editar Categorías

La creación y edición se representa con un formulario que recibe en un campo de texto el nombre de la categoría.

Ejemplo Android LiveData: Crear categoría
Creando nueva categoría

Usaremos la misma actividad para crear y editar la categoría.

Ejemplo Android LiveData: Editar Categoría
Editando categoría existente

En el proyecto encontrarás los siguientes archivos como base de la solución con la arquitectura MVVM.

Arquitectura para crear/editar categorías

Ahora bien, ¿qué datos se observan?

El nombre de la categoría en el campo de texto. Puesto que es el origen para crear un nuevo elemento en el repositorio.

Y ¿qué eventos y estados procesamos?

Basados en la interfaz tenemos conocimientos de:

  • Click en Guardar > Navegación a pantalla de lista
  • Nombre de categoría inválido > Mostrar error

A continuación te muestro como se abordó la solución de lo descrito.

Observar Nombre De Categoría

Observar el valor del campo de texto es primordial para cargar la categoría en una edición y para obtener el último valor a la hora de guardarla.

Es por eso que en AddEditCategoryViewModel lo seteamos con un LiveData:

public class AddEditCategoryViewModel extends ViewModel {

    private final MutableLiveData<String> name = new MutableLiveData<>();    

    private final CategoriesRepository mCategoriesRepository;

    public AddEditCategoryViewModel() {
        mCategoriesRepository = CategoriesRepository.getsInstance();
    }

    public MutableLiveData<String> getName() {
        return name;
    }
}

Cargar Categoría A Editar

La edición requiere que se cargue el objeto de categoría basado en el ID que se pudo haber enviado desde CategoriesActivity.

Basado en eso, desde AddEditCategoriesActivity.onCreate() invocamos al método loadData() encargado de dicha comprobación:

private void loadData() {
    if (getIntent().getExtras() != null) {
        int categoryId = getIntent().getExtras().getInt(CategoriesActivity.EXTRA_CATEGORY_ID);
        mViewModel.start(categoryId);
    } else {
        mViewModel.start(0);
    }
}

Esta acción dispara al método start() de AddEditCategoryViewModel, el cual retiene el valor del id. Luego comprueba si es una nueva categoría, si lo es, marca la bandera como true.

De lo contrario la marca como false, carga la categoría del repositorio y setea el valor al LiveData del nombre actual del formulario.

public class AddEditCategoryViewModel extends ViewModel {

    private int mCategoryId;

    private boolean mIsNewCategory;

    public void start(int categoryId) {
        mCategoryId = categoryId;

        // ¿Es una nueva categoría?
        if (categoryId <= 0) {
            mIsNewCategory = true;
            return;
        }

        mIsNewCategory = false;

        LiveData<Category> category = mCategoriesRepository.getCategory(categoryId);
        name.setValue(category.getValue().getName());

    }

}

De esa manera, al asignar el view model al layout activity_add_edit_category.xml es posible actualizar el campo de texto a través de Two-way Data Binding:

<com.google.android.material.textfield.TextInputEditText
    android:id="@+id/category_name_text_field"
    ...
    android:text="@={viewModel.name}" />

Guardar Categoría

Guardar una categoría consiste en:

  • Validar si el nombre no es nulo y tiene al menos un carácter. En caso negativo, enviar evento de asignación de error
  • Crear un nuevo objeto Category con ID autogenerado si es nuevo o con el ID de edición en caso contrario
  • Añadir al repositorio
  • Lanzar evento de navegación hacia la lista de categorías

Este algoritmo se refleja en el método saveCategory() de la siguiente manera:

public void saveCategory() {

    // Realizar validaciones del estado
    if (name.getValue() == null) {
        mErrorText.setValue(new Event<>(R.string.category_name_error));
        return;
    }

    if (name.getValue().isEmpty()) {
        mErrorText.setValue(new Event<>(R.string.category_name_error));
        return;
    }

    Category category;

    if (mIsNewCategory) {
        // "creación"
        category = new Category(name.getValue());
    } else {
        // "edición"
        category = new Category(mCategoryId, name.getValue());
    }

    // Guardar categoría
    mCategoriesRepository.addCategory(category);

    // Enviar evento de navegación
    mCategorySavedEvent.setValue(new Event<>(mCategoryId));
}

Ahora procesamos el evento de click sobre el action buton desde la actividad.

@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
    int itemId = item.getItemId();

    if (R.id.action_delete == itemId) {

    } else if (R.id.action_save == itemId) {
        mViewModel.saveCategory();
        return true;

    } else {
        return super.onOptionsItemSelected(item);
    }
}

Procesar Evento De Error

En onCreate() llamamos al método setupInputErrors() el cual aplica dos observaciones. Una sobre el evento de ocurrencia del error y otra sobre el nombre de la categoría.

private void setupInputErrors() {
    mViewModel.getName().observe(this, s -> {
        // Limpiar error al editar
        if (mBinding.categoryInputLayout.getError() != null) {
            mBinding.categoryInputLayout.setError(null);
        }
    });

    // Limpiar código para poner lambdas
    mViewModel.getError().observe(this, event -> {
        // Mostrar error en el nombre de la categoría
        Integer msg = event.getContentIfNotHandled();
        if (msg != null) {
            mBinding.categoryInputLayout.setError(getText(msg));
        }
    });
}

El evento del error trae el recurso string con para setear el valor sobre el error mostrado por el TextInputLayout.

La observación del nombre es necesaria ya que deseamos limpiar el error cuando el usuario edite de nuevo el campo de texto.

Ejemplo Android LiveData: Error en EditText
Detectando error en campo de texto

Procesar Evento De Categoría Guardada

Este manejo lo hacemos con el método setupNavigation() en onCreate(). En su interior cerramos la actividad de creación/edición y seteamos un código exitoso de resultado para el caso actual:

private void setupNavigation() {   

    mViewModel.getCategorySavedEvent().observe(this, categoryEvent -> {
        Integer categoryId = categoryEvent.getContentIfNotHandled();
        if (categoryId != null) {
            // Finalizar actividad con resultado exitoso
            setResult(categoryId > 0 ? EDIT_RESULT_OK : ADD_RESULT_OK);
            finish();
        }
    });
}

Eliminar Categorías

Para finalizar veremos cómo se ha solucionado la eliminación.

Ejemplo Android LiveData: Eliminar categoría
Eliminando categoría de la lista

La secuencia de interfaz sería:

  1. Presionar action button con icono de papelera
  2. Despliega un diálogo de confirmación
  3. Al confirmar procesar eliminación
  4. Mostrar mensaje de eliminación exitosa

Veamos el flujo de estas acciones a través de la arquitectura.

Procesar Click Sobre Action Button

La visibilidad por defecto del action button de eliminación es GONE. Si queremos que se revele a la hora de editar una categoría lo hacemos desde onCreateOptionsMenu():

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.add_edit_category_menu, menu);

    // Mostrar icono de eliminar si hay edición
    if (isEdition()) {
        menu.findItem(R.id.action_delete).setVisible(true);
    }
    return true;
}

Con este comportamiento, ya es posible procesar su click, donde iniciaremos un diálogo de confirmación.

@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
    int itemId = item.getItemId();

    if (R.id.action_delete == itemId) {
        // Mostrar diálogo de confirmación
        AlertDialogFragment.create(R.string.dialog_delete_msg,
                R.string.dialog_delete_positive, R.string.dialog_save_negative)
                .show(getSupportFragmentManager(), AlertDialogFragment.DELETE_TAG);
        return true;

    } else if (R.id.action_save == itemId) {
        mViewModel.saveCategory();
        return true;

    } else {
        return super.onOptionsItemSelected(item);
    }
}

El click en el botón de confirmación será despachado al controlador onPositiveClick() de la interfaz que comunica a la activity con el diálogo. Justo allí donde llamaremos al método deleteCategory() del view model:

@Override
public void onPositiveClick(DialogFragment dialog) {
    switch (dialog.getTag()) {
        case AlertDialogFragment.DELETE_TAG:
            mViewModel.deleteCategory();
            break;
        case AlertDialogFragment.DISCARD_TAG:
            // manejar descarte
            break;
    }
}

Comunicar Eliminación Al ViewModel

Eliminar una categoría consiste en pedir al repositorio la remoción del objeto y comunicar un evento de navegación indicando que la eliminación fue exitosa.

El método deleteCategory() muestra lo simple de estas instrucciones:

public void deleteCategory() {
    mCategoriesRepository.delete(mCategoryId);
    mCategoryDeletedEvent.setValue(new Event<>(new Object()));
}

Remover Categoría Del Repositorio

El método CategoriesRepositorio.delete() remueve una categoría basado en el id. Al ser nuestro origen de datos un HashMap invocamos al método remove().

Luego pasamos con setValue() el nuevo estado para que se transmita hacia la lista:

public void delete(int categoryId) {
    dummyData.remove(categoryId);
    observableCategories.setValue(dummyData);
}

Procesar Evento De Categoría Eliminada

Similar que con el evento de categoría guardada, observamos el LiveData desde setupNavigation() para cerrar la actividad de creación/edición y notificar el código de resultado:

private void setupNavigation() {
    mViewModel.getCategoryDeletedEvent().observe(this, objectEvent -> {
        if(objectEvent.getContentIfNotHandled()!=null){
            // Ir hacia la lista de categorías
            setResult(DELETE_RESULT_OK);
            finish();
        }
    });
}

Conclusión

En este tutorial viste como usar la clase LiveData para recibir actualizaciones de tus datos manteniendo una relación con el ciclo de vida de la vista.

La aplicación de categorías de gastos te permitió explorar varios tipos de actualizaciones de UI y procesamiento de eventos.

Ahora, te invito a seguir mi tutorial de ViewModel [próximamente] para que comprendas a fondo más su comportamiento.

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!