Vyvíjíme pro Android: Preference, menu a vlastní Adapter

Téměř každá aplikace potřebuje uživateli nabídnout nějaké nastavení, stejně tak jako na velkou část případů nestačí vestavěné Adaptery. Po přečtení dnešního článku si budete s těmito problémy umět poradit, a navíc se k tomu dozvíte něco o tvorbě menu.

Seriál: Vyvíjíme pro Android (14 dílů)

  1. Vyvíjíme pro Android: Začínáme 15.6.2012
  2. Vyvíjíme pro Android: První krůčky 22.6.2012
  3. Vyvíjíme pro Android: Suroviny, Intenty a jednotky 29.6.2012
  4. Vyvíjíme pro Android: Bližší pohled na pohledy – 1. díl 13.7.2012
  5. Vyvíjíme pro Android: Bližší pohled na pohledy – 2. díl 13.7.2012
  6. Vyvíjíme pro Android: Fragmenty a SQLite databáze 20.7.2012
  7. Vyvíjíme pro Android: Preference, menu a vlastní Adapter 27.7.2012
  8. Vyvíjíme pro Android: Intenty, intent filtry a permissions 3.8.2012
  9. Vyvíjíme pro Android: Content providery 10.8.2012
  10. Vyvíjíme pro Android: Dialogy a activity 17.8.2012
  11. Vyvíjíme pro Android: Stylování a design 24.8.2012
  12. Vyvíjíme pro Android: Notifikace, broadcast receivery a Internet 31.8.2012
  13. Vyvíjíme pro Android: Nahraváme aplikaci na Google Play Store 7.9.2012
  14. Vyvíjíme pro Android: Epilog 14.9.2012

minulém článku jsme se naučili ukládat data do vestavěné SQLite databáze, což nám otevřelo novou plejádu aplikací, jež dokážeme vytvořit, a také jsme se naučili pracovat s fragmenty. Zatímco od fragmentů si dnes dáme oddych, SQLite nás bude provázet celým článkem.

Představte si, že jsme vyhráli státní výběrové řízení (v hodnotě 2 000 000,– CZK), v němž se poptávala tvorba aplikace na Android. A to ne jen tak ledajaké aplikace, jde o komplexní knihovnickou aplikaci s pravděpodobným uplatněním ve vzdělávání.

V zadání stojí, že máme vytvořit editovatelný a personifikovatelný seznam přečtené literatury s možností hodnocení a řazení.

Po důkladném rozboru nám projekťák vytvořil takovéto zadání:

  • Každá kniha se skládá ze čtyř položek: id, název, jméno autora a hodnocení (číslo od 0 do 4).
  • Knihy budou uloženy v SQL databázi.
  • Knihu musí jít uložit a smazat. K tomu ještě získat všechny knihy seřazené podle předaných kritérií.
  • Knihy budou zobrazeny v ListActivity, každá kniha jako jedna položka obsahující její název a jméno autora. Jako pozadí položky se zvolí barva od červené po zelenou, v závislosti na hodnocení knihy. Nazveme to ColorRatingScale™, unikátní uživatelsky přívětivý systém vizuálního zobrazení hodnocení založený na nejnovějších výsledcích psychologických výzkumů amerických odborníků.
  • Po podržení některé položky se zobrazí nabídka na její smazání.
  • Při stisknutí tlačítka menu se zobrazí menu se dvěma položkami – Přidat knihu a Nastavení.
  • Přidat knihu zobrazí Activity s formulářem, který umožní přidat knihu.
  • Nastavení zobrazí Activity, v níž půjde nastavit nadpis Activity přidávající knihu (→ je to personifikovatelné ✓), výchozí hodnocení a potom řazení – podle kterého sloupce a zda sestupně či vzestupně.

To vypadá jednoduše. Sice netušíme, jak udělat nastavení, ale to se poddá.

Preference

Jak jsem psal minulý pátek, jednou z možností ukládání dat jsou na Androidu tzv. Shared Preferences. K tomu ještě existují jen Preferences, což je to samé až na to, že Preferences jsou pro každou Activity zvlášť, zatímco Shared Preferences jsou pro celou aplikaci. My se budeme zajímat o ty globálnější (a dále budu slovo Shared vynechávat).

Objekt SharedPreferences se může získat metodou Context.getSharedPreferences(String name, int mode), která díky name umožní mít několik různých objektů s preferencemi. To může mít smysl u velkých projektů, kdy hrozí, že si omylem jedním klíčem přepíšeme jiný. Ale u většiny aplikací je to zbytečný overkill. Já to dělám jednodušeji, zavolám PreferenceManager.getDefaultSharedPreferences(Context ctx) a dostanu výchozí objekt s preferencemi, který je stejný pro celou aplikaci. Nemusím řešit žádná jména ani nic jiného.

