Vyvíjíme pro Android: Fragmenty a SQLite databáze

V dnešním díle seriálu Vyvíjíme pro Android se naučíme pracovat s vestavěnou SQLite databází a seznámíme se s fragmenty. To všechno na už poněkud rozsáhlejší ukázkové aplikaci.

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

Až dodnes jsme se věnovali tomu, jak vyvíjet uživatelské rozhraní aplikace. Naposledy jsme, hned ve dvojčlánku, poznali všechna možná view i s jejich metodami. Ale téměř každá aplikace potřebuje ukládat data. Ať už jde o seznam poznámek, nějaká uživatelská nastavení, vygenerovaný obrázek či trasu, kterou uživatel ujel na kolečkových bruslích.

Data můžete na Androidu ukládat několika způsoby. Každý se hodí na něco jiného. Do vestavěné SQLite databáze, jež je tématem dnešního článku, půjdou data typu seznam úkolů. Interní úložiště je určené pro soubory, které mají být smazány při smazání aplikace a které nesmí být dostupné ze vnějšku. Externí úložiště je vlastně veřejný filesystem, z nějž může soubory číst, mazat, ukládat či upravovat každý. Shared preferences je key-value úložiště, které umí pracovat jen s primitivními typy. Jeho název by sice napovídal, že je určeno pro různá uživatelská nastavení (pro což mimochodem vskutku slouží a Android poskytuje několik tříd, které tento úkol zjednodušují na úplné minimum), ale použít lze a používá se pro všechna jednoduchá key-value  data.

Fragmenty

Než začneme programovat (přičemž se za běhu seznámíme se vším potřebným), musím vám povědět něco o tom, co jsou to vlastně fragmenty. Už minule jsem vám doporučil přečtení mého dřívějšího článku Dej Androidu tablety!, kde jsem se fragmentům věnoval. Pokud jste tak doteď nepodnikli, doporučuji vám přečíst si ho nyní, neboť nemá smysl se opakovat, a tak budu dnes stručný.

V Androidu 3.0, který reagoval na vzestup poptávky po tabletech, musel Google řešit problém, jak umožnit vývojářům co nejjednodušeji vytvářet aplikace, jejichž rozhraní se dokáže přizpůsobit jak fyzicky velkým, tak malým displejům. A jako řešení tohoto problému zavedli fragmenty.

Fragmenty celkově označují nový přístup ke tvorbě uživatelského rozhraní, kdy mezi Activity a View vstupuje ještě jedna vrstva, a to Fragment. Takový Fragment má za úkol „obalit” nějaký funkční celek, tedy například seznam poznámek, zobrazení jedné poznámky (titulek a text) či formulář přidávající novou poznámku; a to jak potřebná View, tak metody s nimi související. Fragment obalující seznam poznámek se postará například o naplnění ListView daty či zobrazení kontextového menu nad položkou, pakliže ji uživatel „přidržel”.

Fragment ale nemůže vědět všechno. Například neví, co se má stát po kliknutí na název nějaké poznámky. A od toho jsou zde Activity. Každá Activity může obsahovat libovolné množství Fragmentů, které se chovají víceméně jako View. Dají se deklarovat v layoutovém XML souboru anebo přidat v kódu (ačkoli trochu jiným mechanismem). Activity může volat jejich metody, nastavovat jim posluchače událostí (pokud je Fragment nabízí). V případě, že bez nastaveného posluchače by neměl Fragment valného smyslu, může si jeho nastavení dokonce vynutit.

Support Library

K čemu nám ale jsou fragmenty, když jsou až od Androidu 3.0, tedy API 11? Na to Google samozřejmě myslel. Nikdo by nezačal fragmenty používat, kdyby to znamenalo, že musí zavrhnout podporu všech jedničkových a dvojkových Androidů. A proto vznikla takzvaná Support (někdy Compatibility) Library (někdy Package). Ta portuje fragmenty (a nejen fragmenty) až na API 4. Odteď budu ve všech projektech, kde budeme používat fragmenty (což budou prakticky všechny, v nichž bude jen trochu složitější uživatelské rozhraní), automaticky předpokládat, že máte Support Library přidanou do projektu jako knihovnu. To se dělá tak, že kliknete pravým tlačítkem na váš projekt  → Android tools → Add Support Library.

Tím končí úvodní teorie a můžeme jít programovat. Zbytek si povíme za běhu.

Poznámkový blok

Příklady fragmentů jsem nezvolil náhodně, jde o jejich konkrétní využití v dnešní aplikaci. Naprogramujeme si totiž poznámkový blok.

Každá poznámka se bude skládat z titulku, textu a nějakého id. Poznámky uložíme do SQLite databáze (jež sama zvolí jejich id). Veškerá práce s databází bude ve třídě Notes, která nabídne metody pro vytvoření a smazání poznámky a získání jedné a všech poznámek. Potom budeme potřebovat Fragment zobrazující formulář pro přidání poznámky, Fragment zobrazující jednu poznámku a Fragment, který zobrazí seznam poznámek, umožní uživatelům dlouhým klepnutím vyvolat menu, z nějž lze poznámku smazat, a krátkým klepnutím zobrazit detail poznámky. Ještě k tomu musíme přidat nějaké Activity, které Fragmenty správně zobrazí a samozřejmě nějaké suroviny.

