Jetpack Compose v5 - Tworzenie GUI (PDF)

Summary

Ten dokument dostarcza wprowadzenie do Jetpack Compose, biblioteki do tworzenia interfejsów użytkownika w aplikacjach Android. Omówiono w nim deklaratywne podejście do tworzenia UI, funkcje komponowalne oraz rekompozycję, a także zagadnienia dotyczące wydajności. Podane przykłady ilustrują sposób tworzenia aplikacji z wykorzystaniem Jetpack Compose.

Full Transcript

Jetpack Compose Tworzenie GUI w Jetpack Compose Imperatywne tworzenie GUI w XML Oryginalnie hierarchię widoków systemu Android można było przedstawić jako drzewo widżetów interfejsu użytkownika Gdy stan aplikacji zmienia się z powodu interakcji użytkownika, należy zaktualizow...

Jetpack Compose Tworzenie GUI w Jetpack Compose Imperatywne tworzenie GUI w XML Oryginalnie hierarchię widoków systemu Android można było przedstawić jako drzewo widżetów interfejsu użytkownika Gdy stan aplikacji zmienia się z powodu interakcji użytkownika, należy zaktualizować hierarchię interfejsu użytkownika, aby wyświetlała bieżące dane Najczęstszym sposobem aktualizacji interfejsu użytkownika jest przejście po drzewie np. za pomocą funkcji takich jak findViewById() zmiana stanu widżetów poprzez wywołanie metod takich jak Button.setText(String) Imperatywne tworzenie GUI w XML Zamiast findById() można użyć wiązania widoków (view binding) Takie manipulowanie widokami zwiększa prawdopodobieństwo błędów jeśli dane są renderowane w wielu miejscach, łatwo zapomnieć o aktualizacji jednego z widoków łatwo jest również stworzyć niedozwolone stany (gdy dwie aktualizacje powodują konflikt) złożoność utrzymania oprogramowania rośnie wraz z liczbą widoków wymagających aktualizacji Imperatywne tworzenie GUI w XML Imperatywne tworzenie GUI w XML package com.example.w02_01_xml import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import com.example.w02_01_xml.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { private lateinit var binding:ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.helloWorld.setText("Hello Android!") } } Deklaratywne podejście do tworzenia interfejsu użytkownika w Jetpack Compose Jetpack Compose – prosty przykład class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { W0202simple_composableTheme { // Powierzchnia (pojemnik), która używa koloru z motywu Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { Greeting("Android") } } } } } Jetpack Compose – prosty przykład @Composable fun Greeting(name: String, modifier: Modifier = Modifier) { Text( text = "Hello $name!", modifier = modifier ) } @Preview(showBackground = true) @Composable fun GreetingPreview() { W0202simple_composableTheme { Greeting("Android") } } Funkcje komponowalne (Composables) Funkcje komponowalne: Są oznaczone adnotacją @Composable, która oznacza że funkcja ma na celu konwersję danych na interfejs użytkownika Mogą akceptować parametry, które umożliwiają logice opisanie interfejsu użytkownika Wyświetlają UI (np. tekst) poprzez wywołanie funkcji komponowalnych (np. Text()), które w rzeczywistości tworzą elementy UI (np. element tekstowy) - emitują hierarchię interfejsu użytkownika, wywołując inne funkcje komponowalne Funkcje komponowalne (Composables) Funkcje komponowalne niczego nie zwracają. „Emitują” interfejs użytkownika i nie muszą niczego zwracać, ponieważ opisują pożądany stan ekranu Muszą być: szybkie idempotentne - zachowują się w ten sam sposób, gdy są wywoływane wielokrotnie z tym samym argumentem (i nie używają innych wartości, takich jak zmienne globalne lub wywołania funkcji random()). pozbawione skutków ubocznych - opisują interfejs użytkownika bez żadnych skutków ubocznych, takich jak modyfikowanie właściwości lub zmiennych globalnych Deklaratywne tworzenie UI W przypadku obiektowych i imperatywnych SDK do tworzenia GUI interfejs użytkownika jest inicjowany poprzez utworzenie instancji drzewa widżetów (np. „pompowanie XML”) każdy widżet utrzymuje swój własny stan wewnętrzny i udostępnia metody pobierające i ustawiające, które umożliwiają logice aplikacji interakcję z widżetem W deklaratywnym podejściu Compose widżety są „względnie bezstanowe” nie udostępniają funkcji ustawiających ani pobierających stan elementy UI nie są widoczne jako obiekty Deklaratywne tworzenie UI interfejs użytkownika aktualizuje się, wywołując tę samą funkcję, którą można komponować, z różnymi argumentami komponenty są odpowiedzialne za przekształcenie bieżącego stanu aplikacji w interfejs użytkownika za każdym razem, gdy zauważalne aktualizacje danych Gdy użytkownik wchodzi w interakcję z interfejsem użytkownika, interfejs użytkownika wywołuje zdarzenia, takie jak onClick Te zdarzenia powinny powiadamiać logikę aplikacji, która może następnie zmienić stan aplikacji Kiedy stan się zmienia, funkcje komponowalne są wywoływane ponownie z nowymi danymi - powoduje to przerysowanie elementów interfejsu użytkownika - proces ten nazywa się rekompozycją Deklaratywne tworzenie UI https://developer.android.com Logika aplikacji dostarcza dane do funkcji komponowalnych najwyższego poziomu Funkcja używa danych do opisania interfejsu użytkownika, wywołując inne elementy składowe i przekazuje niezbędne dane do tych obiektów w dół hierarchii Deklaratywne tworzenie UI https://developer.android.com Użytkownik wszedł w interakcję z elementem interfejsu użytkownika, powodując zdarzenie. Logika aplikacji reaguje na zdarzenie, a następnie, jeśli to konieczne, funkcje komponowalne są automatycznie wywoływane ponownie z nowymi parametrami Dynamiczna zawartość Funkcje komponowalne są pisane w Kotlinie, a nie w XML Mogą być tak dynamiczne, jak każdy inny fragment kodu (zawierać pętle, instrukcje warunkowe itd.) @Composable fun Greeting(names: List) { for (name in names) { Text("Hello $name") } } Funkcja tworzy różną liczbę komponentów w zależności od parametrów Rekompozycja Rekompozycja to proces ponownego wywoływania funkcji komponowalnych, gdy zmienią się dane wejściowe (parametry funkcji) Kiedy Jetpack Compose wykonuje rekompozycję na podstawie nowych danych wejściowych, wywołuje tylko te funkcje lub wyrażenia lambda, które mogły ulec zmianie, i pomija resztę Dzięki pomijaniu funkcji/wyrażeń lambda, które nie mają zmienionych parametrów, Jetpack Compose może wydajnie wykonywać rekompozycję Funkcje komponowalne mogą być wykonywane nawet przy renderowaniu każdej klatki (np. podczas renderowania animacji) Wykonywanie kosztownych operacji (np. takie jak czytanie z ustawień współdzielonych) powinno odbywać się w tle a wynik powinien być przekazany do funkcji komponowalnej jako parametr Rekompozycja - kolejność Funkcje komponowalne mogą się wykonywać w dowolnej kolejności Można pomyśleć, że funkcje poniżej wykonają się po kolei @Composable fun ButtonRow() { MyFancyNavigation { StartScreen() MiddleScreen() EndScreen() } } Rekompozycja - kolejność Ale to niekoniecznie jest prawdą. Jeśli funkcja komponowalna zawiera wywołania innych funkcji komponowalnych, funkcje te mogą być uruchamiane w dowolnej kolejności (wywołania StartScreen(), MiddleScreen() i EndScreen()) Nie można na przykład ustawić w StartScreen() jakiejś zmiennej globalnej (efekt uboczny) i korzystać z tej zmiennej w MiddleScreen() bo kolejność ich wykonania może być inna Każda z wywoływanych funkcji musi być samodzielna (self-contained) Rekompozycja – wykonanie równoległe Jetpack Compose uruchamiać funkcje komponowalne równolegle. Dzięki temu Compose może korzystać z wielu rdzeni Renderować niewidoczne elementy komponowalne z niższym priorytetem Oznacza to, że funkcja komponowalna może zostać wykonana w puli wątków w tle Jeśli funkcja komponowalna wywołuje funkcję w ViewModel, Compose może wywołać tę funkcję z kilku wątków jednocześnie Ponieważ kolejność wykonania może być dowolna aby mieć pewność, że aplikacja będzie działać poprawnie, funkcje komponowalne, nie powinny powodować żadnych skutków ubocznych Rekompozycja – wykonanie równoległe Do wykonywania czynności powodujących efekty uboczne używa się wywołań zwrotnych (callback), takich jak onClick, które zawsze są wykonywane w wątku interfejsu użytkownika Wywoływana funkcja komponowalna może być wykonana w innym wątku niż funkcja wywołująca Należy unikać kodu modyfikującego zmienne w komponowalnej lambdzie kod nie jest wtedy „wątkowo-bezpieczny” (thread-safe) modyfikacja stanowi niedopuszczalny efekt uboczny komponowalnej lambdy Rekompozycja – wykonanie równoległe @Composable @Deprecated("Example with bug") fun ListWithBug(myList: List) { var items = 0 Row(horizontalArrangement = Arrangement.SpaceBetween) { Column { for (item in myList) { Text("Item: $item") items++ // ŹLE! Efekt uboczny rekompozycji kolumny } } Text("Count: $items") } } W tym przykładzie zmienna items jest modyfikowana przy każdej rekompozycji (nawet w każdej klatce); Interfejs użytkownika wyświetliłby nieprawidłową liczbę Dlatego typu modyfikacje zmiennych nie są obsługiwane/dozwolone w aplikacjach Compose (dlatego że framework może wykonywać funkcje/lambdy komponowalne w różnych wątkach) Rekompozycja – pomija ile się da Gdy fragmenty interfejsu użytkownika są nieaktualne interfejs zostanie wyrenderowany ponownie Compose próbuje ponownie „skomponować” tylko te fragmenty, które wymagają aktualizacji Każda funkcja komponowalna lub lambda mogą być ponownie „skomponowane” niezależnie Ponieważ nie ma gwarancji, że funkcja zawsze się wykona ponownie, wykonanie wszystkich funkcji komponowalnych lub lambd powinno być wolne od skutków ubocznych (aby wykonać efekt uboczny należy użyć wywołań zwrotnych) //Wyświetla pojedyncze imię, które może kliknąć użytkownik @Composable private fun NamePickerItem(name: String, onClicked: (String) -> Unit) { Text(name, Modifier.clickable(onClick = { onClicked(name) })) } Rekompozycja – pomija ile się da @Composable fun NamePicker( //lista "klikalnych" imion z nagłówkiem header: String, names: List, onNameClicked: (String) -> Unit ) { Column { // to się ponownie skomponuje gdy zmieni się [header], // a nie gdy zmieni się [names] Text(header, style = MaterialTheme.typography.bodyLarge) Divider() // LazyColumn = RecycleView w Compose // lambda przekazana do items() jest podobna do // RecyclerView.ViewHolder. LazyColumn { items(names) { name -> // Kiedy zmieni się [name] w elemencie, adapter tego elementu // wykona ponowną kompozycję; kompozycja nie zostanie // wykonana gdy zmieni się [header] NamePickerItem(name, onNameClicked) } } } } Rekompozycja – jest optymistyczna Rekompozycja rozpoczyna się zawsze, gdy Compose uzna, że parametry obiektu mogły ulec zmianie Rekompozycja jest optymistyczna Compose oczekuje zakończenia rekompozycji przed ponowną zmianą parametrów Jeśli parametr ulegnie zmianie przed zakończeniem rekompozycji, funkcja Compose może anulować rekompozycję i uruchomić ją ponownie z nowym parametrem Kiedy rekompozycja zostanie anulowana, Compose odrzuca drzewo UI z niedokończonej rekompozycji. Aby rekompozycja mogła być optymistyczna komponowalne funkcje i lambdy powinny być idempotentne i wolne od skutków ubocznych (inaczej aplikacja może być w stanie niespójnym) Rekompozycja – częste wykonanie Funkcje komponowalne mogą być wykonywane dość często W niektórych przypadkach funkcja komponowalna może się wykonywać dla każdej klatki animacji interfejsu użytkownika Jeśli funkcja wykonuje kosztowne operacje (np. odczyt z pamięci urządzenia), może spowodować zacinanie interfejsu użytkownika Jeśli funkcja komponowalna potrzebuje danych, powinna je otrzymywać przez parametry parametry danych Ustalenie wartości tych parametrów można przenieść do innego wątku a ustalone wartości przekazać do Compose za pomocą mutableStateOf lub LiveData Cykl życia elementów komponowalnych Cykl życia – podstawowe informacje Kompozycja to drzewiasta struktura elementów, które opisują interfejs użytkownika Kiedy Compose uruchamia elementy kompozycji po raz pierwszy (początkowa kompozycja) zaczyna śledzić elementy, które wywołano aby opisać interfejs użytkownika Gdy stan aplikacji ulegnie zmianie, Compose planuje/zleca ponowną kompozycję Rekompozycja ma miejsce, gdy Jetpack Compose ponownie „komponuje” elementy, które mogły ulec zmianie w reakcji na zmiany stanu, a następnie aktualizuje kompozycję, aby odzwierciedlić wszelkie zmiany Kompozycję można utworzyć jedynie poprzez kompozycję początkową Jedynym sposobem modyfikacji kompozycji jest rekompozycja Cykl życia – podstawowe informacje https://developer.android.com Cykl życia elementu w kompozycji w kompozycji: wchodzi do kompozycji zostaje ponownie skomponowany 0 lub więcej razy opuszcza kompozycję Cykl życia – podstawowe informacje Rekompozycja jest zwykle wyzwalana przez zmianę obiektu stanu (State) Compose śledzi je i uruchamia wszystkie elementy składowe w kompozycji, które czytają ten konkretny stan (State), oraz wszelkie wywoływane przez nie funkcje komponowalne, których nie można pominąć Jeśli funkcja komponowalna jest wywoływana wiele razy, w kompozycji umieszczanych jest wiele instancji (różne kolory na rysunku) Każde wywołanie ma swój własny cykl życia w kompozycji https://developer.android.com @Composable fun MyComposable() { Column { Text("Hello") Text("World") } } Cykl życia – zachowanie elementów w kompozycji Instancja elementu komponowalnego w kompozycji jest identyfikowana przez miejsce jego wywołania (ang. call site). Miejsce wywołania to lokalizacja kodu źródłowego, z której wywoływana jest funkcja komponowalna. Ma ono wpływ na jego miejsce w kompozycji, a co za tym idzie, w drzewie interfejsu użytkownika Kompilator Compose traktuje każde miejsce wywołania jako osobną instancję Wywoływanie funkcji komponowalnych z wielu miejsc spowoduje utworzenie wielu instancji w kompozycji Jeśli podczas rekompozycji funkcja komponowalna wywoła inne elementy składowe niż poprzednio, Compose określi, które elementy kompozycji zostały wywołane, a które nie W przypadku elementów, które zostały wywołane w obu kompozycjach, Compose nie wykona ich ponownego komponowania, jeśli ich dane wejściowe nie uległy zmianie Cykl życia – zachowanie elementów w kompozycji @Composable Funkcja LoginScreen warunkowo wywołuje fun LoginScreen(showError: Boolean) { funkcję LoginError ale zawsze wywołuje if (showError) { funkcję LoginInput LoginError() } Każde wywołanie ma unikalne miejsce LoginInput() // miejsce wywołania wpływa na wywołania, którego kompilator użyje do // położenie w kompozycji jednoznacznej identyfikacji } @Composable Mimo że LoginInput nie zawsze jest fun LoginInput() { } wywoływany jako pierwszy instancja @Composable LoginInput zostanie zachowana podczas fun LoginError() { } rekompozycji (ten sam kolor na rysunku) https://developer.android.com Cykl życia – umieszczanie dodatkowych informacji Wielokrotne wywołanie funkcji komponowalnej spowoduje wielokrotne dodanie elementu do kompozycji W wypadku wielokrotnego wywoływania funkcji komponowalnej z tego samego miejsca Compose nie będzie mogło jednoznacznie zidentyfikować każdego wywołania tylko na podstawie miejsca Używa wtedy kolejności wywołań aby zachować odrębność instancji Czasami jest to wystarczające rozwiązanie, ale w niektórych przypadkach może powodować niewłaściwe działanie aplikacji Cykl życia – umieszczanie dodatkowych informacji Jeśli na dole listy zostanie dodany @Composable fun MoviesScreen(movies: List) { nowy film, Compose będzie mogło Column { for (movie in movies) { ponownie wykorzystać instancje // elementy komponowalne MovieOverview // są umieszczane w kompozycji z uwzględnieniem znajdujące się już w kompozycji, // ich indeksu w pętli MovieOverview(movie) ponieważ ich lokalizacja na liście nie } } uległa zmianie } https://developer.android.com Cykl życia – umieszczanie dodatkowych informacji @Composable Jeśli lista filmów ulegnie zmianie poprzez fun MovieOverview(movie: Movie) { dodanie na początek lub na środek listy, Column { usunięcie lub zmianę kolejności elementów, // Jeżeli wykona się rekompozycja // MovieOverview podczas pobierania spowoduje to zmianę kompozycji we // obrazu, zostanie anulowana i wykonana wszystkich wywołaniach MovieOverview, // ponownie val image = loadNetworkImage(movie.url) których parametr wejściowy zmienił pozycję MovieHeader(image) na liście (w przykładzie parametrem jest obraz) } } https://developer.android.com Cykl życia – umieszczanie dodatkowych informacji Najlepiej gdy tożsamość instancji elementu komponowalnego (np. MovieOverview) była powiązana z tożsamością przekazywanych do niej danych (np. filmu) Wtedy przy zmianie kolejności w danych wejściowych wystarczy zmienić odpowiednio kolejność instancji w drzewie kompozycji Compose umożliwia poinformowanie środowiska uruchomieniowego o wartości używanej do identyfikacji elementu komponowalnego Owijając blok kodu wywołaniem key (id1, id2,...) {}, można określić wartości które użyte w celu zidentyfikowania tej instancji w kompozycji Wartość klucza nie musi być unikatowa w skali globalnej, musi być unikatowa jedynie wśród wywołań elementów składowych w miejscu wywołania Niektóre obiekty komponowalne mają wbudowaną obsługę kluczy elementów komponowalnych np. LazyColumn Cykl życia – umieszczanie dodatkowych informacji @Composable @Composable fun MoviesScreenWithKey(movies: List) { fun MoviesScreenLazy(movies: List) { Column { LazyColumn { for (movie in movies) { items(movies, key = // Unikalny identyfikator filmu { movie -> movie.id }) { movie -> key(movie.id) { MovieOverview(movie) MovieOverview(movie) } } } } } } } https://developer.android.com Typy stabilne Stabilność typów umożliwia optymalizację rekompozycji Gdy typy wszystkich parametrów funkcji komponowalnej są stabilne, wartości parametrów są porównywane pod kątem równości na podstawie pozycji elementu w drzewie interfejsu użytkownika Rekompozycja jest pomijana, jeśli wszystkie parametry funkcji komponowalnej są stabilne i ich wartości nie uległy zmianie od poprzedniego wywołania Typ stabilny musi spełniać następujący kontrakt: Wynik equals() dla dwóch konkretnych instancji będzie zawsze taki sam Jeśli właściwość publiczna tego typu ulegnie zmianie to kompozycja musi zostać powiadomiona Wszystkie typy publicznych właściwości rozważanego typu również są stabilne Typy stabilne Kompilator Compose zawsze potraktuje następujące typy jako stabilne (ponieważ są niezmienne (immutable)): Typy proste: Boolean, Int, Long, Float, Char itp. String Typy funkcji (lambda) Ważnym stabilnym typem jest MutableState - jeśli wartość jest przechowywana w MutableState to obiekt stanu jest uważany za stabilny (mimo tego, że wartość może się zmienić), ponieważ Compose zostanie powiadomiony o wszelkich zmianach właściwości State Compose uważa typ za stabilny tylko wtedy, gdy może to „wywnioskować” Interfejsy oraz typy ze zmiennymi (mutable) publicznymi właściwościami są traktowane jako niestabilne Jeśli Compose nie jest w stanie wywnioskować, że typ jest stabilny można wymusić traktowanie go jako stabilnego za pomocą adnotacji @Stable Zarządzanie stanem Stan elementów komponowalnych Funkcje komponowalne mogą używać funkcji remember do przechowywania obiektu w pamięci Wartość obliczona przez funkcję remember jest zachowywana w kompozycji podczas początkowej kompozycji Przechowywana wartość jest zwracana podczas rekompozycji remember może służyć do przechowywania zarówno obiektów zmiennych, jak i niezmiennych Przechowywany obiekt zostanie zapomniany, gdy element komponowalny, który wywołał remember zostanie usunięty z kompozycji Stan elementów komponowalnych Funkcja mutableStateOf() tworzy obserwowalny obiekt MutableState, który jest zintegrowany z Jetpack Compose interface MutableState : State { override var value: T } Wszelkie zmiany wartości value powodują zaplanowane/zlecenie rekompozycji wszystkich funkcji komponowalnych, które odczytują value Istnieją trzy sposoby zadeklarowania obiektu MutableState w elemencie komponowalnym val mutableState = remember { mutableStateOf(initialValue) } var value by remember { mutableStateOf(initialValue) } val (value, setValue) = remember { mutableStateOf(initialValue) } Stan elementów komponowalnych @OptIn(ExperimentalMaterial3Api::class) @Composable fun HelloContent() { Column(modifier = Modifier.padding(16.dp)) { var name by remember { mutableStateOf("") } if (name.isNotEmpty()) { Text( text = "Hello, $name!", modifier = Modifier.padding( bottom = 8.dp), style = MaterialTheme.typography. bodyMedium ) } OutlinedTextField( Zapamiętanych wartości można używać value = name, onValueChange = { name = it }, jako parametrów do innych funkcji label = { Text("Name") } komponowalnych ale też w wyrażeniach ) } logicznych wpływających na wyświetlanie } niektórych elementów Stan elementów komponowalnych remember zachowuje stan tylko podczas rekompozycji (nie jest zachowywany przy zmianach konfiguracji) rememberSaveable zapisuje każdą wartość, którą można zapisać w Bundle W przypadku innych typów można wartości można przekazać niestandardowy obiekt zapisujący UWAGA: używanie (bez obiektów obserwowalnych) zmiennych obiektów, takich jak ArrayList lub mutableListOf() do przechowywania stanu w aplikacji Compose, powoduje, że użytkownicy widzą nieprawidłowe lub nieaktualne dane w aplikacji – nie są obserwowalne przez Compose Zaleca się użycie obserwowalnego pojemnika na dane, takiego jak State i niezmiennej listOf() Istnieją specjalizowane pojemniki na różne typy np. mutableStateListOf() (SnapshotStateList) do przechowywania list Elementy z i bez stanu Element komponowalny, który używa funkcji remember do przechowywania obiektu (tworzy stan wewnętrzny) - jest komponentem ze stanem Funkcja HelloContent() (2 slajdy wcześniej) jest przykładem komponowalnej funkcji stanowej (przechowuje imię) Funkcje komponowalne z wewnętrznym stanem są zwykle mniej przydatne do ponownego użycia i trudniejsze do przetestowania Element komponowalny bezstanowy to obiekt, który nie przechowuje żadnego stanu Łatwym sposobem na stworzenie elementu bezstanowego jest zastosowanie wyciągania stanu (state hoisting) Tworząc elementy komponowalne wielokrotnego użytku warto stworzyć i udostępnić wersję stanową i bezstanową Wyciąganie stanu (state hoisting) Wyciąganie stanu w Compose to wzorzec polegający na przeniesieniu stanu do elementu wywołującego element komponowalny w celu uczynienia elementu wywoływanego bezstanowym Ogólny wzorzec wyciągania stanu w Jetpack Compose polega na zastąpieniu zmiennej stanu w funkcji dwoma parametrami tej funkcji: value: T - aktualna wartość do wyświetlenia (wartość stanu) onValueChange: (T) -> Unit – lambda, która reaguje na zmianę wartości, parametrem jest nowa wartość; zdarzenie w razie potrzeby modyfikuje przechowywany stan W razie potrzeby można dodać więcej parametrów np. dwie lambdy dla zdarzeń onExpand i onCollapse elementu ExpandingCard Wyciąganie stanu (state hoisting) Stan wyciągnięty w ten sposób ma następujące właściwości: Stanowi pojedyncze źródło prawdy – jeżeli przeniesiemy stan wyżej (zamiast go powielić) mamy pewność że istnieje tylko jedna kopia i nie powstaną niespójności Jest hermetyzowany (całkowicie wewnętrzny) - tylko stanowe elementy komponowalne mogą modyfikować swój stan Może być udostępniony – wyciągnięty stan możemy przekazywać zainteresowanym elementom Może być przechwycony – elementy wywołujące bezstanowe elementy komponowalne mogą zignorować lub zmodyfikować zdarzenie przed zmianą stanu Oddzielony (decoupled) - stan elementu bezstanowego (nieprzechowującego swojego stanu) może być przechowywany w dowolnym miejscu np. w obiekcie ViewModel Wyciąganie stanu (state hoisting) - przykład @Composable fun HelloScreen() { var name by rememberSaveable { mutableStateOf("") } HelloContent(name = name, onNameChange = { name = it }) } @Composable fun HelloContent(name: String, onNameChange: (String) -> Unit) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "Hello, $name", modifier = Modifier.padding(bottom = 8.dp), style = MaterialTheme.typography.bodyMedium ) Wzorzec, w którym stan OutlinedTextField(value = name, jest przekazywany w dół onValueChange = onNameChange, a zdarzenia w górę, label = { Text("Name") }) } nazywa się } jednokierunkowym przepływem danych Wyciąganie stanu (state hoisting) – gdzie go umieścić? Zasady pomagające dokąd przenieść stan: co najmniej do najniższego wspólnego rodzica wszystkich elementów komponowalnych, które używają tego stanu (odczytują ten stan) przynajmniej do najwyższego poziomu, na którym ten stan może być zmieniany (zapisywany) jeżeli dwa stany zmieniają się w reakcji na te same zdarzenia, należy je wyciągnąć razem Można wyciągnąć stan wyżej niż wymagają tego te reguły Umieszczenie stanu zbyt nisko utrudnia lub uniemożliwia przestrzeganie wzorca jednokierunkowego przepływu danych Wyciąganie stanu (state hoisting) – rodzaje stanu UI Istnieją dwa typy stanu interfejsu użytkownika: Stan ekranu – stan wyświetlany na ekranie (np. klasa NewsUiState może zawierać artykuły prasowe do wyświetlenia); ten stan jest zwykle połączony z innymi warstwami, ponieważ zawiera dane aplikacji Stan elementu UI - odnosi się do właściwości nieodłącznie związanych z elementami UI, które wpływają na sposób ich renderowania np. element może być pokazany lub ukryty, mieć określoną czcionkę, rozmiar lub kolor czcionki. W przypadku widoków (Views) komponent sam zarządza stanem (jest z natury stanowy i udostępnia metody modyfikacji/odczytywania stanu) W Jetpack Compose stan jest zewnętrzny; przykładem stanu jest ScaffoldState dla elementu Scaffold Wyciąganie stanu (state hoisting) – rodzaje logiki Logika w aplikacji może być logiką biznesową lub logiką interfejsu użytkownika: Logika biznesowa - implementacja wymagań produktu dotyczących danych aplikacji (np. dodanie artykułu do zakładek); logika biznesowa jest zwykle umieszczana w warstwie domeny lub danych; pojemnik przechowujący stan zazwyczaj deleguje tę logikę do tych warstw (poprzez wywołania metod z tych warstw) Logika interfejsu użytkownika - jest powiązana ze sposobem wyświetlania stanu interfejsu użytkownika na ekranie (np. logika nawigacji do określonego ekranu, gdy użytkownik kliknie przycisk) Stan elementu i logika UI Gdy logika interfejsu użytkownika musi odczytywać/zapisać stan, należy ograniczyć czas istnienia stanu zgodnie z jego cyklem życia = należy wyciągnąć stan na właściwy poziom w funkcjach komponowalnych lub umieścić go w zwykłej klasie Element komponowalny jako właściciel stanu - umieszczenie logiki i stanu elementów UI w elementach komponowalnych jest dobrym podejściem, jeśli stan i logika są proste. Stan może być bezpośrednio w elemencie albo „wyciągnięty” na odpowiedni poziom Stan nie zawsze musi być wyciągany – może pozostać wewnętrzny jeżeli żaden inny element komponowalny nie musi go kontrolować – często jest tak np. ze stanem animacji elementu Stan elementu i logika UI – „wyciąganie” nie jest wymagane @Composable fun ChatBubble(message: Message) { var showDetails by rememberSaveable { mutableStateOf(false) } //czy //element ma być rozwinięty ClickableText( text = AnnotatedString(message.content), onClick = { showDetails = !showDetails } // bardzo prosta logika UI – // zwijamy/rozwijamy element ) if (showDetails) { Text(message.timestamp) } } Tutaj stan może być przechowywany wewnątrz elementu komponowalnego - nie ma potrzeby kontrolowania go przez żaden inny obiekt komponowalny Stan elementu i logika UI – „wyciąganie” w obrębie funkcji komponowalnych Sytuacją kiedy „wyciąganie” stanu jest konieczne jest sytuacja kiedy ten sam stan wykorzystuje wiele elementów komponowalnych (logika UI jest wykonywana w kilku miejscach) Wyciągnięcie stanu poprawia możliwości ponownego użycia i testowania Przykład Fragment czatu Przycisk JumpToBottom i pole https://developer.android.com UserInput przewijają listę w dół Stan elementu i logika UI – „wyciąganie” w obrębie funkcji komponowalnych @Composable private fun ConversationScreen(messages: List) { val scope = rememberCoroutineScope() val lazyListState = rememberLazyListState() // Stan wyciągnięty do ekranu // rozmowy MessagesList(messages, lazyListState) // Używamy tego samego stanu w // liście wiadomości UserInput( onMessageSent = { // logika UI modyfikuje stan scope.launch { lazyListState.scrollToItem(0) } }, ) } Stan elementu i logika UI – „wyciąganie” w obrębie funkcji komponowalnych @Composable private fun MessagesList( messages: List, lazyListState: LazyListState = rememberLazyListState() // domyślny stan ) { LazyColumn( state = lazyListState // przekazujemy "wyciągnięcy" stan do listy ) { items(messages, key = { message -> message.id} ) { message -> MessageBubble(item = message) } } val scope = rememberCoroutineScope() JumpToBottom(onClicked = { scope.launch { lazyListState.scrollToItem(0) // logika UI modyfikuje stan } }) } Stan elementu i logika UI – „wyciąganie” w obrębie funkcji komponowalnych Warto zauważyć, że stan listy (parametr lazyListState funkcji MessagesList) ma wartość domyślną (rememberLazyListState()) Jest to typowy wzorzec w aplikacjach Compose Dzięki temu funkcje komponowalne są bardziej elastyczne i nadają się do ponownego użycia Użycie w takiej funkcji komponowalnej w miejscach aplikacji, które nie wymagają kontrolowana stanu jest łatwiejsze (np. podczas testowania lub tworzenia podglądu) Stan elementu i logika UI – stan w osobnej (zwykłej) klasie Kiedy element komponowalny zawiera złożoną logikę interfejsu użytkownika, która korzysta z jednego lub wielu pól stanu elementu UI powinien delegować przechowywanie stanu do osobnej (zwykłej) klasy – właściciela stanu (łatwiejsze testowanie w izolacji i zmniejszenie złożoności) To podejście sprzyja zasadzie separacji zagadnień (separation of concerns): Funkcja komponowalna odpowiada za emitowanie elementów interfejsu użytkownika Właściciel stanu - stanu zawiera logikę interfejsu użytkownika i przechowuje stan elementu UI Właściciele stanu (zwykłe obiekty) zapewniają odpowiednie funkcje elementom komponowalnym Właściciele stanu (zwykłe obiekty) są tworzone i zapamiętywane w kompozycji Ponieważ podlegają cyklowi życia elementu komponowalnego, mogą przyjmować typy dostarczane przez Compose, np. RememberNavController() lub RememberLazyListState() Stan elementu i logika UI – stan w osobnej (zwykłej) klasie Przykładem takiego podejścia jest LazyListState, zaimplementowana w celu kontrolowania UI elementów LazyColumn lub LazyRow LazyListState hermetyzuje stan LazyColumn przechowujący scrollPosition dla tego elementu; udostępnia także metody modyfikowania pozycji przewijania @Stable class LazyListState constructor( firstVisibleItemIndex: Int = 0, firstVisibleItemScrollOffset: Int = 0 ) : ScrollableState { //Pojemnik bieżącej pozycji przewijania listy private val scrollPosition = LazyListScrollPosition( firstVisibleItemIndex, firstVisibleItemScrollOffset ) suspend fun scrollToItem() { } override suspend fun scroll() { } suspend fun animateScrollToItem() { } } Stan elementu i logika UI – stan w osobnej (zwykłej) klasie Zwiększenie złożoności elementu komponowanego zwiększa potrzebę zastosowania właściciela stanu Złożoność może dotyczyć logiki interfejsu użytkownika lub liczby elementów stanu, które należy śledzić Innym powszechnym wzorcem jest użycie zwykłej klasy właściciela stanu do obsługi głównych/złożonych (tych znajdujących się na górze hierarchii) funkcji komponowalnych w aplikacji; można użyć takiej klasy do hermetyzacji stanu na poziomie aplikacji (np. stanu nawigacji) Logika biznesowa Właściciel stanu na poziomie ekranu odpowiada za następujące zadania: Zapewnienie dostępu do logiki biznesowej aplikacji, która zwykle jest umieszczona w innych warstwach aplikacji, takich jak warstwa biznesowa warstwa danych Przygotowanie danych aplikacji do prezentacji na konkretnym ekranie; dane po przygotowaniu stają stanem ekranu Logika biznesowa – ViewModel jako właściciel stanu Modele widoku (ViewModel z Android Architecture Components) nadają się one do zapewnienia dostępu do logiki biznesowej i przygotowania danych aplikacji do prezentacji na ekranie Stan interfejsu użytkownika „wyciągnięty” do modelu widoku jest poza kompozycją Modele widoku są dostarczane framework a czas ich istnienia jest kontrolowany przez ViewModelStoreOwner (który może być aktywnością, fragmentem, grafem nawigacji, cel grafu nawigacji) https://developer.android.com ViewModel jest źródłem prawdy i najniższym w hierarchii wspólnym przodkiem stanu interfejsu użytkownika. Logika biznesowa – ViewModel jako właściciel stanu Zgodnie z wcześniejszymi definicjami (s. 49/50) stan ekranu jest tworzony poprzez zastosowanie reguł biznesowych Biorąc pod uwagę, że często za stosowanie reguł biznesowych odpowiedzialny jest właściciel stanu na poziomie ekranu to stan ekranu jest „wyciągany” właśnie do właściciela stanu na poziomie ekranu – często do ViewModel ViewModel zazwyczaj jest wykorzystywany przez główną funkcję komponowalną (na poziomie ekranu); aby zapewnić dostęp do logiki biznesowej ViewModel nie powinien być przekazywany do funkcji niższego poziomu Logika biznesowa – ViewModel jako właściciel stanu class ConversationViewModel( channelId: String, messagesRepository: MessagesRepository ) : ViewModel() { val messages = messagesRepository // przepływ (flow) komunikatów.getLatestMessages(channelId).stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) // logika biznesowa fun sendMessage(message: Message) { } } ViewModel konwersacji Logika biznesowa – ViewModel jako właściciel stanu @Composable private fun ConversationScreen( conversationViewModel: ConversationViewModel = viewModel() ) { val messages by conversationViewModel.messages.collectAsStateWithLifecycle() ConversationScreen( messages = messages, onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) } ) } @Composable private fun ConversationScreen( messages: List, onSendMessage: (Message) -> Unit ) { MessagesList(messages, onSendMessage) } Elementy komponowalne konsumują stan ekranu „wyciągnięty” do ViewModel; ViewModel nie jest przekazywany niżej Aby użyć funkcji viewModel() wymagane jest dodanie zależności androidx.lifecycle:lifecycle- viewmodel-compose „Wiercenie właściwości” (property drilling) „Wiercenie właściwości” odnosi się do przekazywania danych przez kilka zagnieżdżonych komponentów podrzędnych do miejsca, w którym są one odczytywane Typowym przykładem sytuacji, w której może pojawić się wiercenie właściwości w aplikacji Compose, jest wstrzyknięcie właściciela stanu na poziomie ekranu (na najwyższym poziomie) i przekazanie stanu i zdarzeń do elementów podrzędnych Eksponowanie zdarzeń jako osobnych parametrów lambda poprawia widoczność odpowiedzialności funkcji komponowalnej (od razu widać, co robi) „Wiercenie właściwości” jest lepszym rozwiązaniem niż tworzenie klas opakowujących w celu hermetyzacji stanu i zdarzeń w jednym miejscu, ponieważ zmniejsza to widoczność odpowiedzialności funkcji komponowalnych Przekazywanie funkcjom komponowalnym tylko tych parametrów, których potrzebują, jest dobrą praktyką Logika biznesowa a stan elementu Można przenieść stan elementu interfejsu użytkownika do właściciela stanu na poziomie ekranu, jeśli istnieje logika biznesowa, która wymaga jego odczytania lub zapisania. Jeśli wartość nie jest potrzebna logice biznesowej, nie powinna być „wyciągana” do obiektu odpowiedzialnego za stan na poziomie ekranu. Powinna być zdefiniowana i przechowywana w interfejsie użytkownika, bliżej funkcji komponowalnej, która jej potrzebuje Możliwe jest, że element komponowany na poziomie ekranu będzie miał zarówno ViewModel zapewniający dostęp do logiki biznesowej oraz stan przechowywany z zwykłej klasie, która zarządza logiką interfejsu użytkownika i stanem elementów interfejsu użytkownika Logika biznesowa a stan elementu Za każdym razem, gdy użytkownik wpisuje nowe tekst do pola, aplikacja wywołuje logikę biznesową w celu przedstawienia sugestii Sugestie pochodzą z warstwy danych, a logika wyznaczania listy sugestii użytkowników jest logiką biznesową class ConversationViewModel() : ViewModel() { // wyciągnięty stan elementu var inputMessage by mutableStateOf("") private set //stan elementu jest wykorzystywany przy tworzeniu stanu //ekranu val suggestions: StateFlow = snapshotFlow { inputMessage }.filter { hasSocialHandleHint(it) }.mapLatest { getHandle(it) }.mapLatest { repository.getSuggestions(it) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) fun updateInput(newInput: String) { inputMessage = newInput } } Inne wspierane typy obserwowalne Nie jest wymagane przechowywanie stanu w MutableState Przed odczytaniem wartości z innego typu obserwowalnego należy go przekonwertować na State (aby umożliwić śledzenie zmian przez Compose) Konwersja typów powinna odbywać się w funkcji komponowalnej z użyciem z jednej z funkcji wymienionych poniżej Compose posiada funkcje, które umożliwiają konwersję: collectAsStateWithLifecycle() pobiera wartości z Flow uwzględniając cykl życia (CREATED, DESTROYED, INITIALIZED, RESUMED, STARTED; umożliwia oszczędzanie zasobów); reprezentuje najnowszą wartość wyemitowaną za pomocą State; zalecany sposób pobierania wartości z przepływów ( Flows) w aplikacjach dla Androida; wymaga dodania zależności: androidx.lifecycle:lifecycle-runtime-compose Inne wspierane typy obserwowalne collectAsState() jest podobny do collectAsStateWithLifecycle() - przeznaczony do pobierania wartości z Flow w przypadku kodu niezależnego od platformy; nie wymaga dodawania zależności observeAsState() - rozpoczyna obserwację danych LiveData i reprezentuje ich wartości poprzez State; wymaga dodania zależności: androidx.compose.runtime:runtime-livedata subscribeAsState() - zestaw funkcji rozszerzających, które przekształcają reaktywne strumienie RxJava2 lub RxJava3 (np. Single, Observable, Completable) w State; wymagają dodania zależności: androidx.compose.runtime:runtime-rxjava2 lub androidx.compose.runtime:runtime-rxjava3 Zachowywanie stanu UI (związanego z logiką UI) W zależności od tego, gdzie umieszczony jest stan i logika, można używać różnych API do przechowywania i przywracania stanu interfejsu użytkownika (zazwyczaj kombinacji API) Aplikacja może utracić swój stan UI z powodu następujących zdarzeń: Zmiana konfiguracji – aktywność jest niszczona i odtworzona, chyba że zmiana konfiguracji zostanie obsłużona ręcznie Śmierć procesu inicjowana przez system - aplikacja jest w tle, a urządzenie zwalnia zasoby (takie jak pamięć) aby mogły być wykorzystane przez inne procesy (śmierć procesu inicjowana przez system różni się od śmierci procesu inicjowanej przez użytkownika, w której użytkownik jawnie zamyka aktywność) Zgodnie z najlepszą praktyką należy przynajmniej zachować stan związany z wprowadzaniem danych przez użytkownika i nawigacją (np. pozycję przewijania listy, identyfikator elementu, w którym użytkownik wyświetlił szczegóły, wybrane preferencje/ustawienia, dane w polach tekstowych) Zachowywanie stanu UI (związanego z logiką UI) Funkcja rememberSaveable podobnie jak remember zachowuje stan podczas rekompozycji, ale w odróżnieniu od remember odtwarza stan podczas odtwarzania aktywności lub procesu Obsługiwane są typy danych, które można dodać do obiektów Bundle W przypadku innych typów można użyć Adnotacji @Parcelize Funkcji mapSaver() Funkcji listSaver() Zachowywanie stanu UI (związanego z logiką UI) Typowym zastosowanie rememberSaveable() jest przechowywanie stanu na czas odtworzenia aktywności/procesu w przypadku gdy stan UI znajduje się w funkcjach komponowalnych, albo w zwykłych klasach (istniejących w kompozycji) @Composable fun ChatBubble(message: Message) { var showDetails by rememberSaveable { mutableStateOf(false) } ClickableText( text = AnnotatedString(message.content), onClick = { showDetails = !showDetails } ) if (showDetails) { Text(message.timestamp) } } Adnotacja @Parcelize Dodanie do obiektu adnotacji @Parcelize powoduje, że obiekt staje się „paczkowalny” i może być zapisany w Bundle Wymaga dodania pluginu id("kotlin-parcelize") do build.gradle.kts (dla właściwego modułu np. app) @Parcelize data class City(val name: String, val country: String) : Parcelable @Composable fun CityScreen() { var selectedCity = rememberSaveable { mutableStateOf(City("Madrid", "Spain")) } } Funkcja mapSaver() Funkcja mapSaver() pozwala na zdefiniowanie własnej reguły konwersji obiektu na zbiór wartości, które będzie można zapisać w Bundle val CitySaver = run { val nameKey = "Name" val countryKey = "Country" mapSaver( save = { mapOf(nameKey to it.name, countryKey to it.country) }, restore = { City(it[nameKey] as String, it[countryKey] as String) } ) } @Composable fun CityScreen() { var selectedCity = rememberSaveable(stateSaver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) } } Funkcja listSaver() Funkcja listSaver() pozwala na zapisanie podobnego efektu (bez definiowania kluczy mapy data class City(val name: String, val country: String) val CitySaver = listSaver( save = { listOf(it.name, it.country) }, restore = { City(it as String, it as String) } ) @Composable fun CityScreen() { var selectedCity = rememberSaveable(stateSaver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) } } Zachowywanie stanu UI – najlepsze praktyki Funkcja rememberSaveable() używa obiektów Bundle do przechowywania stanu interfejsu użytkownika Obiekt Bundle jest współdzielony przez inne API, które również do niego zapisują, np. onSaveInstanceState() Rozmiar obiektu Bundle jest ograniczony i przechowywanie dużych obiektów może prowadzić do wyjątków TransactionTooLarge Może to być szczególnie problematyczne w przypadku aplikacji z pojedynczą aktywnością (Single Activity Application), w których w całej aplikacji używany jest ten sam obiekt Bundle Zalecenia: nie należy przechowywać w obiekcie Bundle dużych, złożonych obiektów ani list obiektów należy przechowywać minimalny wymagany stan, taki jak identyfikatory lub klucze, i używać ich do przywracania bardziej złożonego stanu UI Zachowywanie stanu ekranu (związanego z logiką biznesową) Do zachowywania stanu elementu UI znajdującego się w ViewModelu (wymaganego przez logikę biznesową) można użyć API ViewModel Zmiana konfiguracji - zaletą używania ViewModel w aplikacjach dla Androida jest to, że ma wbudowaną obsługę zmian konfiguracji (gdy aktywność zostanie zniszczona i odtworzona, obiekt ViewModel jest przechowywany w pamięci; po odtworzeniu aktywności stara instancja ViewModel jest dołączana do nowej instancji aktywności) Śmierć procesu inicjowana przez system - instancja ViewModel takiego zdarzenia nie przetrwa; aby zachować stan należy użyć API SavedStateHandle SaveStateHandle – najlepsze praktyki SavedStateHandle używa również obiektów Bundle do przechowywania stanu Należy go używać tylko do przechowywania prostych elementów stanu (tzw. stanu przejściowego) Stan ekranu, który jest generowany poprzez zastosowanie reguł biznesowych i dostęp do warstw aplikacji innych niż interfejs użytkownika, nie powinien być przechowywany w SavedStateHandle (może być złożony i zajmować dużo pamięci) Do przechowywania złożonych lub dużych danych należy używać mechanizmów trwałego przechowywania danych (np. bazy) Po odtworzeniu procesu ekran jest odtwarzany z przywróconym stanem przejściowym, który został zapisany w SavedStateHandle (jeśli istnieje); a stan ekranu jest ponownie generowany z wykorzystaniem warstwy danych SaveStateHandle – API saveable API saveable jest jednym z dwóch dostępnych rozwiązań w SavedStateHandle API saveable używa się aby odczytywać i zapisywać stan elementu UI jako MutableState i równocześnie aby zapewnić że dane przetrwają odtwarzanie aktywności oraz odtwarzanie procesu API saveable standardowo obsługuje podstawowe typy Podobnie jak w przypadku rememberSaveable można określić obiekt zapisujący (parametr stateSaver funkcji saveable()) API saveable jest eksperymentalne (2023) SaveStateHandle – API saveable //tworzenie view modelu z uchwytem zapisywania stanu val viewModel = ConversationViewModel(SavedStateHandle()) //pole, w którym użytkownik może coś wpisać @Composable fun UserInput() { TextField( value = viewModel.message, onValueChange = { viewModel.update(it) } ) } SaveStateHandle – API saveable class ConversationViewModel( savedStateHandle: SavedStateHandle ) : ViewModel() { //wykorzystanie API saveable i standardowego obiektu towarzyszącego Saver var message by savedStateHandle.saveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } private set //wywoływana, gdy użytkownik coś wpisze w polu tekstowym fun update(newMessage: TextFieldValue) { message = newMessage } //... } SaveStateHandle – StateFlow StateFlow jest drugim rozwiązaniem dostępnym w SaveStateHandle getStateFlow() używa się do przechowywania stanu i wykorzystania go jako przepływu z SavedStateHandle StateFlow jest tylko do odczytu API wymaga określenia klucza, który będzie identyfikował strumień wartości; za pomocą klucza można pobrać StateFlow i odczytać najnowszą wartość SaveStateHandle – StateFlow //API state flow wymaga określenia klucza private const val CHANNEL_FILTER_SAVED_STATE_KEY = "ChannelFilterKey" //rodzaje kanałów, które może wybraż użytkownik enum class ChannelsFilterType { ALL_CHANNELS, RECENT_CHANNELS, ARCHIVED_CHANNELS } //view model z możliwością filtrowania kanałów wiadomości class ChannelViewModel( channelsRepository: ChannelsRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel() { //właściwość, która będzie zachowana (rodzaj filtra) private val savedFilterType: StateFlow = savedStateHandle.getStateFlow(key = CHANNEL_FILTER_SAVED_STATE_KEY, initialValue = ChannelsFilterType.ALL_CHANNELS ) SaveStateHandle – StateFlow //flow z listą kanałów (wybranych z repozytorium na podstawie filtra) private val filteredChannels: Flow = combine(channelsRepository.getAll(), savedFilterType) { channels, type -> filter(channels, type) }.onStart { emit(emptyList()) } //gdy użytkownik określi filtr (typ kanałów) wybór przekazywany jest do //stateFlow fun setFiltering(requestType: ChannelsFilterType) { savedStateHandle[CHANNEL_FILTER_SAVED_STATE_KEY] = requestType } //... } SaveStateHandle – StateFlow savedFilterType jest zmienną StateFlow, która przechowuje typ filtra zastosowany do listy kanałów aplikacji do czatowania za każdym razem, gdy użytkownik wybierze nowy typ filtra, wywoływana jest metoda setFiltering() savedFilterType to przepływ emitujący najnowszą wartość zapisaną w kluczu filteredChannels subskrybuje przepływ w celu przeprowadzenia filtrowania kanałów Więcej informacji na temat interfejsu API getStateFlow() można znaleźć w dokumentacji SavedStateHandle Zachowywanie stanu - podsumowanie W przypadku stanu używanego w logice UI należy użyć metody rememberSaveable() W przypadku stanu używanego w logice biznesowej, jeżeli jest przechowywany w ViewModel, należy zachować go za pomocą SavedStateHandle Należy używać rememberSaveable i SavedStateHandle do przechowywania niewielkich ilości danych (np. identyfikatorów elementów) - minimum niezbędnego do przywrócenia interfejsu użytkownika do poprzedniego stanu Rozmieszczenie elementów Fazy tworzenia interfejsu w Compose Compose przekształca stan w elementy interfejsu użytkownika poprzez: Kompozycję elementów (composition) Rozmieszczanie elementów (layout) Rysowanie elementów (drawing) https://developer.android.com Layout w Compose Implementacja systemu layoutu Jetpack Compose ma dwa główne cele: Wysoka wydajność Możliwość łatwego pisania niestandardowych układów W przypadku systemu widoków (Views) Androida mogą wystąpić problemy z wydajnością podczas zagnieżdżania niektórych widoków, takich jak RelativeLayout; https://developer.android.com Compose unika wielokrotnego określania rozmiaru elementów - layouty można zagnieżdżać głęboko bez wpływu na wydajność. Obok przedstawiono 3 podstawowe układy elementów Column Układ komponowalny, który umieszcza swoich potomków w pionie Elementy kolumny domyślnie nie przewijają (aby dodać przewijanie Modifier.verticalScroll) Kolumna umożliwia przypisanie wysokości potomków a pomocą modyfikatora ColumnScope.weight Jeśli potomkowi nie zostanie przypisana waga, zostanie on zapytany o preferowaną wysokość, zanim rozmiary potomków z wagami zostaną obliczone proporcjonalnie do ich wagi w oparciu o pozostałą dostępną przestrzeń Jeśli kolumna jest przewijana w pionie (lub jest w kontenerze przewijanym w pionie), podane wagi zostaną pominięte (dostępne miejsce będzie nieskończone) Jeśli żaden z potomków nie ma wag, kolumna będzie tak mała, jak to możliwe Column Aby zmienić wysokość Kolumny należy skorzystać z modyfikatorów Modifier.height...; np. aby wypełnić dostępną wysokość, można użyć Modifier.fillMaxHeight Jeśli co najmniej jeden potomek kolumny ma wagę, kolumna wypełni dostępną wysokość (nie potrzebny jest Modifier.fillMaxHeight) Jeśli rozmiar kolumny ma być ograniczony, należy zastosować modyfikatory Modifier.height lub Modifier.size. Jeśli rozmiar kolumny jest większy niż suma rozmiarów jej elementów podrzędnych, można określić parametr verticalArrangement. Obok przedstawiono wpływ ustawienia różnych wartości rozmieszczenia https://developer.android.com Column - przykład @Composable fun BugdroidCardColumn() { Column { Text("Andrew Android", style = MaterialTheme.typography.headlineSmall) Text( "3 minutes ago", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } Row Układ komponowalny, który umieszcza swoich potomków w poziomie Domyślnie elementy wiersza się nie przewijają (Modifier.horizontalScroll dodaje przewijanie) Układ Row pozwala na określenie szerokości elementów potomnych za pomocą modyfikatora RowScope.weight Jeśli elementowi potomnemu nie zostanie ustawiona waga, zostanie on zapytany o preferowaną szerokość, zanim rozmiary potomków z wagami zostaną obliczone proporcjonalnie do ich wagi w oparciu o pozostałą dostępną przestrzeń Jeśli wiersz można przewijać w poziomie (lub jest w kontenerze przewijanym w poziomie) podane wagi zostaną pominięte (dostępne miejsce jest nieskończone) Gdy żaden z potomków nie ma określonej wagi wiersz będzie tak mały, jak to możliwe Row Aby zmienić szerokość wiersza należy skorzystać z modyfikatorów Modifier.width...; np. aby wypełnić dostępną szerokość, można użyć Modifier.fillMaxWidth Jeśli co najmniej jeden potomek wiersza ma wagę, wiersz wypełni dostępną szerokość (nie jest potrzebny Modifier.fillMaxWidth) Jeśli rozmiar wiersza ma być ograniczony, należy zastosować modyfikatory Modifier.width lub Modifier.size Jeśli rozmiar wiersza jest większy niż suma rozmiarów jego elementów potomnych, można określić parametr horizontalArrangement; obok znajdują się przykłady dla różnych wartości https://developer.android.com rozmieszczenia Row, Column - przykład @Composable fun BugdroidCardRow() { Row(verticalAlignment = Alignment.CenterVertically) { Image(painterResource( id = R.drawable.ic_android_green_50dp), "bugdroid") Column(modifier = Modifier.padding(start = 8.dp)) { Text("Andrew Android", style = MaterialTheme.typography.headlineSmall) Text( "3 minutes ago", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } Row – arrarangement/aligmnent - przykład @Composable fun BugdroidCardArrangement() { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { //reszta jak wcześniej w BugdroidCardRow Image(painterResource(id = R.drawable.ic_android_green_50dp), "bugdroid") Column(modifier = Modifier.padding(start = 8.dp)) { Text("Andrew Android", style = MaterialTheme.typography.headlineSmall) Text( "3 minutes ago", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } Box Pudełko dopasowuje się do zawartości, z uwzględnieniem narzuconych ograniczeń Kiedy elementy potomne są mniejsze od pudełka, domyślnie zostaną umieszczone zgodnie z wartością parametru contentAlignment Aby określić wyrównanie poszczególnych elementów potomnych należy użyć modyfikatora BoxScope.align Domyślnie zawartość będzie mierzona bez przychodzących ograniczeń minimalnego rozmiaru (min), chyba że parametr propagateMinConstraints ma wartość true Ustawienie propagateMinConstraints na true może być przydatne, gdy pudełko zawiera treść, dla której nie można bezpośrednio określić modyfikatorów i potrzebne jest ustawienie minimalnego rozmiaru zawartości Jeśli propagateMinConstraints jest ustawione na true, minimalny rozmiar ustawiony w pudełku zostanie również zastosowany do zawartości, w przeciwnym razie minimalny rozmiar będzie miał zastosowanie tylko do pudełka Jeśli pudełko ma więcej niż jednego potomka, elementy potomne zostaną ułożone jeden na drugim w kolejności kompozycji Box - przykład @Composable fun BugdroidAvatar() { Box(modifier = Modifier.size(60.dp)) { Image( painterResource(id = R.drawable.ic_android_green_50dp), "bugdroid", Modifier.align(Alignment.Center) ) Box( modifier = Modifier.size(20.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surface).border(2.dp, MaterialTheme.colorScheme.surface, CircleShape).align(Alignment.BottomEnd) ) { Icon( Icons.Rounded.CheckCircle, contentDescription = "Check mark" ) } } } Model układu (layoutu) W modelu układu drzewo interfejsu użytkownika jest rozmieszczane w jednym przebiegu @Composable fun SearchResult() { Każdy węzeł jest najpierw proszony o dokonanie pomiaru samego Row { siebie, a następnie o rekurencyjny pomiar wszystkich elementów Image( //... potomnych ) W trakcie rekurencyjnego pomiaru potomków ograniczenia Column { Text( dotyczące rozmiaru są przekazywane w dół drzewa //... Następnie określa się rozmiar i położenie węzłów liści, a ustalone ) Text( rozmiary i instrukcje dotyczące rozmieszczenia są przekazywane z //... powrotem w górę drzewa ) } Krótko – rodzice są mierzeni przed swoimi potomkami, ale ich } rozmiar jest dobierany i są rozmieszczani później niż dzieci } Rozważmy przykład obok Model układu (layoutu) Funkcja SearchResult() z poprzedniego slajdu tworzy drzewo po prawej W funkcji rozmieszczanie elementów drzewa interfejsu użytkownika jest zgodne z następującą kolejnością: SearchResult Row Węzeł główny Row jest proszony o określenie swojego rozmiaru Image Column Węzeł główny Row prosi pierwszego potomka (Image) o Text określenie swojego rozmiaru Text Image jest liściem drzewa, więc zgłasza swój rozmiar i zwraca instrukcje dotyczące rozmieszczenia Węzeł główny Row prosi swojego drugiego potomka (Column) o określenie swojego rozmiaru Model układu (layoutu) Węzeł Column prosi swojego pierwszego potomka (Text) o określenie swojego rozmiaru Pierwszy węzeł Text jest liściem drzewa, więc raportuje swój rozmiar i zwraca instrukcje dotyczące rozmieszczenia SearchResult Węzeł Column prosi swojego drugiego potomka (Text) o określenie Row swojego rozmiaru Image Column Drugi węzeł Text jest liściem, więc zgłasza swój rozmiar i zwraca Text instrukcje dotyczące rozmieszczenia Text Teraz, gdy węzeł Column zwymiarował i umieścił swoje elementy potomne, może określić swój własny rozmiar i położenie Teraz, gdy węzeł Row zwymiarował i umieścił swoje elementy potomne, może określić swój własny rozmiar i położenie Model układu (layoutu) https://developer.android.com/ Używanie modyfikatorów Modyfikatory Modyfikatory umożliwiają dekorowanie lub modyfikowanie kompozycji Modyfikatory umożliwiają wykonywanie następujących czynności: zmiana rozmiaru, układu, zachowania i wyglądu obiektu komponowalnego dodawanie informacji, takich jak etykiety ułatwień dostępu przetwarzanie danych wprowadzonych przez użytkownika dodawanie interakcji wysokiego poziomu, takich jak umożliwienie kliknięcia, przewijania, przeciągania lub powiększania elementu Modyfikatory są standardowymi obiektami Kotlina; modyfikatory tworzy się wywołując jedną z funkcji klasy Modifier Najlepszą praktyką jest, aby wszystkie funkcje komponowalne przyjmowały parametr modifier i przekazywały ten modyfikator swojemu pierwszemu potomkowi, który emituje interfejs użytkownika; dzięki temu kod będzie łatwiejszy do ponownego użycia Proste modyfikatory - przykład W przykładzie modyfikatory wpływają razem na wygląd elementu padding umieszcza przestrzeń wokół elementu. background ustawia kolor tła @Composable fun Greeting() { Column( modifier = Modifier.padding(24.dp).background(color = MaterialTheme.colorScheme.primary) ) { Text(text = "Hello,", color = MaterialTheme.colorScheme.onPrimary) Text(text = "Compose", color = MaterialTheme.colorScheme.onPrimary) } } Kolejność modyfikatorów - przykład Kolejność funkcji modyfikujących jest istotna Wynika to z faktu, iż każda funkcja wprowadza zmiany w modyfikatorze zwróconym przez poprzednią funkcję @Composable fun Greeting2() { Column( modifier = Modifier.background(color = MaterialTheme.colorScheme.primary).padding(24.dp) ) { Text(text = "Hello,", color = MaterialTheme.colorScheme.onPrimary) Text(text = "Compose", color = MaterialTheme.colorScheme.onPrimary) } } Modyfikator - size Domyślnie układy dostępne w aplikacji Compose dopasowują się rozmiarem do elementów potomnych Można jednak ustawić ich rozmiar za pomocą modyfikatora size Określony rozmiar może nie zostać zachowany, jeśli nie spełnia ograniczeń pochodzących od elementu nadrzędnego układu @Composable fun BugdroidCardModifierSize() { Row( modifier = Modifier.size(width = 400.dp, height = 100.dp), verticalAlignment = Alignment.CenterVertically, ) { //reszta jak wcześniej w BugdroidCardRow //... } } Modyfikator - requiredSize Aby rozmiar był stały niezależnie od przychodzących ograniczeń należy użyć modyfikatora requiredSize W przykładzie, nawet jeśli wysokość elementu nadrzędnego jest ustawiona na 100.dp, wysokość obrazu będzie wynosić 150.dp, (pierwszeństwo ma modyfikator requiredSize) @Composable fun BugdroidCardModifierRequiredSize() { Row( modifier = Modifier.size(width = 400.dp, height = 100.dp), verticalAlignment = Alignment.CenterVertically, ) { Image( painterResource(id = R.drawable.ic_android_green_50dp), "bugdroid", modifier = Modifier.requiredSize(150.dp) ) //reszta jak wcześniej w BugdroidCardRow //... } } Modyfikator – fillMax... Aby element potomny wypełniał całą dostępną wysokość/rozmiar/szerokość dozwoloną przez rodzica, należy użyć modyfikatora fillMaxHeight / fillMaxSize / fillMaxWidth @Composable fun BugdroidCardModifierFillMaxSize() { Row( modifier = Modifier.size( width = 400.dp, height = 100.dp), verticalAlignment = Alignment.CenterVertically, ) { Image( painterResource(id = R.drawable.ic_android_green_50dp), "bugdroid", modifier = Modifier.fillMaxHeight() ) //reszta jak wcześniej w BugdroidCardRow //... } } Modyfikator – padding... Za pomocą modyfikatorów padding można dodawać margines wewnętrzny Można też dodać dopełnienie/margines od linii bazowej tekstu do brzegu układu za pomocą paddingFromBaseline @Composable fun BugdroidCardModifierPadding() { Row( modifier = Modifier.size(width = 400.dp, height = 100.dp), verticalAlignment = Alignment.CenterVertically, ) { //reszta jak wcześniej w BugdroidCardRow... Column(modifier = Modifier.padding(start = 8.dp)) { Text( "Andrew Android", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.paddingFromBaseline(top = 50.dp) ) //reszta jak wcześniej w BugdroidCardRow... } } } Modyfikator - offset Aby ustawić element względem jego pierwotnego położenia należy użyć modyfikatora offset (w osiach x i y) Przesunięcia mogą być dodatnie i ujemne Różnica między dopełnieniem a offsetem polega na tym, że offset nie zmienia wymiarów obiektu komponowalnego Modyfikator offset jest stosowany poziomo zgodnie z kierunkiem tekstu @Composable fun BugdroidCardModifierOffset() { Row( modifier = Modifier.size( width = 400.dp, height = 100.dp), verticalAlignment = Alignment.CenterVertically, ) { //reszta jak wcześniej w BugdroidCardRow... Column(modifier = Modifier.padding(start = 8.dp)) { //reszta jak wcześniej w BugdroidCardRow... Text( "3 minutes ago", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.offset(x = 4.dp) ) } }} Ograniczenia zakresu modyfikatorów W Compose istnieją modyfikatory (scoped modifiers), których można używać tylko wtedy, gdy są stosowane do potomków niektórych elementów komponowalnych - Compose wymusza to za pomocą niestandardowych zakresów Np. modyfikator matchParentSize jest dostępny tylko w BoxScope (jest dostępny tylko w obrębie rodzica Box) Ograniczenie zakresu blokuje dodawanie modyfikatorów, które nie działałyby w innych elementach komponowalnych i zakresach W standardowych widokach (Views) Androida nie ma zabezpieczeń zakresu Modyfikator - matchParentSize Aby element potomny miał ten sam rozmiar co pudełko (Box) bez wpływu na rozmiar pudełka, należy użyć modyfikatora matchParentSize Należy pamiętać, że modyfikator matchParentSize jest dostępny tylko w zakresie Box - można go stosować tylko do bezpośrednich elementów podrzędnych pudełka W przykładzie Spacer pobiera swój rozmiar z nadrzędnego Boxa, który z kolei przejmuje swój rozmiar od największego potomka BugdroidCardRow @Composable fun MatchParentSizeComposable() { Box { Spacer( Modifier.matchParentSize().background(MaterialTheme.colorScheme.surfaceVariant) ) BugdroidCardRow() } } Modyfikator – fillMaxSize w pudełku Jeśli zamiast matchParentSize użyjemy fillMaxSize to Spacer zajmie całe dostępne miejsce dla elementu nadrzędnego, co z kolei spowoduje rozciągnięcie elementu nadrzędnego i wypełnienie całego dostępnego miejsca @Composable fun FillMaxSizeComposable() { Box { Spacer( Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant) ) BugdroidCardRow() } } Modyfikator - weight Domyślnie rozmiar elementu komponowalnego jest definiowany przez jego zawartość (element dopasowuje się do zawartości) Można spowodować aby rozmiar elementu komponowalnego był elastyczny w obrębie rodzica Modyfikator weight jest dostępny tylko w zakresach RowScope i ColumnScope @Composable fun BugdroidCardModifierWeight() { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Image( //reszta jak wcześniej w BugdroidCardRow... modifier = Modifier.weight(2f) ) Column( modifier = Modifier.padding(start = 8.dp).weight(1f) ) { //reszta jak wcześniej w BugdroidCardRow... } }} „Wyciąganie” i wielokrotne wykorzystanie modyfikatorów Można łączyć wiele modyfikatorów; łańcuch modyfikatorów jest tworzony poprzez interfejs Modifier, który reprezentuje uporządkowaną, niezmienną (immutable) listę elementów (Modifier.Elements) Każdy Modifier.Element reprezentuje osobne zachowanie (układ/layout, zachowanie rysowania, zachowania związane z gestami, fokusem, semantyką, zdarzenia wejścia np. kliknięcie) Kolejność elementów ma znaczenie: elementy dodane wcześniej zostaną zastosowane jako pierwsze Czasami ponowne użycie tych samych instancji łańcucha modyfikatorów w elementach komponowalnych może być korzystne „Wyciąganie” i wielokrotne wykorzystanie modyfikatorów Można to osiągnąć, poprzez wyodrębnienie ich do zmiennych i przeniesienie ich do wyższych zakresów (scopes) Wpływa to na czytelność/wydajność ponieważ: Modyfikatory nie będą alokowane ponownie, gdy nastąpi rekompozycja obiektów komponowalnych, które ich używają Łańcuchy modyfikatorów mogą być bardzo długie i złożone - ponowne użycie tej samej instancji może zmniejszyć obciążenie środowiska uruchomieniowego Compose podczas ich porównywania „Wyciągnięcie” modyfikatorów zapewnia czystość, spójność kodu, poprawia łatwość konserwacji kodu Wyodrębnianie i ponowne używanie modyfikatorów w przypadku obserwacji często zmieniającego się stanu Jeżeli w elemencie komponowalnym obserwujemy często zmieniające się stany (np. stan animacji, stan przewijania), może zostać wykonana znaczna liczba rekompozycji Modyfikatory będą alokowane przy każdej rekompozycji i potencjalnie dla każdej klatki. @Composable fun AnimatedCircularProgressIndicator() { var isFinished by rememberSaveable { mutableStateOf(false) } val progress by animateFloatAsState( targetValue = if (isFinished) 1f else 0f, animationSpec = tween(durationMillis = 2500) ) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp)) { Wyodrębnianie i ponowne używanie modyfikatorów w przypadku obserwacji często zmieniającego się stanu Jeżeli w elemencie komponowalnym obserwujemy często zmieniające się stany (np. stan animacji, stan przewijania), może zostać wykonana znaczna liczba rekompozycji Modyfikatory będą alokowane przy każdej rekompozycji i potencjalnie dla każdej klatki. CircularProgressIndicator( //modyfikator alokowany przy każdej klatce modifier = Modifier.size(150.dp).clip(RoundedCornerShape(8.dp)).background( MaterialTheme.colorScheme.surfaceVariant).padding(16.dp), progress = progress ) Button(onClick = { isFinished = !isFinished }) { Text(text = "Start") } } } Wyodrębnianie i ponowne używanie modyfikatorów w przypadku obserwacji często zmieniającego się stanu Zamiast tego można wyodrębnić i ponownie wykorzystać tę samą instancję modyfikatora i przekazać ją do elementu komponowalnego @Composable fun AnimatedCircularProgressIndicatorModifierReused() { //...reszta jak poprzednio val reusableModifier = Modifier.size(150.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceVariant).padding(16.dp) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp)) { CircularProgressIndicator( //używamy tego samego obiektu (nie jest tworzony od nowa) modifier = reusableModifier, progress = progress ) //reszta jak poprzednio... } } Wyodrębnianie i ponowne używanie modyfikatorów bez zakresu (unscoped) Modyfikatory mogą mieć zakres nieograniczony lub ograniczony do określonego elementu komponowalnego W przypadku modyfikatorów bez zakresu można je łatwo wyodrębnić i umieścić poza dowolnymi obiektami komponowalnymi jako proste zmienne @Composable fun BugdroidCardRowUnscopedModifiersReused() { val reusableModifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant).padding(8.dp) //reszta jak w BugdroidCardRow... Text( //... modifier = reusableModifier ) Text( //... modifier = reusableModifier ) } Wyodrębnianie i ponowne używanie modyfikatorów bez zakresu (unscoped) Wyodrębnianie modyfikatorów może być szczególnie korzystne w połączeniu z układami leniwymi (listami) W większości przypadków elementy listy mają dokładnie te same modyfikatory val androidCodeNames = listOf( "Petit Four", "Cupcake", "Donut", "Eclair", "Froyo", "Gingerbread", "Honeycomb", "Ice Cream Sandwich", "Jelly Bean", "KitKat", "Lollipop", "Marshmallow", "Nougat", "Oreo", "Pie", "Quince Tart", "Red Velvet Cake", "Snow Cone", "Snow Cone v2", "Tiramisu", "Upside Down Cake", "Vanilla Ice Cream" ) @Composable fun AndroidListUnscopedModifiersReused() { val itemModifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceVariant).padding(8.dp) Wyodrębnianie i ponowne używanie modyfikatorów bez zakresu (unscoped) Wyodrębnianie modyfikatorów może być szczególnie korzystne w połączeniu z układami leniwymi (listami) W większości przypadków elementy listy mają dokładnie te same modyfikatory val spacerModifier = Modifier.height(8.dp) LazyColumn(modifier = Modifier.padding(8.dp)) { items(androidCodeNames) { name -> Text( name, style = MaterialTheme.typography.headlineSmall, modifier = itemModifier ) Spacer(modifier = spacerModifier) } } } Wyodrębnianie i ponowne używanie modyfikatorów o ograniczonym zasięgu W przypadku modyfikatorów, które są ograniczone do określonych obiektów komponowalnych, można je wyodrębnić do najwyższego możliwego poziomu Wyodrębnione modyfikatory z ograniczonym zakresem powinny być przekazywane do bezpośrednich potomków w tym samym zakresie @Composable fun AndroidColumnScopedModifiersReused() { val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant //ten modyfikator nie ma ograniczonego zakresu val spacerModifier = Modifier.height(8.dp) Column( modifier = Modifier.padding(8.dp).verticalScroll(rememberScrollState()) ) { //... align (na następnym slajdzie) wymaga zakresu kolumny Wyodrębnianie i ponowne używanie modyfikatorów o ograniczonym zasięgu val itemModifier = Modifier.clip(RoundedCornerShape(8.dp)).background(surfaceVariantColor).padding(8.dp) //align wymaga zakresu kolumny (nie można go "wyciągnąć" z //kolumny).align(Alignment.CenterHorizontally) for (androidName in androidCodeNames) { Text( androidName, style = MaterialTheme.typography.headlineSmall, //modyfikatory "wyciągnięte" z konkretnego zakresu mogą //być używane tylko w bezpośrednich potomkach zakresu, w //którym są dostępne (w tym wypadku ColumnScope), w //innych nie będą działać modifier = itemModifier ) Spacer(modifier = spacerModifier) } } } Dalsze łączenie wyodrębnionych modyfikatorów Do łańcuchów modyfikatorów można dołączać kolejne @Composable fun ModifierChaining() { Column { val reusableModifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.primary).padding(12.dp) Text( text = "reusableModifier", color = MaterialTheme.colorScheme.onPrimary, modifier = reusableModifier ) // dołączanie ustawień do modyfikatora val resusableModifier2 = reusableModifier.clickable { } Text( text = "reusableModifier2", color = MaterialTheme.colorScheme.onPrimary, modifier = resusableModifier2 ) //... Dalsze łączenie wyodrębnionych modyfikatorów Można też łączyć łancuchy modyfikatorów ze sobą za pomocą funkcji.then() // modyfikator można dołączyć do innego val otherModifier = Modifier.background(MaterialTheme.colorScheme.secondary) Text( text = "otherModifier", color = MaterialTheme.colorScheme.onSecondary, modifier = otherModifier ) //kolor tła zostatnie nadpisany (jest później) val otherModifier2 = otherModifier.then(reusableModifier) Text( text = "otherModifier", color = MaterialTheme.colorScheme.onPrimary, modifier = otherModifier2 ) } } Ograniczenia i kolejność modyfikatorów Ograniczenia - rodzaje Constraints (ograniczenia) pomagają znaleźć odpowiednie rozmiary węzłów drzewa komponentów Definiują minimalne i maksymalne wartości szerokości i wysokości węzła Kiedy węzeł określa swój rozmiar - powinien on mieścić się w przedziale z ograniczeń Rozmiar węzła może być (rodzaje ograniczeń) Ograniczony (bounded) - ma maksymalną i minimalną szerokość i wysokość Nieograniczony (unbounded) - węzeł nie jest ograniczony do żadnego rozmiaru (maksymalna szerokość/wysokość = nieskończoność) Dokładnie określony (exact) – wartości minimalne i maksymalne są ustawione na tę samą wartość Mieszany (combination) - węzeł ma rozmiar zgodny z kombinacją powyższych typów ograniczeń (np. szerokość ograniczona, wysokość nieskończona) Elementy jako łańcuchy modyfikatorów https://developer.android.com Modyfikatory można sobie wyobrazić jako węzły „opakowujące” elementy układu (layoutu) (rysunek po lewej) Implementacja elementów komponowalnych (Image, Text) składa się z łańcucha modyfikatorów opakowujących pojedynczy węzeł układu (layoutu) (rysunek po prawej) Implementacja wiersza i kolumny to węzły układu (layoutu) opisujące rozmieszczenie potomków Oba rysunki przedstawiają tę samą strukturę; po prawej elementy UI są przedstawione jako łańcuchy modyfikatorów Ograniczenia - algorytm Algorytm określania rozmiarów elementów UI działa w następujący sposób: Aby określić swój rozmiar węzeł nadrzędny w drzewie UI przekazuje ograniczenia swojemu pierwszemu potomkowi; jeżeli jest to modyfikator, który nie ma wpływu na pomiar, przekazuje ograniczenia do następnego modyfikatora ograniczenia są przekazywane w dół drzewa w niezmienionej postaci, chyba że przechodzą przez modyfikator wpływający na pomiar - wtedy odpowiednio zmienia się rozmiar ograniczeń po osiągnięciu liścia - liść określa swój rozmiar na podstawie przekazanych ograniczeń i zwraca ustalony rozmiar swojemu rodzicowi Węzeł główny dostosowuje swoje ograniczenia w oparciu o pomiary pierwszego potomka i wywołuje kolejnego ze skorygowanymi ograniczeniami Po zmierzeniu wszystkich potomków węzeł nadrzędny decyduje o swoim własnym rozmiarze i przekazuje to swojemu rodzicowi Całe drzewo jest przeglądane „w głąb”; po zakończeniu przejścia węzły określiły swoje rozmiary i etap pomiaru jest zakończony Modyfikatory wpływające na ograniczenia - size Modyfikator size deklaruje preferowany rozmiar treści https://developer.android.com Umieszczanie wielu modyfikatorów rozmiaru po Jeżeli sobie nie działa drzewo UI powinno być renderowane w Pierwszy modyfikator rozmiaru ustawia ograniczenia kontenerze o rozdzielczości 300x200 dp ograniczenia są „bounded” i dopuszczają minimalne i maksymalne na stałą wartość szerokość 100 dp - 300 dp i wysokości 100 dp - 200 Nawet jeśli drugi modyfikator rozmiaru zażąda dp mniejszego lub większego rozmiaru, nadal musi Modyfikator size dostosowuje przychodzące przestrzegać dokładnie przekazanych granic, więc nie ograniczenia i ustawi 150dp zastąpi wcześniej ustawionych wartości Modyfikatory wpływające na ograniczenia - size https://developer.android.com Jeśli szerokość/wysokość są mniejsze niż najmniejsze ograniczenie lub większe niż największe ograniczenie modyfikator zmieni przekazane ograniczenia tak dokładnie, jak to możliwe, jednocześnie przestrzegając przekazanych ograniczeń Modyfikatory wpływające na ograniczenia - requiredSize Modyfikator requiredSize zastępuje przychodzące ograniczenia i przekazuje określony rozmiar jako dokładne granice Kiedy rozmiar zostanie przekazany w górę drzewa, węzeł potomny zostanie wyśrodkowany w dostępnym obszarze https://developer.android.com Modyfikatory wpływające na ograniczenia - width/height Modyfikator size zmienia zarówno szerokość, jak i wysokość ograniczeń Za pomocą modyfikatora width można ustawić stałą szerokość, ale wysokość pozostawić nieokreśloną Podobnie za pomocą modyfikatora height można ustawić stałą wysokość, ale pozostawić szerokość nieokreśloną https://developer.android.com Modyfikatory wpływające na ograniczenia - sizeIn Modyfikator sizeIn pozwala ustawić dokładne minimalne i maksymalne ograniczenia szerokości i wysokości (pozwala ustalić konkretne przedziały) https://developer.android.com Modyfikatory i ograniczenia – przykład 1 @Composable fun BugdroidBox1() { Box(modifier = Modifier.size(width = 300.dp, height = 200.dp)) { Image( painterResource(id = R.drawable.ic_android_green_50dp), "bugdroid", Modifier.fillMaxSize().size(50.dp) ) } } Modyfikatory i ograniczenia – przykład 1 Modyfikator fillMaxSize zmienia ograniczenia, ustawiając minimalną szerokość i wysokość na wartość maksymalną: 300 dp szerokości i 200 dp wysokości Mimo że modyfikator size chce ustawić rozmiar 50 dp, nadal musi przestrzegać nadchodzących minimalnych ograniczeń; zatem „na wyjściu” modyfikatora size również będą dokładne ograniczenia 300 na 200 (efektywnie podana wartość jest ignorowana) Image przestrzega tych ograniczeń i zgłasza rozmiar 300 na 200, który jest przekazywany w górę drzewa Modyfikatory i ograniczenia – przykład 2 @Composable fun BugdroidBox2() { Box(modifier = Modifier.size(width = 300.dp, height = 200.dp)) { Image( painterResource(id = R.drawable.ic_android_green_50dp), "bugdroid", Modifier.fillMaxSize().wrapContentSize().size(50.dp) ) } } Modyfikatory i ograniczenia – przykład 2 Modyfikator fillMaxSize zmienia ograniczenia, aby ustawić minimalną szerokość i wysokość na dostępne maksimum: 300 dp szerokości i 200 dp wysokości (ustawia ograniczenia dokładne) Modyfikator wrapContentSize resetuje minimalne ograniczenia do ograniczeń „ograniczonych” (bounded) → następny węzeł może ponownie zajmować całą przestrzeń lub być mniejszy Modyfikator size ustawia ograniczenia na minimum = maksimum = 50 Obraz przyjmuje rozmiar 50 na 50 dp i przekazywany jest przez modyfikator size w górę Modyfikator wrapContentSize ma specjalną właściwość – umieszcza potomka w środku dostępnych minimalnych granic, które zostały mu przekazane; rozmiar, jaki przekazuje swoim rodzicom, jest równy minimalnym granicom, które zostały mu przekazane (tutaj 300 na 200 dp) Modyfikatory i ograniczenia – przykład 3 @Composable fun BugdroidBox3() { Box(modifier = Modifier.size(width = 300.dp, height = 200.dp)) { Image( painterResource(id = R.drawable.bugdroid2), "bugdroid", Modifier.clip(CircleShape).padding(10.dp).size(100.dp) ) } } Modyfikatory i ograniczenia – przykład 3 Modyfikator clip nie zmienia ograniczeń Modyfikator padding zmniejsza dostępną przestrzeń Modyfikator size ustawia wszystkie ograniczenia na 100dp Obraz respektuje te ograniczenia i zgłasza rozmiar 100 na 100 dp Modyfikator padding dodaje 10 dp we wszystkich kierunkach, więc zwiększa szerokość i wysokość przekazywaną w górę drzewa o 20 dp W fazie rysowania modyfikator clip działa na płótnie o rozdzielczości 120 na 120 dp → tworzy okrągłą maskę tego rozmiaru Modyfikator padding dodaje wcięcie do swojej zawartości (10 dp we wszystkich kierunkach) więc zmniejsza rozmiar płótna do 100 na 100 dp Obraz jest rysowany na tym płótnie; obraz jest przycinany w oparciu o oryginalny okrąg o rozmiarze 120 dp, więc wynik nie jest okrągły Listy Leniwe listy (Lazy Lists) W przypadku wyświetlania dużej liczby elementów (lub listy o nieznanej długości), użycie układu Column lub Row może spowodować problemy z wydajnością (elementy zostaną rozmieszczone niezależnie od tego, czy są widoczne, czy nie) Compose udostępnia LazyColumn (lista przewijana w pionie) i LazyRow (lista przewijana w poziomie), które rozmieszczają tylko widoczne komponenty (działają one analogicznie do RecyclerView z systemu widoków (Views)) Komponenty Lazy... różnią się od większości układów w aplikacji Compose – nie akceptują bloku @Composable emitującego elementy UI ale udostępniają blok LazyListScope Blok LazyListScope oferuje DSL (domain specific language), który umożliwia aplikacjom opisywanie zawartości listy LazyListScope DSL DSL LazyListScope zapewnia szereg funkcji do opisywania elementów układu: item() - dodaje pojedynczy element items(Int) - dodaje wiele elementów Istnieje wiele funkcji rozszerzających, które umożliwiają dodawanie kolekcji elementów, takich jak lista - items(List) Istnieje wariant funkcji rozszerzającej o nazwie itemsIndexed(), który udostępnia indeks elementu Wszystkie funkcje umożliwiają (opcjonalne) ustawienie klucza LazyList - przykład val listItems = List(5, { index -> ListItem(index, "Item from list: $index") }) LazyColumn( contentPadding = PaddingValues(8.dp), //marginesy elementów verticalArrangement = Arrangement.spacedBy(4.dp) //odstępy między ) { //elementami item(key = 0) //klucze opcjonalne, ale poprawiają wydajność { ListRow("First item") } //rekompozycji items(5, key = { i -> i+1 }) //klucz musi być stabilny i unikalny { index -> ListRow("Item with index: $index") } items(items = listItems, key = { item -> item.id + 6 } ) { item -> ListRow(item.text) } itemsIndexed(items= listItems, key = { index, item -> index+11 }) { index, item -> ListRow(text = "${item.text}, index: $index") } //lambdy mają dodatkowo dostępny numer elementu } } Layouty oparte na slotach Layouty oparte na slotach Komponenty Material Design w dużym stopniu korzystają z interfejsów API opartych na slotach – jest to wzorzec wprowadzony przez Compose, aby umożliwić łatwe dostosowywanie elementów komponowalnych Komponenty są bardziej elastyczne, ponieważ akceptują element potomny, który może się sam konfigurować (w innym wypadku konieczne byłoby udostępnianie parametrów elementów potomych) Sloty pozostawiają puste miejsce w interfejsie użytkownika, które programista może wypełnić według własnego uznania TopAppBar → https://developer.android.com navgation icon // title // actions Layouty oparte na slotach Elementy komponowalne zwykle przyjmują komponowalną lambdę (content: @Composable () -> Unit) Layouty oparte na slotach udostępniają wiele parametrów dotyczących zawartości o różnych zastosowaniach np. TopAppBar umożliwia podanie treści tytułu, ikony nawigacji i akcji Scaffold zapewnia miejsca na najpopularniejsze komponenty Material Design najwyższego poziomu: TopAppBar, BottomAppBar, FloatingActionButton i szuflada (dzięku zastosowaniu komponentu Scaffold, łatwo zapewnić prawidłowe ustawienie i współpracę elementów) Scaffold - przykład var presses by remember { mutableStateOf(0) } Scaffold( topBar = { TopAppBar( colors = TopAppBarDefaults.largeTopAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.primary, ), title = { Text("Top App Bar") } ) }, bottomBar = { BottomA

Use Quizgecko on...
Browser
Browser