Database

Naše třída Database bude zajišťovat veškerou práci s SQLite databází. Je to v podstatě jen omílání třídy Notesminula, snad jen s jediným podstatnějším rozdílem, a to tím, že metoda getBooks umožňuje nastavit, jak budou knihy seřazeny.

public class Database {

    // konstanty

    public static final String[] columns = { COLUMN_ID, COLUMN_TITLE,
        COLUMN_AUTHOR, COLUMN_RATING };

    private SQLiteOpenHelper openHelper;

    static class DatabaseHelper extends SQLiteOpenHelper {

        DatabaseHelper(Context context) {
            super(context, DATABASE_NAME, null, DATABASE_VERSION);
        }

        @Override
        public void onCreate(SQLiteDatabase db) {
            db.execSQL("CREATE TABLE " + TB_NAME + " ("
                    + COLUMN_ID + " INTEGER PRIMARY KEY,"
                    + COLUMN_TITLE + " TEXT NOT NULL,"
                    + COLUMN_AUTHOR + " TEXT NOT NULL,"
                    + COLUMN_RATING + " INTEGER NOT NULL"
                    + ");");
        }

        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            db.execSQL("DROP TABLE IF EXISTS " + TB_NAME);
            onCreate(db);
        }
    }

    public Database(Context ctx) {
        openHelper = new DatabaseHelper(ctx);
    }

    public Cursor getBooks(String orderBy, boolean desc) {
        SQLiteDatabase db = openHelper.getReadableDatabase();
        return db.query(TB_NAME, columns, null, null, null, null, orderBy + (desc ? " DESC" : " ASC"));
    }

    public boolean deleteBook(long id) {
        SQLiteDatabase db = openHelper.getWritableDatabase();
        String[] selectionArgs = { String.valueOf(id) };

        int deletedCount = db.delete(TB_NAME, COLUMN_ID + "= ?", selectionArgs);
        db.close();
        return deletedCount > 0;
    }

    public long addBook(String title, String author, int rating) {
        SQLiteDatabase db = openHelper.getWritableDatabase();

        // Rating smí být jen z rozmení 0 - 4
        rating = Math.max(0, Math.min(rating, 4));

        ContentValues values = new ContentValues();
        values.put(COLUMN_TITLE, title);
        values.put(COLUMN_AUTHOR, author);
        values.put(COLUMN_RATING, rating);

        long id = db.insert(TB_NAME, null, values);
        db.close();
        return id;
    }

    public void close() {
        openHelper.close();
    }
}

Se třídou Database jsme vyřešili ukládání dat v SQLite databázi, ale když už začínáme „odspoda”, měli bychom ještě naprogramovat ukládání uživatelských nastavení.

SettingsActivity

Naštěstí je přizpůsobování aplikací tak častá záležitost, že na ni Android nabízí pomocnou třídu. Ta se jmenuje PreferenceActivity a umožňuje téměř bez práce nabídnout uživateli nějaká nastavení.

Když do Androidu 3.0 přišly fragmenty, překopal Google i PreferenceActivity  – odteď by měla být založena na PreferenceFragment-ech. To není nic odsouzeníhodného. Problém je ale v tom, že se PreferenceFragmenty nedostaly do Support Library. Takže si, pokud nevyvíjíme pro Android 3.0 a výš, můžeme nechat zajít chuť. A protože jsou všechny dříve používané metody deprecated, musíme se, buildujeme-li na nejnovější platformě, smířit i s ohromným množstvím warningů. Já to řeším tak, že před deklaraci třídy, jež dědí od PreferenceActivity, přidám @SuppressWarnings("deprecation"). Není to úplně optimální řešení, ale je nejjednodušší, pokud trvám na co nejvyšším target API.

Na co nejvyšším target API ale trvám ze zištných důvodů. Kdybychom například u dnešní aplikace nastavili target API na Android 2.2 (API 8), na vyšších Androidech by se zapnul „režim kompatibility”. Jde hlavně o vizuální změny, například na ICS by se místo většího průhledného titulku s ikonou aplikace zobrazil ošklivý malý šedý proužek z Androidu 2.2. Stejně tak pro menu, které se zobrazí po klepnutí na klávesu menu by se zvolil dvojkový vzhled (zkuste si to).

A když už jsme u target API, možná jste si už všimli, že přestože byl představen Android 4.1 Jelly Bean, který přinesl novou verzi API (16), používám stále starší patnáctku. Je tomu tak proto, abych zachoval konzistenci celého seriálu. (Stejně mezi nimi nejsou žádné pro nás důležité rozdíly.)