Pro práci s vestavěnou SQLite databází potřebujete alespoň úplné základy SQL. Až na vytvoření tabulek se sice přímo s SQL prakticky nepracuje, ale některé metody přebírají jako argument část SQL dotazu.

Notes

Třída Notes bude takovým modelem, bude zařizovat veškerou komunikaci s databází. Musí umět vytvořit databázi a tabulku, pakliže neexistují, a k tomu bude zapouzdřovat všechny dotazy na databázi.

SQLiteOpenHelper

SQLiteOpenHelper je třída, která zjednodušuje vytváření a otevírání databáze. Díky ní musíme implementovat jen tři metody: Konstruktor zavolá svého rodiče a předá mu Context, verzi databáze a jméno databáze, metoda onCreate je zavolána tehdy, když otevíraná databáze neexistuje, a metoda onUpgrade přijde ke slovu tehdy, když existující databáze má nižší verzi než je ta, kterou dostal jako parametr konstruktor SQLiteOpenHelper. Dokud databázi neměníte, nemusí tahle metoda dělat nic smysluplného.

Podíváme se tedy, jak vypadá začátek souboru Notes.java (deklaraci namespace a importy jsem vynechal). Nejprve vytvoříme důležité konstanty, jako název databáze nebo názvy jednotlivých sloupců. Asi se hodí říci, že název databáze musí být unikátní v rámci aplikace, nikoli v rámci celého telefonu, takže nemusíte používat žádné namespace prefixy. Jako jméno primárního číselného klíče se v celém Androidu používá "_id". Ačkoli to není nutné pro funkčnost samotné databáze (primární klíč se může jmenovat jinak, anebo tam ani být nemusí), je to potřeba například pro SimpleCursorAdapter, s nímž budeme pracovat později.

public class Notes {

    protected static final String DATABASE_NAME = "notepad";
    protected static final int DATABASE_VERSION = 2;

    protected static final String TB_NAME = "notes";

    // Speciální hodnota "_id", pro jednodušší použití SimpleCursorAdapteru
    public static final String COLUMN_ID = "_id";
    public static final String COLUMN_TITLE = "title";
    public static final String COLUMN_NOTE = "note";

    private SQLiteOpenHelper openHelper;

Potom deklarujeme třídu DatabaseHelper, která dědí od SQLiteOpenHelper a pomůže nám databázi vytvořit či otevřít. V onCreate  spustíme přímo SQL dotaz, který vytvoří tabulku s požadovanými sloupci požadovaného typu. Hodnota sloupce _id se bude nastavovat automaticky. V onUpgrade  zde jednoduše smažeme tabulku a vytvoříme novou, tím by však uživatel přišel o data. Ve skutečnosti by bylo potřeba pro každou novou verzi databáze vytvořit pro každou tabulku, která se v dané verzi mění, nějaký SQL řetězec, který ji upgraduje bez ztráty dat (asi ALTER TABLE ...). V konstruktoru třídy Notes se jen vytvoří DatabaseHelper. (Pozor, neotevře se žádné spojení s databází, to je drahé jak časově, tak prostředkově. Spojení budeme vytvářet tehdy, až budou potřeba.)

    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_NOTE + " TEXT NOT NULL"
                    + ");");
        }

        /*
         * Ve skutečnosti je potřeba, abychom uživatelům nemazali data, vytvořit
         * pro každou změnu struktury databáze nějaký upgradovací nedestruktivní
         * SQL příkaz.
         */
        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            db.execSQL("DROP TABLE IF EXISTS notes");
            onCreate(db);
        }
    }

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

Než si ukážeme zbytek kódu, musíme si vysvětlit, jak fungují metody pro dotazování databáze. Na Androidu není zvykem vytvořit celý SQL řetězec a zavolat metodu SQLiteDatabase.rawQuery(String sql, String sqlArgs) (která se ale pro dotazy typu SELECT, INSERT atp. použít dá, narozdíl od execSQL, která je určena pro dotazy nevracející data). Místo toho nám Android poskytl metodu query (má více signatur) pro dotazy SELECT, metody insert, insertOrThrow a insertWithOnConflict pro INSERT, z nichž použijeme tu první (která se od druhé liší tím, jak ohlásí chybu), update a updateWithOnConflict pro UPDATE, a delete pro příkaz  DELETE.

Vezměme si třeba argumenty metody query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit). První argument, table, neznamená nic jiného než jméno tabulky, z níž se bude SELECT-ovat. Řetězcové pole columns obsahuje názvy těch sloupců, které chceme získat. Pokud předáte null, dostanete všechny sloupce, což se ale nedoporučuje, jednak kvůli samodokumentovatelnosti kódu, jednak kvůli paměťovým optimalizacím. Nechceme-li získat všechny řádky, předáme selection, což je to, co by v SQL řetězci přišlo bezprostředně za WHERE. Místo hodnot, s nimiž porovnáváte, použijte otazník a samotnou hodnotu pak vložte do pole selectionArgs. Další argumenty, groupBy, having, orderBy a limit přebírají jako hodnotu přesně to, co byste do SQL řetězce napsali za odpovídající příkaz. Jako kterýkoli argument kromě table můžete předat  null.

