180 minút s Androidom: 10. stretnutie [HTML/Markdown Editor]

Vzorová aplikácia

Vytvorme jednoduchý editor pre syntax Markdown spolu s náhľadom syntaxe.

Prispôsobme ju pre tablety: na telefónoch budeme môcť buď upravovať zdrojový text, alebo prezerať jeho náhľad (v dvoch “aktivitách”), a na tabletoch, kde máme dosť miesta, budeme vidieť zdrojový text i náhľad vedľa seba.

Pozn. na stretnutí sme urobili kostru pre HTML editor. Markdown je jednoduchým rozšírením tohto nápadu.

Výsledný projekt

Zdrojový kód sa nachádza na Githube.

markdownr-04

Koncepty, ktoré zvládneme

  • fragmenty
  • lišta akcií (action bar)
  • podpora kompatibilnej knižnice v7
  • použitie iných Java knižníc

Postup prác

  1. pripravíme najprv verziu pre oba “panely” vedľa seba
  2. potom ju prispôsobíme pre použitie na výšku i na šírku
  3. potom ju prispôsobíme pre malé zariadenia

Fragmenty

  • predstavujú “podaktivity”
    • autonómne časti používateľského rozhrania, ktorú možno použiť na viacerých miestach.
  • z fragmentov možno poskladať používateľské rozhranie aktivity
  • vďaka fragmentov môžeme budovať flexibilné rozhrania použiteľné na mnohých typoch zariadení

Fragmenty v našej aplikácii

  • textový editor bude tvoriť jeden fragment
  • náhľad bude druhý fragment
  • fragmenty môžeme ukladať vedľa seba
  • dynamicky vytvárať a zahadzovať

Zdroják a náhľad bok po boku

Štruktúra fragmentu

  • fragment je trieda dediaca od Fragment
    • pre zachovanie kompatibility importnime android.support.v4.app.Fragment z knižnice kompatibility
  • fragment potrebuje vlastný XML layout

Fragment č. 1: editor Markdownu

  • vyrobíme nový fragment, buď ručne, alebo pomocou File | New Android Object | New Blank Fragment.

XML Layout

Layout je tvorený jediným textovým políčkom

<EditText
        xmlns:android="http://schemas.android.com/apk/res/android" 
        android:id="@+id/edit_text_markdown_source"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:inputType="textMultiLine"
        android:gravity="top|left"
/>
  • nastavíme typ na textMultiline, čím indikujeme viacriadkový text
  • nastavíme centrovanie textu na vľavo-hore, pretože štandardné nastavenie centruje vertikálne.
    • nastavenia môžeme kombinovať znakom rúry | (klasické C/C++ bitové OR)
    • pozor, toto je iné nastavenie než layout_gravity, ktoré ovplyvňuje centrovanie celého komponentu v celom layoute

Java

package sk.upjs.ics.android.markdownr;

import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

public class MarkdownSourceFragment extends Fragment {

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_markdown_source, container, false);
    }
}
  • fragment musí mať jeden implicitný konštruktor (bez parametrov, verejný).
  • ukážková trieda ho má, pozor ale na situácie, keď vyrobíme viacparametrový konštruktor
  • naparsovanie layoutu z XML prebehne v metóde onCreateView()

    • inflater: inštancia nafukovača XML
    • container: rodičovský kontajner, do ktorého sa načítajú vnorené komponenty z XML
  • zavoláme inflate() na nafukovači, potrebujeme:

    • identifikátor layoutu so XML
    • kontajner, z ktorého sa preberú parametre layoutu deklarované v XML
    • indikátor, či sa majú komponenty automaticky napojiť na rodiča: v tomto prípade napojenie nechceme (nevieme, kto bude v konečnom dôsledku rodičom fragmentu)
    • podrobné vysvetlenie: Dave Smith: Layout inflation as Intended
    • metóda vráti naparsovaný view

Úprava aplikácie