PreferenceActivity je dokonce tak šikovná, že aby všechno fungovalo jak má, stačí v onCreate  předat identifikátor xml suroviny definující, jaká nastavení chci mít. Ona se už postará jak o zobrazení jednotlivých ovládacích prvků, tak o jejich správné uložení.

Já ještě vždy přidávám statické metody pro získání jednotlivých preferencí.

settings.xml

Bohužel s tím, jak původní koncept PreferenceActivity zastaral, zmizely z dokumentace i návody, jak ho používat. Takže se musíme spolehnout na dokumentaci balíčku android.preference, který obsahuje třídy jako EditTextPreference či ListPreference, jež se dají deklarovat v XML (všimněte si XML atributů v jejich dokumentaci), ukázkových projektů v {složka_kam_jste_nainstalovali_adk}/samples, z nichž většina (zmíním třeba CubeLiveWallpaper) obsahuje starou verzi preferencí, pokud nějakou, a samozřejmě dnešní článek.

V /res/xml  vytvořte nový Android XML File (v Resource Type  vyberte Preference a jako kořenový element PreferenceScreen) a pojmenujte ho  settings.xml.

Všechny třídy definovatelné v XML souboru s preferencemi dědí od Preference. Ta má čtyři důležité atributy:

Důležité atributy třídy Preference
Atribut Hodnota Popis
android:key Řetězec Klíč, pod kterým se daná preference uloží a díky němuž ji můžete zase získat.
android:title Řetězec Krátký (max. několikaslovný) název dané preference.
android:summary Řetězec Nepovinný atribut, může obsahovat podrobnější či jasnější popis toho, co ta preference nastavuje.
android:defaultValue Výchozí hodnota dané preference. U EditTextPreference  to bude řetězec, u CheckBoxPreference  pravdivostní hodnota. Také nepovinný atribut.

Kde je možný řetězec, je samozřejmě možná řetězcová surovina, kde je potřeba pravdivostní hodnota je možná booleanovská surovina atd., ale to už snad nemusím zmiňovat. Zajímají-li vás nějaké metody či další atributy, podívejte se do dokumentace.

Teď známe všechno, co teď znát potřebujeme, a vrhneme se na vytváření  /res/xml/settings.xml.

Kořenovým elementem je PreferenceScreen. Ten jen obaluje všechny ostatní preference. Nastavíme-li mu atribut android:title, jeho hodnota bude použita jako titulek Activity.

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
    android:title="@string/settings" >

A začneme s nastavením nadpisu formulářové Activity. K tomu slouží EditTextPreference. Ta kromě základních atributů podporuje mimojiné všechny, které se dají nastavit v EditTextu.

    <EditTextPreference
        android:key="add_new_book_title"
        android:summary="@string/add_new_book_summary"
        android:title="@string/add_new_book_title" />

Další přijde na řadu nastavení výchozího hodnocení knížek. Aby uživatel nemusel zadávat čísla, nabídneme mu seznam pěti slovních hodnocení a s čísly budeme pracovat pouze interně.

Použijeme na to ListPreference. Ta umožní uživateli vybrat si z nějakého pole řetězců ( <string-array> resource) předaného atributem android:entries. Uživatelem vybraný řetězec se interně přetvoří na jiný řetězec z pole, které jsme nastavili jako hodnotu atributu android:entryValues. Platí, že z prvního prvku entries se stane první prvek entryValues a tak dále. Jako android:defaultValue nastavíme tu entryValue, která odpovídá té entry, jež chceme, aby se uživateli zobrazila jako výchozí.

    <ListPreference
        android:defaultValue="4"
        android:entries="@array/rating_names"
        android:entryValues="@array/rating_values"
        android:key="default_rating"
        android:summary="@string/default_rating_summary"
        android:title="@string/default_rating_title" />

Dalším elementem, který se nám hodí, je PreferenceCategory. Ta dokáže seskupit několik preferencí podobného účelu a dát jim nějaký společný nadpis. V našem případě seskupí nastavení řazení seznamu knížek, které se sestává opět z  ListPreference

    <PreferenceCategory android:title="@string/ordering" >
        <ListPreference
            android:defaultValue="0"
            android:entries="@array/order_by_names"
            android:entryValues="@array/order_by_values"
            android:key="order_by"
            android:title="@string/order_by_title" />