Kdybychom třeba měli tabulku s demografickými údaji obyvatelů Kocourkova a chtěli se zeptat na jména mužů starších 40 let, kterážto by měla být seřazena sestupně podle věku, mohl by dotaz vypadat nějak takto:

db.query("kocourkov", new String[]{"jmeno"}, "pohlavi = ? AND vek > ?", new String[]{"muz", "40"}, null, null, "vek DESC");

Můžete si všimnout, že jsem vynechal argument limit, neboť existuje i varianta metody query bez něj. V reálném případě bychom místo řetězců použili nějaké konstanty. Takovéto volání metody query by zhruba odpovídalo následujícímu SQL příkazu:

SELECT jmeno FROM kocourkov WHERE pohlavi = "muz" AND vek > 40 ORDER BY vek DESC

Argumenty metody delete jsou podmnožinou těch, o nichž jsme mluvili, neboť při mazání nemá smysl něco seskupovat či řadit. Metoda vrací počet smazaných řádků, pokud je nastavený parametr where. Pokud chcete smazat všechny řádky a zároveň zjistit, kolik že jste jich smazali, předejte jako where něco, co je vždy pravda. Třeba  "1".

Metoda insert(String table, String nullColumnHack, ContentValues values) má dva zatím neznámé atributy. Values jsou hodnoty nového řádku, reprezentované objektem ContentValues. Je to velmi jednoduchý objekt, stačí nám znát jeho konstruktor bez argumentů a metoda put, jejímž prvním argumentem je název odpovídajícího sloupce a druhým je hodnota, na níž ho chceme nastavit. NullColumnHack je jméno nějakého sloupce, který může být NULL. Je tam proto, že kdyby se jako values předal prázdný objekt ContentValues, nešlo by vytvořit validní SQL dotaz. Je-li nastaven parametr nullColumnHack, explicitně se daný sloupec nastaví na NULL. Pokud to nepotřebujete, můžete jako hodnotu tohoto argumentu předat null. Metoda vrátí id nově přidaného řádku, anebo -1, pokud se řádek nepodařilo přidat (a zároveň jsme nepoužili takovou variantu metody insert, která by vyhodila výjimku).

A nakonec metoda update, která kombinuje argumenty insert a delete. Sloupce uvedené ve values nahradí, ty neuvedené nechá být. Vrátí počet ovlivněných řádků.

Asi jste si všimli, že jsem vynechal návratovou hodnotu metody query. Ta totiž vrací objekt Cursor, který reprezentuje vrácená data a umožňuje s nimi paměťově efektivně pracovat. Je jednoduchý, ale přesto si ho pořádně představíme až tehdy, když ho budeme potřebovat.