Aj keď naša aplikácia nie je hotová, už môžeme spustiť hlavnú aktivitu s jediným fragmentom. Upravme activity_main.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" 
    android:orientation="horizontal"
    >

    <fragment
        android:id="@+id/fragment_markdown_source"
        android:name="sk.upjs.ics.android.markdownr.MarkdownSourceFragment"
        android:layout_width="0dip"
        android:layout_height="fill_parent"
        android:layout_weight="1"
        />

</LinearLayout>
  • vytvoríme horizontálny lineárny layout
    • pre aktivity bok po boku
  • fragment deklarujeme elementom <fragment>
    • uvedieme názov triedy s fragmentom, ktorú sme vytvorili
    • uvedieme identifikátor, potrebný pre manažovanie stavu po pauzovaní, či obnovení
    • dáme fragmentu váhu: 1 pri jednom komponente znamená, že fragment zaberie celú šírku (toto nie je povinné, ale hodí sa to v ďalšom kroku)

Spustenie aplikácie… neúspešné

Pri prvom spustení [na Androide 2.x] dostaneme výnimku, kde príčina bude:

Caused by: java.lang.ClassNotFoundException: 
android.view.fragment in loader 
dalvik.system.PathClassLoader
[/data/app/sk.upjs.ics.android.markdownr-1.apk]

Alebo (Android 4.4.2):

Caused by: android.app.Fragment$InstantiationException: 
Trying to instantiate a class
sk.upjs.ics.android.markdownr.MarkdownSourceFragment 
that is not a Fragment

Príčinou je naša aktivita, ktorá nepodporuje fragmenty. Musíme zmeniť jej triedu.

Hlavná aktivita musí dediť od FragmentActivity!

public class MainActivity extends FragmentActivity  {
...

Spustenie aplikácie II.

  • spustí sa aktivita
  • textové políčko zaberie celú obrazovku

markdownr-03

markdownr-01

Fragment č. 2: náhľad

Náhľad vybavíme pomocou zabudovaného webového prehliadača WebView.

Layout (fragment_markdown_preview.xml)

<?xml version="1.0" encoding="utf-8"?>
<WebView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/web_view_markdown"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent" 
 />

Komponentu prideľme identifikátor, budeme ho potrebovať neskôr pri nastavovaní obsahu.

Java

package sk.upjs.ics.android.markdownr;

import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebView;

public class MarkdownPreviewFragment extends Fragment {
    private WebView webView;

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

        webView = (WebView) view.findViewById(R.id.web_view_markdown);
        webView.loadData("<i>No Markdown source</i>", "text/html; charset=UTF-8", null);
        return view;
    }

}
  • na rozdiel od predošlého fragmentu si nafúknutý view poznačíme do premennej
  • z nej totiž budeme vyťahovať komponent(y) pomocou findViewById()

WebView

  • zabudovaný HTML5 prehliadač
  • vychádza z Chrome/Chromium/Webkit
  • metódou loadData() načítame ľubovoľné stringové dáta ako HTML
    • uveďme HTML kód
    • uveďme MIME typ obsahu (content type) vrátane znakovej sady
    • kódovanie vynecháme (null)
  • komponent potrebuje právo internetu. Dodajme do manifestu:

    <uses-permission android:name="android.permission.INTERNET" />
    

Úprava hlavnej aktivity

Do activity_main.xml dodajme druhý <fragment>:

<fragment
    android:id="@+id/fragment_markdown_preview"
    android:name="sk.upjs.ics.android.markdownr.MarkdownPreviewFragment"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:layout_weight="1"
    />

Teraz využijeme aj atribút layout_weight: dva komponenty s váhou 1 dávajú spolu váhu 2 a teda každý zaberie polovicu šírky nadradeného kontajnera.

Pozorujme správanie

  • oba fragmenty sa uložia vedľa seba
  • môžeme pozorovať, ako sa zachováva rozloženie i pri rotácii zariadenia

