Les menus et items de menu
Introduction
-
Types de menu :
- Menu d'options : obtenu depuis un bouton physique ou la barre d'action ; y résident les actions principales de l'application (navigation, paramétrage, recherche...)
- Menu contextuel ou popup : obtenu depuis un clic long sur un élément de vue pour proposer des choix spécifiques
- Événement de sélection d'item de menu : onMenuItemClick(MenuItem item) ou Activity.onOptionsItemSelected(MenuItem item) ; il faut tester l'item sélectionné avec un switch (en utilisant par exemple item.getItemId())
Exemple de ressource XML de menu
<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android"> <item android:id="@+id/menu_search" android:icon="@drawable/menu_search" android:title="@string/menu_search" android:showAsAction="ifRoom|collapseActionView" android:actionViewClass="android.widget.SearchView" /> <!-- We define a menu group --> <group android:id="@+id/group_temporal"> <item android:id="@+id/menu_start" android:title="@string/menu_start" /> <item android:id="@+id/menu_stop" android:title="@string/menu_stop" /> </group> </menu>
Les menus de la barre d'action
ActionBar
- Barre d'action en haut d'écran apparue depuis Android 3.0 (API11), récupérable avec Activity.getActionBar()
- Permet la navigation dans une application, son paramétrage ainsi que de lancer des actions
-
Barre peuplable en redéfinissant Activity.onCreateOptionsMenu(Menu menu)
- Généralement on charge un menu défini par une ressource XML (utilisation de MenuInflater)
- Mise à jour possible de la bar en redéfinissant Activity.onPrepareOptionsMenu(Menu menu)
-
Placement des éléments de la barre d'action :
- Éléments de navigation en haut à gauche
- Items d'actions en haut à droite (en bas sur petits écrans si propriété d'activité uiOptions="splitActionBarWhenNarrow")
-
Si trop d'items d'action : masquage dans un menu popup overflow
- Pas très agréable pour les UIs de certains appareils (TV connectée notamment)
Éléments de navigation
-
Retour top (logo haut gauche)
- remplaçable par une icône haut avec actionBar.setDisplayHomeAsUpEnabled(true)
- Clic récupérable avec onOptionsItemSelected() avec l'ID android.R.id.home
- Utile pour revenir à l'activité principale :
- Intent intent = new Intent(this, HomeActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); startActivity(intent);
-
Onglets
- Instantiation d'un ActionBar.Tab, setText(...), setIcon(...)
- Création d'un TabListener (code exécuté lors du choix du tab, typiquement chargement de Fragment) et enregistrement avec Tab.setTabListener()
- Ajout dans la Bar avec Bar.addTab(Tab tab)
Items d'action
-
Vue pour un item
- Classiquement : icône + texte
- Choix d'une vue personnalisée avec android:actionViewClass
- Affichage dans l'ActionBar configurable avec android:showAsAction (ifRoom, withText, never, collapseActionView)
- Certaines vues (comme SearchView) proposent deux versions (réduites et étendue) avec CollapsibleActionView
- ActionProvider : définition d'item avec action prédéfinie (e.g. ShareActionProvider pour partager un élément de différentes façons); propriété android:actionProviderClass
Exemple : un diaporama d'images
Réalisons une activité affichant différentes images extraites du répertoire assets de l'application.
Tout d'abord nous réalisons un menu statique XML permettant d'avancer, reculer ou sélectionner une image aléatoire à afficher :
<?xml version="1.0" encoding="utf-8"?> <!-- Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License --> <androidx.constraintlayout.widget.ConstraintLayout 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" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".menu.DiaporamaActivity"> <TextView android:id="@+id/textView4" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginLeft="8dp" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" android:layout_marginRight="8dp" android:text="Use the menu to go to a new random picture or for direct access to a picture" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <ImageView android:id="@+id/pictureView" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginStart="8dp" android:layout_marginLeft="8dp" android:layout_marginTop="8dp" android:layout_marginEnd="8dp" android:layout_marginRight="8dp" android:layout_marginBottom="8dp" android:scaleType="fitCenter" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textView4" tools:src="@tools:sample/avatars" /> </androidx.constraintlayout.widget.ConstraintLayout>
Remarquons l'existence d'un groupe vide dans le menu : nous allons le peupler à l'exécution en fonction des images listées dans le répertoire assets.
Écrivons ensuite l'activité qui récupère les images, peuple le menu avec les items dans onCreateOptionsMenu(...) et prépare le menu en cochant le bon item (correspondant à l'image actuellement affichée) dans onPrepareOptionsMenu(...).
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.menu; import android.app.Activity; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Bundle; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.widget.ImageView; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Random; import fr.upem.coursand.R; /** * An activity displaying pictures from the assets directory. */ public class DiaporamaActivity extends Activity { /** Path of the pictures into the assets directory */ public static final String PICTURES_PATH = "desserts"; public static List<String> getPicturePaths(Context context) { List<String> result = new ArrayList<>(); try { for (String filename : context.getResources().getAssets().list(PICTURES_PATH)) result.add(PICTURES_PATH + "/" + filename); } catch (IOException e) { Log.e(DiaporamaActivity.class.getName(), "IOException while fetching picture paths", e); } return result; } /** Random number generator */ private static final Random rng = new Random(); private List<String> picturesPaths; /** The index of the currently displayed picture */ private int currentPictureIndex = -1; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_diaporama); picturesPaths = getPicturePaths(this); displayPicture(0); } private final int firstMenuItemID = Menu.FIRST; private Bitmap loadAssetBitmap(String path) { try { return BitmapFactory.decodeStream(getAssets().open(path)); } catch (IOException e) { Log.e(this.getClass().getName(), "Cannot load bitmap for path " + path); return null; } } /** Display a picture on the ImageView */ protected void displayPicture(int index) { if (index != currentPictureIndex) { ImageView iv = findViewById(R.id.pictureView); iv.setImageBitmap(loadAssetBitmap(picturesPaths.get(index))); currentPictureIndex = index; } } /** Called once in the life of the activity when the menu is created */ @Override public boolean onCreateOptionsMenu(Menu menu) { // load the menu from the XML file getMenuInflater().inflate(R.menu.diaporama_menu, menu); int i = 0; for (String path: picturesPaths) { int start = path.indexOf("/") + 1; int stop = path.lastIndexOf("."); String name = path.substring(start, stop); MenuItem mi = menu.add(R.id.directPictureAccess /* group id */, firstMenuItemID + i /* item id */, 0 /* the order */, name /* title */); mi.setCheckable(true); mi.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER); // never put the individual picture items in the action bar i++; } return true; // do not forget to return true to display the menu } /** Called before each time the menu is displayed */ @Override public boolean onPrepareOptionsMenu(Menu menu) { // Check the correct item for (int i = firstMenuItemID; i < firstMenuItemID + picturesPaths.size(); i++) menu.findItem(i).setChecked(currentPictureIndex == i - firstMenuItemID); return true; // in order to display the menu } /** Called when an element of the menu is clicked */ @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.randomPictureAccess) displayPicture(rng.nextInt(picturesPaths.size())); else if (item.getItemId() == R.id.previousPictureAccess) displayPicture((currentPictureIndex - 1) % picturesPaths.size()); else if (item.getItemId() == R.id.nextPictureAccess) displayPicture((currentPictureIndex + 1) % picturesPaths.size()); else { // direct access to a picture int index = item.getItemId() - firstMenuItemID; if (index >= 0 && index < picturesPaths.size()) displayPicture(index); else return false; // the menu item is not managed by our method (if we have forgotten a case) } return true; } }
Tiroir de navigation (navigation drawer)
Principe d'implantation
- Menu de navigation escamotable (avec animation de glissement horizontal)
-
Cette page de tutoriel explique les étapes de sa mise en place :
- Ajout de bibliothèques de dépendance dans le fichier build.gradle
-
Création du layout de l'activité affichant le tiroir
-
Racine : DrawerLayout
-
LinearLayout pour l'affichage de la barre d'outils et du contenu principal
- Toolbar pour afficher la barre d'outils (non native, nécessaire pour que le tiroir se superpose complètement sur la barre)
- Layout de son choix pour le contenu principal (préférable de le créer dans un fichier externe mylayout.xml inclus avec ``<include layout="@layout/mylayout" />)
-
NavigationView pour le tiroir de navigation
- avec une propriété menu indiquant si nécessaire une ressource de menu statique
- avec une propriété headerLayout permettant d'intégrer un layout en-tête du tiroir (avant les items du menu)
-
LinearLayout pour l'affichage de la barre d'outils et du contenu principal
-
Racine : DrawerLayout
- Ajout du listener gérant les clics sur les items du menu (navigationView.setNavigationItemSelectedListener(...))
-
Écriture de la méthode onCreate(...) de l'activité :
- Pour installer le layout de l'activité avec setContentView(...)
- Pour installer la barre d'outils
- Pour rajouter une icône en haut à gauche de la barre d'outils permettant de demander l'affichage du tiroir de notification
- Possibilité d'ajouter un DrawerListener afin d'écouter les évènements clé du tiroir (ouverture, fermeture, changement d'état...)
Exemple : informations sur des villes
Réalisons une activité pour afficher diverses informations sur les plus grandes villes du monde.
Nous fournissons un fichier texte au format TSV (colonnes séparées par des tabulations) placé dans le répertoire assets avec les informations pour chaque ville sur une ligne. Nous écrivons une classe en Kotlin chargée de lire ce fichier :
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.topcities import android.content.Context import java.io.Serializable import java.lang.Math.* import java.util.* class City(val name: String, val latitude: Float, val longitude: Float, val population: Int, val elevation: Float, val timeZone: TimeZone): Serializable { companion object { /** Load a city from a CSV text line */ fun loadFromLine(line: String): City { val c = line.split("\t") return City("${c[0]} ${c[3]}", c[1].toFloat(), c[2].toFloat(), c[4].toInt(), c[5].toFloat(), TimeZone.getTimeZone(c[6])) } /** Load all the cities from a CSV text file */ fun loadFromAsset(context: Context, path: String): List<City> { return context.assets.open(path).reader().readLines().map { loadFromLine(it) } } const val R = 6372.8 // in kilometers /** Compute the distance between two geographical points */ fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val λ1 = toRadians(lat1) val λ2 = toRadians(lat2) val Δλ = toRadians(lat2 - lat1) val Δφ = toRadians(lon2 - lon1) return 2 * R * asin(sqrt(pow(sin(Δλ / 2), 2.0) + pow(sin(Δφ / 2), 2.0) * cos(λ1) * cos(λ2))) } fun findNearest(cities: List<City>, latitude: Float, longitude: Float): City? { return cities.minByOrNull { haversine(latitude.toDouble(), longitude.toDouble(), it.latitude.toDouble(), it.longitude.toDouble()) } } } }
Écrivons maintenant le layout pour notre activité avec inclusion du tiroir (qui repose sur l'inclusion d'un layout pour le contenu affiché pour une ville) :
<?xml version="1.0" encoding="utf-8"?> <!-- Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License --> <!-- Use DrawerLayout as root container for activity --> <androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/drawer_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true"> <!-- Layout to contain contents of main body of screen (drawer will slide over this) --> <LinearLayout android:id="@+id/content_frame" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" android:theme="@style/ThemeOverlay.AppCompat.ActionBar" /> <include layout="@layout/content_top_cities" /> </LinearLayout> <!-- Container for contents of drawer - use NavigationView to make configuration easier --> <com.google.android.material.navigation.NavigationView android:id="@+id/nav_view" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_gravity="start" android:fitsSystemWindows="true" /> </androidx.drawerlayout.widget.DrawerLayout>
La classe Java de l'activité réalise le travail de configuration nécessaire afin d'installer la barre d'actions, y rajouter un bouton déclenchant l'apparition du tiroir de navigation avec une méthode permettant de réaliser une action (afficher les informations) lors du clic sur une des villes du menu :
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.topcities; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; import android.widget.ImageView; import android.widget.TextView; import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import com.google.android.material.navigation.NavigationView; import java.util.Calendar; import java.util.List; import java.util.Random; import fr.upem.coursand.R; import fr.upem.coursand.configchange.ConfigChangeSupportActivity; /** An activity displaying essential facts about top cities using a navigation drawer */ public class TopCitiesActivity extends ConfigChangeSupportActivity { /** Where the cities data are in the assets directory */ public static final String CITIES_FILEPATH = "cities/topcities.csv"; static class State { List<City> cities; int selectedCity = -1; } private State state = new State(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_top_cities); if (state.cities == null) loadCities(); initToolbar(); initDrawer(); // display a random city displayCity(new Random().nextInt(state.cities.size())); } private void loadCities() { state.cities = City.Companion.loadFromAsset(this, CITIES_FILEPATH); } private void initToolbar() { // install the toolbar Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); ActionBar actionbar = getSupportActionBar(); actionbar.setDisplayHomeAsUpEnabled(true); actionbar.setHomeAsUpIndicator(R.drawable.ic_menu); } private void initDrawer() { // configure the drawer with a listener for an item selection DrawerLayout drawerLayout = findViewById(R.id.drawer_layout); NavigationView navigationView = findViewById(R.id.nav_view); // Populate the menu with the cities Menu menu = navigationView.getMenu(); int i = 0; for (City city: state.cities) menu.add(Menu.NONE, Menu.FIRST + (i++), Menu.NONE, city.getName()); // add a listener for the city selection navigationView.setNavigationItemSelectedListener( menuItem -> { menuItem.setChecked(true); displayCity(menuItem.getItemId() - Menu.FIRST); drawerLayout.closeDrawers(); return true; }); } /** To open the drawer when the menu icon is clicked */ @Override public boolean onOptionsItemSelected(MenuItem item) { DrawerLayout drawerLayout = findViewById(R.id.drawer_layout); if (item.getItemId() == android.R.id.home) { drawerLayout.openDrawer(GravityCompat.START); return true; } return super.onOptionsItemSelected(item); } protected void setText(int id, Object value) { ((TextView)findViewById(id)).setText(value.toString()); } protected void displayCity(int i) { City city = state.cities.get(i); // update selection about the menu NavigationView navigationView = findViewById(R.id.nav_view); Menu menu = navigationView.getMenu(); if (state.selectedCity >= 0) menu.findItem(state.selectedCity + Menu.FIRST).setChecked(false); menu.findItem(i + Menu.FIRST).setChecked(true); state.selectedCity = i; // display information about the city getSupportActionBar().setTitle(city.getName()); setText(R.id.latitudeTextView, city.getLatitude()); setText(R.id.longitudeTextView, city.getLongitude()); setText(R.id.populationTextView, city.getPopulation()); setText(R.id.meanAltitudeTextView, city.getElevation()); // display the local hour of the city Calendar calendar = Calendar.getInstance(city.getTimeZone()); setText(R.id.localHourTextView, String.format("%02d:%02d", calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE))); // display the planisphere ImageView planisphere = findViewById(R.id.planisphereView); Bitmap bm = BitmapFactory.decodeResource(getResources(), R.drawable.equirectangular_world_map); bm = bm.copy(Bitmap.Config.ARGB_8888, true); // to allow modification of the bitmap Canvas canvas = new Canvas(bm); Paint paint = new Paint(); paint.setColor(Color.RED); canvas.drawCircle(bm.getWidth() / 360.0f * (city.getLongitude() + 180.0f), bm.getHeight() - bm.getHeight()/180.0f * (city.getLatitude() + 90.0f), 50.0f, paint); planisphere.setImageBitmap(bm); } }
Menu contextuel
- Propose un choix d'action sur un élément d'UI ; typiquement affiché par un clic long
- Affichage dans la barre d'actions de l'activité des items du menu contextuel
-
Usage classique :
-
Création d'un ActionMode.CallBack avec :
- boolean onCreationActionMode(ActionMode mode, Menu menu) : on peuple le menu (par exemple avec un MenuInflater depuis une ressource)
- boolean onPrepareActionMode(ActionMode mode, Menu menu)
- boolean onActionItemClicked(ActionMode mode, MenuItem item) : on peut éventuellement sortir du mode menu avec mode.finish()
- void onDestroyActionMode(ActionMode mode) : lorsque l'on sort du menu
-
Création d'un ActionMode.CallBack avec :
Enregistrement d'un listener sur un clic long :
someView.setOnLongClickListener(new View.OnLongClickListener() { public boolean onLongClick(View view) { if (mActionMode != null) return false ; mActionMode = getActivity().startActionMode(callback); return true; } });
Exemple : activité avec un bouton
- Affichage d'un toast lors du clic sur le bouton
- Affichage d'un menu contextuel permettant de sélectionner le message à afficher (hello, date ou version)
// Code sample from Coursand [http://igm.univ-mlv.fr/~chilowi/], under the Apache 2.0 License package fr.upem.coursand.toaster; import android.os.Build; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import android.view.ActionMode; import android.view.Menu; import android.view.MenuItem; import android.widget.Button; import android.widget.Toast; import java.util.Date; import fr.upem.coursand.R; public class ToasterActivity extends AppCompatActivity { private int lastSelectedItemId = -1; private String message = "no message"; protected void updateMessage(int itemId) { String message = null; switch (itemId) { case R.id.showHelloWorldItem: message = "Hello World!"; break; case R.id.showDateItem: message = new Date().toString(); break; case R.id.showVersionItem: message = Build.VERSION.CODENAME; break; default: message = "42"; break; } lastSelectedItemId = itemId; this.message = message; } private final ActionMode.Callback actionModeCallback = new ActionMode.Callback() { @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { getMenuInflater().inflate(R.menu.toaster_menu, menu); return true; // something is done } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { // put a check mark for the last selected item for (int i = 0; i < menu.size(); i++) menu.getItem(i).setChecked(menu.getItem(i).getItemId() == lastSelectedItemId); return true; // return false since nothing is done } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { updateMessage(item.getItemId()); mode.finish(); return false; } @Override public void onDestroyActionMode(ActionMode mode) { // executed when we exit from the action mode currentActionMode = null; } }; private ActionMode currentActionMode = null; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_toaster); Button toastButton = findViewById(R.id.toastButton); // display the toast on click toastButton.setOnClickListener( v-> Toast.makeText(this, message, Toast.LENGTH_SHORT).show()); // display the context menu allowing to select the message to display // when we press a long click on the button toastButton.setOnLongClickListener( v -> { if (currentActionMode == null) { currentActionMode = startActionMode(actionModeCallback); return true; } else return false; }); } }