Kontextová lišta akcií / Contextual Action Bar

S príchodom lišty akcií v Androide 4.x sa otvorili nové možnosti. Nielenže na nej môžeme zobrazovať tlačidlá, karty, prípadne rozbaľovacie zoznamy, môžeme sa tiež prepínať medzi viacerými rozličnými sadami widgetov a to podľa toho, akej činnosti sa používateľ chce venovať.

Ak vyberiete text v textovom políčku, zrazu sa lišta akcií prepne do režimu zobrazovania tlačidiel pre prácu so schránkou (teda miestna obdoba Ctrl-C, Ctrl-X a Ctrl-V).

Kontextová lišta akcií pre kopírovanie

Takýchto režimov, presnejšie režimov akcií (action mode) môže mať aplikácia viacero.

Krásny príklad ekšn módu predstavuje hromadná práca s položkami zoznamu. Ak máme zoznam ListView, používateľ môže podržať prst nad ľubovoľnou položkou, čím sa prepne do režimu viacnásobného výberu položiek. Vybrané položky vie označiť ako obľúbené, zdieľať, či odstrániť. Z tohto režimu sa kedykoľvek vie vrátiť späť do bežnej práce so zoznamom, kde klik na položku zobrazí detailovú aktivitu.

Webbookmarkr: demonštrácia hromadného výberu

Vytvorme si ukážkovú aplikáciu Webookmark, ktorá zobrazí v zozname všetky stránky, ktoré má používateľ uložené ako záložky v prehliadači.

Kontextová lišta akcií pre kopírovanie

Aplikácia bude mať nasledovnú štruktúru:

  • fragment so zoznamom BookmarkListFragment
  • hlavná aktivita MainActivity obaľujúca fragment
  • layout súbor pre lištu akcií v režime hromadného výberu

Fragment so zoznamom

Zoznam záložiek bude zobrazovaný v zoznamovom fragmente. Prečo? Aktivita s widgetom ListView je skvelá a užitočné, ale vyskúšajme si iný prístup: Android ponúka triedu android.app.ListFragment, čo je presne fragment s jediným zoznamom na celú šírku displeja a so skvelými pomocnými metódami, ktoré napríklad umožnia zobraziť zástupný text, ak v zozname nie sú žiadne dáta,

Dáta zoznamu budeme vyťahovať zo systémového content providera pre záložky a históriu prehliadača, ktorý je identifikovaný adresou Uri v konštante Browser.BookmarkColumns. Z tabuľky využijeme stĺpec TITLE s popiskom záložky.

Samozrejme, pri načítavaní dát využijeme mechanizmus loaderov a callbackov, aby sme dodržali zásady o efektívnom a asynchrónnom prístupe k dátam.

Vytvorme podľa toho kostru fragmentu:

public class BookmarkListFragment extends ListFragment implements LoaderManager.LoaderCallbacks<Cursor> {

    private static final int BOOKMARK_LIST_LOADER_ID = 0;

    private SimpleCursorAdapter listViewAdapter;

    @Override
    public void onViewCreated(View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        initListAdapter();
        setListAdapter(this.listViewAdapter);
        getLoaderManager().initLoader(BOOKMARK_LIST_LOADER_ID, Bundle.EMPTY, this);
    }

    private void initListAdapter() {
        String[] from = { Browser.BookmarkColumns.TITLE, Browser.BookmarkColumns.URL };
        int[] to = { android.R.id.text1, android.R.id.text2 };
        this.listViewAdapter = new SimpleCursorAdapter(
                getActivity(),
                android.R.layout.simple_list_item_2,
                Defaults.NO_CURSOR,
                from, to,
                Defaults.NO_FLAGS);
    }

    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        CursorLoader loader = new CursorLoader(getActivity());
        loader.setUri(Browser.BOOKMARKS_URI);
        return loader;
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
        this.listViewAdapter.swapCursor(cursor);
    }

    @Override
    public void onLoaderReset(Loader<Cursor> loader) {
        this.listViewAdapter.swapCursor(Defaults.NO_CURSOR);
    }

}