Otázka zlých jazykov

  • Keďže v každom fragmente je len jediný komponent, pokojne by sme sa obišli aj bez nich; komponenty by sme ukladali vedľa seba v layoute
  • Fragmenty sa však tvária ako kontajnery komponentov (v Swingu: panely, v HTML: <div>y)

markdownr-02

Odlišný layout pri orientácii na výšku

  • na malých zariadeniach vyzerá výzor bok po boku veľmi divne
    • strašne málo miesta pre monitor
  • urobme samostatný layout pre malé displeje i pre veľké

Kvalifikátory (qualifiers)

  • umožňujú definovať layouty, konštanty a pod. pre rozličné typy zariadení
  • príklady:
    • layout-sw600dp: layouty pre zariadenia so šírkou aspoň 600 dp (typizovaný 7palcový tablet)
    • layout-sw600dp-land: ako v predošlom bode, ale na šírku

Modifikácia layoutov

  1. vytvorme adresár res/layout-sw600dp-land
  2. Vezmime res/layout/activity_main.xml a skopírujme ho doň. Tento dvojpanelový layout použijeme pre horizontálne rotované tabletové zariadenie
  3. upravme res/layout/activity_main.xml: vyhoďme z neho deklaráciu pravého (náhľadového fragmentu)
  4. otestujme to na rozličných zariadeniach

Pozorovanie

  • Android automaticky načíta správny XML layout podľa zariadenia
  • v Java kóde nemusíme nič meniť

Dynamické fragmenty

  • na malých displejoch buď zobrazíme zdroják alebo náhľad
  • vyriešime to dynamickými fragmentami
    • lebo staticky deklarované fragmenty v XML nemožno odstraňovať, ani pridávať za behu
  • dynamicky budeme zamieňať fragment zdrojáku za fragment náhľadu a naopak.
  • na obrazovke bude vidno stále len jeden fragment

Úprava hlavnej aktivity

Upravme res/layout/activity_main.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity" 
    android:orientation="horizontal"
    android:id="@+id/layout_root"
 />
  • vymazali sme všetky deklarácie fragmentov
  • pridali sme však identifikátor! (Budeme ho potrebovať o chvíľu)

Kód

  • Identifikátor layoutu (R.id.layout_root) sa nachádza v layoute pre jediný fragment. Duálne zobrazenie tento identifikátor nemá.
  • Takto vieme zistiť, v akom režime sa nachádzame.

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    
        if(isSinglePane()) {
            showMarkdownSource();
        }
    }
    

Metóda na zistenie je jednoduchá, overí existenciu komponentu s identifikátorom:

public boolean isSinglePane() {
    return findViewById(R.id.layout_root) != null;
}

Ak sme v dvojpanelovom režime, nemusíme robiť nič špeciálne. Načítalo sa totiž XML s fixnými dvoma fragmentmi.

Dynamické nahrádzanie fragmentov: transakcie

  • Metóda showMarkdownSource() nahradí obsah fragmentu.
  • dynamické zmeny fragmentov bežia v transakcii:
    • postupnosť zmien, ktorá musí prebehnúť celá
    • zaradí sa do fronty udalostí v hlavnom vlákne

Kód

private void showMarkdownSource() {
    getSupportFragmentManager()
        .beginTransaction()
        .replace(R.id.layout_root, new MarkdownSourceFragment())
        .commit();
}   
  • získame inštanciu fragment managera (metóda support vráti variant z knižnice kompatibility)
  • začneme transakciu
  • nahradíme komponent layoutu našim fragmentom. Tu využijeme opäť identifikátor z XML
  • transakciu komitneme

Prepínanie medzi dynamickými fragmentami [bonus]

Plán:

  1. vytvoríme tlačidlo v menu / na action bare
  2. vytvoríme enum so stavmi: zdroják, náhľad, oba
  3. zabezpečíme prepínanie stavov

Položka menu / tlačidlo na actionbare

Upravme res/menu/main.xml

