Aplikacja To Do - Xamarin

Wczoraj opisałem pusty projekt, który dostajemy w Visual Studio, tworząc projekt F# > Android. Dziś czas na zbudowanie krok po kroku naszej pierwszej aplikacji - prostej listy zadań.

Layout

Zaczniemy od najprzyjemniejszej i najprostszej części, czyli ustalenia jak nasza aplikacja będzie wyglądać. W tym celu klikniemy dwukrotnie w plik Main.axml w naszym projekcie, aby otworzyć designer. Z menu View wybieramy Toolbox, aby mieć dostęp do kontrolek, które będziemy mogli umieścić w naszym layoutcie.

Możecie w tym momencie pobawić się umieszczając różne kontrolki i uruchamiając projekt, żeby zobaczyć jak się zachowują.

Będą nas interesować cztery kontrolki: poznany wcześniej LinearLayout, EditText do wprowadzania tekstu, CheckBox do odznaczania naszych zadań i Button do dodawania nowych zadań.

Więc na naszą pustą formę upuśmy EditText i Button, aby uzyskać taki wygląd:

AndroidDesigner

Następnie przejdziemy do zakładki Source w designerze (lub naciśniemy F7) i dodamy między EditText i Button kontrolkę LinearLayout i odpowiednio je nazwiemy (zmienimy id) aby uzyskać:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">
    <EditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/txtTask"/>
    <LinearLayout
        android:orientation="vertical"
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:id="@+id/layout" />
    <Button
        android:text="Dodaj"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/btnAdd" />
</LinearLayout>

I to nam na ten moment wystarczy. W kodzie ustalimy akcję po naciśnięciu przycisku dodawania, aby tworzyć obiekty CheckBox w naszym layout.

Ustalanie zachowania

Zazwyczaj kiedy tworzymy aplikacje okienkowe mamy doczynienia z programowaniem reaktywnym - t.j. piszemy zachowanie w odpowiedzi na pewne zdarzenie. Takim zdarzeniem może np. być naciśnięcie przycisku lub rozpoczęcie pisania w odpowiednim oknie.

W naszej aplikacji będziemy chcieli podpiąć się najpierw pod zdarzenie naciśnięcia przycisku “Dodaj”. Ale zanim to zrobimy musimy w pewien sposób otrzymać referencje do obiektów w naszym layoutcie. Posłuży nam do tego metoda FindViewById<T>(id) należąca do Activity.

W naszej MainActivity w przeładowanej metodzie OnCreate, poniżej this.SetContentView(Resource_Layout.Main) dodamy następujący kod:

let layout = this.FindViewById<LinearLayout>(Resource_Id.layout)
let txtTask = this.FindViewById<EditText>(Resource_Id.txtTask)
let btnAdd = this.FindViewById<Button>(Resource_Id.btnAdd)

W ten sposób uzyskujemy referencje do kontrolek, które zadeklarowaliśmy w layoutcie. Zwróćmy uwagę na Resource_Id.... Jest to statyczna klasa automatycznie generowana w pliku Resources/Resource.designer.fs na podstawie naszych layoutów. Odpowiednie id nadaliśmy przez atrybut android:id w definicji layoutu.

Mając referencje do kontrolek możemy dodać im zachowanie. Chcemy aby po naciśnięciu przycisku dodać nowy CheckBox z tekstem z okienka tekstowego do kontrolki layout:

btnAdd.Click.Add(fun args ->
    let newCheckBox = new CheckBox(this)
    newCheckBox.Text <- txtTask.Text
    newCheckBox.CheckedChange.Add(fun args ->
        layout.RemoveView(newCheckBox)
    )
    layout.AddView(newCheckBox, 0)
)

Nasz przycisk btnAdd ma zdarzenie Click, któremu dodajemy zachowanie. Pod zdarzenie można podpiąć wiele zachowań.

Nasze zachowanie to anonimowa funkcja, która tworzy nowy obiekt typu CheckBox, ustala jego tekst na zawartość kontrolki txtTask, następnie dodaje zachowanie do naszego checkboxa - jeśli zmieni się jego stan zaznaczenia to usuniemy go z layoutu, a kiedy mamy go przygotowanego to dodajemy go do layoutu na pierwszą pozycję (stąd 0).

Na dobrą sprawę mógłbym ten post zakończyć, bo mamy działającą aplikację ToDo, ale dodamy jeszcze dwa smaczki.

Używanie plików

Na ten moment po wyjściu z aplikacji nasze taski znikają. Chcielibyśmy więc zapisać je sobie podczas zamykania aplikacji oraz wczytać podczas otwierania. Zanim napiszemy kolejny kawałek kodu, zastanowimy się jak wygląda system plików w systemie Android.

Android jest oparty o Linux i ma znany z Linuxa system plików. Został on jednak dodatkowo zaostrzony, t.j. aplikacja może tworzyć pliki tylko w swoich lokalnych folderach oraz takich, do których użytkownik przyznał im dostęp. Ponieważ niekoniecznie chcemy aby użytkownik miał styczność z danymi aplikacji, mógłby je usunąć niechcący (lub chcący), więc będziemy przechowywali nasz plik w folderze lokalnym.

W tym celu utworzymy w naszej MainActivity pole savePath, któremu przypiszemy

let savePath = System.Environment.GetFolderPath(
                System.Environment.SpecialFolder.LocalApplicationData) + "/ToDo/tasks"