Teď už víme všechno potřebné a můžeme si ukázat zbytek třídy Notes. Všimněte si metod SQLiteOpenHelper.getReadableDatabase(), resp. SQLiteOpenHelper.getWritableDatabase(), které vrátí objekt SQLiteDatabase, v prvním případě jen pro čtení, v tom druhém i s možností zapisovat. Tyto objekty fungují do té doby, než se na nich zavolá metoda close, anebo se zavolá metoda SQLiteOpenHelper.close(), která zavře všechny otevřené databáze vytvořené tím konkrétním SQLiteOpenHelperem. Tu jsme zvolili my s tím, že necháme na uživatelích naší třídy, aby tuhle metodu zavolali tehdy, až nebudou potřebovat otevřené databáze a data z nich získaná, tedy objekty Cursor. V případě vkládací či mazací metody můžeme databázi zavřít hned.

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

    protected static final String ORDER_BY = COLUMN_ID + " DESC";


    public Cursor getNotes() {
        SQLiteDatabase db = openHelper.getReadableDatabase();
        return db.query(TB_NAME, columns, null, null, null, null, ORDER_BY);
    }

    public Cursor getNote(long id) {
        SQLiteDatabase db = openHelper.getReadableDatabase();
        String[] selectionArgs = { String.valueOf(id) };
        return db.query(TB_NAME, columns, COLUMN_ID + "= ?", selectionArgs,
                null, null, ORDER_BY);
    }

    public boolean deleteNote(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 insertNote(String title, String text) {
        SQLiteDatabase db = openHelper.getWritableDatabase();

        ContentValues values = new ContentValues();
        values.put(COLUMN_TITLE, title);
        values.put(COLUMN_NOTE, text);

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

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

Poslední složená závorka byla zavírací závorkou třídy Notes. Tu tímto máme hotovou a jdeme se vrhnout na fragmenty.

NotesListFragment

Stejně tak jako existovala ListActivity, existuje i ListFragment (a přestože odkazuji do dokumentace na android.app.ListFragment, vy děďte od android.support.v4.app.ListFragment (obecně, kdykoli dostanete při importu na výběr z více tříd stejného jména, vyberte tu ze Support Library, pokud nemáte dobrý důvod udělat to jinak)). Tato třída nabízí některá zjednodušení pro práci s ListView. Nabízí i vestavěný layout, ale my tentokrát chceme, aby se uživateli, když zatím nemá uložené žádné poznámky, zobrazil text "Nemáte uloženy žádné poznámky.", a to červeně, takže si vytvoříme vlastní layout. Na ten jsou kladeny jisté podmínky, a to, že ListView musí mít id @android:id/list a TextView, které se zobrazí, kdyby mělo ListView být prázdné, musí mít id  @android:id/empty:

<?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" >

    <ListView
        android:id="@android:id/list"
        android:layout_width="match_parent"
        android:layout_height="0dip"
        android:layout_weight="1"
        android:visibility="gone" />

    <TextView
        android:id="@android:id/empty"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@string/no_notes_available"
        android:textColor="#f00"
        android:visibility="visible" />

</LinearLayout>

Fragment nenabízí žádnou metodu setContenView. Místo toho implementujeme metodu View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState). LayoutInflater je šikovná třída, jejíž metoda inflate umí z XML definice layoutu vytvořit strom View. My použijeme konkrétně metodu inflate(int resource, ViewGroup root, boolean attachToRoot), které předáme identifikátor layoutové suroviny, potom ViewGroup, které bude daný fragment obsahovat ( container z onCreateView), a false, jež značí, že vytvořený strom nemá být rovnou vložen do  root u.

Protože bez možnosti kliknutí na název poznámky by fragment neměl takový smysl, donutíme Activity používající NotesListFragment, aby implementovala OnNoteClickedListener, což je rozhraní definované uvnitř třídy NotesListFragment. Pro otestování, zda ho třída implementuje, je vhodná metoda onAttach(Activity activity), která se zavolá tehdy, když je Fragment umístěn do nějaké Activity. Tam zkusíme předanou activity přetypovat na OnNoteClickedListener, a pokud přetypování selže, vyhodíme výjimku.

Metoda onActivityCreated se zavolá poté, co skončí metoda onCreate „rodičovské” Activity. Tam můžeme bezpečně naplnit ListView daty atd.

Pojďme se tedy podívat na začátek NotesListFragment.java (namespace a importy vynechány). Volání registerForContextMenu způsobí, že ListView bude reagovat na podržení nějaké položky tím, že zobrazí kontextové menu. K tomu se dostaneme za chvilku. updateList je vlastní metoda, tu implementujeme také za brzy.

public class NotesListFragment extends ListFragment {

    OnNoteClickedListener listener;

    public static interface OnNoteClickedListener {
        public void onNoteClicked(long id);
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {

        View view = inflater.inflate(R.layout.notes_list, container, false);
        return view;
    }

    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        try {
            listener = (OnNoteClickedListener) activity;
        } catch (ClassCastException e) {
            throw new ClassCastException(activity.toString()
                    + " must implement OnNoteClickedListener");
        }
    }

    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        registerForContextMenu(getListView());
        updateList();
    }

V metodě updateList budeme používat SimpleCursorAdapter. (Pozor! Importujte android.support.v4.widget.SimpleCursorAdapter, což je třída ze SupportLibrary, která implementuje konstruktor přidaný až v API 11. Ten, který se používal dříve, je nyní deprecated.) To je další vestavěný Adapter. Tento, narozdíl od minule představeného ArrayAdapter u, naplňuje ListView daty z Cursoru, což se nám hodí, neboť Cursor vrací metody třídy Notes. Jeho konstruktor přebírá nějaký Context (v případě fragmentu výsledek volání ( getActivity()), identifikátor surovinového layoutu, který zobrazí každý řádek Cursoru, samotný Cursor, řetězcové pole, což je pole názvů sloupců, které chceme zobrazit, pole integerů, což je pole id těch view, do kterých chceme dané sloupce zobrazit, a jeden integer flags který určuje něco, co nás teď zase tak nemusí zajímat, a proto předáme  0.

Mezi námi, ten argument flags je to jediné, v čem se konstruktory navenek liší. V tomhle případě mají určitě navrch zastánci nastavování Target API version na co nejnižší číslo, alespoň z pohledu uživatele, který musí použít třídu ze Support Library jen proto, aby vypnul to, co nabízí navíc. Ale zase na druhou stranu, kdybychom třídu Notes implementovali jako content provider (možná někdy příště) a implementovali potřebné věci, díky parametru flags bychom se mohli zbavit ručního updatování seznamu poznámek, když je nějaká smazána či přidána.

Znovu připomenu, že SimpleCursorAdapter vyžaduje, aby předaný Cursor obsahoval sloupec _id. Hodnotu tohoto sloupce potom dostanete například v metodě, která se zavolá při klepnutí na nějakou položku seznamu. Díky tomu tu položku můžete jednoduše identifikovat.

Protože zobrazujeme jen jeden text, jako layout pro každý řádek Cursoru použijeme androidí vestavěný android.R.layout.simple_list_item_1, který obsahuje jedno TextView s id  android.R.id.text1:

    public void updateList() {
        Context ctx = getActivity();
        Notes notes = new Notes(ctx);

        String[] from = { Notes.COLUMN_TITLE };
        int[] to = { android.R.id.text1 };

        ListAdapter adapter = new SimpleCursorAdapter(ctx,
                android.R.layout.simple_list_item_1, notes.getNotes(), from,
                to, 0);

        setListAdapter(adapter);

        notes.close();
    }

Po Activity jsme vyžadovali, aby uměla reagovat na klepnutí na nějakou položku seznamu. Tak to teď implementujeme, je to opravdu triviální:

    @Override
    public void onListItemClick(ListView l, View v, int position, long id) {
        listener.onNoteClicked(id);
    }

A už nám zbývá jen vytvořit kontextové menu nad každou položkou, nabízející její smazání. První krok jsme už udělali tím, že jsme zavolali registerForContextMenu(getListView()). Dále ještě musíme implementovat metodu onCreateContextMenu, která se zavolá při vytváření kontextového menu a má za úkol přidat potřebné položky, a metodu onContextItemSelected, jež se zavolá po zvolení nějaké položky kontextového menu:

    @Override
    public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo){
        super.onCreateContextMenu(menu, v, menuInfo);
        menu.add(0, MENU_DELETE_ID, 0, R.string.delete);
    }

    @Override
    public boolean onContextItemSelected(MenuItem item) {
        AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo();
        switch (item.getItemId()) {
            case MENU_DELETE_ID:
                deleteNote(info.id);
                return true;
            default:
                return super.onContextItemSelected(item);
        }
    }

    private void deleteNote(long id){
        Context ctx = getActivity();
        Notes notes = new Notes(ctx);

        if(notes.deleteNote(id)){
            Toast.makeText(ctx, R.string.note_deleted, Toast.LENGTH_SHORT).show();
            updateList();
        } else{
            Toast.makeText(ctx, R.string.note_not_deleted, Toast.LENGTH_SHORT).show();
        }
    }
}