<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@+id/show_source_menu" 
        android:title="Show source"
        />

    <item android:id="@+id/show_html_menu" 
        android:title="Preview"
        />

</menu>
  • dodali sme dve položky
  • budeme ich dynamicky skrývať a zobrazovať

Položky menu

  • menu budeme budovať dynamicky
  • využijeme metódu onCreateOptionsMenu()
  • dynamicky usúdime, čo sa má zjaviť a čo nie

Menu vytvoríme klasicky:

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    super.onPrepareOptionsMenu(menu);
    getMenuInflater().inflate(R.menu.main, menu);
    MenuItem showHtmlMenu = menu.findItem(R.id.show_html_menu);
    MenuItem showHtmlSourceMenu = menu.findItem(R.id.show_source_menu);

    if(mode == Mode.SOURCE) {
        showHtmlMenu.setVisible(true);
        showHtmlSourceMenu.setVisible(false);
    } else if (mode == Mode.PREVIEW) {
        showHtmlMenu.setVisible(false);
        showHtmlSourceMenu.setVisible(true);
    } else {
        showHtmlMenu.setVisible(false);
        showHtmlSourceMenu.setVisible(false);           
    }
    return true;
}

Položky budeme skrývať a zobrazovať po výbere:

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    switch(item.getItemId()) {
    case R.id.show_html_menu:
        showHtml();
        supportInvalidateOptionsMenu();
        return true;
    case R.id.show_source_menu:
        showMarkdownSource();
        supportInvalidateOptionsMenu();
        return true;
    }
    return super.onOptionsItemSelected(item);
}

Dôležité je zavolať supportInvalidateOptionsMenu (Android 4.x: invalidateOptionsMenu()), ktorým úplne prebudujeme menu i položky na lište akcií.

Enum a prepínanie stavov

  • po výbere položky menu v onOptionsItemSelected() prepneme indikátor a zavoláme príslušnú zmenu fragmentu
  • doplníme metódu na zobrazenie HTML:

    private void showHtml() {
        getSupportFragmentManager()
            .beginTransaction()
            .replace(R.id.layout_root, new MarkdownPreviewFragment())
            .commit();
        mode = Mode.PREVIEW;
        supportInvalidateOptionsMenu();
    }
    

Sledujme správanie

  • menu sa dynamicky skrýva a zobrazuje v lište akcií podľa potreby
  • tlačidlá sa tiež dynamicky schovávajú

Knižnica kompatibility v7

  • štandardný projekt pre Android 2.x vie využívať knižnicu kompatibility
  • a používať veci “z budúcnosti”
  • knižnica kompatibility je štandardne v4:
    • čítame “veci z nového Androidu” použiteľné dokonca v API Level 4″ (Android 1.6)
  • niektoré nové veci však nemôžu byť v takomto starom API použiteľné
    • príklad: ActionBar
  • novšia verzia: knižnica kompatibility v7: požadujúca minimálne API Level 7 (2.1/Froyo)

Ako importnúť knižnicu?

  • knižnica sa dodáva ako JAR
  • ten však neobsahuje resources, teda grafické elementy, ktoré chceme používať

Importneme

  • V Eclipse:

    • File | Import… | Existing Android Project…
    • importneme z útrob Android SDK, napr.

      c:\android\sdk\extras\android\support\v7\appcompat 
      
    • Vznikne tak nový projekt v našom workspace.

Riešenie problémov

  • ak sa projekt sťažuje, že je nastavený na konkrétny API Level (napr. 16):
    • buď stiahneme príslušný API level cez správcu SDK
    • alebo pravý klik na tento projekt, Properties, sekcia Android a vyberme Project Build Target na nejaký iný než je nastavený, stlačme OK. Následne opäť proces zopakujme, a vyberme Project Build Target na taký API Level, oproti ktorému kompilujeme projekty (typicky najnovší Android).
    • inak povedané: vyberme ľubovoľný PBT, potvrďme dialóg, a potom ho vráťme späť.
    • cheat je prebratý zo StackOverflow

