Ogromną rolę przy tworzeniu aplikacji mobilnych odgrywa część wizualna, czyli User Interface (UI). Możemy tutaj wyodrębnić kilka składowych elementów, które w połączeniu wygenerują nam to co użytkownik zobaczy na ekranie po odpaleniu naszej apki:
- wygląd komponentów (design)
- animacje
- rozmieszczenie komponentów (layout)
Wszystkie z nich są ważne i trudno sobie wyobrazić profesjonalny produkt bez chociażby jednej z nich. Dzisiaj skupię się na ostatniej, czyli w jaki sposób rozmieszczać komponenty na ekranie, aby spełniały nasze oczekiwania (lub oczekiwania Design Team-u). Jest to rzecz, która potrafi być frustrująca - zwłaszcza na początku przygody z technologią. O ile łatwo wyszukać w dokumentacji elementy wizualne, które umiemy sobie wyobrazić - przycisk, listę, czy belkę nawigacji - to znalezienie odpowiedzi na pytanie jak sprawić, żeby rzecz X była obok rzeczy Y, która jest nad rzeczą Z bywa zdecydowanie trudniejsze.
Layout == widget
Tak jak człowiek w większości składa się z wody, tak Flutter opiera się w znacznej części na widgetach. Są nimi zarówno przyciski, przełączniki, ikony, a nawet wyświetlany na ekranie tekst, jak również obiekty których nie widzi nasz użytkownik, a mimo to znajdują się na ekranie i pełnią krytyczną rolę. Mowa tutaj o wierszach (Row
) i kolumnach (Column
), które w elegancki sposób organizują kolejność wyświetlania widgetów wizualnych.
W dostępnej skrzynce narzędziowej mamy rzecz jasna do dyspozycji więcej narzędzi niż tylko wiersz/kolumna - o tym przeczytasz w kolejnym wpisie. Nie przedłużając wstępu - przejdźmy do praktyki i realnych zastosowań gridu!
Centralna rozgrzewka
Pamiętasz jak zaczyna się każdy kurs dowolnego języka programowania? Oczywiście że tak, trudno zapomnieć te dwa magiczne słowa: Hello world
. Tak samo zaczniemy i my - wyświetlimy ten napis na samym środku ekranu. Jedyna różnica będzie polegała na tym, że posłużymy się polską wersją językową tego zwrotu.
import 'package:flutter/material.dart';
void main() => runApp(App());
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Text(
"Witaj świecie",
style: TextStyle(
fontSize: 32,
color: Colors.white,
),
),
),
),
);
}
}
Na urządzeniu zobaczysz czarne tło na całej powierzchni, a na środku ekranu napis “Witaj świecie” - tak jak na załączonym poniżej zrzucie. Nie jest to co prawda przykład zbyt złożony, ale od czegoś trzeba zacząć - teraz będzie tylko trudniej lepiej.
Czy taki layout ma realne zastosowanie w produkcyjnej aplikacji? Oczywiście! W jednej ze swoich gier wyświetlam ekran informujący gracza o poprawnej odpowiedzi. Jest to prosty ekran z tłem i zadowoloną buźką w środku. Rozwiązanie proste i dające efekt który chciałem osiągnąć:
Grid
Pierwsze rozmieszczenie za nami, jednak trudno zbudować aplikację której layout będzie opierał się wyłącznie na pojedynczym elemencie wyświetlanym na środku ekranu. W przykładzie powyżej możesz zaobserwować, że zarówno Container
jak i Center
przyjmują parametr child
(z angielskiego dziecko - liczba pojedyncza), któremu można przekazać tylko jeden widget. Nie mamy możliwości przekazania listy elementów które chcemy narysować. Żegnaj przygodo.
Jeśli posiadasz doświadczenie jako Web Developer i pracowałeś chociaż przez krótką chwilę z HTML to coś Ci tu nie pasuje. Przecież do <div>
, czy dowolnego innego znacznika można przekazać tyle elementów ile sobie zażyczę! Co to za herezja, aby blokować taką możliwość? Odpowiedź jest następująca: Flutter musi wiedzieć jak poukładać listę komponentów - pionowo? poziomo? - i dlatego dostarcza nam dedykowane widgety, które wiedzą co i jak.
Row
Do umieszczania elementów w poziomie (jeden obok drugiego) posługujemy się widgetem Row
. W odróżnieniu od wielu innych wbudowanych komponentów nie przyjmuje on parametru , lecz child
children
(z angielskiego dzieci - liczba mnoga) do którego jesteśmy w stanie przekazać listę obiektów do narysowania.
Napiszmy prostą aplikację, której zadaniem będzie wyświetlanie pojedynczych liter ze słowa Witaj w kolorowych bloczkach, które narysujemy jeden obok drugiego (od lewej do prawej).
class App extends StatelessWidget {
Widget buildItem(String letter, Color color) {
return Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.blue),
child: Text(
letter,
style: TextStyle(
fontSize: 32,
color: color,
),
)
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: Container(
height: 300,
decoration: BoxDecoration(color: Colors.green),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
buildItem("W", Colors.black),
buildItem("I", Colors.white),
buildItem("T", Colors.black),
buildItem("A", Colors.white),
buildItem("J", Colors.black),
],
),
),
),
);
}
}
Po uruchomieniu aplikacji powinieneś zobaczyć następujący zrzut:
O to dokładnie nam chodziło! Czarne tło pochodzi z Scaffold
, który zawsze okupuje pełny rozmiar ekranu. Na górze widzimy zieloną część o wysokości 300 jednostek z kontenera Container
. Dlaczego ustawiliśmy sztywną wysokość? Widget ten domyślnie zajmie tyle miejsca na ekranie, ile miejsca potrzebuje jego dziecko - w tym wypadku Row
. W celu demonstracyjnym konfiguracji tego ostatniego - narzuciliśmy mu wymiar umożliwiający swobodną zmianę dostępnych parametrów pozycjonowania.
mainAxisAlignment
Główna oś na której rozmieszczone są elementy. Jeśli pamiętasz ze szkoły oś współrzędnych X Y to mamy tutaj naszego X. W przykładzie wykorzystaliśmy wartość spaceAround
, jednak dostępne są również inne warianty konfiguracyjne.
- spaceAround - układa elementy na całej dostępnej szerokości i z każdej ze stron dodaje tzw. światło, czyli pustą przestrzeń. Wolna przestrzeń między dziećmi jest 2x większa, niż ta oddzielająca dziecko od rodzica
- spaceEvenly - identyczne zachowanie jak spaceAround, z tą różnicą, że przestrzeń między elementami i rodzicem jest jednakowej wielkości.
- spaceBetween - podobnie jak spaceAround elementy rozłożone są na całej szerokości, jednak bez pustej przestrzeni z przodu i na końcu (przyleganie do krawędzi rodzica).
- start - elementy przylegają do siebie i ustawiane są na początku osi (lewa strona).
- center - elementy przylegają do siebie i ustawiane są na środku osi.
- end - analogicznie jak start z tym, że elementy stłoczone są na końcu osi (prawa strona).
crossAxisAlignment
Dodatkowa oś, która odpowiada za pionowe rozmieszczenie elementów (Y). Nasz przykład używa wartości center
, jednak dostępnych wariantów jest więcej i wszystko zależy od tego jaki efekt chcesz uzyskać.
- center - wymusza rozkład w centralnej części wiersza. Elementy znajdują się na środku ekranu, tak jak po użyciu widgetu
Center
. - start - elementy umieszczone są na samej górze kontenera.
- end - przeciwieństwo start, elementy widoczne są na dole.
- stretch - służy do rozciągnięcia elementów w taki sposób, aby zapełniły całą dostępną wysokość.
- baseline - opcja przydatna jedynie gdy elementami składowymi wiersza jest bezpośrednio tekst, który chcemy wyrównać względem siebie. Parametr do poprawnego działania wymaga ustawienia dodatkowego parametru
textBaseline
na rodzicuRow
.
Column
Wiemy już jak rozmieszczać elementy w poziomie, czas przyjrzeć się jak zrobić to samo dla pionu (jeden pod drugim). Zadanie to powierzamy widgetowi Column
, który działa w sposób analogiczny jak Row
. Najważniejsze różnice to:
- Elementy rozmieszczone są pionowo, nie poziomo.
Column
okupuje maksymalną dostępną wysokość rodzica, a szerokość tylko taką jakiej potrzebuje do narysowania najszerszego ze swoich dzieci (Row
zajmuje maksymalną szerokość, a wysokość najwyższego z dzieci).
Nanieśmy odpowiednie modyfikacje do poprzedniego kodu. Jedynymi zmianami, które wprowadzimy jest ustalanie szerokości zamiast wysokości (height
na width
) i podmiana widgetu Row
na Column
. Cała reszta pozostaje bez zmian.
class App extends StatelessWidget {
Widget buildItem(String letter, Color color) {
return Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.blue),
child: Text(
letter,
style: TextStyle(
fontSize: 32,
color: color,
),
)
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: Container(
// height replaced with width
width: 300,
decoration: BoxDecoration(color: Colors.green),
// Row changed to Column
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
buildItem("W", Colors.black),
buildItem("I", Colors.white),
buildItem("T", Colors.black),
buildItem("A", Colors.white),
buildItem("J", Colors.black),
],
),
),
),
);
}
}
Uruchomienie aplikacji spowoduje wyświetlenie poniższego ekranu:
Ponownie uzyskujemy spodziewany wynik! Elementy narysowane są jeden pod drugim, a czarny pas po prawej stronie to nic innego jak Scaffold
umieszczony pod spodem naszego layoutu. Na koniec przyjrzyjmy się opcjom konfiguracyjnym - jest to czysta formalność, gdyż ustawienia przekładają się 1:1 z tym co widzieliśmy w Row
.
mainAxisAlignment
Jako, że widget służy do układania komponentów w pionie to jego główną osią nie jest X, lecz Y. Trzeba o tym pamiętać - mi do tej pory zdarza się sporadycznie ustawić parametry nie tak jak powinienem. Konfiguracja jest identyczna jak w Row
, pozwól, więc że nie będę jej tutaj ponownie wklejał.
crossAxisAlignment
Druga z osi, która rozmieszcza elementy w poziomie X. Ponownie opcje konfiguracyjne możesz podejrzeć w sekcji w której omawialiśmy widget Row
.
Synteza
Przeszliśmy przez pozycjonowanie poziome i pionowe - jest to mocny fundament przy projektowaniu layoutu naszej aplikacji. Możesz (a nawet powinieneś) osadzać kolumny w wierszach, a wiersze w kolumnach. W końcu Column
i Row
to widgety, nic więc nie stoi na przeszkodzie aby w drzewie komponentów zawierały się wzajemnie.
Na zakończenie spójrz jeszcze na bardziej złożony (ale nieskomplikowany!) ekran, który posiada wiele różnych elementów i demonstruje w jaki sposób uzyskać pożądany efekt:
import 'package:flutter/material.dart';
void main() => runApp(App());
class App extends StatelessWidget {
Widget buildInfo(Icon icon, String text) {
return Column(
children: [
icon,
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(text, style: TextStyle(color: Colors.white)),
),
],
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.amber,
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Column(
children: <Widget>[
Text(
"Pikachu",
style: TextStyle(
color: Colors.black,
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
Image.network(
"http://pluspng.com/img-png/pikachu-face-png-png-svg-512.png",
width: 160,
height: 160,
),
],
),
Text(
"Pikachu jest jednym z najbardziej rozpoznawalnych Pokémonów, "
"głównie ze względu na fakt, że jest on centralną postacią w serii anime. "
"Pikachu jest powszechnie uważany za najbardziej popularnego Pokémona.",
textAlign: TextAlign.center,
style: TextStyle(height: 1.4, fontSize: 16),
),
Container(
padding: EdgeInsets.all(16),
color: Colors.black,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildInfo(
Icon(Icons.flash_on, color: Colors.yellowAccent),
"Type",
),
buildInfo(
Icon(Icons.insert_emoticon, color: Colors.greenAccent),
"Mood",
),
buildInfo(
Icon(Icons.do_not_disturb_alt, color: Colors.redAccent),
"Full HP",
),
],
),
),
],
),
),
);
}
}