A máme hotový NotesListFragment.

Někoho možná napadlo, jaktože jsem mluvil o tom, že Fragment si nemá všímat svého okolí, a přitom jsem přímo v něm používal třídu Notes, což je hodně daleké okolí. Samozřejmě jsem mohl jednak při vytváření Fragmentu rovnou požádat o Cursor a jednak vyžadovat po Activity, aby implementovala ještě další interface a aby si uměla poradit se smazáním poznámky. Koneckonců tento přístup jsem zvolil u Fragmentu pro přidání poznámky a sami uvidíte, jaké výhody a jaké nevýhody to přineslo. Já osobně se v tomto případě přikláním k akademicky možná ne úplně čistému, ale rozhodně jednoduššímu a praktičtějšímu používání Notes v rámci Fragmentů.

AddNoteFragment

AddNoteFragment je jednoduchý Fragment, který zobrazí formulář umožňující přidat novou poznámku. Upozorním jen na dvě věci: Přímo na třídě Fragment není definovaná metoda findViewById. Musíte zavolat getView, díky čemuž získáte View vrácené onCreateView, a vyhledávat na něm. Druhé upozornění je na atribut android:onClick, který mi u Fragmentu nefungoval, metoda se hledala na Activity obsahující Fragment.

<?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:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:text="@string/add_note"
        android:textAppearance="?android:attr/textAppearanceLarge" />

    <EditText
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/title"
        android:inputType="text"
        android:lines="1" />

    <EditText
        android:id="@+id/text"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:gravity="top|left"
        android:hint="@string/text"
        android:inputType="textMultiLine" />

    <Button
        android:id="@+id/submit"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="right"
        android:text="@string/submit" />

</LinearLayout>
public class AddNoteFragment extends Fragment {
    OnAddNoteListener listener;

    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        try {
            listener = (OnAddNoteListener) activity;
        } catch (ClassCastException e) {
            throw new ClassCastException(activity.toString()
                    + " must implement OnAddNoteListener");
        }
    }


    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.add_note, null);

        Button submit = (Button)view.findViewById(R.id.submit);
        submit.setOnClickListener(new View.OnClickListener() {
            public void onClick(View v) {
                onSubmitClicked();
            }
        });

        return view;
    }

    public void onSubmitClicked(){
        View root = getView();

        String title = ((EditText)root.findViewById(R.id.title)).getText().toString();
        String text = ((EditText)root.findViewById(R.id.text)).getText().toString();

        listener.onAddNote(title, text);
    }

    public static interface OnAddNoteListener {
        public void onAddNote(String title, String text);
    }
}

SingleNoteFragment

SingleNoteFragment je dnešní poslední Fragment, který zobrazí jednu poznámku. Layout má naprosto nezajímavý,

<?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/textAppearanceLarge" />

    <TextView
        android:id="@+id/text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="15dp"
        android:textAppearance="?android:attr/textAppearanceMedium" />

</LinearLayout>

i začátek jeho kódu nepřináší nic nového,

public class SingleNoteFragment extends Fragment {

    private long id;