Następnie utworzymy prywatną listę dla naszych checkboxów:

let mutable tasks = []

Będziemy do niej dodawali nowe checkboxy, aby podczas zamykania aplikacji spisać je do pliku. Aby ułatwić sobie nieco pracę i zachować regułę DRY, przeniesiemy tworzenie nowych checkboxów do osobnej metody:

member this.AddCheckBox text (layout:LinearLayout) =
    let newCheckBox = new CheckBox(this)
    newCheckBox.Text <- text
    newCheckBox.CheckedChange.Add(fun args ->
            layout.RemoveView(newCheckBox)
    )
    layout.AddView(newCheckBox, 0)
    tasks <- newCheckBox :: tasks

Oraz zmodyfikujemy nasze zachowanie dla przycisku

btnAdd.Click.Add(fun args ->
    this.AddCheckBox (txtTask.Text) layout
    txtTask.Text <- String.Empty
)

Dołożyłem jeszcze czyszczenie txtTask po dodaniu nowego checkboxa.

Ok, mając to, przejdziemy teraz do przeładowania metody OnStop(), która jest wywoływana podczas zamykania naszej aplikacji.

override this.OnStop () =
    base.OnStop()
    let mutable out = ""
    tasks |> List.iter (fun t -> out <- out + t.Text + "\n")
    File.WriteAllText(savePath, out)

Najpierw uruchamiamy base.OnStop(), żeby uruchomić wszystkie domyślne procesy zamykania, a następnie tworzymy sobie zmienną out typu string, do której dodajemy tekst, linijka po linijce, z naszych checkboxów. Następnie zapisujemy zawartość out do pliku savePath. Proste, prawda?

Teraz aby wczytać nasze taski, na koniec metody OnCreate dodamy następujący kawałek kodu:

if File.Exists(savePath) then
    let tasks = File.ReadAllLines(savePath)
    Array.iter (fun t -> this.AddCheckBox t layout) tasks

Sprawdzamy czy nasz plik istnieje i jeśli tak, to go wczytujemy (lokalna stała tasks przykrywa chwilowo tą, którą zadeklarowaliśmy powyżej). Otrzymujemy tablicę stringów (lokalne tasks), po którym się iterujemy wywołując this.AddCheckBox.

Tworzenie tasków Enterem

Chcielibyśmy również móc tworzyć taski za pomocą klawisza Enter po wpisaniu tekstu do kontrolki txtTask. W tym celu dodamy zachowanie do zdarzenia txtTask.KeyPress w metodzie OnCreate.

txtTask.KeyPress.Add(fun (args:View.KeyEventArgs) ->
    if args.KeyCode = Keycode.Enter then
        if not String.IsNullOrEmpty(txtTask.Text) then
            this.AddCheckBox (txtTask.Text) layout
        txtTask.Text <- String.Empty
        args.Handled <- true
    else
        args.Handled <- false
)

Sprawdzamy najpierw czy wciśnięty został Enter i jeśli nasze okienko nie było puste to wywołujemy this.AddCheckBox, po czym czyścimy okienko i ustawiamy flagę Handled na true, co oznacza, że kolejne zachwowania w naszym zdarzeniu nie będą już tego klawisza po nas obsługiwać.

To by było na tyle. Zobaczcie czy się kompiluje, jeśli nie to piszcie komentarzach, a ja postaram się wszelkie błędy poprawić.

Poniżej pełna klasa MainActivity

[<Activity (Label = "ToDo", MainLauncher = true, Icon = "@drawable/Icon")>]
type MainActivity () =
    inherit Activity ()

    let savePath = System.Environment.GetFolderPath(
                      System.Environment.SpecialFolder.LocalApplicationData) + "/ToDo/tasks"
    let mutable tasks = []

    member this.AddCheckBox text (layout:LinearLayout) =
        let newCheckBox = new CheckBox(this)
        newCheckBox.Text <- text
        newCheckBox.CheckedChange.Add(fun args ->
                layout.RemoveView(newCheckBox)
        )
        layout.AddView(newCheckBox, 0)
        tasks <- newCheckBox :: tasks

    override this.OnCreate (bundle) =
        base.OnCreate (bundle)
        this.SetContentView (Resource_Layout.Main)

        let layout = this.FindViewById<LinearLayout>(Resource_Id.layout)
        let txtTask = this.FindViewById<EditText>(Resource_Id.txtTask)
        let btnAdd = this.FindViewById<Button>(Resource_Id.btnAdd)

        btnAdd.Click.Add(fun args ->
            this.AddCheckBox (txtTask.Text) layout
            txtTask.Text <- String.Empty
        )

        txtTask.KeyPress.Add(fun (args:View.KeyEventArgs) ->
            if args.KeyCode = Keycode.Enter then
                if not String.IsNullOrEmpty(txtTask.Text) then
                    this.AddCheckBox (txtTask.Text) layout
                txtTask.Text <- String.Empty
                args.Handled <- true
            else
                args.Handled <- false
        )

        if File.Exists(savePath) then
            let tasks = File.ReadAllLines(savePath)
            Array.iter (fun t -> this.AddCheckBox t layout) tasks

    override this.OnStop () =
        base.OnStop()
        let mutable out = ""
        tasks |> List.iter (fun t -> out <- out + t.Text + "\n")
        File.WriteAllText(savePath, out)