a ještě z CheckBoxPreference, která, jak již název napovídá, umožňuje rozhodovat mezi dvěma stavy, pravda-nepravda. Místo atributu android:summary nabízí hned dva, a to android:summaryOn a android:summaryOff, přičemž první se zobrazí, když je checkbox zaškrtnutý, a druhý, když není.

        <CheckBoxPreference
            android:defaultValue="true"
            android:key="desc"
            android:summaryOff="@string/desc_off"
            android:summaryOn="@string/desc_on"
            android:title="@string/desc" />
    </PreferenceCategory>

</PreferenceScreen>

To by prosím bylo settings.xml, a my plynule přecházíme k  SettingsActivity.

Já osobně obvykle jako název Activity, která dědí od PreferenceActivity, používám zase PreferenceActivity. Stejně tak když mluvím o PreferenceActivity, nemyslím tím přímo androidí třídu, ale mám na mysli tu třídu, která v rámci dané aplikace plní účel třídy s nastavením. Dnes ovšem naši nastavovací třídu pojmenujeme SettingsActivity, abychom se v nové oblasti nezamotali.

V podstatě by SettingsActivity stačilo definovat nějak takto:

@SuppressWarnings("deprecation")
public class SettingsActivity extends android.preference.PreferenceActivity {
    @Override
    protected void onCreate(Bundle icicle) {
        super.onCreate(icicle);
        addPreferencesFromResource(R.xml.settings);
    }
}

Víte, proč onCreate dostává jako parametr rampouch? Stack Overflow odpoví.

My však chceme, aby SettingsActivity nabízela ještě ostatním třídám statické metody, které umožní jednoduše získat hodnoty všech preferencí. Nejprve si definujeme konstanty, které obsahují klíč jednotlivých preferencí.

    public static final String FORM_TITLE = "add_new_book_title";
    public static final String DEFAULT_RATING = "default_rating";
    public static final String ORDER_BY = "order_by";
    public static final String DESC = "desc";

Získání hodnoty nějaké preference prohíbá zhruba následovně:

  1. Zavoláme metodu PreferenceManager.getDefaultSharedPreferences(Context ctx), která vrátí nějaký objekt  SharedPreferences.
  2. Na něm potom voláme metody typu getBoolean(String key, boolean default), getInt(String key, int default) či getString(String key, String default), které jako první parametr přebírají klíč chtěné preference a druhým parametrem je hodnota, která se vrátí, pokud preference s klíčem key neexistuje. Pokud existuje preference s předaným klíčem, ale má jiný typ, vyhodí se ClassCastException. Na to pozor, například ListPreference ukládá vše jako řetězec. Přetypovat kdyžtak musíme ručně.

Metody pro získání výchozího nadpisu a booleanu, zda se má řadit sestupně, jsou triviální.

    public static String getFormTitle(Context ctx){
        return PreferenceManager.getDefaultSharedPreferences(ctx)
                .getString(FORM_TITLE, "");
    }

    public static boolean getOrderDesc(Context ctx){
        return PreferenceManager.getDefaultSharedPreferences(ctx)
                .getBoolean(DESC, true);
    }

Ani výchozí hodnocení není nic složitého, jen pozor na explicitní přetypování.

    public static int getDefaultRating(Context ctx){
        return Integer.valueOf(
            PreferenceManager.getDefaultSharedPreferences(ctx)
                .getString(DEFAULT_RATING, "4"));
    }

U getOrderByColumn  jsme se rozhodli, že vrátíme rovnou název daného sloupce. V preferencích je uložené číslo od 0 do 3 (ale je uložené jako řetězec!), kde 0 je řazení podle id, 1 podle názvu, 2 podle autora a 3 podle oblíbenosti. My si proto vytvoříme pomocné pole, do nějž umístíme názvy sloupců tabulky s knihami v před chvílí jmenovaném pořadí, a potom vrátíme n-tý prvek tohoto pole. (Asi by to šlo udělat čistěji, ale pro jednoduchost dnes čistotu obětujeme.)

    protected static final String[] ORDER_BY_COLUMNS = {
        Database.COLUMN_ID,
        Database.COLUMN_TITLE,
        Database.COLUMN_AUTHOR,
        Database.COLUMN_RATING
    };

    public static String getOrderByColumn(Context ctx){
        int index = Integer.valueOf(
            PreferenceManager.getDefaultSharedPreferences(ctx)
                .getString(ORDER_BY, "0"));
        return ORDER_BY_COLUMNS[index];
    }

A tak jsme naprogramovali SettingsActivity, jen ji nesmíme zapomenout zapsat do Manifestu.

AddBookActivity