    public SingleNoteFragment(long id) {
        this.id = id;
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {
        View root = inflater.inflate(R.layout.single_note, container, false);

        TextView title = (TextView) root.findViewById(R.id.title);
        TextView text = (TextView) root.findViewById(R.id.text);

        Notes notes = new Notes(getActivity());
        Cursor note = notes.getNote(id);

ale pak zjistíme, že potřebujeme pracovat s Cursorem. Potřebujeme nějak získat první řádek (on jich ani víc nemá, teoreticky jich dokonce může mít méně, ačkoli prakticky by se stát nemělo, že by SingleNoteFragment dostal id neexistující poznámky) a z něj vytáhnout titulek a text poznámky. Nejprve zjistíme, jaké indexy mají v rámci Cursoru námi chtěné sloupce:

        int titleIndex = note.getColumnIndex(Notes.COLUMN_TITLE);
        int textIndex = note.getColumnIndex(Notes.COLUMN_NOTE);

Potom ověříme, máme-li alespoň jeden záznam k dispozici. Pokud ne, zobrazíme nějakou chybovou hlášku:

        if (note.getCount() < 1) {
            title.setText(R.string.error);
            title.setError("");
        }

A pokud záznam k dispozici máme, přejdeme na první řádek Cursoru, získáme z něj požadované řetězce a ty umístíme do dříve získaných TextView. Kdybychom měli řádků více, můžeme moveToNext volat, dokud nám bude vracet true. (V případě našeho Fragmentu by asi bylo dokumentačně lepší místo moveToNext použít moveToFirst, ale moveToNext funguje také a je univerzálnější, tak jsem použil to, abyste si zapamatovali užitečnější metodu.)

        else {
            note.moveToNext();
            title.setText(note.getString(titleIndex));
            text.setText(note.getString(textIndex));
        }

A nakonec uzavřeme kurzor, databázi, vrátíme vytvořené View a uzavřeme deklaraci metody i třídy:

        note.close();
        notes.close();
        return root;
    }
}

Při vydání článku jsme ve tvorbě třídy SingleNoteFragment zapoměli na důležitou věc, viz Errata.

Tím máme hotové všechny Fragmenty. Ale ještě nás čekají Activity, do nichž Fragmenty umístíme.

Na tabletu nám bude stačit jedna Activity, NotepadActivity  – ta hlavní. Ale na telefonech musíme vytvořit další dvě pomocné Activity, neboť tam dokáže NotepadActivity  zobrazit pouze NotesListFragment. Na AddNoteFragment budeme potřebovat AddNoteActivity a na SingleNoteFragment využijeme  SingleNoteActivity.

Telefony je označení pro zařízení se šířkou menší než je ta, kterou níže definujeme jako minimální pro dvousloupcový layout. Tablety jsou zařízení, na nichž se NotepadActivity zobrazí dvousloupcově.

SingleNoteActivity

Začneme SingleNoteActivity, která je nejjednodušší. V Intentu dostane jako extra id poznámky určené ke zobrazení, v metodě onCreate potom vytvoří SingleNoteFragment, předá mu id a umístí ho do FrameLayoutu. Jelikož její layoutový soubor obsahuje právě jen FrameLayout s id single_note_container, nebudu ho zde ani ukazovat.

Každá Activity, která má pracovat s Fragmenty ze Support Library, musí dědit od FragmentActivity. To je třída, která sama dědí od Activity (takže se nebojte, že byste měli zapomenout to, co jste se o Activitách už naučili) a přidává podporu Fragmentů (ze Support Library).

public class SingleNoteActivity extends FragmentActivity {
    public static final String EXTRA_ID = "id";

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

        long id = getIntent().getLongExtra(EXTRA_ID, -1);

        Fragment f = new SingleNoteFragment(id);

Aha. Ale jak fragment umístit do vytvořeného kontejneru? Na to existuje třída, která se jmenuje FragmentManager (v našem případě android.support.v4.app.FragmentManager). Ta se stará o přidávání, odebírání nebo nahrazování Fragmentů v jednotlivých View (a zdaleka nejen o to). Abych byl přesnější, o to, co jsem vyjmenoval, se ve skutečnosti stará třída FragmentTransaction (resp. její supportový ekvivalent), kterou získáme z FragmentManageru zavoláním metody beginTransaction. V rámci transakce přidáme, odebere, vyměníme či přeházíme Fragmenty a transakci commit neme. (Proč je na to potřeba transakce, si ukážeme u  NotepadActivity.)

Pracujeme-li se Support Library, tzn. s  FragmentActivity, android.support.v4.app.Fragment atd., musíme pro získání FragmentManageru použít metodu getSupportFragmentManager. Kdybyste se rozhodli vyvíjet jen pro Android 3.0 a vyšší, a Support Library tudíž nepoužívat, příslušná metoda se jmenuje getFragmentManager a je definována přímo na  Activity.

        FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
        ft.replace(R.id.container, f);
        ft.commit();
    }
}

AddNoteActivity

Protože jsme se rozhodli, že AddNoteFragment nechá uložení nové poznámky na někom jiném, musí AddNoteActivity implementovat rozhraní AddNoteFragment.OnAddNoteListener. A abychom ukládání neimplementovali dvakrát, AddNoteActivity tuhle povinnost přenese na NotepadActivity. Využije přitom toho, že jedna Activity může spustit druhou s tím, že po ní chce, aby jí vrátila nějaká data. Ta spuštěná Activity běží tak dlouho, jak je jí libo, a když má všechna data získaná, vloží je do Intentu, zavolá setResult(int resultCode, Intent data), které předá vytvořený Intent a RESULT_OK nebo RESULT_CANCELED ( RESULT_FIRST_USER necháme stranou). Potom zavolá finish, čímž se ukončí a nechá na frameworku, aby pustil do popředí Activity, která tuto spustila, a předal ji Intent s daty.

public class AddNoteActivity extends FragmentActivity implements AddNoteFragment.OnAddNoteListener{
    public static final String EXTRA_TITLE = "title";
    public static final String EXTRA_TEXT = "text";


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

    public void onAddNote(String title, String text) {
        Intent result = new Intent();
        result.putExtra(EXTRA_TITLE, title);
        result.putExtra(EXTRA_TEXT, text);

        setResult(RESULT_OK, result);
        finish();
    }
}

