1. Custom Layout
Jetpack Compose memungkinkan pembuatan custom layout yang tidak tersedia di Compose standar. Dengan Layout composable, Anda mengontrol penuh measurement dan placement child elements.
@Composable
fun CircularLayout(
radius: Float,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
val radiusPx = radius.toPx()
val centerX = constraints.maxWidth / 2
val centerY = constraints.maxHeight / 2
val angleStep = 360f / placeables.size
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEachIndexed { index, placeable ->
val angle = Math.toRadians((angleStep * index - 90).toDouble())
val x = centerX + radiusPx * cos(angle) - placeable.width / 2
val y = centerY + radiusPx * sin(angle) - placeable.height / 2
placeable.place(x.toInt(), y.toInt())
}
}
}
}
// Penggunaan
@Composable
fun CircularMenu() {
CircularLayout(radius = 120.dp) {
repeat(6) { index ->
FloatingActionButton(onClick = { /* action */ }) {
Icon(Icons.Default.Star, "Item $index")
}
}
}
}
1.1 Custom Flow Layout
@Composable
fun FlowRow(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
content: @Composable () -> Unit
) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0)) }
val maxWidth = constraints.maxWidth
var xPos = 0
var yPos = 0
var maxHeight = 0
layout(maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
if (xPos + placeable.width > maxWidth) {
xPos = 0
yPos += maxHeight
maxHeight = 0
}
placeable.place(xPos, yPos)
xPos += placeable.width
maxHeight = maxOf(maxHeight, placeable.height)
}
}
}
}
// Tag chips
@Composable
fun TagCloud(tags: List) {
FlowRow(modifier = Modifier.padding(8.dp)) {
tags.forEach { tag ->
SuggestionChip(
onClick = { },
label = { Text(tag) },
modifier = Modifier.padding(4.dp)
)
}
}
}
2. Animation
Compose menyediakan API animasi yang powerful: animateXxxAsState, AnimatedVisibility, AnimatedContent, dan updateTransition.
@Composable
fun AnimationDemo() {
var expanded by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(true) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// AnimatedVisibility
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
Card(modifier = Modifier.padding(16.dp)) {
Text("Hello!", modifier = Modifier.padding(16.dp))
}
}
// animateXxxAsState
val color by animateColorAsState(
targetValue = if (expanded) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.secondary,
animationSpec = tween(durationMillis = 500),
label = "color"
)
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 100.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "size"
)
Box(
modifier = Modifier
.size(size)
.background(color, RoundedCornerShape(16.dp))
.clickable { expanded = !expanded }
)
// AnimatedContent
AnimatedContent(
targetState = expanded,
transitionSpec = {
fadeIn() + slideInHorizontally { it } togetherWith
fadeOut() + slideOutHorizontally { -it }
},
label = "content"
) { isExpanded ->
if (isExpanded) {
Text("Konten diperluas!", style = MaterialTheme.typography.headlineMedium)
} else {
Text("Konten ringkas", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
2.1 Animation Specs
| Spec | Karakteristik | Contoh Penggunaan |
|---|---|---|
| spring() | Fisika realistis, bounce | Gesture feedback |
| tween() | Kurva halus dengan durasi | Color, opacity transition |
| keyframes() | Kontrol per-frame | Animasi kompleks multi-step |
| repeatable() | Animasi berulang | Loading spinner |
| snaps() | Langsung tanpa interpolasi | Tab indicator switch |
3. Side Effects
Side effects di Compose dijalankan dengan lifecycle-aware functions: LaunchedEffect, DisposableEffect, SideEffect, dan rememberCoroutineScope.
@Composable
fun SideEffectsDemo(userId: String) {
val viewModel: UserViewModel = viewModel()
// LaunchedEffect — berjalan saat key berubah, auto-cancel
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
// DisposableEffect — cleanup saat dispose
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) {
// Save state
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// SideEffect — berjalan setiap recomposition sukses
SideEffect {
analytics.trackScreen("UserProfile")
}
// rememberCoroutineScope — scope yang tied ke composable
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch {
viewModel.saveUser()
}
}) {
Text("Simpan")
}
// derivedStateOf — compute dari state lain
val sortedUsers by remember {
derivedStateOf { viewModel.users.sortedBy { it.name } }
}
// snapshotFlow — observe Compose state sebagai Flow
LaunchedEffect(Unit) {
snapshotFlow { viewModel.searchQuery }
.debounce(300)
.collect { query ->
viewModel.search(query)
}
}
}
4. State Hoisting
State hoisting adalah pola untuk memindahkan state ke parent composable, sehingga child menjadi stateless dan reusable.
// Stateless composable (reusable)
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSearch: () -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier.fillMaxWidth(),
placeholder = { Text("Cari...") },
trailingIcon = {
IconButton(onClick = onSearch) {
Icon(Icons.Default.Search, "Search")
}
},
singleLine = true
)
}
// Stateful composable (parent owns state)
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
var query by remember { mutableStateOf("") }
Column {
SearchBar(
query = query,
onQueryChange = { query = it },
onSearch = { viewModel.search(query) }
)
LazyColumn {
items(viewModel.results) { result ->
SearchResultItem(result)
}
}
}
}
State hoisting membuat composable: (1) lebih reusable, (2) lebih mudah di-test, (3) single source of truth. Parameter: value: T dan onValueChange: (T) -> Unit.
5. Testing
@RunWith(AndroidJUnit4::class)
class SearchBarTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun searchBar_displaysQuery() {
composeTestRule.setContent {
SearchBar(
query = "Hello",
onQueryChange = {},
onSearch = {}
)
}
composeTestRule
.onNodeWithText("Hello")
.assertIsDisplayed()
}
@Test
fun searchBar_callsOnQueryChange() {
var changedQuery = ""
composeTestRule.setContent {
SearchBar(
query = "",
onQueryChange = { changedQuery = it },
onSearch = {}
)
}
composeTestRule
.onNodeWithText("Cari...")
.performTextInput("Test")
assertEquals("Test", changedQuery)
}
@Test
fun listScroll_showsItems() {
val items = (1..50).map { "Item $it" }
composeTestRule.setContent {
LazyColumn {
items(items) { Text(it) }
}
}
composeTestRule.onNodeWithText("Item 1").assertIsDisplayed()
composeTestRule.onNodeWithText("Item 1")
.performScrollToIndex(49)
composeTestRule.onNodeWithText("Item 50").assertIsDisplayed()
}
}
6. Interop dengan View System
// AndroidView — gunakan View di Compose
@Composable
fun WebViewComposable(url: String) {
AndroidView(
factory = { context ->
WebView(context).apply {
webViewClient = WebViewClient()
loadUrl(url)
}
},
update = { webView ->
webView.loadUrl(url)
}
)
}
// ComposeView — gunakan Compose di View
class ComposeFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
MaterialTheme {
MyComposableScreen()
}
}
}
}
}
7. Best Practices
| Praktik | Alasan |
|---|---|
| Hindari mutableListOf di composable | Akan di-reset saat recomposition |
| Gunakan key() di LazyColumn | Optimasi recomposition |
| Gunakan remember {} untuk expensive compute | Hindari re-compute setiap recomposition |
| State hoisting | Reusability dan testability |
| derivedStateOf untuk computed state | Hindari recomposition berlebihan |
| Stable types untuk parameter | Compose skip recomposition jika data sama |