Na AddBookActivity by nebylo nic nového, kdybychom nechtěli uživateli nabídnout výběr z pěti slovních hodnocení. Takhle si představíme nové View, a to Spinner. Je to potomek AdapterView, ale přesto Adapter nebudeme muset vytvářet, za což vděčíme atributu android:entries, který dokáže přijmout pole řetězců, jež zobrazí. A my díky metodám setSelection a getSelectedItemPosition můžeme nastavit, resp. získat pozici vybrané položky. Výpis layoutu by byl zbytečně dlouhý, ukážu jen <Spinner> a zmíním TextView s id form_title, do nějž přijde uživatelem nastavený titulek a Button s nastaveným onClick na  saveBook.

<Spinner
    android:id="@+id/rating"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:entries="@array/rating_names"
    android:prompt="@string/rating" />

Protože bude AddBookActivity volána hlavní Activitou s tím, že si přeje vědět o výsledku (aby mohla obnovit seznam knih), po kliknutí na tlačítko nastavíme výsledek a AddBookActivity ukončíme.

public class AddBookActivity extends Activity {

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.add_book);

        ((TextView) findViewById(R.id.form_title)).setText(SettingsActivity
                .getFormTitle(this));

        ((Spinner) findViewById(R.id.rating)).setSelection(SettingsActivity
                .getDefaultRating(this));
    }

    public void saveBook(View v) {
        String title = ((EditText) findViewById(R.id.title)).getText()
                .toString();
        String author = ((EditText) findViewById(R.id.author)).getText()
                .toString();
        int rating = ((Spinner) findViewById(R.id.rating))
                .getSelectedItemPosition();

        Database db = new Database(this);
        if (db.addBook(title, author, rating) > -1)
            setResult(RESULT_OK);
        else
            setResult(RESULT_CANCELED);

        finish();
    }
}

BookshelfAdapter

Protože máme speciální požadavky na položky seznamu knížek, musíme si implementovat vlastní Adapter. Ale až se mi chce říci, že na světě není nic jednoduššího. Databáze vrací Cursor, takže budeme dědit od třídy CursorAdapter (přesněji android.support.v4.widget.CursorAdapter!), která se za nás stará o úplně všechno, kromě samotného vytvoření View a jeho naplnění daty.

Ač to zní jako práce pro jednu metodu, jsou potřeba dvě, neboť u delších seznamů (a nejen seznamů), kdy nejsou všechny položky vidět, to nefunguje tak, že by si AdapterView pamatovalo všechna vytvořená View a se všemi posunovalo. Uložené má jen ty viditelné a ty v těsném okolí, ostatních se zbavuje a podle potřeby je zase znovu vytváří. Díky tomu se dosáhne vyšší paměťové efektivity.

A aby se zvýšila i efektivita výpočetní, využije se toho, že každá položka má (měla by mít) stejnou strukturu View, a tak se View skryté a zapomenuté položky vezme a nechá se naplnit daty jiné položky, kterou zrovna potřebujeme.

Z toho důvodu musíme implementovat metodu View newView(Context ctx, Cursor c, ViewGroup parent), která vytvoří nové View, naplní ho daty a vrátí ho, a metodu void bindView(View recycle, Context ctx, Cursor c), která jako recycle obdrží View dříve vrácené metodou newView (včetně všech dat do něj vložených) a má za úkol toto View naplnit jinými daty. Pravděpodobně bude implementace metody newView vypadat tak, že se vytvoří potřebná struktura View, pak se na ní zavolá bindView a pak se už naplněné View vrátí.

Cursor je vždycky nastavený na správné pozici.

Protože budeme prázdnou View strukturu získávat LayoutInflaterem z XML suroviny, asi se hodí si /res/layout/book.xml ukázat:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <TextView
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:textAppearance="?android:attr/textAppearanceMedium" />

    <TextView
        android:id="@+id/author"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:textAppearance="?android:attr/textAppearanceSmall" />

</LinearLayout>

BookshelfAdapter v konstruktoru ze surovin získá pole integerů reprezentujících barvy jednotlivých hodnocení. Nejhorší hodnocení (0) má červenou barvu, která je na první pozici. Nejlepší hodnocení (4) má barvu zelenou a ta se nachází na pozici páté.

public class BookshelfAdapter extends CursorAdapter {

    private int[] colors;

    public BookshelfAdapter(Context context, Cursor c, int flags) {
        super(context, c, flags);
        colors = context.getResources().getIntArray(R.array.rating_colors);
    }

Metoda newView si sežene LayoutInflater, s jeho pomocí vytvoří z R.layout.book  strom View, na ten zavolá bindView a pak ho vrátí.