Asi se ptáte, kam se poděl AddNoteFragment. Ten, protože nepotřebuje žádné speciální parametry, je definovaný v layoutu:

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

    <fragment
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        class="com.example.notepad.AddNoteFragment" />

</FrameLayout>

NotepadActivity

Zbývá nám už jen NotepadActivity, která je ovšem ze všech Activit nejkomplikovanější. Na telefonu totiž zobrazí jen NotesListFragment a při kliknutí na nějakou poznámku (či při žádosti o vytvoření nové poznámky) spustí speciální Activity, na tabletu bude mít dva sloupce. V levém zobrazí totéž co na telefonu, tedy tlačítko s nabídkou vytvoření nové poznámky a hned pod ním seznam poznámek, ale obsah pravého se bude střídat. Někdy tam nebude nic, někdy AddNoteFragment a jindy  SingleNoteFragment.

Nejdříve si vytvoříme layoutový soubor, který bude obsahovat to telefonu i tabletu společné, tedy levý tabletový sloupec. Pojmenujeme ho  /res/layout/single_column_main.xml.

<?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" >

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="onAddNoteClicked"
        android:text="@string/add_note" />

    <fragment
        android:id="@+id/notes_list"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        class="com.example.notepad.NotesListFragment" />

</LinearLayout>

A tímto vám představuji nový element, který můžete použít v layoutových souborech, a to <include>. Ten umožní vložit jeden layout do druhého. V nejjednodušší podobě mu stačí jen atribut layout (bez namespace android:), jehož hodnotou je odkaz na vkládanou layoutovou surovinu. Dokáže si ale poradit s jakýmkoli layoutovým atributem. V takovém případě se tento atribut nastaví kořenovému View vkládaného layoutu. Pokud tam je daný atribut už přítomen, přepíše se.

V /res/layout/main.xml  jsem musel navíc přidat FrameLayout, přestože je v podstatě zbytečný. Android si ale neuměl poradit s tím, když bylo <include> kořenovým elementem. (A single_column_main  jako contentView nastavit nemohu, neboť právě díky layoutu rozliším tablet od telefonu.)

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

    <!-- Include nemůže být kořenovým elementem -->

    <include layout="@layout/single_column_main" />

</FrameLayout>

Teď si vytvořte novou podsložku /res s názvem layout-w660dp. (Vzpomínáte na modifikátory, o nichž jsme si povídali nedávno?) Hodnota minimální šířky je zvolena tak, že by to měly být zhruba dva Nexusy S na šířku. Ale není to žádné standardní pravidlo, záleží na konkrétní situaci.

Do této složky potom vytvořte nový soubor a pojmenujte ho main.xml (ne, to není chyba, jmenuje se stejně). Jeho obsah bude následující: (Opět, šířka levého sloupce nemá žádný hlubokomyslný základ, dokonce by možná bylo hezčí, kdybych nastavil nějaký poměr pomocí  layout_weight.)

<?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="horizontal" >

    <FrameLayout
        android:layout_width="330dp"
        android:layout_height="match_parent" >

        <include layout="@layout/single_column_main" />
    </FrameLayout>

    <FrameLayout
        android:id="@+id/right_column"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />

</LinearLayout>

Do FrameLayoutu s id right_column budeme vkládat různé Fragmenty.

A tím nám už zbývá jen NotepadActivity. V onCreate  nastavíme contentView na main a zjištěním existence pravého panelu šikovně určíme, zda jsme na tabletu:

public class NotepadActivity extends FragmentActivity implements
        NotesListFragment.OnNoteClickedListener,
        AddNoteFragment.OnAddNoteListener {

    private boolean dualPane;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        dualPane = findViewById(R.id.right_column) != null;
    }

Při klepnutí na poznámku v  NotesListFragment u musíme zobrazit její detail.

    public void onNoteClicked(long id) {
        showNote(id);
    }

    private void showNote(long id){
        if(dualPane){

Pokud jsme na tabletu, umístíme SingleNoteFragment do pravého sloupce, jehož případný předchozí obsah odstraníme. Tím, že zavoláme metodu FragmentTransaction.addToBackStack zajistíme, že klepne-li uživatel na tlačítko zpět, nově přidaný Fragment zmizí a znovu se zobrazí to, co tam bylo předtím. Jako parametr předáme null a nebudeme si toho moc všímat.

            Fragment f = new SingleNoteFragment(id);
            FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
            ft.replace(R.id.right_column, f);
            ft.addToBackStack(null);
            ft.commit();
        }

Pokud máme k dispozici jen jeden sloupec, spustíme  SingleNoteActivity.

        else{
            Intent i = new Intent(this, SingleNoteActivity.class);
            i.putExtra(SingleNoteActivity.EXTRA_ID, id);
            startActivity(i);
        }
    }

Klepne-li uživatel na tlačítko „Přidat poznámku”, zavolá se metoda onAddNoteClicked. Ta buď umístí do pravého sloupce AddNoteFragment,

    public void onAddNoteClicked(View v){
        if(dualPane){
            Fragment f = new AddNoteFragment();
            FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
            ft.replace(R.id.right_column, f);
            ft.addToBackStack(null);
            ft.commit();
        }

anebo spustí AddNoteActivity, ale tím, že zavolá startActivityForResult, dá najevo, že si přeje dostat data vrácená spuštěnou Activity. (To proběhne v metodě onActivityResult, a aby spuštějící Activity poznala, která že spuštěná Activity jí zrovna teď něco vrátila, nastaví se nějaký  requestCode.)

        else{
            Intent i = new Intent(this, AddNoteActivity.class);
            startActivityForResult(i, REQUEST_ADD_NOTE);
        }
    }

    private static final int REQUEST_ADD_NOTE = 0;