Všimnime si špeciality prístupu k tradičným komponentom:

  • ku kontextu pristúpime cez aktivitu (getActivity()), keďže fragment nie je kontextom,
  • ako pristupujeme k zabudovanému zoznamu cez zdedenú metódu getListView(),
  • a ako nastavujeme adaptér zoznamu ListView cez zdedenú setListAdapter().

Záznam z tabuľky zobrazíme v položke s dvoma podpoložkami, reprezentovanej systémovým layoutom android.R.layout.simple_list_item_2. Tomu zodpovedajú aj dva prvky v poliach from a to, kde sa stĺpec TITLE namapuje na textové políčko so systémovým identifikátorom android.R.id.text1 a adresa URL záložky sa namapuje na políčko so systémovým identifikátorm android.R.id.text2.

To je všetko, čo sa týka fragmentu, poďme ho obaliť aktivitou!

Hlavná aktivita

public class MainActivity extends ActionBarActivity {

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

        getFragmentManager()
                .beginTransaction()
                .add(android.R.id.content, new BookmarkListFragment())
                .commit();
    }
}

Hlavná aktivita nepotrebuje nič špeciálne (dokonca ani layout v XML!), pretože jej jedinou úlohou je zahrnúť do seba fragment v rámci transakcie.

Zoznam a akčný režim

Zoznam ListView má takmer automatickú podporu pre akčný režim. Potrebujeme niekoľko konfiguračných nastavení:

  • potrebujeme nastaviť režim výberu položiek na hromadný výber s použitím akčného režimu:

    getListView().setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE_MODAL);
    
  • potrebujeme nastaviť poslucháča pre udalosti prepínania z a do akčného režimu:

    getListView().setMultiChoiceModeListener(...)
    

Zoznamový poslucháč zmien stavu

Na zoznamovom widgete môžeme zaregistrovať poslucháča na hromadný výber v akčnom režime, ktorého reprezentuje objekt implementujúci interfejs AbsListView.MultiChoiceModeListener.

  • boolean onCreateActionMode(ActionMode mode, Menu menu): metóda sa zavolá vo chvíli, keď sa zoznamový widget prepína do akčného režimu. V tomto prípade by sme mali nafúknuť zo XML definíciu položiek lišty akcií, ktoré sa zobrazia v tomto režime.
  • boolean onPrepareActionMode(ActionMode mode, Menu menu): volá sa hneď po vytvorení akčného režimu alebo v situácii, keď sa položky lišty akcií v akčnom režime zneplatnili (napr. volaním ActionMode#invalidate())
  • void onDestroyActionMode(ActionMode mode) zavolaná pri vypínaní akčného režimu

Ďalšie dve metódy súvisia s obsluhou kliknutia:

  • void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) sa zavolá pri výbere, čiže pri zahrnutí položky do hromadného výberu
  • boolean onActionItemClicked(ActionMode mode, MenuItem item) je volaná vo chvíli, keď sa klikne na tlačidlo na lište akcií.

Metódy, ktoré vracajú booleovskú hodnotu, majú vrátiť true, ak implementácia obslúžila danú metódu. V opačnom prípade to znamená, že sa využije štandardná obsluha (neprepneme sa do akčného módu, príprava nie je potrebná atď) z rodičovskej implementácie.

Poslucháčom akčného módu môže byť samotná inštancia fragmentu, ktorú upravíme, aby implementovala interfejs MultiChoiceModeListener:

public class BookmarkListFragment extends ListFragment implements LoaderManager.LoaderCallbacks<Cursor>, AbsListView.MultiChoiceModeListener

Prepnutie do akčného režimu

V metóde onCreateActionMode() nafúkneme položky lišty akcií, ktoré reprezentujú akcie, ktoré môže používateľ vykonať s hromadným výberom a následne nastavíme text pre hlavičku, ktorá navrhuje používateľovi, čo má v akčnom režime robiť:

@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
    mode.getMenuInflater().inflate(R.menu.bookmarks_cab, menu);
    mode.setTitle("Select Items");
    return true;
}

Tlačidlá v akčnom režime

Čo znamená resource R.menu.bookmars_cab? Vytvorme nový súbor pre resource (New Android Resource File) typu Menu s názvom bookmarks_cab.xml a s obsahom:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="https://schemas.android.com/apk/res/android">
    <item android:id="@+id/listViewDeleteSelectedAction" android:title="Delete" />
</menu>

Jediná položka na lište akcií v akčnom režime umožní zmazať vybrané položky zoznamu.

Metódy pre prípravu položiek akčného módu a pre jeho zatvorenie

Metóda pre prípravu položiek lišty akcií v akčnom móde nebude potrebná, rovnako ako metóda volaná pri zatváraní režimu akcií. Ponecháme ich prázdne:

@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
    return false;
}

@Override
public void onDestroyActionMode(ActionMode mode) {
    // do nothing
}

Obsluha výberu položky zoznamu

Ak používateľ klikne na položku zoznamu, chceme, aby to znamenalo jej zahrnutie do hromadného výberu (alebo odobratie z neho). Ak prekryjeme metódu onItemCheckedStateChanged(), a umne nastavíme vizuál pre layout položky, dostaneme skvelú vec: nielenže nám bude zoznam evidovať zoznam vybraných položiek, ale zároveň ich bude zvýrazňovať preddefinovanou farbou.

Na to, aby to fungovalo, potrebujeme:

  • Zmeniť typ layoutu položky v adaptéri na android.R.layout.simple_list_item_activated_2. Tento layout položky zoznamu reprezentuje dve podpoložky (väčším písmom pre hlavičku a menším pre popis), ktorá zároveň podporuje aktivovaný stav, teda stav užitočný pre vybrané položky.
  • Zistiť stav vybraných položiek pomocou metód getCheckedXXX().
    @Override
    public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) {
        int checkedCount = getListView().getCheckedItemCount();
        mode.setSubtitle("Selected item(s): " + checkedCount);
    }
    

Nastavenie podtitulku na objekte mode ovplyvní druhý riadok na lište akcií.

Obsluha výberu položky z lišty akcií

OK, vybrali sme položky v zozname, a čo s nimi? Môžeme ich zmazať!

V metóde onActionItemClicked() sa dozvieme, na ktoré tlačidlo v lište akcií používateľ klikol, a zavoláme pomocnú metódu:

@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
    switch(item.getItemId()) {
        case R.id.listViewDeleteSelectedAction:
            deleteSelectedListItems();
            return true;
    }
    return false;
}

Mazanie položiek

Mazanie položiek nijak mimoriadne nesúvisí s kontextovou lištou akcií. Najdôležitejšie je zistiť identifikátory vybraných položiek, ktoré zodpovedajú primárnym kľúčom riadkov tabuľky content providera. Vďaka metóde getCheckedItemIds() je to ľahké.

Každý vybraný identifikátor použijeme ako parameter pri mazaní z content providera cez jeho metódu delete().

    private void deleteSelectedListItems() {
        for (long id : getListView().getCheckedItemIds()) {
            getActivity().getContentResolver().delete(Browser.BOOKMARKS_URI, BaseColumns._ID + " = " + id, Defaults.NO_SELECTION_ARGS);
            getLoaderManager().restartLoader(BOOKMARK_LIST_LOADER_ID, Bundle.EMPTY, this);
        }
    }

Je veľmi dôležité aktualizovať zoznam po jeho vymazaní: a na to využijeme možnosť reštartovať príslušný kurzorový loader.

Po vymazaní položiek sa akčný mód automaticky skončí.

Bonus: pluralizácia položiek