Využitie knižnice v našom projekte

Projekt importnime do nášho projektu:

  • pravý klik na tento projekt, Properties, sekcia Android
  • sekcia Library
  • pomocou tlačidla Add… pridajme knižnicu kompatibility

Riešenie problémov

  • Ak sa projekt sťažuje, že je chyba v SHA-1 (uvidíme to v Eclipse, vo view Console), kliknime na adresár libs v našom projekte a odstráňme (Delete) súbor android-support-v4.jar

Lišta akcií / Action Bar v Android 2.x

  • vďaka knižnici kompatibility v7 môžeme mať lištu akcií i v starom Androide
  • aktivita, ktorá chce lištu akcií, musí oddediť od ActionBarActivity

Spustenie aktivity

Uvidíme výnimku:

java.lang.RuntimeException: Unable to start activity ComponentInfo
{sk.upjs.ics.android.markdownr/
sk.upjs.ics.android.markdownr.MainActivity}: 
java.lang.IllegalStateException: 
You need to use a Theme.AppCompat theme (or descendant) 
with this activity.

Musíme upraviť manifest: nastavme tému pre aplikáciu <application>:

android:theme="@style/Theme.AppCompat.Light.DarkActionBar"
  • Táto téma bude svetlá, s tmavou lištou akcií.
  • Preskúšajte aj iné možnosti.

Obdivovanie lišty

  • Lišta vyzerá analogicky ako v novších Androidoch.
  • Tlačidlo s troma bodkami (vpravo) je však stotožnené s ponukou menu v tradičnom duchu Androidu 2.X
  • Na Androide 4.x však lišta vyzerá korektne.

Bonus: odovzdávanie informácií medzi fragmentami

  • chceme dodávať do fragmentu informácie pri jeho vytváraní
  • nemôžeme však využiť konštruktor s viacerými parametrami
    • presnejšie, môžeme, ale musíme si vytvoriť aj bezparametrový konštruktor
      • Android ho využíva pri dynamickom vytváraní inštnacií
  • namiesto toho môžeme využiť settery
  • alebo ešte lepšie argumenty
    • argumenty totiž prežijú aj rekonfiguráciu fragmentu vďaka zabudovanej podpore onSaveInstanceState()

Riešenie: statická továrenská metóda

private static final String ARG_KEY_HTML_DATA = "htmlData";

...

public static MarkdownPreviewFragment newInstance(String htmlData) {
    MarkdownPreviewFragment fragment = new MarkdownPreviewFragment();
    Bundle args = new Bundle();
    args.putString(ARG_KEY_HTML_DATA, htmlData);

    fragment.setArguments(args);

    return fragment;
}   

Vyzdvihnutie argumentov

private static final String DEFAULT_HTML = "<i>No Markdown source</i>";

private String getHtmlData() {
    Bundle args = getArguments();
    if(args != null && args.containsKey(ARG_KEY_HTML_DATA)) {
        return args.getString(ARG_KEY_HTML_DATA);
    }
    return DEFAULT_HTML;
}   

Načítanie textu

webView.loadData(getHtmlData(), "text/html", "utf-8");

Použitie z hlavnej aktivity

Upravíme transakciu pri zobrazovaní HTML:

private void showHtml() {
    EditText htmlSourceEditText = (EditText) findViewById(R.id.edit_text_markdown_source);
    MarkdownPreviewFragment fragment = MarkdownPreviewFragment.newInstance(htmlSourceEditText.getText().toString());

    getSupportFragmentManager()
        .beginTransaction()
        .replace(R.id.layout_root, fragment)
        .commit();
    mode = Mode.PREVIEW;
}   
  • najprv z aktuálneho layoutu pre zdrojový kód v Markdowne vytiahneme textové políčko
  • získame z neho text
  • vytvoríme inštanciu fragmentu statickou metódou, ktorú sme pred chvíľou napísali
  • spustíme transakciu