V onActivityResult  zjistíme, zdali vše proběhlo v pořádku (pokud ne – uživatel třeba ukončil AddNoteActivity tlačítkem zpět , ignorujeme to), a pokud ano, zavoláme metodu onAddNote, jíž musíme kvůli AddNoteFragment.OnAddNoteListener stejně implementovat.

    @Override
    protected void onActivityResult (int requestCode, int resultCode, Intent data){
        if(requestCode == REQUEST_ADD_NOTE){
            if(resultCode != RESULT_OK)
                return;

            String title = data.getStringExtra(AddNoteActivity.EXTRA_TITLE);
            String text = data.getStringExtra(AddNoteActivity.EXTRA_TEXT);

            onAddNote(title, text);
        }

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

A nakonec, dnes už opravdu poslední kód, metoda onAddNote. Ta zkusí poznámku uložit.

    public void onAddNote(String title, String text) {
        Notes notes = new Notes(this);
        long id = notes.insertNote(title, text);

Pokud se to povede, získá NotesListFragment a donutí ho obnovit své ListView,

        if(id > -1){
            ((NotesListFragment) getSupportFragmentManager().findFragmentById(
                    R.id.notes_list)).updateList();

a pokud je ještě k tomu uživatel na tabletu, rovnou mu nově vytvořenou poznámku zobrazí.

            if(dualPane)
                showNote(id);
        }

Pokud se uložení z nějakého důvodu nepovede, uživatel na to bude upozorněn.

        else{
            Toast.makeText(this, R.string.note_not_added, Toast.LENGTH_LONG).show();
        }
    }
}

Tím jsme dokončili NotepadActivity, a pokud všechny Activity správně zapíšeme do manifestu, měli bychom mít funkční spustitelný poznámkový blok, který se umí přizpůsobit tabletům.

Procvičování

Zkuste přidat možnost upravit poznámku. Zda jako formulář použijete upravený AddNoteFragment, anebo si vytvoříte nový, je pro účely procvičování jedno. Pokud se naskytne nějaký problém, rád porádím, budu-li vědět.

Závěr

Dnes jsme se naučili pracovat s SQLite databází, používat Fragmenty a k tomu jsme si trochu rozšířili znalosti některých už dříve představených tříd. Po přečtení dnešního článku jste už schopni vytvořit poměrně složitou funkční aplikaci. Zdrojové kódy dnešní aplikace si můžete stáhnout tady.

Příště se podíváme na preference, tedy druhou možnost ukládání dat v Androidu, a naučíme se, jak jednoduše vytvořit nastavení. A co dál? Mám vymyšlená témata ještě na několik dalších článků, ale znovu apeluji na vás, čtenáře, abyste si řekli, o co máte zájem. S jakými problémy jste se při vlastních experimentech setkali, co byste ještě chtěli umět. Bude to pro mě velmi cenná inspirace.

Tip na konec

Naučte se rovnou přemýšlet ve fragmentech. Když si budete rozmýšlet novou androidí aplikaci, rovnou si ji v duchu rozdělte na kousky, které půjdou umístit do Fragmentů. Fragmenty nejenže umožňují celkem jednoduše a rychle vyrobit přizpůsobivý layout, ale i dělí aplikaci na menší a jednodušší části a ve výsledku tak pomáhají vytvořit čistší návrh.

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: 34

Přehled komentářů

razor co dál?
j3nda me by zajimala grafika
Rado2 Re: me by zajimala grafika
j3nda Re: me by zajimala grafika
Matěj Konečný Re: me by zajimala grafika
https://double.mojeid.cz/#d7Ac2Rzshw komunikace se serverem
Matěj Konečný Re: komunikace se serverem
https://double.mojeid.cz/#d7Ac2Rzshw Re: komunikace se serverem
Matěj Konečný Re: komunikace se serverem
rogisoft Problém s EditText v API 16
Matěj Konečný Re: Problém s EditText v API 16
phoose soubory
Matěj Konečný Re: soubory
phoose Re: soubory
Matěj Konečný Re: soubory
KiN SQLite
Matěj Konečný Re: SQLite
KiN Re: SQLite
KiN Re: SQLite
Jirka Parametr v konstruktoru fragmentu
Matěj Konečný Re: Parametr v konstruktoru fragmentu
jirkam01 Re: Parametr v konstruktoru fragmentu
jirkam01 Potřeba více aktivit?
Kubicon Padání Notepadu
Matěj Konečný Re: Padání Notepadu
Liner Listenery
Petr Modifikátor layoutu nefunguje na API 2.2?
Petr Re: Modifikátor layoutu nefunguje na API 2.2?
Tomáš ListView textColor
Tonda Na nic tutorial
Vitek MENU_DELETE_ID
Majky je niekde zdroják ?
Martin Hassman Re: je niekde zdroják ?
Pepin Pad pri otoceni aplikace pri zobrazeni ve dvou panelech
Zdroj: https://www.zdrojak.cz/?p=3686