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.
Přehled komentářů