En el tutorial Diseño Android: Cuadros de diálogo de selección vimos cómo crear un cuadro de diálogo de selección de opciones (simple y múltiple) encapsulado en un fragment. También echamos un vistazo rápido a las características que presentan en general de los cuadros de diálogo en Android. En el presente tutorial haremos lo mismo pero en lugar de mostrar un listado de elementos a seleccionar tendremos un pequeño formulario.
Proyecto de ejemplo
El proyecto de ejemplo tiene como target Android 30 y será compatible hasta Android 4.4 (Api 19) y superior. Usaremos algunos widgets gráficos proporcionados por Material Components for Android y AndroidX, librerías que son la evolución de la ya obsoleta librería de compatibilidad. Para el formulario vamos a recurrir el widget TextInputLayout tratado en el tutorial Diseño Android: EditText con Material Design y TextInputLayout. El fichero build.gradle queda tal que así.
apply plugin: 'com.android.application' android { compileSdkVersion 30 defaultConfig { applicationId "com.danielme.dialogfragmentform" minSdkVersion 19 targetSdkVersion 30 versionCode 1 versionName "1.0" multiDexEnabled true } buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'com.google.android.material:material:1.2.1' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1' }
La aplicación tiene una única pantalla, gestionada por la clase MainActivity, con la típica Toolbar y en la que mostraremos dos campos de texto cuyo contenido estableceremos con el formulario del cuadro de diálogo, así como un botón para abrir el mismo.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <com.google.android.material.appbar.MaterialToolbar android:id="@+id/toolbar" style="@style/AppTheme.Toolbar" /> <TextView android:id="@+id/textViewFirstNameLabel" style="@style/AppTheme.TextViewLabel" android:text="@string/firstname" /> <TextView android:id="@+id/textViewFirstNameContent" style="@style/AppTheme.TextViewContent" /> <TextView android:id="@+id/textViewLastNameLabel" style="@style/AppTheme.TextViewLabel" android:text="@string/lastname" /> <TextView android:id="@+id/textViewLastNameContent" style="@style/AppTheme.TextViewContent"/> <Button android:id="@+id/button" style="@style/AppTheme.Margins" android:layout_width="match_parent" android:layout_height="wrap_content" android:onClick="edit" android:text="@string/edit"/> </LinearLayout>
Construyendo el formulario
Tal y como se ha comentado en la introducción, nuestro cuadro de diálogo estará encapsulado dentro de un fragment. Vamos a crearlo especializando la clase AppCompatDialogFragment para que se aplique automáticamente un diseño consecuente con las guías de estilo de Material Design. En este fragment, sobrescribimos el método onCreateDialog el cual devolverá el Dialog que mostrará el fragment. Este diálogo lo creamos de forma «fluida» con el builder MaterialAlertDialogBuilder. Su particularidad es que el contenido será el siguiente layout con nuestro formulario.
<?xml version="1.0" encoding="utf-8"?> <ScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="@dimen/dialog_margin" android:layout_marginRight="@dimen/dialog_margin" android:orientation="vertical"> <com.google.android.material.textfield.TextInputLayout android:id="@+id/textInputLayoutFirstName" style="@style/AppTheme.TextInputLayoutForm" android:hint="@string/firstname" app:counterEnabled="true" app:counterMaxLength="20"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/textInputFirstName" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textEmailAddress" android:maxLength="20" android:maxLines="1"/> </com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputLayout style="@style/AppTheme.TextInputLayoutForm" android:hint="@string/lastname" app:counterEnabled="true" app:counterMaxLength="30"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/textInputLastName" android:layout_width="match_parent" android:layout_height="wrap_content" android:imeOptions="actionDone" android:inputType="text" android:maxLength="30" android:maxLines="1"/> </com.google.android.material.textfield.TextInputLayout> </LinearLayout> </ScrollView>
Tendremos que «inflar» este layout, configurar los elementos del mismo que así lo requieran, e incluirlo en el Dialog con el método setView. Usaremos un AlertDialog porque ya incluye el título y los botones.
@NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { View content = LayoutInflater.from(getContext()).inflate(R.layout.fragment_form, null); setupContent(content); AlertDialog alertDialog = new MaterialAlertDialogBuilder(getContext()) .setView(content) .setCancelable(true) .setNegativeButton(getString(R.string.cancel), null) .setTitle(R.string.edit) .setPositiveButton(getString(R.string.save), (dialogInterface, i) -> returnValues()) .create(); //asegura que se muestre el teclado con el diálogo completo alertDialog.getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN); return alertDialog; }
Una vez inflado el layout, procedemos a configurar los EditText con los valores iniciales, asegurando además que el cursor se muestre al final del texto. Asimismo, usaremos lo visto en Tip Android #34: Personalizar el teclado con imeOptions, para que cuando estemos en el segundo campo podamos directamente desde el teclado realizar la acción de guardado de los campos.
private void setupContent(View content) { textInputLayoutFirstName = content.findViewById(R.id.textInputLayoutFirstName); textInputFirstName = content.findViewById(R.id.textInputFirstName); textInputLastName = content.findViewById(R.id.textInputLastName); textInputFirstName.setText(getArguments().getString(ARG_FIRSTNAME)); textInputFirstName.setSelection(getArguments().getString(ARG_FIRSTNAME).length()); textInputLastName.setText(getArguments().getString(ARG_LASTNAME)); textInputLastName.setSelection(getArguments().getString(ARG_LASTNAME).length()); textInputLastName.setOnEditorActionListener((textView, actionId, keyEvent) -> { if (actionId == EditorInfo.IME_ACTION_DONE) { returnValues(); dismiss(); return true; } return false; }); }
También necesitamos recibir esos valores iniciales de los campos del formulario (y que se muestran en MainActivity), y, en el caso de que el usuario pulse «Guardar», enviar los nuevos valores de vuelta a MainActivity.
El envío de datos de la Activity al fragment lo haremos mediante el típico «constructor estático» (un fragment sólo puede tener como constructor el constructor vacío a menos que se utilice FragmentFactory).
public static FormFragment newInstance(String firstName, String lastName) { Bundle args = new Bundle(); args.putString(ARG_FIRSTNAME, firstName); args.putString(ARG_LASTNAME, lastName); FormFragment frag = new FormFragment(); frag.setArguments(args); return frag; }
El envío de los datos a la activity podemos hacerlo invocando un método de la misma o bien mediante eventos, ya sea con LocalBroadcast o con un bus de eventos como EventBus (muy recomendable). Ambas opciones ya se describen en el tutorial Diseño Android: Cuadros de diálogo de selección, así que en esta ocasión iré a lo sencillo y recurriré a la primera estrategia definiendo una interfaz y haciendo que sea implementada por la activity.
public interface FormDialogListener { void update(String firstname, String lastname); }
@Override public void update(String firstName, String lastName) { textViewFirstName.setText(firstName); textViewLastName.setText(lastName); }
En el fragment se sobrescribe el método onAttach para recuperar la Activity que muestra el fragment como un objeto de tipo FormDialogListener. Guardamos la Activity en un atributo para posteriormente poder ejecutar su método update.
private FormDialogListener listener; @Override public void onAttach(@NonNull Context context) { super.onAttach(context); if (context instanceof FormDialogListener) { listener = (FormDialogListener) context; } else { throw new IllegalArgumentException("context is not FormDialogListener"); } }
Ahora unimos todas piezas y el fragment queda tal que así.
package com.danielme.dialogfragmentform; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import android.widget.EditText; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatDialogFragment; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputLayout; public class FormDialogFragment extends AppCompatDialogFragment { public static final String TAG = FormDialogFragment.class.getSimpleName(); private static final String ARG_FIRSTNAME = "ARG_FIRSTNAME"; private static final String ARG_LASTNAME = "ARG_LASTNAME"; private TextInputLayout textInputLayoutFirstName; private EditText textInputFirstName; private EditText textInputLastName; private FormDialogListener listener; public static FormDialogFragment newInstance(String firstName, String lastName) { Bundle args = new Bundle(); args.putString(ARG_FIRSTNAME, firstName); args.putString(ARG_LASTNAME, lastName); FormDialogFragment frag = new FormDialogFragment(); frag.setArguments(args); return frag; } @Override public void onAttach(Context context) { super.onAttach(context); if (context instanceof FormDialogListener) { listener = (FormDialogListener) context; } else { throw new IllegalArgumentException("context is not FormDialogListener"); } } @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { View content = LayoutInflater.from(getContext()).inflate(R.layout.fragment_form, null); setupContent(content); AlertDialog alertDialog = new MaterialAlertDialogBuilder(getContext()) .setView(content) .setCancelable(true) .setNegativeButton(getString(R.string.cancel), null) .setTitle(R.string.edit) .setPositiveButton(getString(R.string.save), (dialogInterface, i) -> returnValues()) .create(); //asegura que se muestre el teclado con el diálogo completo alertDialog.getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN); return alertDialog; } private void returnValues() { listener.update(textInputFirstName.getText().toString(), textInputLastName.getText().toString()); } private void setupContent(View content) { textInputLayoutFirstName = content.findViewById(R.id.textInputLayoutFirstName); textInputFirstName = content.findViewById(R.id.textInputFirstName); textInputLastName = content.findViewById(R.id.textInputLastName); textInputFirstName.setText(getArguments().getString(ARG_FIRSTNAME)); textInputFirstName.setSelection(getArguments().getString(ARG_FIRSTNAME).length()); textInputLastName.setText(getArguments().getString(ARG_LASTNAME)); textInputLastName.setSelection(getArguments().getString(ARG_LASTNAME).length()); textInputLastName.setOnEditorActionListener((textView, actionId, keyEvent) -> { if (actionId == EditorInfo.IME_ACTION_DONE) { returnValues(); dismiss(); return true; } return false; }); } }
Este fragment lo mostramos desde MainActivity creándolo e invocando a su método show ya que se trata de un AppCompatDialogFragment. Obsérvese además que se ha implementado la interfaz FormDialogListener que vimos anteriormente.
package com.danielme.dialogfragmentform; import android.os.Bundle; import android.view.View; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; public class MainActivity extends AppCompatActivity implements FormDialogListener{ private TextView textViewFirstName; private TextView textViewLastName; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); setSupportActionBar((Toolbar) findViewById(R.id.toolbar)); textViewFirstName = findViewById(R.id.textViewFirstNameContent); textViewLastName = findViewById(R.id.textViewLastNameContent); } public void edit(View view) { FormDialogFragment form = FormDialogFragment.newInstance(textViewFirstName.getText().toString(), textViewLastName.getText().toString()); form.show(getSupportFragmentManager(), FormDialogFragment.TAG); } @Override public void update(String firstName, String lastName) { textViewFirstName.setText(firstName); textViewLastName.setText(lastName); } }
Validaciones
Vamos a añadir a nuestro formulario una validación: el campo First Name no puede estar vacío. Para hacerlo recurrimos nuevamente al tutorial correspondiente e implementamos la validación al pulsarse el botón guardar.
.setPositiveButton(getString(R.string.save), (dialogInterface, i) -> if (validate()) returnValues())
private boolean validate() { if (TextUtils.isEmpty(textInputFirstName.getText())) { textInputLayoutFirstName.setError(getString(R.string.mandatory)); textInputLayoutFirstName.setErrorEnabled(true); return false; } return true; }
Una pequeña mejora de usabilidad: si se muestra el mensaje de error y el usuario edita el campo, se borra el mensaje.
textInputFirstName.addTextChangedListener(new TextWatcher() { @Override public void afterTextChanged(Editable s) { if (textInputFirstName.getVisibility() == View.VISIBLE) { textInputLayoutFirstName.setError(null); } } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { //nothing here } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { //nothing here } });
Sin embargo, al estar nuestro formulario dentro de un AlertDialog tenemos un problema: al pulsare el botón de guardar tras ejecutarse el código de nuestro listener el cuadro de diálogo se cierra de forma automática por lo que si la validación falla el usuario no verá el mensaje de error. Para evitar este cierre automático recurro a la siguiente estrategia:
- Al construir el AlertDialog, establecer el listener del botón a null.
AlertDialog alertDialog = new MaterialAlertDialogBuilder(getContext()) .setView(content) .setCancelable(true) .setNegativeButton(getString(R.string.cancel), null) .setTitle(R.string.edit) .setPositiveButton(getString(R.string.save), null) .create();
- Sobrescribir el método onStart y establecer aquí los listener de los botones, teniendo en cuenta que a partir de ahora el cierre del cuadro de diálogo hay que realizarlo de forma explícita cuando sea necesario invocando al método dismiss del mismo.
@Override public void onStart() { super.onStart(); Button positiveButton = ((AlertDialog) getDialog()).getButton(Dialog.BUTTON_POSITIVE); positiveButton.setOnClickListener(view -> { if (validate()) { returnValues(); getDialog().dismiss(); } }); }
Tras estos cambios ya hemos terminado nuestro formulario.
Código de ejemplo
El proyecto de ejemplo para Android Studio se encuentra disponible en GitHub. Para más información sobre cómo utilizar GitHub, consultar este artículo.