    @Override
    public View newView(Context ctx, Cursor c, ViewGroup root) {
        LayoutInflater inflater = LayoutInflater.from(ctx);
        View view = inflater.inflate(R.layout.book, root, false);
        bindView(view, ctx, c);
        return view;
    }

BindView nejdříve zjistí indexy potřebných sloupců v Cursoru a potom naplní příslušná View odpovídajícími daty.

Přetypování na LinearLayout není funkčně nutné, ale plní dokumentační funkci, aby bylo hned jasné, co je oldView  zač.

    @Override
    public void bindView(View oldView, Context ctx, Cursor c) {
        int titleIndex = c.getColumnIndex(Database.COLUMN_TITLE);
        int authorIndex = c.getColumnIndex(Database.COLUMN_AUTHOR);
        int ratingIndex = c.getColumnIndex(Database.COLUMN_RATING);

        int color = colors[c.getInt(ratingIndex)];
        ((LinearLayout)oldView).setBackgroundColor(color);

        ((TextView)oldView.findViewById(R.id.title))
                .setText(c.getString(titleIndex));

        ((TextView)oldView.findViewById(R.id.author))
                .setText(c.getString(authorIndex));
    }
}

Nastavení pozadí celému layoutu není úplně šťastný nápad. Při podržení nějaké položky, která má kontextové menu, taková ta hezká animace probíhá totiž na ještě pozadnějším pozadí, takže když nenecháme alespoň část položky průhlednou, uživatel nebude vědět, že se něco brzy stane, dokud se to nestane. Jednoduše obejít by to šlo tak, že bychom jako pozadí použili poloprůhledné barvy. Ale tím bychom přišli o jejich sytost, takže se v téhle aplikaci smíříme s tím, že jsme přišli o animaci.

Ale rozhodně to tak není správně. Pokud byste něco takového opravdu potřebovali v reálu, zvažte, jestli nestačí nastavit barvu textu. Nebo jen nějaký čtvereček někde. Nebo si vytvořte vlastní animaci při podržení. Nebo cokoliv, ale určitě to nenechávejte takhle, je to těžký prohřešek proti použitelnosti (usability).

BookshelfActivity

BookshelfActivity, potomek ListActivity, má na starosti hned několik věcí:

  • zobrazuje seznam knížek,
  • přizpůsobí se změně nastavení,
  • stará se o kontextové menu ListView a maže knihy,
  • stará se o options menu, spouští SettingsActivityAddBookActivity
  • a při přidání knihy obnoví seznam.

V onCreate  se BookshelfActivity přihlásí k tomu, že ji zajímá, změní-li se nějak preference (rozhraní SharedPreferences.OnSharedPreferenceChangeListener implementujeme o něco níže), naplní ListView daty (metoda updateList) a požádá o kontextové menu pro své ListView.

public class BookshelfActivity extends ListActivity implements
        SharedPreferences.OnSharedPreferenceChangeListener {

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

        PreferenceManager.getDefaultSharedPreferences(this)
                .registerOnSharedPreferenceChangeListener(this);

        updateList();

        registerForContextMenu(getListView());
    }

V onDestroy, což je metoda, která se volá těsně předtím, než je Activity úplně zničena a zapomenuta, se o změnu preferencí zajímat přestane.

    @Override
    public void onDestroy() {
        PreferenceManager.getDefaultSharedPreferences(this)
                .unregisterOnSharedPreferenceChangeListener(this);
    }

Pozn.: Při vydání článku jsme zapomněli do metody vložit také super.onDestroy(); viz Errata.

V metodě updateList se nic zvláštního neděje – získají se data z databáze, ta se předají nově vytvořenému BookshelfAdapter u a ten se nastaví jako Adapter pro ListView.

    protected void updateList() {
        String orderBy = SettingsActivity.getOrderByColumn(this);
        boolean desc = SettingsActivity.getOrderDesc(this);

        Database db = new Database(this);
        Cursor c = db.getBooks(orderBy, desc);

        BookshelfAdapter adapter = new BookshelfAdapter(this, c, 0);

        setListAdapter(adapter);

        db.close();
    }

Při změně preferencí se zjistí, zda se nějak změnily ty řadící (protože ostatní dvě se BookshelfActivity netýkají), a pokud ano, obnoví se data v ListView.

    public void onSharedPreferenceChanged(SharedPreferences sharedPreferences,
            String key) {
        if (key.equals(SettingsActivity.ORDER_BY)
                || key.equals(SettingsActivity.DESC)) {
            updateList();
        }
    }

