finish skills
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
# Compose State Management Patterns
|
||||
|
||||
Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see `kotlin-expert` skill.
|
||||
|
||||
## Core State Functions
|
||||
|
||||
### remember
|
||||
|
||||
Cache values across recompositions:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Counter() {
|
||||
var count by remember { mutableStateOf(0) }
|
||||
|
||||
Button(onClick = { count++ }) {
|
||||
Text("Clicked $count times")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Simple UI state (toggles, counters, text input)
|
||||
**Visual pattern**: Button press → state changes → UI updates
|
||||
|
||||
### derivedStateOf
|
||||
|
||||
Compute state from other state, recompose only when result changes:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ScrollToTopButton(listState: LazyListState) {
|
||||
// Only recomposes when showButton value changes (not every scroll pixel)
|
||||
val showButton by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
FloatingActionButton(onClick = { /* scroll to top */ }) {
|
||||
Icon(Icons.Default.ArrowUpward, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Input state changes frequently, but derived result changes rarely
|
||||
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility
|
||||
**Performance**: Prevents recomposition on every scroll event
|
||||
|
||||
### produceState
|
||||
|
||||
Convert non-Compose state into Compose state:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadUserProfile(userId: String): State<User?> {
|
||||
return produceState<User?>(initialValue = null, userId) {
|
||||
value = repository.fetchUser(userId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(userId: String) {
|
||||
val user by LoadUserProfile(userId)
|
||||
|
||||
when (user) {
|
||||
null -> LoadingState("Loading profile...")
|
||||
else -> ProfileCard(user!!)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Convert Flow, LiveData, callbacks into Compose state
|
||||
**Visual pattern**: Async operation → state updates → UI reflects changes
|
||||
**Lifecycle**: Coroutine cancelled when composable leaves composition
|
||||
|
||||
## State Hoisting Pattern
|
||||
|
||||
Move state up to make composables reusable and testable:
|
||||
|
||||
### Before (Stateful)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchBar() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search...") }
|
||||
)
|
||||
}
|
||||
```
|
||||
❌ Hard to test, can't control state externally
|
||||
|
||||
### After (Stateless)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search...") },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
Column {
|
||||
SearchBar(query = query, onQueryChange = { query = it })
|
||||
SearchResults(query = query)
|
||||
}
|
||||
}
|
||||
```
|
||||
✅ Reusable, testable, state controlled by parent
|
||||
|
||||
**Hoisting principle**: State goes up, events go down
|
||||
- State: `query: String` (read-only)
|
||||
- Events: `onQueryChange: (String) -> Unit` (write-only)
|
||||
|
||||
## Amethyst State Patterns
|
||||
|
||||
### Theme-Aware State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedContent() {
|
||||
val isDark = !isLightTheme()
|
||||
// Adjust visuals based on theme
|
||||
val iconTint = if (isDark) Color.White else Color.Black
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Derive state from MaterialTheme
|
||||
**Visual**: Component adapts to light/dark theme automatically
|
||||
|
||||
### Relay Status State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val statusColor = when {
|
||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
||||
else -> RelayStatusColors.Connected
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
|
||||
tint = statusColor
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Visual state derived from domain state
|
||||
**Visual mapping**:
|
||||
- 0 relays → Red + X icon
|
||||
- 1-2 relays → Yellow + Check icon
|
||||
- 3+ relays → Green + Check icon
|
||||
|
||||
### Loading/Empty/Error States
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
when (uiState) {
|
||||
is UiState.Loading -> LoadingState("Loading feed...")
|
||||
is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() })
|
||||
is UiState.Error -> FeedErrorState(
|
||||
errorMessage = uiState.message,
|
||||
onRetry = { viewModel.retry() }
|
||||
)
|
||||
is UiState.Success -> LazyColumn {
|
||||
items(uiState.items) { FeedItem(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Sealed class → visual state component
|
||||
**Components**:
|
||||
- `LoadingState` - Progress indicator
|
||||
- `EmptyState` - Empty message + refresh
|
||||
- `ErrorState` - Error message + retry
|
||||
- Success - Actual content
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Toggle State
|
||||
```kotlin
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = { isExpanded = !isExpanded }) {
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand"
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
Text("Expanded content...")
|
||||
}
|
||||
```
|
||||
|
||||
### List State with Actions
|
||||
```kotlin
|
||||
var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) }
|
||||
|
||||
Column {
|
||||
AddButton(onClick = {
|
||||
items = items + "Item ${items.size + 1}"
|
||||
})
|
||||
|
||||
items.forEachIndexed { index, item ->
|
||||
Row {
|
||||
Text(item)
|
||||
RemoveButton(onClick = {
|
||||
items = items.filterIndexed { i, _ -> i != index }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TextField State
|
||||
```kotlin
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Enter text") }
|
||||
)
|
||||
```
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Avoid Unnecessary Recomposition
|
||||
```kotlin
|
||||
// ❌ Bad: Recomposes on every scroll position change
|
||||
@Composable
|
||||
fun BadScrollButton(scrollState: ScrollState) {
|
||||
if (scrollState.value > 100) { // scrollState.value changes constantly
|
||||
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Good: Only recomposes when visibility changes
|
||||
@Composable
|
||||
fun GoodScrollButton(scrollState: ScrollState) {
|
||||
val showButton by remember {
|
||||
derivedStateOf { scrollState.value > 100 }
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Stable Parameters
|
||||
Use `@Immutable` data classes (see `kotlin-expert`) to prevent recomposition:
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class UserProfile(val name: String, val avatar: String)
|
||||
|
||||
@Composable
|
||||
fun ProfileCard(profile: UserProfile) {
|
||||
// Only recomposes when profile instance changes
|
||||
Row {
|
||||
RobohashImage(robot = profile.avatar)
|
||||
Text(profile.name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Kotlin State
|
||||
|
||||
For ViewModel state, Flow, StateFlow → See `kotlin-expert` skill
|
||||
|
||||
Common integration pattern:
|
||||
```kotlin
|
||||
// ViewModel (Kotlin state)
|
||||
class FeedViewModel {
|
||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
}
|
||||
|
||||
// Composable (Compose state)
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
// Use uiState to render UI
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Function | Use Case | Recomposes When |
|
||||
|----------|----------|----------------|
|
||||
| `remember { mutableStateOf() }` | Local UI state | State value changes |
|
||||
| `derivedStateOf { }` | Computed state | Derived result changes |
|
||||
| `produceState { }` | Async/Flow → State | Async operation updates value |
|
||||
| `collectAsState()` | Flow → State | Flow emits new value |
|
||||
| State hoisting | Reusable components | Parent passes new state |
|
||||
|
||||
## Sources
|
||||
|
||||
State management patterns based on:
|
||||
- [State and Jetpack Compose - Android Developers](https://developer.android.com/develop/ui/compose/state)
|
||||
- [When should I use derivedStateOf?](https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b)
|
||||
- [Advanced State and Side Effects](https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects)
|
||||
- AmethystMultiplatform codebase patterns (2025)
|
||||
Reference in New Issue
Block a user