Bonus: udržiavanie stavov

  • naše fragmenty sa riadia životným cyklom
  • môžu byť odstreľované a vytvárané nanovo
  • zatiaľ fungujeme zle:
    • skúste otočiť zariadenie, keď je vidieť len fragment so zdrojákom (text sa stratí)
    • skúste zobraziť náhľad a potom opäť zdroják (text sa stratí)
  • vyriešime to obídením: všetko budeme ukladať do SharedPreferences
  • týmto zaistíme, že dáta prežijú aj reštart telefónu

Metódy v MarkdownSourceFragment

@Override
public void onResume() {
    super.onResume();

    SharedPreferences preferences = getActivity().getPreferences(Activity.MODE_PRIVATE);
    String htmlSource = preferences.getString(PREFERENCE_KEY_MARKDOWN_SOURCE, "");
    markdownSourceEditText.setText(htmlSource);
}

@Override
public void onPause() {
    SharedPreferences preferences = getActivity().getPreferences(Activity.MODE_PRIVATE);
    Editor editor = preferences.edit();
    editor.putString(PREFERENCE_KEY_MARKDOWN_SOURCE, markdownSourceEditText.getText().toString());
    editor.commit();

    super.onPause();
}

Bonus: prevod pomocou knižnice

Nechávame na pozorného čitateľa.

Využiť sa môže knižnica MarkdownPapers.

Konfigurácia tlačidiel na lište akcií

  • action bar pojme všetky položky z menu
  • môžeme ale prispôsobiť výzor a niektoré z nich uviesť ako tlačidlá na lište

Konfigurácia v XML [Android 2.x]

  • v Android 4.x stačí dodať atribút android:showAsAction
  • v starých Androidoch musíme definovať vlastný menný priestor v XML
  • príklad: definujeme menný priestor app s preddefinovanou hodnotou
  • atribút showAsAction bude z nového priestoru:

    <menu xmlns:android="http://schemas.android.com/apk/res/android" 
          xmlns:app="http://schemas.android.com/apk/res-auto"      
        >
    
        <item
            android:id="@+id/show_source_menu"
            android:title="Show source"
            app:showAsAction="ifRoom"
            />
    
  • showAsAction="ifRoom": položka menu sa zobrazí ako tlačidlo na lište akcií, ak pre ňu bude miesto.

Bonus: rozbehanie konverzie

  • do adresára libs skopírujeme markdownpapers-core-1.4.2.jar
    • nemá žiadne závislosti, okrem bežnej Javy
  • konverziu urobíme pomocou AsyncTasku a vlastnej metódy.
  • v doInBackground() vykonáme konverziu (beží na pozadí, v samostatnom vlákne)
  • v onPostExecute() aktualizujeme používateľské rozhranie (beží v hlavnom vlákne)
  • chyby propagujeme do hlavného vlákna ako HTML

    private void refreshPreview() {
        AsyncTask<String, Void, String> task = new AsyncTask<String, Void, String>() {
    
            @Override
            protected String doInBackground(String... params) {
                StringReader in = new StringReader(params[0]);
                StringWriter out = new StringWriter();
                try {
                    Markdown md = new Markdown();
                    md.transform(in, out);
                } catch (ParseException e) {
                    return "<i>Syntax error</i>";
                }
                return out.toString();
            }
    
            @Override
            protected void onPostExecute(String result) {
                WebView webView = (WebView) findViewById(R.id.web_view_markdown);
                webView.loadData(result, MarkdownPreviewFragment.CONTENT_TYPE, MarkdownPreviewFragment.ENCODING);
            }
        };
    
        task.execute(getSourceCode());
    }
    
  • metódu voláme pri prepnutí do HTML režimu (showHtml())
  • je vhodné dodať tretie tlačidlo “Refresh” dostupné len v dvojpanelovom režime, ktorým aktualizujeme HTML

Pridaj komentár

Vaša e-mailová adresa nebude zverejnená. Vyžadované polia sú označené *