Při vytváření kontextového menu se setkáváme s něčím novým, a to s deklarováním menu v XML. Vytvořte si v /res  složku menu a v ní soubor (Android XML File, Eclipse podle složky pozná, že jde o menu) context.xml. Pro detailnější popis viz Menus, nám stačí vědět, že potomci elementu <menu> se jmenují <item> a my jim nastavíme dva atributy: android:id, díky němuž později zjistíme, na kterou položku menu uživatel klepl, a android:title, což je text, který se v menu zobrazí. Všechny možné atributy a další věci, které můžete v menu resource dělat, jsou popsány v Menu Resource.

My v našem kontextovém menu chceme jen jednu položku, takže /res/menu/context.xml bude vypadat takhle:

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

    <item
        android:id="@+id/delete"
        android:title="@string/delete"/>

</menu>

V onCreateContextMenu  získáme MenuInflater, což je obdoba LayoutInflater u pro menu, a jeho metodou inflate vložíme položky menu definované v XML do menu zobrazovaného.

    @Override
    public void onCreateContextMenu(ContextMenu menu, View v,
            ContextMenu.ContextMenuInfo menuInfo) {
        super.onCreateContextMenu(menu, v, menuInfo);
        getMenuInflater().inflate(R.menu.context, menu);
    }

S metodou onContextItemSelected jsme pracovali už minule.

    @Override
    public boolean onContextItemSelected(MenuItem item) {
        AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo();
        switch (item.getItemId()) {
        case R.id.delete:
            Database db = new Database(this);
            if (db.deleteBook(info.id)) {
                Toast.makeText(this, R.string.deleted, Toast.LENGTH_SHORT).show();
                updateList();
            } else {
                Toast.makeText(this, R.string.not_deleted, Toast.LENGTH_SHORT).show();
            }
            return true;
        default:
            return super.onContextItemSelected(item);
        }
    }

A už nám zbývá jen nekontextové options menu. To by podle designových pouček mělo být od Androidu 3.0 nahrazeno Action Barem, ale poněvadž ten opět nemá oficiální portaci na nižší Androidy (ačkoli ve {složka_kam_jste_nainstalovali_adk}/samples/android-15/ActionBarCompat ukazují, jak si takový Action Bar vyrobit) a protože na vysvětlování Action Baru nemáme čas, použijeme klasické options menu (které podle mě stejně má smysl i v dobách Action Baru).

Definice menu v /res/menu/options_menu.xml  je jednoduchá:

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

    <item
        android:id="@+id/add_book"
        android:title="@string/add_book">
    </item>

    <item
        android:id="@+id/preferences"
        android:title="@string/preferences">
    </item>

</menu>

V metodě onCreateOptionsMenu uděláme víceméně totéž, co v  onCreateContextMenu.

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        MenuInflater inflater = getMenuInflater();
        inflater.inflate(R.menu.options_menu, menu);
        return true;
    }

Po klepnutí na některou z položek menu se spustí metoda onOptionsItemSelected, obdoba onContextItemSelected. V ní spustíme buď AddBookActivity, anebo SettingsActivity, podle toho, co uživatel vybral.

    protected static final int REQUEST_ADD_BOOK = 0;

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        Intent i;
        switch (item.getItemId()) {
        case R.id.add_book:
            i = new Intent(this, AddBookActivity.class);
            startActivityForResult(i, REQUEST_ADD_BOOK);
            return true;
        case R.id.preferences:
            i = new Intent(this, SettingsActivity.class);
            startActivity(i);
            return true;
        default:
            return super.onOptionsItemSelected(item);
        }
    }

A pokud AddBookActivity skončí úspěchem, obnovíme ListView.

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        if (requestCode == REQUEST_ADD_BOOK && resultCode == RESULT_OK)
            updateList();

        super.onActivityResult(requestCode, resultCode, data);
    }
}

Gratuluji, máme hotovo a vy za sebou máte svůj první reálný projekt pro Android. Věřím, že zadavatelé budou spokojeni, a doufám, že si výběrového řízení nevšimne Lupa.cz.

Časté problémy

Vytvářím menu v závislosti na hodnotě vlastnosti blabla. Pokud je blabla true, mělo by se v menu nabídnout Odhlásit, jinak Přihlásit. Proč to nefunguje?

Protože používáte onCreateOptionsMenu místo onPrepareOptionsMenu. A na Androidu >= 3.0 se ani onPrepareOptionsMenu nevolá vždy, viz Changing menu items at runtime.

Stejně jako ty dneska jsem si definoval pole barev jako <integer-array>. Jaktože se mi správná barva nezobrazuje?

Zatímto v color resource můžete barvu definovat mnoha různými způsoby, chcete-li s ní pracovat jako s číslem, existuje jediný správný formát, a to 0xaarrggbb, kde aa je průhlednost (0 = průhledný, ff = neprůhledný), rr je červená, gg je zelená a bb je modrá. Žádné zkratky ani vypuštění alphy nefungují.