Všimnime si, ako tvoríme hlavičku aktivity v prípade akčného režimu: cez reťazec "Selected Item(s)". Ak by sme vytvárali lokalizovanú aplikáciu, určite by sme neboli šťastní, keby sme videli v slovenčine "Vybrané položky: 1". Našťastie, i na toto mysleli dizajnéri Androidu. Vieme definovať jazykové resources, ktoré korektne vyskloňujú a naformátujú reťazce takéhoto typu. Dynamicky potom vieme zistiť, že nemáme "Žiadne vybrané položky", ale budeme mať "1 vybratá položka", ale i "5 vybratých položiek."

V súbore strings.xml zaveďme

<plurals name="selected_bookmarks">
    <item quantity="one">One selected bookmark</item>
    <item quantity="other">%d bookmarks selected</item>
</plurals>

Definujeme plurálový reťazec, ktorý pre jednu (one) položku vrátime reťazec iný než pre prípad iného (other) počtu položiek. Vo formátovaní vieme využívať zástupné parametre, napr. %d reprezentujúce číselný parameter dosadený za behu.

Po úprave kódu bude vyzerať metóda:

@Override
public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) {
    int checkedCount = getListView().getCheckedItemCount();
    String checkedCountMessage = getResources().getQuantityString(R.plurals.selected_bookmarks, checkedCount, checkedCount);
    mode.setSubtitle(checkedCountMessage);
}

Najprv získame getResource() a z neho reťazec kvantity (getQuantityString()), kde získame referenciu na príslušný plurálový resource. Ďalší parameter metódy reprezentuje počet položiek, z ktorého sa vyberie príslušný reťazec a druhá (zopakovaná) položka predstavuje hodnotu zamenenú za zástupný znak %d v reťazci.

Výsledný kód

Celý kód fragmentu, ktorý implementuje LoaderCallbacks pre callbacky loaderov a `MultiChoiceListener* pre callbacky akčného módu, vyzerá nasledovne:

public class BookmarkListFragment extends ListFragment implements LoaderManager.LoaderCallbacks<Cursor>, AbsListView.MultiChoiceModeListener {


    @Override
    public void onViewCreated(View view, Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        initListAdapter();
        initList();
        setListAdapter(this.listViewAdapter);
        getLoaderManager().initLoader(BOOKMARK_LIST_LOADER_ID, Bundle.EMPTY, this);
    }

    private void initList() {
        getListView().setChoiceMode(AbsListView.CHOICE_MODE_MULTIPLE_MODAL);
        getListView().setMultiChoiceModeListener(this);
    }

    // ----- Fragment UI Logic

    private void deleteSelectedListItems() {
        for (long id : getListView().getCheckedItemIds()) {
            getActivity().getContentResolver().delete(Browser.BOOKMARKS_URI, BaseColumns._ID + " = " + id, Defaults.NO_SELECTION_ARGS);
            getLoaderManager().restartLoader(BOOKMARK_LIST_LOADER_ID, Bundle.EMPTY, this);
        }
    }

    // ----- Contextual Action Bar callbacks

    @Override
    public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) {
        int checkedCount = getListView().getCheckedItemCount();
        String checkedCountMessage = getResources().getQuantityString(R.plurals.selected_bookmarks, checkedCount, checkedCount);
        mode.setSubtitle(checkedCountMessage);
    }

    @Override
    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
        MenuInflater menuInflater = getActivity().getMenuInflater();
        menuInflater.inflate(R.menu.bookmarks_cab, menu);

        mode.setTitle("Select Items");

        return true;
    }

    @Override
    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
        return false;
    }

    @Override
    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
        switch(item.getItemId()) {
            case R.id.listViewDeleteSelectedAction:
                deleteSelectedListItems();
                return true;
        }
        return false;
    }

    @Override
    public void onDestroyActionMode(ActionMode mode) {
        // do nothing
    }
}

Výsledný kód nájdete na GitHube, v repozitári novotnyr/android-webbookmark-demo-2015.

Záver

Výsledná aplikácia ukazuje klasický príklad práce so zoznamom a content providerom, kde navyše položky zoznamu umožňujú hromadné spracovanie. Táto situácia sa hodí pre viacero typických aplikácií, od zoznamov úloh až po spracovanie mailov.

Ďalšie pramene