Procvičování

Už toho umíte opravdu poměrně dost. Všímejte si, co vás trápí, co vám na telefonu chybí. Určitě používáte nějakou komplexní aplikaci pro jeden jednoduchý účel, pro nějž je ale zbytečně složitá. Zkuste si naprogramovat jednoduchý, jednoúčelový program, který řeší vaše problémy. Nebojte se, že něco zprasíte, to je naprosto v pořádku. Příště už budete vědět lépe, jak na to.

A pokud se setkáte s nějakým zajímavým problémem, anebo nebudete vědět, jak něco udělat, určitě se mi o tom zmiňte tady v komentářích. Seriál píšu pro vás, a protože už máme většinu základních věcí probranou, jeho další vývoj hodně závisí na vašich přáních.

Log.d

Už jsem se o tom jednou v Tipu na konec zmiňoval, ale přijde mi vhodné na to upozornit znovu. Pro různé debugovací zprávy existuje velmi šikovná třída Log. Nejčastěji se používá metoda d(String tag, String msg. Pokud například chcete zaznamenat, jaké číslo je v proměnné count, zavoláte Log.d("count", String.valueOf(count)); (kde místo prvního řetězce můžete dát cokoliv, co vám pomůže debugovací výpis identifikovat ve spleti ostatních). Všechny logy se zapisují do LogCatu, což je okénko v Eclipse, které zobrazíte Window → Show View → LogCat. Dají se v něm i vytvářet filtry a Eclipse většinou samo nabídne filtr na právě debugovanou aplikaci.

Debugování Javy v Eclipse

Android nabízí i pokročilý javový debugger, který dokáže nastavovat breakpointy, zjišťovat hodnoty proměnných atd. Opravdu důkladný popis debugování Javy v Eclipse si můžete přečíst ve článku Java Debugging with Eclipse – Tutorial, debugování Androidu není (minimálně u jednodušších věcí) rozdílné.

Zde jen, pokud nechcete odkazovaný článek číst, řeknu, že breakpoint nastavíte dvojklikem vlevo hned vedle čísla řádku, na kterém chcete zastavit. Aplikaci potom ale musíte spustit nikoli zelenou šipečkou, ale zeleným broukem hned vedle šipečky.

Závěr

Dnes jsme vytvořili program na zakázku, ale přesto jsme se naučili plno nového. Umíme ukládat preference a umíme uživateli jednoduše poskytnout nastavení v příjemném rozhraní. Umíme si vytvořit vlastní Adapter (kdybyste nepracovali s  CursorAdapter em, ale dědili rovnou od BaseAdapter u, je sice potřeba implementovat docela dost metod, ale ty už jsou opravdu jednoduché). Poznali jsme nové View, Spinner, a v neposlední řadě jsme se naučili deklarovat menu v surovinách a nabídnout uživateli nejen kontextové, ale i options menu.

Zdrojové kódy dnešní aplikace si můžete stáhnout tady.

Příště se budeme věnovat content providerům.

Tip na konec

Potřebujete-li vyzkoušet, jak se budou chovat různé hodnoty android:defaultValue, brzy zjistíte, že poté, co PreferenceActivity jednou spustíte, výchozí hodnota se nastaví jako uživatelem zvolená a další změny hodnoty atributu nic nedělají. Jděte do nastavení nebo, máte-li zobrazenou domovskou složku, klikněte na tlačítko menu (tím myslím buď hw tlačítko, anebo sw tlačítko, které ale napodobuje to hw), zvolte něco typu Spravovat aplikace (na anglickém ICS s výchozím launcherem Manage Apps), najděte tu svou laděnou aplikaci, klikněte na ni a zvolte něco typu Smazat data ( Clear data). Smaže to sice nejen nastavení, ale dokonce všechno, co uživatel stihl natropit, ale alespoň to funguje.

Matěj začal programovat ve třinácti v PHP, pak v JavaScriptu a Lispu. Nakonec si koupil Androida, a tak programuje hlavně pro něj.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 10

Přehled komentářů

Kolemjdoucí Obrázky?
Matěj Konečný Re: Obrázky?
KiN Aplikace
Matěj Konečný Re: Aplikace
KiN Re: Aplikace
robert+ Otazka do plena
Matěj Konečný Re: Otazka do plena
Petr Stažený příklad vyhazuje výjimku
Petr Re: Stažený příklad vyhazuje výjimku
Martin onDestroy()
Zdroj: https://www.zdrojak.cz/?p=3687